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.
Files changed (35) hide show
  1. {ossify-0.2.5 → ossify-0.2.7}/PKG-INFO +2 -2
  2. {ossify-0.2.5 → ossify-0.2.7}/pyproject.toml +2 -2
  3. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/__init__.py +1 -1
  4. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/algorithms.py +9 -3
  5. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/data_layers.py +103 -42
  6. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/file_io.py +15 -3
  7. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/graph_functions.py +16 -5
  8. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/plot.py +71 -10
  9. {ossify-0.2.5 → ossify-0.2.7}/.github/workflows/mkdocs_publish.yml +0 -0
  10. {ossify-0.2.5 → ossify-0.2.7}/.github/workflows/python-package.yml +0 -0
  11. {ossify-0.2.5 → ossify-0.2.7}/.gitignore +0 -0
  12. {ossify-0.2.5 → ossify-0.2.7}/.pre-commit-config.yaml +0 -0
  13. {ossify-0.2.5 → ossify-0.2.7}/LICENSE +0 -0
  14. {ossify-0.2.5 → ossify-0.2.7}/README.md +0 -0
  15. {ossify-0.2.5 → ossify-0.2.7}/mkdocs.yml +0 -0
  16. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/__init__.py +0 -0
  17. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/base.py +0 -0
  18. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/graph.py +0 -0
  19. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/mapping.py +0 -0
  20. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/mesh.py +0 -0
  21. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/morph.py +0 -0
  22. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/points.py +0 -0
  23. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/_sync/table.py +0 -0
  24. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/base.py +0 -0
  25. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/compartment_models/minnie65_ds15_us0_bd0.json +0 -0
  26. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/compartment_models/v1dd_ds15_us0_bd0.json +0 -0
  27. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/compartment_models/xgb/minnie65_ds15_us0_bd0.ubj +0 -0
  28. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/compartment_models/xgb/v1dd_ds15_us0_bd0.ubj +0 -0
  29. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/compartments.py +0 -0
  30. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/plot3d.py +0 -0
  31. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/plot_utils.py +0 -0
  32. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/structured_prediction.py +0 -0
  33. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/sync_classes.py +0 -0
  34. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/translate.py +0 -0
  35. {ossify-0.2.5 → ossify-0.2.7}/src/ossify/utils.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: ossify
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: Mesh and skeleton analysis
5
5
  Author-email: Casey Schneider-Mizell <caseysm@gmail.com>
6
6
  License-File: LICENSE
@@ -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.5"
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.5"
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
@@ -5,7 +5,7 @@ from .base import *
5
5
  from .file_io import *
6
6
  from .translate import *
7
7
 
8
- __version__ = "0.2.5"
8
+ __version__ = "0.2.7"
9
9
 
10
10
 
11
11
  def __getattr__(name):
@@ -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
- close_vind = high_vinds[np.argmin(skeleton.distance_to_root(high_vinds))]
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
- min_ind = np.argmin(skeleton.distance_to_root(skeleton.segments[relseg]))
554
- axon_split_ind = skeleton.segments[relseg][min_ind]
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
- Custom vertex index array to use for mapping. If None, uses self.vertex_index.
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
- vertices = np.arange(len(self.vertex_index))
473
- as_positional = True
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
- if vertex_index is None:
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, all vertices are used.
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
- # Vertices must be positional but in the base space for the dijkstra
2379
- if as_positional:
2380
- if vertices is None:
2381
- vertices = self.vertex_index
2382
- else:
2383
- vertices = self.vertex_index[vertices]
2384
- as_positional = False
2385
- vertices, as_positional = self._vertices_to_positional(
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
- self._dag_cache.distance_to_root = dtr.flatten()
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, all vertices are used.
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 distance from the root for each vertex.
2417
- """
2418
- vertices, _ = self._vertices_to_positional(vertices, as_positional)
2419
- if self._dag_cache.hops_to_root is not None:
2420
- htr = self._dag_cache.hops_to_root
2421
- else:
2422
- htr = sparse.csgraph.dijkstra(
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
- self._dag_cache.hops_to_root = htr
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
- if vertices is None:
3331
- vertices = self.vertex_index
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
- return pd.read_feather(df_buf)
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
- Cache values are always in positional indices.
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
- distance_to_root: Optional[np.ndarray] = None
41
- hops_to_root: Optional[np.ndarray] = None
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.distance_to_root = None
60
- self.hops_to_root = None
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
- data_bounds_min = np.asarray(data_bounds_min)
1552
- data_bounds_max = np.asarray(data_bounds_max)
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 = 0.5
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 = np.asarray(data_bounds_min)
1645
- data_bounds_max = np.asarray(data_bounds_max)
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
- x_inches = x_extent / units_per_inch
1654
- y_inches = y_extent / units_per_inch
1655
- z_inches = z_extent / units_per_inch
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