ossify 0.2.5__tar.gz → 0.2.7__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.5 → ossify-0.2.7}/PKG-INFO +2 -2
- {ossify-0.2.5 → ossify-0.2.7}/pyproject.toml +2 -2
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/__init__.py +1 -1
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/algorithms.py +9 -3
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/data_layers.py +103 -42
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/file_io.py +15 -3
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/graph_functions.py +16 -5
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/plot.py +71 -10
- {ossify-0.2.5 → ossify-0.2.7}/.github/workflows/mkdocs_publish.yml +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/.github/workflows/python-package.yml +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/.gitignore +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/.pre-commit-config.yaml +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/LICENSE +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/README.md +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/mkdocs.yml +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/__init__.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/base.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/graph.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/mapping.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/mesh.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/morph.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/points.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/table.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/base.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/compartment_models/minnie65_ds15_us0_bd0.json +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/compartment_models/v1dd_ds15_us0_bd0.json +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/compartment_models/xgb/minnie65_ds15_us0_bd0.ubj +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/compartment_models/xgb/v1dd_ds15_us0_bd0.ubj +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/compartments.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/plot3d.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/plot_utils.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/structured_prediction.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/sync_classes.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/src/ossify/translate.py +0 -0
- {ossify-0.2.5 → ossify-0.2.7}/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.7"
|
|
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.7"
|
|
99
99
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
100
100
|
serialize = ["{major}.{minor}.{patch}"]
|
|
101
101
|
regex = false
|
|
@@ -547,11 +547,17 @@ def _label_axon_synapse_flow(
|
|
|
547
547
|
"""feature an axon compartment by synapse betweenness. All parameters are as positional indices."""
|
|
548
548
|
syn_btw = synapse_betweenness(skeleton, pre_syn_inds, post_syn_inds)
|
|
549
549
|
high_vinds = np.flatnonzero(syn_btw == max(syn_btw))
|
|
550
|
-
|
|
550
|
+
# Everything here is positional, so every lookup must say so: on a masked
|
|
551
|
+
# skeleton (the ntimes > 1 path runs inside a mask_context) vertex indices
|
|
552
|
+
# and positional indices are different spaces.
|
|
553
|
+
close_vind = high_vinds[
|
|
554
|
+
np.argmin(skeleton.distance_to_root(high_vinds, as_positional=True))
|
|
555
|
+
]
|
|
551
556
|
if extend_feature_to_segment:
|
|
552
557
|
relseg = skeleton.segment_map[close_vind]
|
|
553
|
-
|
|
554
|
-
|
|
558
|
+
seg = skeleton.segments_positional[relseg]
|
|
559
|
+
min_ind = np.argmin(skeleton.distance_to_root(seg, as_positional=True))
|
|
560
|
+
axon_split_ind = seg[min_ind]
|
|
555
561
|
else:
|
|
556
562
|
axon_split_ind = close_vind
|
|
557
563
|
downstream_inds = skeleton.downstream_vertices(
|
|
@@ -459,18 +459,26 @@ class PointMixin(ABC):
|
|
|
459
459
|
as_positional : bool
|
|
460
460
|
Whether input vertices are positional indices (True) or vertex indices (False).
|
|
461
461
|
vertex_index : Optional[np.ndarray], optional
|
|
462
|
-
|
|
462
|
+
The index space the result is expressed in: positions are returned
|
|
463
|
+
relative to this array. If None, uses self.vertex_index. Passing a
|
|
464
|
+
different array (e.g. ``base_vertex_index``) maps this layer's
|
|
465
|
+
vertices into that other space.
|
|
463
466
|
|
|
464
467
|
Returns
|
|
465
468
|
-------
|
|
466
469
|
Tuple[np.ndarray, bool]
|
|
467
470
|
Tuple of (positional_indices, is_positional_flag).
|
|
468
471
|
"""
|
|
469
|
-
if vertex_index is None:
|
|
470
|
-
vertex_index = self.vertex_index
|
|
471
472
|
if vertices is None:
|
|
472
|
-
|
|
473
|
-
|
|
473
|
+
if vertex_index is None:
|
|
474
|
+
vertices = np.arange(len(self.vertex_index))
|
|
475
|
+
as_positional = True
|
|
476
|
+
else:
|
|
477
|
+
# "All of my vertices", but expressed in the caller's target
|
|
478
|
+
# index space -- going via vertex ids is the only way to cross
|
|
479
|
+
# between spaces, since positions do not correspond.
|
|
480
|
+
vertices = self.vertex_index
|
|
481
|
+
as_positional = False
|
|
474
482
|
else:
|
|
475
483
|
vertices = np.asarray(vertices)
|
|
476
484
|
if np.issubdtype(vertices.dtype, np.bool_):
|
|
@@ -480,11 +488,15 @@ class PointMixin(ABC):
|
|
|
480
488
|
)
|
|
481
489
|
vertices = np.flatnonzero(vertices)
|
|
482
490
|
as_positional = True
|
|
491
|
+
elif vertices.size == 0:
|
|
492
|
+
# An empty list arrives as float64, and fastremap passes the
|
|
493
|
+
# dtype straight through, leaving a result that is not a legal
|
|
494
|
+
# index array. Give it an integer dtype up front.
|
|
495
|
+
vertices = vertices.astype(np.intp)
|
|
496
|
+
if vertex_index is None:
|
|
497
|
+
vertex_index = self.vertex_index
|
|
483
498
|
if not as_positional:
|
|
484
|
-
|
|
485
|
-
vertex_index_map = self.vertex_index_map
|
|
486
|
-
else:
|
|
487
|
-
vertex_index_map = {v: i for i, v in enumerate(vertex_index)}
|
|
499
|
+
vertex_index_map = {v: i for i, v in enumerate(vertex_index)}
|
|
488
500
|
# fastremap.remap rejects 0-d arrays, so a scalar vertex would crash
|
|
489
501
|
# here. Remap on a 1-d view and restore the caller's shape, keeping
|
|
490
502
|
# scalar-in -> scalar-out (as the positional path already does).
|
|
@@ -2164,6 +2176,56 @@ class SkeletonLayer(GraphLayer):
|
|
|
2164
2176
|
"""
|
|
2165
2177
|
return np.asarray(self._base_properties["base_parent_array"])
|
|
2166
2178
|
|
|
2179
|
+
def _vertices_to_base_positional(
|
|
2180
|
+
self, vertices: Optional[np.ndarray], as_positional: bool
|
|
2181
|
+
) -> np.ndarray:
|
|
2182
|
+
"""Map a vertex selection on this (possibly masked) layer to positional
|
|
2183
|
+
indices into the **base**, unmasked arrays.
|
|
2184
|
+
|
|
2185
|
+
``base_csgraph``/``base_csgraph_binary`` span the original unmasked
|
|
2186
|
+
skeleton, so anything used to index their dijkstra results must be in
|
|
2187
|
+
base positional space -- not this layer's. The two only coincide when
|
|
2188
|
+
unmasked. Conversion always goes via vertex ids, the one index space
|
|
2189
|
+
the masked layer and the base snapshot share.
|
|
2190
|
+
|
|
2191
|
+
Parameters
|
|
2192
|
+
----------
|
|
2193
|
+
vertices : Optional[np.ndarray]
|
|
2194
|
+
Vertex indices, positional indices, or a boolean mask over this
|
|
2195
|
+
layer's vertices. If None, every vertex of *this* layer is used, in
|
|
2196
|
+
this layer's positional order, so the result lines up 1:1 with
|
|
2197
|
+
``self.vertices``.
|
|
2198
|
+
as_positional : bool
|
|
2199
|
+
Whether ``vertices`` are positional indices into this layer. A
|
|
2200
|
+
boolean mask is implicitly positional whatever this says.
|
|
2201
|
+
|
|
2202
|
+
Returns
|
|
2203
|
+
-------
|
|
2204
|
+
np.ndarray
|
|
2205
|
+
Positional indices into the base (unmasked) arrays. Scalar in,
|
|
2206
|
+
scalar out.
|
|
2207
|
+
"""
|
|
2208
|
+
if vertices is None:
|
|
2209
|
+
vertices = self.vertex_index
|
|
2210
|
+
else:
|
|
2211
|
+
vertices = np.asarray(vertices)
|
|
2212
|
+
if np.issubdtype(vertices.dtype, np.bool_):
|
|
2213
|
+
if len(vertices) != self.n_vertices:
|
|
2214
|
+
raise ValueError(
|
|
2215
|
+
"If vertices is a boolean array, it must have the same length as the number of vertices."
|
|
2216
|
+
)
|
|
2217
|
+
vertices = self.vertex_index[np.flatnonzero(vertices)]
|
|
2218
|
+
else:
|
|
2219
|
+
if vertices.size == 0:
|
|
2220
|
+
# An empty list is float64, which cannot index an array.
|
|
2221
|
+
vertices = vertices.astype(np.intp)
|
|
2222
|
+
if as_positional:
|
|
2223
|
+
vertices = self.vertex_index[vertices]
|
|
2224
|
+
base_positional, _ = self._vertices_to_positional(
|
|
2225
|
+
vertices, as_positional=False, vertex_index=self.base_vertex_index
|
|
2226
|
+
)
|
|
2227
|
+
return base_positional
|
|
2228
|
+
|
|
2167
2229
|
def _reset_derived_properties(self) -> None:
|
|
2168
2230
|
super()._reset_derived_properties()
|
|
2169
2231
|
self._dag_cache = gf.DAGCache()
|
|
@@ -2366,35 +2428,30 @@ class SkeletonLayer(GraphLayer):
|
|
|
2366
2428
|
Parameters
|
|
2367
2429
|
----------
|
|
2368
2430
|
vertices : Optional[np.ndarray]
|
|
2369
|
-
The vertices to get the distance from the root for. If None,
|
|
2431
|
+
The vertices to get the distance from the root for. If None, every
|
|
2432
|
+
vertex of this layer is used, in this layer's positional order, so
|
|
2433
|
+
the result lines up 1:1 with ``self.vertices``.
|
|
2370
2434
|
as_positional : bool
|
|
2371
2435
|
If True, the vertices are treated as positional indices. If False, they are treated as vertex features.
|
|
2372
2436
|
|
|
2373
2437
|
Returns
|
|
2374
2438
|
-------
|
|
2375
2439
|
np.ndarray
|
|
2376
|
-
The distance from the root for each vertex.
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
vertices, as_positional, vertex_index=self.base_vertex_index
|
|
2387
|
-
)
|
|
2388
|
-
if self._dag_cache.distance_to_root is not None:
|
|
2389
|
-
dtr = self._dag_cache.distance_to_root
|
|
2390
|
-
else:
|
|
2391
|
-
dtr = sparse.csgraph.dijkstra(
|
|
2440
|
+
The distance from the root for each vertex. One value per requested
|
|
2441
|
+
vertex of *this* layer, so on a masked skeleton ``distance_to_root()``
|
|
2442
|
+
always equals ``distance_to_root(np.arange(n), as_positional=True)``
|
|
2443
|
+
and ``distance_to_root(self.vertex_index)``.
|
|
2444
|
+
"""
|
|
2445
|
+
# The dijkstra runs on the base graph, so the selection must be in base
|
|
2446
|
+
# positional space -- which is not this layer's space when masked.
|
|
2447
|
+
base_positional = self._vertices_to_base_positional(vertices, as_positional)
|
|
2448
|
+
if self._dag_cache.base_distance_to_root is None:
|
|
2449
|
+
self._dag_cache.base_distance_to_root = sparse.csgraph.dijkstra(
|
|
2392
2450
|
self.base_csgraph,
|
|
2393
2451
|
directed=False,
|
|
2394
2452
|
indices=self.base_root_positional,
|
|
2395
|
-
)
|
|
2396
|
-
|
|
2397
|
-
return dtr[vertices]
|
|
2453
|
+
).flatten()
|
|
2454
|
+
return self._dag_cache.base_distance_to_root[base_positional]
|
|
2398
2455
|
|
|
2399
2456
|
def hops_to_root(
|
|
2400
2457
|
self,
|
|
@@ -2406,26 +2463,29 @@ class SkeletonLayer(GraphLayer):
|
|
|
2406
2463
|
Parameters
|
|
2407
2464
|
----------
|
|
2408
2465
|
vertices : Optional[np.ndarray]
|
|
2409
|
-
The vertices to get the distance from the root for. If None,
|
|
2466
|
+
The vertices to get the distance from the root for. If None, every
|
|
2467
|
+
vertex of this layer is used, in this layer's positional order, so
|
|
2468
|
+
the result lines up 1:1 with ``self.vertices``.
|
|
2410
2469
|
as_positional : bool
|
|
2411
2470
|
If True, the vertices are treated as positional indices. If False, they are treated as vertex features.
|
|
2412
2471
|
|
|
2413
2472
|
Returns
|
|
2414
2473
|
-------
|
|
2415
2474
|
np.ndarray
|
|
2416
|
-
The
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2475
|
+
The hop count from the root for each vertex. One value per requested
|
|
2476
|
+
vertex of *this* layer, so on a masked skeleton ``hops_to_root()``
|
|
2477
|
+
always equals ``hops_to_root(np.arange(n), as_positional=True)`` and
|
|
2478
|
+
``hops_to_root(self.vertex_index)``.
|
|
2479
|
+
"""
|
|
2480
|
+
# Same base-space contract as distance_to_root; see that method.
|
|
2481
|
+
base_positional = self._vertices_to_base_positional(vertices, as_positional)
|
|
2482
|
+
if self._dag_cache.base_hops_to_root is None:
|
|
2483
|
+
self._dag_cache.base_hops_to_root = sparse.csgraph.dijkstra(
|
|
2423
2484
|
self.base_csgraph_binary,
|
|
2424
2485
|
directed=False,
|
|
2425
2486
|
indices=self.base_root_positional,
|
|
2426
|
-
)
|
|
2427
|
-
|
|
2428
|
-
return htr[vertices]
|
|
2487
|
+
).flatten()
|
|
2488
|
+
return self._dag_cache.base_hops_to_root[base_positional]
|
|
2429
2489
|
|
|
2430
2490
|
def child_vertices(self, vertices=None, as_positional=False) -> dict:
|
|
2431
2491
|
"""Get mapping from vertices to their child nodes.
|
|
@@ -3327,8 +3387,9 @@ class PointCloudLayer(PointMixin):
|
|
|
3327
3387
|
if self._cell.skeleton is None:
|
|
3328
3388
|
raise ValueError("Cell does not have a Skeleton object.")
|
|
3329
3389
|
|
|
3330
|
-
|
|
3331
|
-
|
|
3390
|
+
# Do not pre-fill ``vertices`` here: it would hand vertex ids to a call
|
|
3391
|
+
# that may be reading them as positional indices. ``map_index_to_layer``
|
|
3392
|
+
# already resolves None correctly for *both* index spaces.
|
|
3332
3393
|
skel_idx = self.map_index_to_layer(
|
|
3333
3394
|
layer=SKEL_LAYER_NAME, source_index=vertices, as_positional=as_positional
|
|
3334
3395
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import io
|
|
2
2
|
import os
|
|
3
3
|
import tarfile
|
|
4
|
+
import warnings
|
|
4
5
|
from enum import IntEnum
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import TYPE_CHECKING, BinaryIO, Optional, Union
|
|
@@ -464,7 +465,16 @@ def load_dataframe(tinfo, tf) -> pd.DataFrame:
|
|
|
464
465
|
Loaded DataFrame
|
|
465
466
|
"""
|
|
466
467
|
df_buf = pa.BufferReader(tf.extractfile(tinfo).read())
|
|
467
|
-
|
|
468
|
+
with warnings.catch_warnings():
|
|
469
|
+
# pandas' read_feather wraps pyarrow.feather.read_feather, which pyarrow
|
|
470
|
+
# >=24 deprecated in favor of pyarrow.ipc.open_file; this is purely an
|
|
471
|
+
# upstream implementation detail pandas hasn't addressed yet.
|
|
472
|
+
warnings.filterwarnings(
|
|
473
|
+
"ignore",
|
|
474
|
+
message=r"pyarrow\.feather\.read_feather is deprecated",
|
|
475
|
+
category=FutureWarning,
|
|
476
|
+
)
|
|
477
|
+
return pd.read_feather(df_buf)
|
|
468
478
|
|
|
469
479
|
|
|
470
480
|
def load_array(tinfo, tf) -> np.ndarray:
|
|
@@ -1067,13 +1077,15 @@ def import_legacy_meshwork(
|
|
|
1067
1077
|
|
|
1068
1078
|
def _process_pcg_skel_import(cell: Cell) -> Cell:
|
|
1069
1079
|
if "compartment" in cell.annotations:
|
|
1080
|
+
# ``map_annotations_to_feature`` already returns a frame whose column is
|
|
1081
|
+
# named "compartment", so passing ``name`` here is redundant -- and
|
|
1082
|
+
# ``add_feature`` warns that it ignores ``name`` for a DataFrame.
|
|
1070
1083
|
cell.skeleton.add_feature(
|
|
1071
1084
|
cell.skeleton.map_annotations_to_feature(
|
|
1072
1085
|
"compartment",
|
|
1073
1086
|
distance_threshold=0,
|
|
1074
1087
|
agg={"compartment": ("compartment", "mean")},
|
|
1075
|
-
)
|
|
1076
|
-
"compartment",
|
|
1088
|
+
)
|
|
1077
1089
|
)
|
|
1078
1090
|
if "segment_properties" in cell.annotations:
|
|
1079
1091
|
features = cell.skeleton.map_annotations_to_feature(
|
|
@@ -13,7 +13,14 @@ from .utils import build_csgraph, single_path_length
|
|
|
13
13
|
class DAGCache:
|
|
14
14
|
"""
|
|
15
15
|
Container for cached DAG properties to optimize repeated computations.
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
Cache values are in positional indices of the layer that owns the cache,
|
|
18
|
+
**except** the two ``base_``-prefixed fields, which are in positional
|
|
19
|
+
indices of the original unmasked skeleton (``base_csgraph`` /
|
|
20
|
+
``base_csgraph_binary``). On an unmasked layer the two spaces coincide; on
|
|
21
|
+
a masked one they do not, so never index a ``base_`` array with a
|
|
22
|
+
layer-positional index. Use
|
|
23
|
+
``SkeletonLayer._vertices_to_base_positional`` to convert.
|
|
17
24
|
|
|
18
25
|
Attributes
|
|
19
26
|
----------
|
|
@@ -37,9 +44,11 @@ class DAGCache:
|
|
|
37
44
|
branch_points: Optional[np.ndarray] = None
|
|
38
45
|
end_points: Optional[np.ndarray] = None
|
|
39
46
|
segments: Optional[List[List[int]]] = None
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
segment_map: Optional[np.ndarray] = None
|
|
48
|
+
base_distance_to_root: Optional[np.ndarray] = None
|
|
49
|
+
base_hops_to_root: Optional[np.ndarray] = None
|
|
42
50
|
cover_paths: Optional[List[List[int]]] = None
|
|
51
|
+
path_lengths: Optional[Dict[Tuple[int, int], float]] = None
|
|
43
52
|
root: Optional[int] = None
|
|
44
53
|
|
|
45
54
|
def __post_init__(self):
|
|
@@ -55,10 +64,12 @@ class DAGCache:
|
|
|
55
64
|
self.branch_points = None
|
|
56
65
|
self.end_points = None
|
|
57
66
|
self.segments = None
|
|
67
|
+
self.segment_map = None
|
|
58
68
|
self.root = None
|
|
59
|
-
self.
|
|
60
|
-
self.
|
|
69
|
+
self.base_distance_to_root = None
|
|
70
|
+
self.base_hops_to_root = None
|
|
61
71
|
self.cover_paths = None
|
|
72
|
+
self.path_lengths = None
|
|
62
73
|
|
|
63
74
|
|
|
64
75
|
def build_parent_node_array(vertices, edges) -> np.ndarray:
|
|
@@ -968,7 +968,7 @@ def plot_points(
|
|
|
968
968
|
# which dominates small synapse markers).
|
|
969
969
|
if "linewidths" not in scatter_kws:
|
|
970
970
|
scatter_kws["linewidths"] = 0
|
|
971
|
-
if isinstance(palette, str):
|
|
971
|
+
if isinstance(palette, str) and colors is not None and not isinstance(colors, str):
|
|
972
972
|
scatter_kws["cmap"] = palette
|
|
973
973
|
if color_norm is not None:
|
|
974
974
|
scatter_kws["vmin"], scatter_kws["vmax"] = color_norm
|
|
@@ -1514,6 +1514,53 @@ def plot_cell_multiview(
|
|
|
1514
1514
|
return axes
|
|
1515
1515
|
|
|
1516
1516
|
|
|
1517
|
+
_MIN_PANEL_INCHES = 0.5
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
def _pad_degenerate_axes(
|
|
1521
|
+
bounds_min: np.ndarray,
|
|
1522
|
+
bounds_max: np.ndarray,
|
|
1523
|
+
fraction: float = 0.02,
|
|
1524
|
+
) -> Tuple[np.ndarray, np.ndarray, List[int]]:
|
|
1525
|
+
"""Expand any zero-extent axis so a flat cell is still renderable.
|
|
1526
|
+
|
|
1527
|
+
A cell that is perfectly flat along one axis -- a planar reconstruction, a
|
|
1528
|
+
2D-derived skeleton stored with z=0, or a synthetic test skeleton -- has a
|
|
1529
|
+
zero extent there. Panel sizes are derived from the extents, so a zero
|
|
1530
|
+
extent yields a zero-size panel and an empty figure, and matplotlib warns
|
|
1531
|
+
when asked to set identical axis limits. Padding the degenerate axis to a
|
|
1532
|
+
small fraction of the largest extent keeps the panel a thin strip, which is
|
|
1533
|
+
an honest depiction of flat data.
|
|
1534
|
+
|
|
1535
|
+
Parameters
|
|
1536
|
+
----------
|
|
1537
|
+
bounds_min, bounds_max : np.ndarray
|
|
1538
|
+
Per-axis data bounds.
|
|
1539
|
+
fraction : float
|
|
1540
|
+
Width to give a degenerate axis, as a fraction of the largest
|
|
1541
|
+
non-degenerate extent. If every axis is degenerate (a single point),
|
|
1542
|
+
one unit is used.
|
|
1543
|
+
|
|
1544
|
+
Returns
|
|
1545
|
+
-------
|
|
1546
|
+
Tuple[np.ndarray, np.ndarray, List[int]]
|
|
1547
|
+
Padded ``(bounds_min, bounds_max)`` and the indices of the axes padded.
|
|
1548
|
+
"""
|
|
1549
|
+
bounds_min = np.array(bounds_min, dtype=float)
|
|
1550
|
+
bounds_max = np.array(bounds_max, dtype=float)
|
|
1551
|
+
extents = bounds_max - bounds_min
|
|
1552
|
+
degenerate = np.flatnonzero(extents <= 0)
|
|
1553
|
+
if len(degenerate) == 0:
|
|
1554
|
+
return bounds_min, bounds_max, []
|
|
1555
|
+
largest = float(extents.max())
|
|
1556
|
+
pad = fraction * largest if largest > 0 else 1.0
|
|
1557
|
+
for i in degenerate:
|
|
1558
|
+
center = (bounds_min[i] + bounds_max[i]) / 2
|
|
1559
|
+
bounds_min[i] = center - pad / 2
|
|
1560
|
+
bounds_max[i] = center + pad / 2
|
|
1561
|
+
return bounds_min, bounds_max, [int(i) for i in degenerate]
|
|
1562
|
+
|
|
1563
|
+
|
|
1517
1564
|
def single_panel_figure(
|
|
1518
1565
|
data_bounds_min: np.ndarray,
|
|
1519
1566
|
data_bounds_max: np.ndarray,
|
|
@@ -1548,8 +1595,12 @@ def single_panel_figure(
|
|
|
1548
1595
|
>>> fig, ax = create_single_panel_figure(bounds_min, bounds_max, 10)
|
|
1549
1596
|
>>> # Creates 10" x 5" figure with 10 units per inch
|
|
1550
1597
|
"""
|
|
1551
|
-
|
|
1552
|
-
|
|
1598
|
+
# Pad a flat axis before deriving sizes, so the panel is not zero-sized and
|
|
1599
|
+
# matplotlib is never asked for identical limits. The clamp below already
|
|
1600
|
+
# reports degenerate input, so this does not warn separately.
|
|
1601
|
+
data_bounds_min, data_bounds_max, _ = _pad_degenerate_axes(
|
|
1602
|
+
data_bounds_min, data_bounds_max
|
|
1603
|
+
)
|
|
1553
1604
|
|
|
1554
1605
|
# Calculate data extents
|
|
1555
1606
|
data_width = data_bounds_max[0] - data_bounds_min[0]
|
|
@@ -1562,7 +1613,7 @@ def single_panel_figure(
|
|
|
1562
1613
|
# Clamp degenerate dimensions so the figure is still renderable and
|
|
1563
1614
|
# compatible with peers in a lineup or panel. We warn — the data is
|
|
1564
1615
|
# degenerate, and the caller likely wants to know.
|
|
1565
|
-
_MIN_INCHES =
|
|
1616
|
+
_MIN_INCHES = _MIN_PANEL_INCHES
|
|
1566
1617
|
if fig_width < _MIN_INCHES or fig_height < _MIN_INCHES:
|
|
1567
1618
|
warnings.warn(
|
|
1568
1619
|
f"single_panel_figure received degenerate bounds "
|
|
@@ -1641,18 +1692,28 @@ def multi_panel_figure(
|
|
|
1641
1692
|
>>> fig, axes_dict = create_multi_panel_figure(bounds_min, bounds_max, 10, "side_by_side")
|
|
1642
1693
|
>>> xy_ax, zy_ax = axes_dict["xy"], axes_dict["zy"]
|
|
1643
1694
|
"""
|
|
1644
|
-
data_bounds_min =
|
|
1645
|
-
|
|
1695
|
+
data_bounds_min, data_bounds_max, degenerate = _pad_degenerate_axes(
|
|
1696
|
+
data_bounds_min, data_bounds_max
|
|
1697
|
+
)
|
|
1698
|
+
if degenerate:
|
|
1699
|
+
axis_names = ", ".join("xyz"[i] for i in degenerate)
|
|
1700
|
+
warnings.warn(
|
|
1701
|
+
f"multi_panel_figure received a degenerate {axis_names} extent "
|
|
1702
|
+
f"(0 units); padding it so the panel remains visible. The cell is "
|
|
1703
|
+
f"flat along {'these axes' if len(degenerate) > 1 else 'this axis'}.",
|
|
1704
|
+
stacklevel=2,
|
|
1705
|
+
)
|
|
1646
1706
|
|
|
1647
1707
|
# Calculate data extents for each dimension
|
|
1648
1708
|
x_extent = data_bounds_max[0] - data_bounds_min[0]
|
|
1649
1709
|
y_extent = data_bounds_max[1] - data_bounds_min[1]
|
|
1650
1710
|
z_extent = data_bounds_max[2] - data_bounds_min[2]
|
|
1651
1711
|
|
|
1652
|
-
# Convert to sizes in inches
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1712
|
+
# Convert to sizes in inches. Clamp as single_panel_figure does, so a panel
|
|
1713
|
+
# of a thin or flat cell is still large enough to see.
|
|
1714
|
+
x_inches = max(x_extent / units_per_inch, _MIN_PANEL_INCHES)
|
|
1715
|
+
y_inches = max(y_extent / units_per_inch, _MIN_PANEL_INCHES)
|
|
1716
|
+
z_inches = max(z_extent / units_per_inch, _MIN_PANEL_INCHES)
|
|
1656
1717
|
|
|
1657
1718
|
if layout == "side_by_side":
|
|
1658
1719
|
# xy | zy layout
|
|
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
|