ossify 0.2.2__tar.gz → 0.2.4__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.
- {ossify-0.2.2 → ossify-0.2.4}/PKG-INFO +1 -1
- {ossify-0.2.2 → ossify-0.2.4}/pyproject.toml +2 -2
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/__init__.py +1 -1
- ossify-0.2.4/src/ossify/_sync/base.py +534 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/morph.py +71 -9
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/data_layers.py +7 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/file_io.py +107 -9
- ossify-0.2.2/src/ossify/_sync/base.py +0 -280
- {ossify-0.2.2 → ossify-0.2.4}/.github/workflows/mkdocs_publish.yml +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/.github/workflows/python-package.yml +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/.gitignore +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/.pre-commit-config.yaml +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/LICENSE +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/README.md +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/mkdocs.yml +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/__init__.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/graph.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/mapping.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/mesh.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/points.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/_sync/table.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/algorithms.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/base.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/compartment_models/minnie65_ds15_us0_bd0.json +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/compartment_models/v1dd_ds15_us0_bd0.json +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/compartment_models/xgb/minnie65_ds15_us0_bd0.ubj +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/compartment_models/xgb/v1dd_ds15_us0_bd0.ubj +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/compartments.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/graph_functions.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/plot.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/plot3d.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/plot_utils.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/structured_prediction.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/sync_classes.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/translate.py +0 -0
- {ossify-0.2.2 → ossify-0.2.4}/src/ossify/utils.py +0 -0
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
allow-direct-references = true
|
|
7
7
|
[project]
|
|
8
8
|
name = "ossify"
|
|
9
|
-
version = "0.2.
|
|
9
|
+
version = "0.2.4"
|
|
10
10
|
description = "Mesh and skeleton analysis"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.11"
|
|
@@ -95,7 +95,7 @@ default-groups = ["dev", "docs", "lint", "profile", "viz"]
|
|
|
95
95
|
|
|
96
96
|
|
|
97
97
|
[tool.bumpversion]
|
|
98
|
-
current_version = "0.2.
|
|
98
|
+
current_version = "0.2.4"
|
|
99
99
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
100
100
|
serialize = ["{major}.{minor}.{patch}"]
|
|
101
101
|
regex = false
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
from typing import Optional, Union
|
|
2
|
+
|
|
3
|
+
import fastremap
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
DEFAULT_SPATIAL_COLUMNS = ["x", "y", "z"]
|
|
8
|
+
|
|
9
|
+
# Ossify's canonical identifier space. Identifiers are nominally drawn from the
|
|
10
|
+
# uint64 segmentation ID space, but every ID we actually support fits within the
|
|
11
|
+
# nonnegative range of a signed 64-bit integer. We therefore own a single
|
|
12
|
+
# internal representation -- ``int64`` -- so that a value never depends on
|
|
13
|
+
# whether an upstream source happened to hand us signed or unsigned integers.
|
|
14
|
+
_INT64_MAX = int(np.iinfo(np.int64).max) # 9223372036854775807
|
|
15
|
+
_UINT64_INT64_MAX = np.uint64(_INT64_MAX)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _range_error(name: str, kind: str) -> ValueError:
|
|
19
|
+
if kind == "negative":
|
|
20
|
+
return ValueError(
|
|
21
|
+
f"{name}: identifiers must be nonnegative, but negative values were found."
|
|
22
|
+
)
|
|
23
|
+
return ValueError(
|
|
24
|
+
f"{name}: identifiers must be <= {_INT64_MAX} (int64 max), "
|
|
25
|
+
f"but larger values were found."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_and_cast_integer_ndarray(
|
|
30
|
+
arr: np.ndarray, null_mask: Optional[np.ndarray], name: str
|
|
31
|
+
) -> np.ndarray:
|
|
32
|
+
"""Validate a signed/unsigned integer ndarray and cast to ``int64``.
|
|
33
|
+
|
|
34
|
+
Validation uses integer-safe comparisons only -- no value ever passes
|
|
35
|
+
through a floating-point representation, which would silently lose
|
|
36
|
+
precision for IDs above ``2**53``.
|
|
37
|
+
"""
|
|
38
|
+
kind = arr.dtype.kind
|
|
39
|
+
if kind == "i":
|
|
40
|
+
# Signed integers already fit within int64 (int8..int64); only the
|
|
41
|
+
# lower bound can be violated. Null positions carry a sentinel and are
|
|
42
|
+
# excluded from the check.
|
|
43
|
+
negative = arr < 0
|
|
44
|
+
if null_mask is not None:
|
|
45
|
+
negative = negative & ~null_mask
|
|
46
|
+
if negative.any():
|
|
47
|
+
raise _range_error(name, "negative")
|
|
48
|
+
return arr.astype(np.int64, copy=False)
|
|
49
|
+
if kind == "u":
|
|
50
|
+
# Unsigned integers cannot be negative; only uint64 can exceed int64
|
|
51
|
+
# max. Compare against a uint64 scalar so numpy stays in integer land
|
|
52
|
+
# (mixing a uint64 array with a Python int upcasts to float64).
|
|
53
|
+
too_large = arr > _UINT64_INT64_MAX
|
|
54
|
+
if null_mask is not None:
|
|
55
|
+
too_large = too_large & ~null_mask
|
|
56
|
+
if too_large.any():
|
|
57
|
+
raise _range_error(name, "too_large")
|
|
58
|
+
# Every remaining value is <= int64 max, so this cast is lossless.
|
|
59
|
+
return arr.astype(np.int64)
|
|
60
|
+
raise TypeError(
|
|
61
|
+
f"{name}: expected integer identifiers, got array of dtype {arr.dtype}."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _canonicalize_object_array(
|
|
66
|
+
arr: np.ndarray, allow_null: bool, name: str
|
|
67
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
68
|
+
"""Canonicalize an object-dtype array element by element.
|
|
69
|
+
|
|
70
|
+
Object arrays are the only place mixed Python ints, ``None``/``pd.NA`` and
|
|
71
|
+
stray floats can coexist, so each element is inspected individually.
|
|
72
|
+
"""
|
|
73
|
+
n = len(arr)
|
|
74
|
+
out = np.zeros(n, dtype=np.int64)
|
|
75
|
+
null_mask = np.zeros(n, dtype=bool)
|
|
76
|
+
for i, v in enumerate(arr):
|
|
77
|
+
if v is None or v is pd.NA:
|
|
78
|
+
null_mask[i] = True
|
|
79
|
+
continue
|
|
80
|
+
if isinstance(v, (bool, np.bool_)):
|
|
81
|
+
raise TypeError(
|
|
82
|
+
f"{name}: boolean values are not valid identifiers (got {v!r})."
|
|
83
|
+
)
|
|
84
|
+
if isinstance(v, (float, np.floating)):
|
|
85
|
+
# A float may already have lost precision before Ossify saw it, so
|
|
86
|
+
# even an integral-looking float is rejected. A NaN is treated as a
|
|
87
|
+
# null when nulls are permitted.
|
|
88
|
+
if v != v: # NaN
|
|
89
|
+
null_mask[i] = True
|
|
90
|
+
continue
|
|
91
|
+
raise TypeError(
|
|
92
|
+
f"{name}: float identifiers are not accepted (got {v!r}); "
|
|
93
|
+
f"a float may already have lost precision above 2**53."
|
|
94
|
+
)
|
|
95
|
+
if isinstance(v, (int, np.integer)):
|
|
96
|
+
iv = int(v)
|
|
97
|
+
if iv < 0:
|
|
98
|
+
raise _range_error(name, "negative")
|
|
99
|
+
if iv > _INT64_MAX:
|
|
100
|
+
raise _range_error(name, "too_large")
|
|
101
|
+
out[i] = iv
|
|
102
|
+
continue
|
|
103
|
+
raise TypeError(
|
|
104
|
+
f"{name}: non-integer identifier {v!r} of type {type(v).__name__}."
|
|
105
|
+
)
|
|
106
|
+
if null_mask.any() and not allow_null:
|
|
107
|
+
raise ValueError(f"{name}: null identifier values are not allowed here.")
|
|
108
|
+
return out, null_mask
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _extract_int64(
|
|
112
|
+
values, allow_null: bool, name: str
|
|
113
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
114
|
+
"""Core: return ``(int64_ndarray, null_mask)`` for any array-like input."""
|
|
115
|
+
dtype = getattr(values, "dtype", None)
|
|
116
|
+
if dtype is not None and pd.api.types.is_extension_array_dtype(dtype):
|
|
117
|
+
# pandas nullable integer container (Int64/UInt64/Int32/...).
|
|
118
|
+
ea = values.array if isinstance(values, (pd.Series, pd.Index)) else values
|
|
119
|
+
if not pd.api.types.is_integer_dtype(ea.dtype):
|
|
120
|
+
raise TypeError(
|
|
121
|
+
f"{name}: expected integer identifiers, got extension dtype {ea.dtype}."
|
|
122
|
+
)
|
|
123
|
+
null_mask = np.asarray(ea.isna())
|
|
124
|
+
if null_mask.any() and not allow_null:
|
|
125
|
+
raise ValueError(f"{name}: null identifier values are not allowed here.")
|
|
126
|
+
# Materialize the underlying signed/unsigned native array (nulls filled
|
|
127
|
+
# with a sentinel 0) then validate exactly like a plain ndarray.
|
|
128
|
+
native = ea.to_numpy(dtype=ea.dtype.numpy_dtype, na_value=0)
|
|
129
|
+
int64 = _validate_and_cast_integer_ndarray(native, null_mask, name)
|
|
130
|
+
return int64, null_mask
|
|
131
|
+
|
|
132
|
+
arr = values if isinstance(values, np.ndarray) else np.asarray(values)
|
|
133
|
+
if arr.size == 0:
|
|
134
|
+
# np.asarray([]) is float64; an empty ID container is still valid.
|
|
135
|
+
return arr.astype(np.int64), np.zeros(0, dtype=bool)
|
|
136
|
+
kind = arr.dtype.kind
|
|
137
|
+
if kind in ("i", "u"):
|
|
138
|
+
int64 = _validate_and_cast_integer_ndarray(arr, None, name)
|
|
139
|
+
return int64, np.zeros(len(int64), dtype=bool)
|
|
140
|
+
if kind == "f":
|
|
141
|
+
raise TypeError(
|
|
142
|
+
f"{name}: float identifiers are not accepted; a float may already "
|
|
143
|
+
f"have lost precision above 2**53."
|
|
144
|
+
)
|
|
145
|
+
if kind == "b":
|
|
146
|
+
raise TypeError(f"{name}: boolean values are not valid identifiers.")
|
|
147
|
+
if kind == "O":
|
|
148
|
+
return _canonicalize_object_array(arr, allow_null, name)
|
|
149
|
+
raise TypeError(f"{name}: unsupported identifier dtype {arr.dtype}.")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _wrap_null(int64: np.ndarray, null_mask: np.ndarray, allow_null: bool):
|
|
153
|
+
"""Return an ``int64`` ndarray, or a nullable ``Int64`` array if NA present."""
|
|
154
|
+
if allow_null and null_mask.any():
|
|
155
|
+
return pd.arrays.IntegerArray(int64, null_mask.copy())
|
|
156
|
+
return int64
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def canonicalize_ids(
|
|
160
|
+
values,
|
|
161
|
+
*,
|
|
162
|
+
allow_null: bool = False,
|
|
163
|
+
name: str = "id",
|
|
164
|
+
):
|
|
165
|
+
"""Convert integer identifiers to Ossify's canonical ``int64`` representation.
|
|
166
|
+
|
|
167
|
+
Ossify treats identifiers as living in the nonnegative ``int64`` range even
|
|
168
|
+
though the nominal ID space is ``uint64``. Upstream sources may hand us the
|
|
169
|
+
same ID as a Python ``int``, a NumPy signed/unsigned integer, or inside a
|
|
170
|
+
pandas container, and joining two differently-typed key spaces can make
|
|
171
|
+
pandas coerce values through ``float64`` -- silently collapsing distinct IDs
|
|
172
|
+
above ``2**53``. Canonicalizing every ID to ``int64`` before it participates
|
|
173
|
+
in a lookup removes that failure mode.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
values :
|
|
178
|
+
The identifier(s). Accepted forms: a Python ``int``; a NumPy signed or
|
|
179
|
+
unsigned integer scalar; a NumPy integer array; a list/tuple of the
|
|
180
|
+
above; a pandas ``Series`` or ``Index``; or a pandas nullable integer
|
|
181
|
+
(``Int64``/``UInt64``/...) container.
|
|
182
|
+
allow_null :
|
|
183
|
+
Whether nulls (``None``/``pd.NA``/``NaN``) are permitted. Only pass
|
|
184
|
+
``True`` where the calling mapping operation legitimately supports
|
|
185
|
+
missing values; otherwise a null raises.
|
|
186
|
+
name :
|
|
187
|
+
Label used in error messages (typically the column or layer name).
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
:
|
|
192
|
+
The canonical form, matching the input's container shape:
|
|
193
|
+
|
|
194
|
+
- scalar in -> ``np.int64`` scalar;
|
|
195
|
+
- ``Series`` in -> ``Series`` (index and name preserved);
|
|
196
|
+
- ``Index`` in -> ``Index`` (name preserved);
|
|
197
|
+
- array-like in -> ``np.ndarray`` of ``int64``.
|
|
198
|
+
|
|
199
|
+
Where nulls are present (and permitted) the result uses pandas nullable
|
|
200
|
+
``Int64`` so missing values survive without a float cast; otherwise a
|
|
201
|
+
plain ``int64`` container is returned.
|
|
202
|
+
|
|
203
|
+
Raises
|
|
204
|
+
------
|
|
205
|
+
TypeError
|
|
206
|
+
If a value is a float (including integral-looking floats), a boolean, or
|
|
207
|
+
any non-integer object.
|
|
208
|
+
ValueError
|
|
209
|
+
If a value is negative, exceeds ``int64`` max, or is null where nulls are
|
|
210
|
+
not allowed.
|
|
211
|
+
"""
|
|
212
|
+
# Scalars. Order matters: bool is a subclass of int, and np.bool_ must be
|
|
213
|
+
# rejected before the integer check.
|
|
214
|
+
if isinstance(values, (bool, np.bool_)):
|
|
215
|
+
raise TypeError(f"{name}: boolean values are not valid identifiers.")
|
|
216
|
+
if isinstance(values, (int, np.integer)):
|
|
217
|
+
iv = int(values)
|
|
218
|
+
if iv < 0:
|
|
219
|
+
raise _range_error(name, "negative")
|
|
220
|
+
if iv > _INT64_MAX:
|
|
221
|
+
raise _range_error(name, "too_large")
|
|
222
|
+
return np.int64(iv)
|
|
223
|
+
if isinstance(values, (float, np.floating)):
|
|
224
|
+
raise TypeError(
|
|
225
|
+
f"{name}: float identifiers are not accepted; a float may already "
|
|
226
|
+
f"have lost precision above 2**53."
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if isinstance(values, pd.Index):
|
|
230
|
+
int64, null_mask = _extract_int64(values, allow_null, name)
|
|
231
|
+
return pd.Index(_wrap_null(int64, null_mask, allow_null), name=values.name)
|
|
232
|
+
if isinstance(values, pd.Series):
|
|
233
|
+
int64, null_mask = _extract_int64(values, allow_null, name)
|
|
234
|
+
return pd.Series(
|
|
235
|
+
_wrap_null(int64, null_mask, allow_null),
|
|
236
|
+
index=values.index,
|
|
237
|
+
name=values.name,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
int64, null_mask = _extract_int64(values, allow_null, name)
|
|
241
|
+
return _wrap_null(int64, null_mask, allow_null)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _canonicalize_index(index: pd.Index) -> pd.Index:
|
|
245
|
+
"""Return ``index`` in canonical int64 form if it holds integer identifiers.
|
|
246
|
+
|
|
247
|
+
Non-integer indexes (e.g. a string or float index) are returned untouched
|
|
248
|
+
so canonicalization never changes behavior for data that is not an ID.
|
|
249
|
+
"""
|
|
250
|
+
dtype = index.dtype
|
|
251
|
+
if dtype.kind in ("i", "u") or (
|
|
252
|
+
pd.api.types.is_extension_array_dtype(dtype)
|
|
253
|
+
and pd.api.types.is_integer_dtype(dtype)
|
|
254
|
+
):
|
|
255
|
+
return canonicalize_ids(index, name="node index")
|
|
256
|
+
return index
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def mask_and_remap(
|
|
260
|
+
arr: np.ndarray,
|
|
261
|
+
mask: Union[np.ndarray, list],
|
|
262
|
+
) -> np.ndarray:
|
|
263
|
+
"""Given an array in unmasked indexing and a mask,
|
|
264
|
+
return the array in remapped indexing and omit rows with masked values.
|
|
265
|
+
|
|
266
|
+
Parameters
|
|
267
|
+
----------
|
|
268
|
+
arr :
|
|
269
|
+
NxM array of indices
|
|
270
|
+
mask :
|
|
271
|
+
1D array of indices to mask, either as a boolean mask or as a list of indices
|
|
272
|
+
"""
|
|
273
|
+
if np.array(mask).dtype == bool:
|
|
274
|
+
mask = np.where(mask)[0]
|
|
275
|
+
return _mask_and_remap(np.array(arr, dtype=int), mask)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _mask_and_remap(
|
|
279
|
+
arr: np.ndarray,
|
|
280
|
+
mask: np.ndarray,
|
|
281
|
+
) -> np.ndarray:
|
|
282
|
+
mask_dict = {k: v for k, v in zip(mask, range(len(mask)))}
|
|
283
|
+
mask_dict[-1] = -1
|
|
284
|
+
|
|
285
|
+
arr_offset = arr + 1
|
|
286
|
+
arr_mask_full = fastremap.remap(
|
|
287
|
+
fastremap.mask_except(arr_offset, list(mask + 1)) - 1,
|
|
288
|
+
mask_dict,
|
|
289
|
+
)
|
|
290
|
+
if len(arr_mask_full.shape) == 1:
|
|
291
|
+
return arr_mask_full[~np.any(arr_mask_full == -1)]
|
|
292
|
+
else:
|
|
293
|
+
return arr_mask_full[~np.any(arr_mask_full == -1, axis=1)]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class Layer:
|
|
297
|
+
def __init__(
|
|
298
|
+
self,
|
|
299
|
+
nodes: Union[pd.DataFrame, np.ndarray],
|
|
300
|
+
facets: Union[pd.DataFrame, np.ndarray],
|
|
301
|
+
spatial_columns: Optional[list] = None,
|
|
302
|
+
relation_columns: Optional[list] = None,
|
|
303
|
+
copy: bool = True,
|
|
304
|
+
**kwargs,
|
|
305
|
+
):
|
|
306
|
+
"""A class for representing a set of nodes and their relationships (facets).
|
|
307
|
+
|
|
308
|
+
Parameters
|
|
309
|
+
----------
|
|
310
|
+
nodes :
|
|
311
|
+
A DataFrame or nx3 array of the nodes/vertices/points in the layer.
|
|
312
|
+
If an array is provided, it must be nx3 and the columns will be named
|
|
313
|
+
"x", "y", and "z" unless `spatial_columns` is provided.
|
|
314
|
+
facets :
|
|
315
|
+
A DataFrame or m x k array of the facets/edges/faces in the layer.
|
|
316
|
+
Some columns must correspond to indices in `nodes`, these are specified
|
|
317
|
+
by `relation_columns`.
|
|
318
|
+
spatial_columns :
|
|
319
|
+
A list of column names in `nodes` that correspond to spatial coordinates.
|
|
320
|
+
If not provided and `nodes` is a DataFrame, all columns will be used. If
|
|
321
|
+
`nodes` is an array, defaults to ["x", "y", "z"].
|
|
322
|
+
relation_columns :
|
|
323
|
+
A list of column names in `facets` that correspond to indices in `nodes`.
|
|
324
|
+
If not provided and `facets` is a DataFrame, all columns will be used.
|
|
325
|
+
copy :
|
|
326
|
+
Whether to copy the input DataFrames. If False, the input DataFrames
|
|
327
|
+
may be modified in place.
|
|
328
|
+
"""
|
|
329
|
+
if not isinstance(nodes, pd.DataFrame):
|
|
330
|
+
if isinstance(nodes, np.ndarray):
|
|
331
|
+
if nodes.shape[1] == 3:
|
|
332
|
+
nodes = pd.DataFrame(nodes, columns=DEFAULT_SPATIAL_COLUMNS)
|
|
333
|
+
if spatial_columns is None:
|
|
334
|
+
spatial_columns = DEFAULT_SPATIAL_COLUMNS
|
|
335
|
+
else:
|
|
336
|
+
raise ValueError("Nodes must be an nx3 array")
|
|
337
|
+
else:
|
|
338
|
+
raise ValueError("Nodes must be a DataFrame or an nx3 array")
|
|
339
|
+
|
|
340
|
+
if copy:
|
|
341
|
+
nodes = nodes.copy()
|
|
342
|
+
self.nodes: pd.DataFrame = nodes
|
|
343
|
+
# Node indexes hold identifiers, so pin them to Ossify's canonical int64
|
|
344
|
+
# representation. This keeps every downstream lookup (masking joins,
|
|
345
|
+
# link joins) in a single signed key space -- mixing int64 and uint64
|
|
346
|
+
# keys is what lets pandas collapse distinct IDs above 2**53 via float.
|
|
347
|
+
self.nodes.index = _canonicalize_index(self.nodes.index)
|
|
348
|
+
|
|
349
|
+
if spatial_columns is None:
|
|
350
|
+
spatial_columns = []
|
|
351
|
+
self.spatial_columns = spatial_columns
|
|
352
|
+
|
|
353
|
+
if facets is None:
|
|
354
|
+
facets = pd.DataFrame()
|
|
355
|
+
if not isinstance(facets, pd.DataFrame):
|
|
356
|
+
facets = pd.DataFrame(facets)
|
|
357
|
+
if relation_columns is None:
|
|
358
|
+
relation_columns = facets.columns.tolist()
|
|
359
|
+
if copy:
|
|
360
|
+
facets = facets.copy()
|
|
361
|
+
self.facets: pd.DataFrame = facets
|
|
362
|
+
|
|
363
|
+
if relation_columns is None:
|
|
364
|
+
relation_columns = []
|
|
365
|
+
self.relation_columns = relation_columns
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def vertices(self) -> np.ndarray:
|
|
369
|
+
"""Array of the spatial coordinates of the vertices"""
|
|
370
|
+
return self.vertices_df.to_numpy(dtype=float)
|
|
371
|
+
|
|
372
|
+
@property
|
|
373
|
+
def vertices_df(self) -> pd.DataFrame:
|
|
374
|
+
"""DataFrame of the spatial coordinates of the vertices"""
|
|
375
|
+
return (
|
|
376
|
+
self.nodes[self.spatial_columns]
|
|
377
|
+
if self.spatial_columns is not None
|
|
378
|
+
else self.nodes
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def points(self) -> np.ndarray:
|
|
383
|
+
"""Alias for vertices"""
|
|
384
|
+
return self.vertices
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def n_nodes(self) -> int:
|
|
388
|
+
"""Number of nodes in the layer."""
|
|
389
|
+
return len(self.nodes)
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def n_vertices(self) -> int:
|
|
393
|
+
"""Number of vertices in the layer. Alias for n_nodes."""
|
|
394
|
+
return self.n_nodes
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def n_points(self) -> int:
|
|
398
|
+
"""Number of points in the layer. Alias for n_nodes."""
|
|
399
|
+
return self.n_nodes
|
|
400
|
+
|
|
401
|
+
@property
|
|
402
|
+
def n_facets(self) -> int:
|
|
403
|
+
"""Number of facets (edges/faces) in the layer."""
|
|
404
|
+
return len(self.facets)
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def nodes_index(self) -> pd.Index:
|
|
408
|
+
"""Index of the nodes DataFrame."""
|
|
409
|
+
return self.nodes.index
|
|
410
|
+
|
|
411
|
+
@property
|
|
412
|
+
def vertices_index(self) -> pd.Index:
|
|
413
|
+
"""Index of the vertices. Alias for nodes_index."""
|
|
414
|
+
return self.nodes_index
|
|
415
|
+
|
|
416
|
+
@property
|
|
417
|
+
def points_index(self) -> pd.Index:
|
|
418
|
+
"""Index of the points. Alias for nodes_index."""
|
|
419
|
+
return self.nodes_index
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def facets_index(self) -> pd.Index:
|
|
423
|
+
"""Index of the facets DataFrame."""
|
|
424
|
+
return self.facets.index
|
|
425
|
+
|
|
426
|
+
@property
|
|
427
|
+
def edge_index(self) -> pd.Index:
|
|
428
|
+
"""Index of the edges. Alias for facets_index."""
|
|
429
|
+
return self.facets_index
|
|
430
|
+
|
|
431
|
+
@property
|
|
432
|
+
def facets_positional(self) -> np.ndarray:
|
|
433
|
+
"""Array of the facets in positional indexing, such that 0 corresponds to the
|
|
434
|
+
first node in its current node index ordering"""
|
|
435
|
+
return mask_and_remap(self.facets[self.relation_columns], self.nodes.index)
|
|
436
|
+
|
|
437
|
+
def query_nodes(self, query_str: str):
|
|
438
|
+
"""Query the nodes DataFrame and return a new layer with the
|
|
439
|
+
corresponding nodes and facets.
|
|
440
|
+
|
|
441
|
+
Parameters
|
|
442
|
+
----------
|
|
443
|
+
query_str :
|
|
444
|
+
A query string to pass to `pd.DataFrame.query` on the nodes DataFrame.
|
|
445
|
+
|
|
446
|
+
Returns
|
|
447
|
+
-------
|
|
448
|
+
:
|
|
449
|
+
A new layer with the queried nodes and corresponding facets.
|
|
450
|
+
|
|
451
|
+
Notes
|
|
452
|
+
-----
|
|
453
|
+
When masking by nodes, only relationships that reference exclusively the
|
|
454
|
+
remaining nodes are kept.
|
|
455
|
+
"""
|
|
456
|
+
new_nodes = self.nodes.query(query_str)
|
|
457
|
+
new_index = new_nodes.index
|
|
458
|
+
return self.mask_by_node_index(new_index, new_nodes=new_nodes)
|
|
459
|
+
|
|
460
|
+
def mask_nodes(self, mask: np.ndarray):
|
|
461
|
+
"""Mask the nodes DataFrame and return a new layer with the
|
|
462
|
+
corresponding nodes and facets.
|
|
463
|
+
|
|
464
|
+
Parameters
|
|
465
|
+
----------
|
|
466
|
+
mask :
|
|
467
|
+
A boolean mask array to filter the nodes DataFrame. This masking is applied
|
|
468
|
+
in positional indexing (i.e. order, not key matters).
|
|
469
|
+
|
|
470
|
+
Returns
|
|
471
|
+
-------
|
|
472
|
+
:
|
|
473
|
+
A new layer with the masked nodes and corresponding facets.
|
|
474
|
+
|
|
475
|
+
Notes
|
|
476
|
+
-----
|
|
477
|
+
When masking by nodes, only relationships that reference exclusively the
|
|
478
|
+
remaining nodes are kept.
|
|
479
|
+
"""
|
|
480
|
+
new_nodes = self.nodes.iloc[mask]
|
|
481
|
+
new_index = new_nodes.index
|
|
482
|
+
return self.mask_by_node_index(new_index, new_nodes=new_nodes)
|
|
483
|
+
|
|
484
|
+
def mask_by_node_index(
|
|
485
|
+
self,
|
|
486
|
+
new_index: Union[np.ndarray, pd.Index, pd.Series],
|
|
487
|
+
new_nodes: Optional[pd.DataFrame] = None,
|
|
488
|
+
):
|
|
489
|
+
"""Create a new layer containing only the specified nodes and their facets.
|
|
490
|
+
|
|
491
|
+
Parameters
|
|
492
|
+
----------
|
|
493
|
+
new_index :
|
|
494
|
+
Index of nodes to keep in the new layer.
|
|
495
|
+
new_nodes :
|
|
496
|
+
Pre-filtered nodes DataFrame. If None, nodes will be filtered automatically
|
|
497
|
+
based on new_index.
|
|
498
|
+
|
|
499
|
+
Returns
|
|
500
|
+
-------
|
|
501
|
+
:
|
|
502
|
+
A new layer instance containing only the specified nodes and facets that
|
|
503
|
+
reference those nodes.
|
|
504
|
+
|
|
505
|
+
Notes
|
|
506
|
+
-----
|
|
507
|
+
Only facets that reference exclusively the nodes in new_index are kept.
|
|
508
|
+
"""
|
|
509
|
+
if new_nodes is None:
|
|
510
|
+
new_nodes = self.nodes.loc[self.nodes.index.intersection(new_index)]
|
|
511
|
+
|
|
512
|
+
new_facets = self.facets[
|
|
513
|
+
self.facets[self.relation_columns].isin(new_index).all(axis=1)
|
|
514
|
+
]
|
|
515
|
+
out = self.__class__((new_nodes, new_facets), **self.get_params())
|
|
516
|
+
return out
|
|
517
|
+
|
|
518
|
+
@property
|
|
519
|
+
def layer_type(self) -> str:
|
|
520
|
+
"""String identifier of the layer type (e.g., 'mesh', 'points', 'graph')."""
|
|
521
|
+
return str(self.__class__).strip(">'").split(".")[-1].lower()
|
|
522
|
+
|
|
523
|
+
def get_params(self) -> dict:
|
|
524
|
+
"""Get the parameters used to initialize this layer.
|
|
525
|
+
|
|
526
|
+
Returns
|
|
527
|
+
-------
|
|
528
|
+
:
|
|
529
|
+
Dictionary containing layer initialization parameters.
|
|
530
|
+
"""
|
|
531
|
+
return {
|
|
532
|
+
"spatial_columns": self.spatial_columns,
|
|
533
|
+
"relation_columns": self.relation_columns,
|
|
534
|
+
}
|
|
@@ -5,7 +5,7 @@ from typing import Any, Optional, Union
|
|
|
5
5
|
import numpy as np
|
|
6
6
|
import pandas as pd
|
|
7
7
|
|
|
8
|
-
from .base import Layer
|
|
8
|
+
from .base import Layer, canonicalize_ids
|
|
9
9
|
from .graph import Graph
|
|
10
10
|
from .mapping import project_points_to_nearest
|
|
11
11
|
from .mesh import Mesh
|
|
@@ -349,6 +349,12 @@ class MorphSync:
|
|
|
349
349
|
mapping_df.name = target
|
|
350
350
|
mapping_df = mapping_df.to_frame().reset_index()
|
|
351
351
|
|
|
352
|
+
# Canonicalize both endpoint columns to int64 so a link never stores
|
|
353
|
+
# one side as uint64 and the other as int64. A mixed-dtype link is what
|
|
354
|
+
# lets a later join coerce keys through float and mismatch IDs > 2**53.
|
|
355
|
+
mapping_df[source] = canonicalize_ids(mapping_df[source], name=source)
|
|
356
|
+
mapping_df[target] = canonicalize_ids(mapping_df[target], name=target)
|
|
357
|
+
|
|
352
358
|
self.links[(source, target)] = mapping_df
|
|
353
359
|
self.links[(target, source)] = mapping_df
|
|
354
360
|
|
|
@@ -443,9 +449,11 @@ class MorphSync:
|
|
|
443
449
|
"""
|
|
444
450
|
if source_index is None:
|
|
445
451
|
source_index = self.layers[source].nodes_index
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
452
|
+
# Canonicalize the source key space to int64 up front so the first
|
|
453
|
+
# join key matches the (also int64) link index exactly.
|
|
454
|
+
source_index = canonicalize_ids(source_index, name=source)
|
|
455
|
+
if not isinstance(source_index, pd.Index):
|
|
456
|
+
source_index = pd.Index(source_index)
|
|
449
457
|
|
|
450
458
|
joined_mapping = pd.DataFrame(index=source_index)
|
|
451
459
|
joined_mapping[source] = source_index
|
|
@@ -453,10 +461,34 @@ class MorphSync:
|
|
|
453
461
|
path = self.get_link_path(source, target)
|
|
454
462
|
for i in range(len(path) - 1):
|
|
455
463
|
current_source, current_target = path[i], path[i + 1]
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
464
|
+
link_df = self.links[(current_source, current_target)]
|
|
465
|
+
|
|
466
|
+
# Both key spaces participating in the join must be canonicalized
|
|
467
|
+
# to int64 *before pandas sees them*. A stored link may still carry
|
|
468
|
+
# legacy mixed int64/uint64 dtypes (e.g. from an older .osy file);
|
|
469
|
+
# joining a uint64 index against an int64/Int64 key column makes
|
|
470
|
+
# pandas coerce to float64 and silently collapse IDs above 2**53.
|
|
471
|
+
key_index = canonicalize_ids(
|
|
472
|
+
pd.Index(link_df[current_source]), name=current_source
|
|
473
|
+
)
|
|
474
|
+
# A stored link endpoint is always a real, non-null identifier, so
|
|
475
|
+
# the target values are canonicalized strictly (a null here is
|
|
476
|
+
# malformed and raises). They are then held as nullable Int64 so
|
|
477
|
+
# that the <NA>s the left join *introduces* for unmatched rows do
|
|
478
|
+
# not force a float cast -- a float target ID would lose precision
|
|
479
|
+
# before it becomes the next hop's join key.
|
|
480
|
+
values = canonicalize_ids(link_df[current_target], name=current_target)
|
|
481
|
+
mapping_series = pd.Series(
|
|
482
|
+
pd.array(values, dtype="Int64"),
|
|
483
|
+
index=key_index,
|
|
484
|
+
name=current_target,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# The join key column of joined_mapping may itself carry <NA> from a
|
|
488
|
+
# previous hop; canonicalize it (nulls allowed) so it stays in the
|
|
489
|
+
# int64 key space alongside key_index.
|
|
490
|
+
joined_mapping[current_source] = canonicalize_ids(
|
|
491
|
+
joined_mapping[current_source], allow_null=True, name=current_source
|
|
460
492
|
)
|
|
461
493
|
|
|
462
494
|
try:
|
|
@@ -693,10 +725,40 @@ class MorphSync:
|
|
|
693
725
|
new_morphology._add_layer(
|
|
694
726
|
other_layer_name, other_layer.mask_by_node_index(other_indices)
|
|
695
727
|
)
|
|
696
|
-
|
|
728
|
+
|
|
729
|
+
# Rebuild links restricted to the retained nodes of each layer rather
|
|
730
|
+
# than copying the original (unpruned) tables. Carrying whole link
|
|
731
|
+
# tables over would leave endpoints that reference nodes no longer in
|
|
732
|
+
# their layer; pruning here makes "retained links reference retained
|
|
733
|
+
# objects" true by construction, not by a later runtime check.
|
|
734
|
+
new_morphology.links = self._prune_links(new_morphology)
|
|
697
735
|
|
|
698
736
|
return new_morphology
|
|
699
737
|
|
|
738
|
+
def _prune_links(self, new_morphology: "MorphSync") -> dict:
|
|
739
|
+
"""Restrict every link table to endpoints present in the masked layers.
|
|
740
|
+
|
|
741
|
+
Both endpoint columns and both layer indexes are canonicalized to int64
|
|
742
|
+
before the membership test so the ``isin`` lookup cannot mismatch a
|
|
743
|
+
uint64 key against an int64 index (the same float-collision hazard the
|
|
744
|
+
mapping joins avoid).
|
|
745
|
+
"""
|
|
746
|
+
pruned: dict = {}
|
|
747
|
+
for (src, tgt), link_df in self.links.items():
|
|
748
|
+
if src not in new_morphology.layers or tgt not in new_morphology.layers:
|
|
749
|
+
continue
|
|
750
|
+
src_keys = pd.Index(canonicalize_ids(link_df[src], name=src))
|
|
751
|
+
tgt_keys = pd.Index(canonicalize_ids(link_df[tgt], name=tgt))
|
|
752
|
+
src_retained = canonicalize_ids(
|
|
753
|
+
new_morphology.layers[src].nodes_index, name=src
|
|
754
|
+
)
|
|
755
|
+
tgt_retained = canonicalize_ids(
|
|
756
|
+
new_morphology.layers[tgt].nodes_index, name=tgt
|
|
757
|
+
)
|
|
758
|
+
keep = src_keys.isin(src_retained) & tgt_keys.isin(tgt_retained)
|
|
759
|
+
pruned[(src, tgt)] = link_df[np.asarray(keep)]
|
|
760
|
+
return pruned
|
|
761
|
+
|
|
700
762
|
def get_link_as_layer(self, source: str, target: str) -> Graph:
|
|
701
763
|
"""Create a Graph layer representing the mapping between two layers.
|
|
702
764
|
|
|
@@ -24,6 +24,7 @@ from scipy import sparse, spatial
|
|
|
24
24
|
|
|
25
25
|
from . import graph_functions as gf
|
|
26
26
|
from . import utils
|
|
27
|
+
from ._sync.base import canonicalize_ids
|
|
27
28
|
from .sync_classes import *
|
|
28
29
|
|
|
29
30
|
if TYPE_CHECKING:
|
|
@@ -1025,6 +1026,12 @@ class PointMixin(ABC):
|
|
|
1025
1026
|
if len(mask) == self.n_vertices and np.issubdtype(mask.dtype, np.bool_):
|
|
1026
1027
|
mask = mask.astype(bool)
|
|
1027
1028
|
else:
|
|
1029
|
+
# `mask` is a set of vertex identifiers. Canonicalize it to int64
|
|
1030
|
+
# so the membership test shares the (also int64) vertex_index key
|
|
1031
|
+
# space -- np.isin over mixed int64/uint64 arrays otherwise picks
|
|
1032
|
+
# float64 as the common type and collides IDs above 2**53.
|
|
1033
|
+
if mask.dtype.kind in ("i", "u"):
|
|
1034
|
+
mask = canonicalize_ids(mask, name=self.layer_name)
|
|
1028
1035
|
mask = np.isin(self.vertex_index, mask)
|
|
1029
1036
|
return self._morphsync.apply_mask(
|
|
1030
1037
|
layer_name=self.layer_name,
|
|
@@ -16,6 +16,7 @@ from scipy.sparse import load_npz, save_npz
|
|
|
16
16
|
|
|
17
17
|
from ossify import utils
|
|
18
18
|
|
|
19
|
+
from ._sync.base import canonicalize_ids
|
|
19
20
|
from .base import Cell
|
|
20
21
|
from .data_layers import GraphLayer, Link, MeshLayer, PointCloudLayer, SkeletonLayer
|
|
21
22
|
|
|
@@ -912,6 +913,68 @@ def build_point_cloud(
|
|
|
912
913
|
return cell
|
|
913
914
|
|
|
914
915
|
|
|
916
|
+
def _exact_integer_id_coverage(link_ids: pd.Index, source_ids: pd.Index) -> bool:
|
|
917
|
+
"""Test whether two integer-ID domains are exactly the same set.
|
|
918
|
+
|
|
919
|
+
Both arguments must already be canonicalized ``int64`` pandas ``Index``
|
|
920
|
+
objects. The comparison is order-independent and stays entirely in the
|
|
921
|
+
integer domain -- it never coerces identifiers through ``float64`` -- so it
|
|
922
|
+
is safe for IDs above ``2**53``.
|
|
923
|
+
|
|
924
|
+
Parameters
|
|
925
|
+
----------
|
|
926
|
+
link_ids :
|
|
927
|
+
The linkage column for a candidate source layer.
|
|
928
|
+
source_ids :
|
|
929
|
+
That layer's vertex index.
|
|
930
|
+
|
|
931
|
+
Returns
|
|
932
|
+
-------
|
|
933
|
+
:
|
|
934
|
+
``True`` iff the two contain exactly the same set of IDs.
|
|
935
|
+
"""
|
|
936
|
+
if len(link_ids) != len(source_ids):
|
|
937
|
+
return False
|
|
938
|
+
# ``symmetric_difference`` is empty iff neither side has an ID the other
|
|
939
|
+
# lacks. Using pandas ``Index`` set ops keeps the comparison in int64 and
|
|
940
|
+
# avoids materializing Python ``set`` objects over large ID arrays.
|
|
941
|
+
return len(link_ids.symmetric_difference(source_ids)) == 0
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _linkage_source_error(linkage_pair, link_df, cell, sample_size: int = 10) -> str:
|
|
945
|
+
"""Build a concise diagnostic message for an unresolvable linkage source.
|
|
946
|
+
|
|
947
|
+
Reports, for each endpoint, the layer vertex count, the linkage row count,
|
|
948
|
+
the number of unique linkage IDs, and the missing/extra ID counts, plus a
|
|
949
|
+
small bounded sample of the offending IDs. This replaces the enormous
|
|
950
|
+
pandas ``KeyError`` that a bad ``.loc`` reindex would otherwise raise.
|
|
951
|
+
"""
|
|
952
|
+
lines = [
|
|
953
|
+
f"Could not infer a valid source layer for linkage "
|
|
954
|
+
f"({linkage_pair[0]!r}, {linkage_pair[1]!r}) with {len(link_df)} rows.",
|
|
955
|
+
"A source endpoint must map exactly one linkage row to each of its "
|
|
956
|
+
"vertices (unique, complete coverage). Neither endpoint qualifies:",
|
|
957
|
+
]
|
|
958
|
+
for source in linkage_pair:
|
|
959
|
+
source_ids = canonicalize_ids(
|
|
960
|
+
pd.Index(cell._all_objects[source].vertex_index), name=source
|
|
961
|
+
)
|
|
962
|
+
link_ids = canonicalize_ids(pd.Index(link_df[source]), name=source)
|
|
963
|
+
unique_link_ids = link_ids.unique()
|
|
964
|
+
missing = source_ids.difference(link_ids)
|
|
965
|
+
extra = link_ids.difference(source_ids)
|
|
966
|
+
lines.append(
|
|
967
|
+
f" - {source!r}: vertices={len(source_ids)}, "
|
|
968
|
+
f"link_rows={len(link_ids)}, unique_link_ids={len(unique_link_ids)}, "
|
|
969
|
+
f"missing_source_ids={len(missing)}, extra_link_ids={len(extra)}"
|
|
970
|
+
)
|
|
971
|
+
if len(missing):
|
|
972
|
+
lines.append(f" missing sample: {missing[:sample_size].tolist()}")
|
|
973
|
+
if len(extra):
|
|
974
|
+
lines.append(f" extra sample: {extra[:sample_size].tolist()}")
|
|
975
|
+
return "\n".join(lines)
|
|
976
|
+
|
|
977
|
+
|
|
915
978
|
def build_linkage(
|
|
916
979
|
linkage_pair,
|
|
917
980
|
tf,
|
|
@@ -919,17 +982,52 @@ def build_linkage(
|
|
|
919
982
|
) -> Cell:
|
|
920
983
|
prefix = f"linkage/{linkage_pair[0]}/{linkage_pair[1]}"
|
|
921
984
|
link_df = load_dataframe(f"{prefix}/linkage.feather", tf)
|
|
922
|
-
#
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
985
|
+
# Legacy .osy files may store link columns with mixed int64/uint64 dtypes
|
|
986
|
+
# (dtype optimization only downcast signed ints, leaving uint64 untouched).
|
|
987
|
+
# Canonicalize both columns to int64 before any label-based reindex below so
|
|
988
|
+
# that lookup cannot collide two IDs above 2**53 through a float coercion of
|
|
989
|
+
# mismatched signed/unsigned keys.
|
|
990
|
+
for col in linkage_pair:
|
|
991
|
+
if col in link_df.columns:
|
|
992
|
+
link_df[col] = canonicalize_ids(link_df[col], name=col)
|
|
993
|
+
|
|
994
|
+
# The archive sorts the pair names, so serialized column order does not
|
|
995
|
+
# preserve the original source direction. Infer the source by exact
|
|
996
|
+
# source-domain validation rather than row count alone: a layer qualifies
|
|
997
|
+
# as source only when its linkage column has no missing IDs, is unique, has
|
|
998
|
+
# one row per vertex, and covers exactly that layer's vertex index. Row
|
|
999
|
+
# count alone is ambiguous whenever the two layers have equal cardinality
|
|
1000
|
+
# (e.g. a many-to-one graph <-> annotation link where the counts coincide).
|
|
1001
|
+
candidates = []
|
|
1002
|
+
for source, target in (
|
|
1003
|
+
(linkage_pair[0], linkage_pair[1]),
|
|
1004
|
+
(linkage_pair[1], linkage_pair[0]),
|
|
1005
|
+
):
|
|
1006
|
+
source_ids = canonicalize_ids(
|
|
1007
|
+
pd.Index(cell._all_objects[source].vertex_index), name=source
|
|
1008
|
+
)
|
|
1009
|
+
link_ids = canonicalize_ids(pd.Index(link_df[source]), name=source)
|
|
1010
|
+
if (
|
|
1011
|
+
len(link_ids) == len(source_ids)
|
|
1012
|
+
and link_ids.is_unique
|
|
1013
|
+
and _exact_integer_id_coverage(link_ids, source_ids)
|
|
1014
|
+
):
|
|
1015
|
+
candidates.append((source, target))
|
|
1016
|
+
|
|
1017
|
+
if not candidates:
|
|
1018
|
+
raise ValueError(_linkage_source_error(linkage_pair, link_df, cell))
|
|
1019
|
+
|
|
1020
|
+
# Zero candidates raises above. One candidate is the unambiguous source.
|
|
1021
|
+
# Two candidates means a true bijection: both columns are unique and cover
|
|
1022
|
+
# their layers exactly, so the mapping is one-to-one. MorphSync stores every
|
|
1023
|
+
# link in both directions (see ``add_link``), so either choice yields the
|
|
1024
|
+
# same bidirectional link; pick the first deterministically.
|
|
1025
|
+
source_layer, target_layer = candidates[0]
|
|
931
1026
|
|
|
932
1027
|
layer = cell._all_objects[source_layer]
|
|
1028
|
+
# Validation above guarantees the source column is unique and covers exactly
|
|
1029
|
+
# the source vertex index, so this reindex is a total, collision-free
|
|
1030
|
+
# reordering -- it can no longer raise a large KeyError.
|
|
933
1031
|
cell._all_objects[source_layer]._process_linkage(
|
|
934
1032
|
Link(
|
|
935
1033
|
link_df.set_index(source_layer).loc[layer.vertex_index][target_layer],
|
|
@@ -1,280 +0,0 @@
|
|
|
1
|
-
from typing import Optional, Union
|
|
2
|
-
|
|
3
|
-
import fastremap
|
|
4
|
-
import numpy as np
|
|
5
|
-
import pandas as pd
|
|
6
|
-
|
|
7
|
-
DEFAULT_SPATIAL_COLUMNS = ["x", "y", "z"]
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def mask_and_remap(
|
|
11
|
-
arr: np.ndarray,
|
|
12
|
-
mask: Union[np.ndarray, list],
|
|
13
|
-
) -> np.ndarray:
|
|
14
|
-
"""Given an array in unmasked indexing and a mask,
|
|
15
|
-
return the array in remapped indexing and omit rows with masked values.
|
|
16
|
-
|
|
17
|
-
Parameters
|
|
18
|
-
----------
|
|
19
|
-
arr :
|
|
20
|
-
NxM array of indices
|
|
21
|
-
mask :
|
|
22
|
-
1D array of indices to mask, either as a boolean mask or as a list of indices
|
|
23
|
-
"""
|
|
24
|
-
if np.array(mask).dtype == bool:
|
|
25
|
-
mask = np.where(mask)[0]
|
|
26
|
-
return _mask_and_remap(np.array(arr, dtype=int), mask)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _mask_and_remap(
|
|
30
|
-
arr: np.ndarray,
|
|
31
|
-
mask: np.ndarray,
|
|
32
|
-
) -> np.ndarray:
|
|
33
|
-
mask_dict = {k: v for k, v in zip(mask, range(len(mask)))}
|
|
34
|
-
mask_dict[-1] = -1
|
|
35
|
-
|
|
36
|
-
arr_offset = arr + 1
|
|
37
|
-
arr_mask_full = fastremap.remap(
|
|
38
|
-
fastremap.mask_except(arr_offset, list(mask + 1)) - 1,
|
|
39
|
-
mask_dict,
|
|
40
|
-
)
|
|
41
|
-
if len(arr_mask_full.shape) == 1:
|
|
42
|
-
return arr_mask_full[~np.any(arr_mask_full == -1)]
|
|
43
|
-
else:
|
|
44
|
-
return arr_mask_full[~np.any(arr_mask_full == -1, axis=1)]
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
class Layer:
|
|
48
|
-
def __init__(
|
|
49
|
-
self,
|
|
50
|
-
nodes: Union[pd.DataFrame, np.ndarray],
|
|
51
|
-
facets: Union[pd.DataFrame, np.ndarray],
|
|
52
|
-
spatial_columns: Optional[list] = None,
|
|
53
|
-
relation_columns: Optional[list] = None,
|
|
54
|
-
copy: bool = True,
|
|
55
|
-
**kwargs,
|
|
56
|
-
):
|
|
57
|
-
"""A class for representing a set of nodes and their relationships (facets).
|
|
58
|
-
|
|
59
|
-
Parameters
|
|
60
|
-
----------
|
|
61
|
-
nodes :
|
|
62
|
-
A DataFrame or nx3 array of the nodes/vertices/points in the layer.
|
|
63
|
-
If an array is provided, it must be nx3 and the columns will be named
|
|
64
|
-
"x", "y", and "z" unless `spatial_columns` is provided.
|
|
65
|
-
facets :
|
|
66
|
-
A DataFrame or m x k array of the facets/edges/faces in the layer.
|
|
67
|
-
Some columns must correspond to indices in `nodes`, these are specified
|
|
68
|
-
by `relation_columns`.
|
|
69
|
-
spatial_columns :
|
|
70
|
-
A list of column names in `nodes` that correspond to spatial coordinates.
|
|
71
|
-
If not provided and `nodes` is a DataFrame, all columns will be used. If
|
|
72
|
-
`nodes` is an array, defaults to ["x", "y", "z"].
|
|
73
|
-
relation_columns :
|
|
74
|
-
A list of column names in `facets` that correspond to indices in `nodes`.
|
|
75
|
-
If not provided and `facets` is a DataFrame, all columns will be used.
|
|
76
|
-
copy :
|
|
77
|
-
Whether to copy the input DataFrames. If False, the input DataFrames
|
|
78
|
-
may be modified in place.
|
|
79
|
-
"""
|
|
80
|
-
if not isinstance(nodes, pd.DataFrame):
|
|
81
|
-
if isinstance(nodes, np.ndarray):
|
|
82
|
-
if nodes.shape[1] == 3:
|
|
83
|
-
nodes = pd.DataFrame(nodes, columns=DEFAULT_SPATIAL_COLUMNS)
|
|
84
|
-
if spatial_columns is None:
|
|
85
|
-
spatial_columns = DEFAULT_SPATIAL_COLUMNS
|
|
86
|
-
else:
|
|
87
|
-
raise ValueError("Nodes must be an nx3 array")
|
|
88
|
-
else:
|
|
89
|
-
raise ValueError("Nodes must be a DataFrame or an nx3 array")
|
|
90
|
-
|
|
91
|
-
if copy:
|
|
92
|
-
nodes = nodes.copy()
|
|
93
|
-
self.nodes: pd.DataFrame = nodes
|
|
94
|
-
|
|
95
|
-
if spatial_columns is None:
|
|
96
|
-
spatial_columns = []
|
|
97
|
-
self.spatial_columns = spatial_columns
|
|
98
|
-
|
|
99
|
-
if facets is None:
|
|
100
|
-
facets = pd.DataFrame()
|
|
101
|
-
if not isinstance(facets, pd.DataFrame):
|
|
102
|
-
facets = pd.DataFrame(facets)
|
|
103
|
-
if relation_columns is None:
|
|
104
|
-
relation_columns = facets.columns.tolist()
|
|
105
|
-
if copy:
|
|
106
|
-
facets = facets.copy()
|
|
107
|
-
self.facets: pd.DataFrame = facets
|
|
108
|
-
|
|
109
|
-
if relation_columns is None:
|
|
110
|
-
relation_columns = []
|
|
111
|
-
self.relation_columns = relation_columns
|
|
112
|
-
|
|
113
|
-
@property
|
|
114
|
-
def vertices(self) -> np.ndarray:
|
|
115
|
-
"""Array of the spatial coordinates of the vertices"""
|
|
116
|
-
return self.vertices_df.to_numpy(dtype=float)
|
|
117
|
-
|
|
118
|
-
@property
|
|
119
|
-
def vertices_df(self) -> pd.DataFrame:
|
|
120
|
-
"""DataFrame of the spatial coordinates of the vertices"""
|
|
121
|
-
return (
|
|
122
|
-
self.nodes[self.spatial_columns]
|
|
123
|
-
if self.spatial_columns is not None
|
|
124
|
-
else self.nodes
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
@property
|
|
128
|
-
def points(self) -> np.ndarray:
|
|
129
|
-
"""Alias for vertices"""
|
|
130
|
-
return self.vertices
|
|
131
|
-
|
|
132
|
-
@property
|
|
133
|
-
def n_nodes(self) -> int:
|
|
134
|
-
"""Number of nodes in the layer."""
|
|
135
|
-
return len(self.nodes)
|
|
136
|
-
|
|
137
|
-
@property
|
|
138
|
-
def n_vertices(self) -> int:
|
|
139
|
-
"""Number of vertices in the layer. Alias for n_nodes."""
|
|
140
|
-
return self.n_nodes
|
|
141
|
-
|
|
142
|
-
@property
|
|
143
|
-
def n_points(self) -> int:
|
|
144
|
-
"""Number of points in the layer. Alias for n_nodes."""
|
|
145
|
-
return self.n_nodes
|
|
146
|
-
|
|
147
|
-
@property
|
|
148
|
-
def n_facets(self) -> int:
|
|
149
|
-
"""Number of facets (edges/faces) in the layer."""
|
|
150
|
-
return len(self.facets)
|
|
151
|
-
|
|
152
|
-
@property
|
|
153
|
-
def nodes_index(self) -> pd.Index:
|
|
154
|
-
"""Index of the nodes DataFrame."""
|
|
155
|
-
return self.nodes.index
|
|
156
|
-
|
|
157
|
-
@property
|
|
158
|
-
def vertices_index(self) -> pd.Index:
|
|
159
|
-
"""Index of the vertices. Alias for nodes_index."""
|
|
160
|
-
return self.nodes_index
|
|
161
|
-
|
|
162
|
-
@property
|
|
163
|
-
def points_index(self) -> pd.Index:
|
|
164
|
-
"""Index of the points. Alias for nodes_index."""
|
|
165
|
-
return self.nodes_index
|
|
166
|
-
|
|
167
|
-
@property
|
|
168
|
-
def facets_index(self) -> pd.Index:
|
|
169
|
-
"""Index of the facets DataFrame."""
|
|
170
|
-
return self.facets.index
|
|
171
|
-
|
|
172
|
-
@property
|
|
173
|
-
def edge_index(self) -> pd.Index:
|
|
174
|
-
"""Index of the edges. Alias for facets_index."""
|
|
175
|
-
return self.facets_index
|
|
176
|
-
|
|
177
|
-
@property
|
|
178
|
-
def facets_positional(self) -> np.ndarray:
|
|
179
|
-
"""Array of the facets in positional indexing, such that 0 corresponds to the
|
|
180
|
-
first node in its current node index ordering"""
|
|
181
|
-
return mask_and_remap(self.facets[self.relation_columns], self.nodes.index)
|
|
182
|
-
|
|
183
|
-
def query_nodes(self, query_str: str):
|
|
184
|
-
"""Query the nodes DataFrame and return a new layer with the
|
|
185
|
-
corresponding nodes and facets.
|
|
186
|
-
|
|
187
|
-
Parameters
|
|
188
|
-
----------
|
|
189
|
-
query_str :
|
|
190
|
-
A query string to pass to `pd.DataFrame.query` on the nodes DataFrame.
|
|
191
|
-
|
|
192
|
-
Returns
|
|
193
|
-
-------
|
|
194
|
-
:
|
|
195
|
-
A new layer with the queried nodes and corresponding facets.
|
|
196
|
-
|
|
197
|
-
Notes
|
|
198
|
-
-----
|
|
199
|
-
When masking by nodes, only relationships that reference exclusively the
|
|
200
|
-
remaining nodes are kept.
|
|
201
|
-
"""
|
|
202
|
-
new_nodes = self.nodes.query(query_str)
|
|
203
|
-
new_index = new_nodes.index
|
|
204
|
-
return self.mask_by_node_index(new_index, new_nodes=new_nodes)
|
|
205
|
-
|
|
206
|
-
def mask_nodes(self, mask: np.ndarray):
|
|
207
|
-
"""Mask the nodes DataFrame and return a new layer with the
|
|
208
|
-
corresponding nodes and facets.
|
|
209
|
-
|
|
210
|
-
Parameters
|
|
211
|
-
----------
|
|
212
|
-
mask :
|
|
213
|
-
A boolean mask array to filter the nodes DataFrame. This masking is applied
|
|
214
|
-
in positional indexing (i.e. order, not key matters).
|
|
215
|
-
|
|
216
|
-
Returns
|
|
217
|
-
-------
|
|
218
|
-
:
|
|
219
|
-
A new layer with the masked nodes and corresponding facets.
|
|
220
|
-
|
|
221
|
-
Notes
|
|
222
|
-
-----
|
|
223
|
-
When masking by nodes, only relationships that reference exclusively the
|
|
224
|
-
remaining nodes are kept.
|
|
225
|
-
"""
|
|
226
|
-
new_nodes = self.nodes.iloc[mask]
|
|
227
|
-
new_index = new_nodes.index
|
|
228
|
-
return self.mask_by_node_index(new_index, new_nodes=new_nodes)
|
|
229
|
-
|
|
230
|
-
def mask_by_node_index(
|
|
231
|
-
self,
|
|
232
|
-
new_index: Union[np.ndarray, pd.Index, pd.Series],
|
|
233
|
-
new_nodes: Optional[pd.DataFrame] = None,
|
|
234
|
-
):
|
|
235
|
-
"""Create a new layer containing only the specified nodes and their facets.
|
|
236
|
-
|
|
237
|
-
Parameters
|
|
238
|
-
----------
|
|
239
|
-
new_index :
|
|
240
|
-
Index of nodes to keep in the new layer.
|
|
241
|
-
new_nodes :
|
|
242
|
-
Pre-filtered nodes DataFrame. If None, nodes will be filtered automatically
|
|
243
|
-
based on new_index.
|
|
244
|
-
|
|
245
|
-
Returns
|
|
246
|
-
-------
|
|
247
|
-
:
|
|
248
|
-
A new layer instance containing only the specified nodes and facets that
|
|
249
|
-
reference those nodes.
|
|
250
|
-
|
|
251
|
-
Notes
|
|
252
|
-
-----
|
|
253
|
-
Only facets that reference exclusively the nodes in new_index are kept.
|
|
254
|
-
"""
|
|
255
|
-
if new_nodes is None:
|
|
256
|
-
new_nodes = self.nodes.loc[self.nodes.index.intersection(new_index)]
|
|
257
|
-
|
|
258
|
-
new_facets = self.facets[
|
|
259
|
-
self.facets[self.relation_columns].isin(new_index).all(axis=1)
|
|
260
|
-
]
|
|
261
|
-
out = self.__class__((new_nodes, new_facets), **self.get_params())
|
|
262
|
-
return out
|
|
263
|
-
|
|
264
|
-
@property
|
|
265
|
-
def layer_type(self) -> str:
|
|
266
|
-
"""String identifier of the layer type (e.g., 'mesh', 'points', 'graph')."""
|
|
267
|
-
return str(self.__class__).strip(">'").split(".")[-1].lower()
|
|
268
|
-
|
|
269
|
-
def get_params(self) -> dict:
|
|
270
|
-
"""Get the parameters used to initialize this layer.
|
|
271
|
-
|
|
272
|
-
Returns
|
|
273
|
-
-------
|
|
274
|
-
:
|
|
275
|
-
Dictionary containing layer initialization parameters.
|
|
276
|
-
"""
|
|
277
|
-
return {
|
|
278
|
-
"spatial_columns": self.spatial_columns,
|
|
279
|
-
"relation_columns": self.relation_columns,
|
|
280
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|