ossify 0.2.6__tar.gz → 0.2.8__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.6 → ossify-0.2.8}/PKG-INFO +2 -2
  2. {ossify-0.2.6 → ossify-0.2.8}/pyproject.toml +2 -2
  3. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/__init__.py +1 -1
  4. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/algorithms.py +9 -3
  5. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/base.py +54 -7
  6. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/data_layers.py +223 -94
  7. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/file_io.py +4 -2
  8. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/graph_functions.py +16 -5
  9. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/plot.py +95 -10
  10. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/translate.py +5 -8
  11. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/utils.py +4 -1
  12. {ossify-0.2.6 → ossify-0.2.8}/.github/workflows/mkdocs_publish.yml +0 -0
  13. {ossify-0.2.6 → ossify-0.2.8}/.github/workflows/python-package.yml +0 -0
  14. {ossify-0.2.6 → ossify-0.2.8}/.gitignore +0 -0
  15. {ossify-0.2.6 → ossify-0.2.8}/.pre-commit-config.yaml +0 -0
  16. {ossify-0.2.6 → ossify-0.2.8}/LICENSE +0 -0
  17. {ossify-0.2.6 → ossify-0.2.8}/README.md +0 -0
  18. {ossify-0.2.6 → ossify-0.2.8}/mkdocs.yml +0 -0
  19. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/_sync/__init__.py +0 -0
  20. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/_sync/base.py +0 -0
  21. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/_sync/graph.py +0 -0
  22. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/_sync/mapping.py +0 -0
  23. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/_sync/mesh.py +0 -0
  24. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/_sync/morph.py +0 -0
  25. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/_sync/points.py +0 -0
  26. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/_sync/table.py +0 -0
  27. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/compartment_models/minnie65_ds15_us0_bd0.json +0 -0
  28. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/compartment_models/v1dd_ds15_us0_bd0.json +0 -0
  29. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/compartment_models/xgb/minnie65_ds15_us0_bd0.ubj +0 -0
  30. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/compartment_models/xgb/v1dd_ds15_us0_bd0.ubj +0 -0
  31. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/compartments.py +0 -0
  32. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/plot3d.py +0 -0
  33. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/plot_utils.py +0 -0
  34. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/structured_prediction.py +0 -0
  35. {ossify-0.2.6 → ossify-0.2.8}/src/ossify/sync_classes.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.6
3
+ Version: 0.2.8
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.6"
9
+ version = "0.2.8"
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.6"
98
+ current_version = "0.2.8"
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.6"
8
+ __version__ = "0.2.8"
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(
@@ -398,6 +398,7 @@ class Cell:
398
398
  features: Optional[Union[dict, pd.DataFrame]] = None,
399
399
  *,
400
400
  vertex_index: Optional[Union[str, np.ndarray]] = None,
401
+ faces_as_positional: Optional[bool] = None,
401
402
  linkage: Optional[Link] = None,
402
403
  spatial_columns: Optional[list] = None,
403
404
  ) -> Self:
@@ -408,11 +409,25 @@ class Cell:
408
409
  vertices : Union[np.ndarray, pd.DataFrame, MeshLayer]
409
410
  The vertices of the mesh, or a MeshLayer object.
410
411
  faces : Union[np.ndarray, pd.DataFrame]
411
- The faces of the mesh. If faces are provided as a dataframe, faces should be in dataframe indices.
412
+ The triangular faces of the mesh. **If ``vertex_index`` is given
413
+ these are positional indices into ``vertices``** and are remapped
414
+ onto the vertex index for you; otherwise they are vertex indices
415
+ already -- the index of ``vertices`` when it is a DataFrame, or
416
+ positional when it is a plain array. Mesh formats such as OBJ and
417
+ PLY give positional faces, so ``vertex_index`` is usually what you
418
+ want when labelling mesh vertices with ids.
412
419
  features : Optional[Union[dict, pd.DataFrame]]
413
420
  Additional features for the mesh. If passed as dictionary, the key is the feature name and the values are an array of feature values.
414
421
  vertex_index : Optional[Union[str, np.ndarray]]
415
- The column to use as a vertex index for the mesh, if vertices are a dataframe.
422
+ Vertex indices to label the mesh's vertices by: either the name of
423
+ a column in ``vertices``, or an array with one value per vertex.
424
+ Supplying this changes how ``faces`` are read -- see above.
425
+ faces_as_positional : Optional[bool]
426
+ Whether ``faces`` are positional indices into ``vertices``. The
427
+ default, None, infers it as described above: positional exactly
428
+ when ``vertex_index`` is supplied. Pass it explicitly when the two
429
+ spaces overlap -- a ``vertex_index`` whose values are also valid
430
+ positions cannot be told apart any other way.
416
431
  linkage : Optional[Link]
417
432
  The linkage information for the mesh.
418
433
  spatial_columns: Optional[list] = None
@@ -435,6 +450,7 @@ class Cell:
435
450
  name=self.MESH_LN,
436
451
  vertices=vertices,
437
452
  faces=faces,
453
+ faces_as_positional=faces_as_positional,
438
454
  features=features,
439
455
  morphsync=self._morphsync,
440
456
  spatial_columns=spatial_columns,
@@ -453,6 +469,7 @@ class Cell:
453
469
  root: Optional[int] = None,
454
470
  *,
455
471
  vertex_index: Optional[Union[str, np.ndarray]] = None,
472
+ edges_as_positional: Optional[bool] = None,
456
473
  linkage: Optional[Link] = None,
457
474
  spatial_columns: Optional[list] = None,
458
475
  inherited_properties: Optional[dict] = None,
@@ -465,13 +482,28 @@ class Cell:
465
482
  vertices : Union[np.ndarray, pd.DataFrame, SkeletonLayer]
466
483
  The vertices of the skeleton, or a SkeletonLayer object.
467
484
  edges : Union[np.ndarray, pd.DataFrame]
468
- The edges of the skeleton.
485
+ The edges of the skeleton, as pairs of vertices. **If ``vertex_index``
486
+ is given these are positional indices into ``vertices``** and are
487
+ remapped onto the vertex index for you; otherwise they are vertex
488
+ indices already -- the index of ``vertices`` when it is a DataFrame,
489
+ or positional when it is a plain array.
469
490
  features : Optional[Union[dict, pd.DataFrame]]
470
491
  The features for the skeleton.
471
492
  root : Optional[int]
472
- The root vertex for the skeleton, required of the edges are not already consistent with a single root.
493
+ The root of the skeleton, as a **vertex index** -- note this is the
494
+ opposite convention to ``edges``, which are positional when
495
+ ``vertex_index`` is given. Required if the edges are not already
496
+ consistent with a single root.
473
497
  vertex_index : Optional[Union[str, np.ndarray]]
474
- The vertex index for the skeleton.
498
+ Vertex indices to label the skeleton's vertices by: either the name of
499
+ a column in ``vertices``, or an array with one value per vertex.
500
+ Supplying this changes how ``edges`` are read -- see above.
501
+ edges_as_positional : Optional[bool]
502
+ Whether ``edges`` are positional indices into ``vertices``. The
503
+ default, None, infers it as described above: positional exactly
504
+ when ``vertex_index`` is supplied. Pass it explicitly when the two
505
+ spaces overlap -- a ``vertex_index`` whose values are also valid
506
+ positions cannot be told apart any other way.
475
507
  linkage : Optional[Link]
476
508
  The linkage information for the skeleton. Typically, you will define the source vertices for the skeleton if using a graph-to-skeleton mapping.
477
509
  spatial_columns: Optional[list] = None
@@ -495,6 +527,7 @@ class Cell:
495
527
  name=self.SKEL_LN,
496
528
  vertices=vertices,
497
529
  edges=edges,
530
+ edges_as_positional=edges_as_positional,
498
531
  features=features,
499
532
  root=root,
500
533
  morphsync=self._morphsync,
@@ -514,6 +547,7 @@ class Cell:
514
547
  features: Optional[Union[dict, pd.DataFrame]] = None,
515
548
  *,
516
549
  vertex_index: Optional[Union[str, np.ndarray]] = None,
550
+ edges_as_positional: Optional[bool] = None,
517
551
  spatial_columns: Optional[list] = None,
518
552
  linkage: Optional[Link] = None,
519
553
  ) -> Self:
@@ -526,11 +560,23 @@ class Cell:
526
560
  vertices : Union[np.ndarray, pd.DataFrame, SkeletonLayer]
527
561
  The vertices of the graph.
528
562
  edges : Union[np.ndarray, pd.DataFrame]
529
- The edges of the graph.
563
+ The edges of the graph, as pairs of vertices. **If ``vertex_index``
564
+ is given these are positional indices into ``vertices``** and are
565
+ remapped onto the vertex index for you; otherwise they are vertex
566
+ indices already -- the index of ``vertices`` when it is a DataFrame,
567
+ or positional when it is a plain array.
530
568
  features : Optional[Union[dict, pd.DataFrame]]
531
569
  The features for the graph.
532
570
  vertex_index : Optional[Union[str, np.ndarray]]
533
- The vertex index for the graph.
571
+ Vertex indices to label the graph's vertices by: either the name of
572
+ a column in ``vertices``, or an array with one value per vertex.
573
+ Supplying this changes how ``edges`` are read -- see above.
574
+ edges_as_positional : Optional[bool]
575
+ Whether ``edges`` are positional indices into ``vertices``. The
576
+ default, None, infers it as described above: positional exactly
577
+ when ``vertex_index`` is supplied. Pass it explicitly when the two
578
+ spaces overlap -- a ``vertex_index`` whose values are also valid
579
+ positions cannot be told apart any other way.
534
580
  spatial_columns: Optional[list] = None
535
581
  The spatial columns for the graph, if vertices are a dataframe.
536
582
 
@@ -552,6 +598,7 @@ class Cell:
552
598
  name=self.GRAPH_LN,
553
599
  vertices=vertices,
554
600
  edges=edges,
601
+ edges_as_positional=edges_as_positional,
555
602
  features=features,
556
603
  morphsync=self._morphsync,
557
604
  spatial_columns=spatial_columns,
@@ -89,26 +89,6 @@ class EdgeMixin(ABC):
89
89
  return np.empty((0, 2), dtype=int)
90
90
  return self.layer.edges_positional
91
91
 
92
- def _map_edges_to_index(
93
- self, edges: np.ndarray, vertex_indices: np.ndarray
94
- ) -> np.ndarray:
95
- """Remap positional edges to vertex indices.
96
-
97
- Parameters
98
- ----------
99
- edges : np.ndarray
100
- Edge array with positional indices.
101
- vertex_indices : np.ndarray
102
- Array of vertex indices to map to.
103
-
104
- Returns
105
- -------
106
- np.ndarray
107
- Edge array with vertex indices instead of positional indices.
108
- """
109
- index_map = {ii: v for ii, v in enumerate(vertex_indices)}
110
- return fastremap.remap(edges, index_map)
111
-
112
92
  @property
113
93
  def csgraph(self) -> sparse.csr_matrix:
114
94
  """
@@ -318,26 +298,6 @@ class FaceMixin(ABC):
318
298
  self._csgraph = None
319
299
  self._trimesh = None
320
300
 
321
- def _map_faces_to_index(
322
- self, faces: np.ndarray, vertex_indices: np.ndarray
323
- ) -> np.ndarray:
324
- """Remap positional faces to vertex indices.
325
-
326
- Parameters
327
- ----------
328
- faces : np.ndarray
329
- Face array with positional indices.
330
- vertex_indices : np.ndarray
331
- Array of vertex indices to map to.
332
-
333
- Returns
334
- -------
335
- np.ndarray
336
- Face array with vertex indices instead of positional indices.
337
- """
338
- index_map = {ii: v for ii, v in enumerate(vertex_indices)}
339
- return fastremap.remap(faces, index_map)
340
-
341
301
  def surface_area(
342
302
  self,
343
303
  vertices: Optional[np.ndarray] = None,
@@ -444,6 +404,75 @@ class PointMixin(ABC):
444
404
  )
445
405
  self._process_linkage(linkage)
446
406
 
407
+ def _index_connectivity(
408
+ self,
409
+ connectivity: np.ndarray,
410
+ vertices: pd.DataFrame,
411
+ as_positional: Optional[bool],
412
+ vertex_index: Optional[Union[str, np.ndarray]],
413
+ what: str = "edges",
414
+ ) -> np.ndarray:
415
+ """Put edges or faces into vertex-index space at construction.
416
+
417
+ Every layer type takes connectivity that may be written either as
418
+ positional indices into ``vertices`` or as vertex indices, so the rule
419
+ lives here once rather than in each constructor.
420
+
421
+ ``as_positional=None`` keeps the historical behaviour -- positional
422
+ exactly when ``vertex_index`` was supplied -- so callers that never
423
+ pass the flag are unaffected. An explicit ``True``/``False`` overrides
424
+ it, and is the only way to disambiguate a vertex index whose values
425
+ are also valid positions.
426
+
427
+ Parameters
428
+ ----------
429
+ connectivity : np.ndarray
430
+ Edge pairs or face triples.
431
+ vertices : pd.DataFrame
432
+ The processed vertex table; its index supplies the vertex indices.
433
+ as_positional : Optional[bool]
434
+ Whether ``connectivity`` holds positional indices. None infers it.
435
+ vertex_index : Optional[Union[str, np.ndarray]]
436
+ The vertex index argument, used only for the inference above.
437
+ what : str
438
+ Name used in error messages -- "edges" or "faces".
439
+
440
+ Returns
441
+ -------
442
+ np.ndarray
443
+ ``connectivity`` in vertex-index space.
444
+ """
445
+ positional = (
446
+ vertex_index is not None if as_positional is None else bool(as_positional)
447
+ )
448
+ if not positional:
449
+ return connectivity
450
+
451
+ arr = np.asarray(connectivity)
452
+ if arr.size:
453
+ # Without this check, values that are actually vertex indices
454
+ # surface as KeyError from inside fastremap, which says nothing
455
+ # about the convention that was violated.
456
+ low, high = int(arr.min()), int(arr.max())
457
+ if low < 0 or high >= len(vertices):
458
+ if as_positional is None:
459
+ hint = (
460
+ f"`{what}` are read as positional indices because "
461
+ f"`vertex_index` was given; pass "
462
+ f"{what}_as_positional=False if they are vertex "
463
+ "indices already."
464
+ )
465
+ else:
466
+ hint = (
467
+ f"`{what}_as_positional=True` was given, so `{what}` "
468
+ "must be positional indices."
469
+ )
470
+ raise ValueError(
471
+ f"{what} contain values outside the valid positional range "
472
+ f"0..{len(vertices) - 1} (found {low}..{high}). {hint}"
473
+ )
474
+ return fastremap.remap(arr, dict(enumerate(vertices.index)))
475
+
447
476
  def _vertices_to_positional(
448
477
  self,
449
478
  vertices: Optional[np.ndarray],
@@ -459,18 +488,26 @@ class PointMixin(ABC):
459
488
  as_positional : bool
460
489
  Whether input vertices are positional indices (True) or vertex indices (False).
461
490
  vertex_index : Optional[np.ndarray], optional
462
- Custom vertex index array to use for mapping. If None, uses self.vertex_index.
491
+ The index space the result is expressed in: positions are returned
492
+ relative to this array. If None, uses self.vertex_index. Passing a
493
+ different array (e.g. ``base_vertex_index``) maps this layer's
494
+ vertices into that other space.
463
495
 
464
496
  Returns
465
497
  -------
466
498
  Tuple[np.ndarray, bool]
467
499
  Tuple of (positional_indices, is_positional_flag).
468
500
  """
469
- if vertex_index is None:
470
- vertex_index = self.vertex_index
471
501
  if vertices is None:
472
- vertices = np.arange(len(self.vertex_index))
473
- as_positional = True
502
+ if vertex_index is None:
503
+ vertices = np.arange(len(self.vertex_index))
504
+ as_positional = True
505
+ else:
506
+ # "All of my vertices", but expressed in the caller's target
507
+ # index space -- going via vertex ids is the only way to cross
508
+ # between spaces, since positions do not correspond.
509
+ vertices = self.vertex_index
510
+ as_positional = False
474
511
  else:
475
512
  vertices = np.asarray(vertices)
476
513
  if np.issubdtype(vertices.dtype, np.bool_):
@@ -480,11 +517,15 @@ class PointMixin(ABC):
480
517
  )
481
518
  vertices = np.flatnonzero(vertices)
482
519
  as_positional = True
520
+ elif vertices.size == 0:
521
+ # An empty list arrives as float64, and fastremap passes the
522
+ # dtype straight through, leaving a result that is not a legal
523
+ # index array. Give it an integer dtype up front.
524
+ vertices = vertices.astype(np.intp)
525
+ if vertex_index is None:
526
+ vertex_index = self.vertex_index
483
527
  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)}
528
+ vertex_index_map = {v: i for i, v in enumerate(vertex_index)}
488
529
  # fastremap.remap rejects 0-d arrays, so a scalar vertex would crash
489
530
  # here. Remap on a 1-d view and restore the caller's shape, keeping
490
531
  # scalar-in -> scalar-out (as the positional path already does).
@@ -786,8 +827,17 @@ class PointMixin(ABC):
786
827
  right_index=True,
787
828
  how="left",
788
829
  )
830
+ # "majority" is ossify's own reducer, not something pandas understands,
831
+ # so it has to be swapped for the callable wherever it appears -- including
832
+ # inside a per-feature dict, which is the documented way to give different
833
+ # features different aggregations.
789
834
  if agg == "majority":
790
835
  agg = utils.majority_agg()
836
+ elif isinstance(agg, dict):
837
+ agg = {
838
+ key: utils.majority_agg() if value == "majority" else value
839
+ for key, value in agg.items()
840
+ }
791
841
  # Group by target layer and aggregate, then reindex to ensure all target vertices are included
792
842
  grouped_result = mapping_merged.groupby(layer).agg(agg)
793
843
  target_layer_index = self._morphsync.layers[layer].nodes.index
@@ -1486,6 +1536,7 @@ class GraphLayer(PointMixin, EdgeMixin):
1486
1536
  spatial_columns: Optional[list] = None,
1487
1537
  *,
1488
1538
  vertex_index: Optional[Union[str, np.ndarray]] = None,
1539
+ edges_as_positional: Optional[bool] = None,
1489
1540
  features: Optional[Union[dict, pd.DataFrame]] = None,
1490
1541
  morphsync: MorphSync = None,
1491
1542
  linkage: Optional[Link] = None,
@@ -1503,8 +1554,9 @@ class GraphLayer(PointMixin, EdgeMixin):
1503
1554
  self._cell = None
1504
1555
 
1505
1556
  if not existing:
1506
- if vertex_index:
1507
- edges = self._map_edges_to_index(edges, vertices.index)
1557
+ edges = self._index_connectivity(
1558
+ edges, vertices, edges_as_positional, vertex_index, "edges"
1559
+ )
1508
1560
  self._morphsync.add_graph(
1509
1561
  graph=(vertices, edges),
1510
1562
  name=self.layer_name,
@@ -1857,6 +1909,7 @@ class SkeletonLayer(GraphLayer):
1857
1909
  root: Optional[int] = None,
1858
1910
  *,
1859
1911
  vertex_index: Optional[Union[str, np.ndarray]] = None,
1912
+ edges_as_positional: Optional[bool] = None,
1860
1913
  features: Optional[Union[dict, pd.DataFrame]] = None,
1861
1914
  morphsync: MorphSync = None,
1862
1915
  linkage: Optional[dict] = None,
@@ -1873,8 +1926,9 @@ class SkeletonLayer(GraphLayer):
1873
1926
 
1874
1927
  if inherited_properties is None:
1875
1928
  # Add as a morphsync layer
1876
- if vertex_index:
1877
- edges = self._map_edges_to_index(edges, vertices.index)
1929
+ edges = self._index_connectivity(
1930
+ edges, vertices, edges_as_positional, vertex_index, "edges"
1931
+ )
1878
1932
  self._morphsync.add_graph(
1879
1933
  graph=(vertices, edges),
1880
1934
  name=self.layer_name,
@@ -2164,6 +2218,56 @@ class SkeletonLayer(GraphLayer):
2164
2218
  """
2165
2219
  return np.asarray(self._base_properties["base_parent_array"])
2166
2220
 
2221
+ def _vertices_to_base_positional(
2222
+ self, vertices: Optional[np.ndarray], as_positional: bool
2223
+ ) -> np.ndarray:
2224
+ """Map a vertex selection on this (possibly masked) layer to positional
2225
+ indices into the **base**, unmasked arrays.
2226
+
2227
+ ``base_csgraph``/``base_csgraph_binary`` span the original unmasked
2228
+ skeleton, so anything used to index their dijkstra results must be in
2229
+ base positional space -- not this layer's. The two only coincide when
2230
+ unmasked. Conversion always goes via vertex ids, the one index space
2231
+ the masked layer and the base snapshot share.
2232
+
2233
+ Parameters
2234
+ ----------
2235
+ vertices : Optional[np.ndarray]
2236
+ Vertex indices, positional indices, or a boolean mask over this
2237
+ layer's vertices. If None, every vertex of *this* layer is used, in
2238
+ this layer's positional order, so the result lines up 1:1 with
2239
+ ``self.vertices``.
2240
+ as_positional : bool
2241
+ Whether ``vertices`` are positional indices into this layer. A
2242
+ boolean mask is implicitly positional whatever this says.
2243
+
2244
+ Returns
2245
+ -------
2246
+ np.ndarray
2247
+ Positional indices into the base (unmasked) arrays. Scalar in,
2248
+ scalar out.
2249
+ """
2250
+ if vertices is None:
2251
+ vertices = self.vertex_index
2252
+ else:
2253
+ vertices = np.asarray(vertices)
2254
+ if np.issubdtype(vertices.dtype, np.bool_):
2255
+ if len(vertices) != self.n_vertices:
2256
+ raise ValueError(
2257
+ "If vertices is a boolean array, it must have the same length as the number of vertices."
2258
+ )
2259
+ vertices = self.vertex_index[np.flatnonzero(vertices)]
2260
+ else:
2261
+ if vertices.size == 0:
2262
+ # An empty list is float64, which cannot index an array.
2263
+ vertices = vertices.astype(np.intp)
2264
+ if as_positional:
2265
+ vertices = self.vertex_index[vertices]
2266
+ base_positional, _ = self._vertices_to_positional(
2267
+ vertices, as_positional=False, vertex_index=self.base_vertex_index
2268
+ )
2269
+ return base_positional
2270
+
2167
2271
  def _reset_derived_properties(self) -> None:
2168
2272
  super()._reset_derived_properties()
2169
2273
  self._dag_cache = gf.DAGCache()
@@ -2185,24 +2289,38 @@ class SkeletonLayer(GraphLayer):
2185
2289
  Parameters
2186
2290
  ----------
2187
2291
  root : Optional[int]
2188
- Proposed root node index. If None, attempts to infer from graph structure.
2292
+ Proposed root, as a **vertex index** -- the same space as
2293
+ ``vertex_index``, not a positional index. Note this differs from
2294
+ ``edges``, which are positional when ``vertex_index`` is supplied.
2295
+ If None, attempts to infer from graph structure.
2189
2296
 
2190
2297
  Returns
2191
2298
  -------
2192
2299
  int
2193
- The root node index.
2300
+ The root vertex index.
2194
2301
 
2195
2302
  Raises
2196
2303
  ------
2197
2304
  ValueError
2198
- If no root specified and multiple potential roots found.
2305
+ If the given root is not one of this layer's vertices, or if no
2306
+ root was specified and multiple potential roots were found.
2199
2307
  """
2200
2308
  if root is not None:
2201
- return int(root)
2309
+ root = int(root)
2310
+ # Numpy membership rather than building a Python set: a layer can
2311
+ # have millions of vertices and this runs on every construction.
2312
+ if not np.any(self.vertex_index == root):
2313
+ raise ValueError(
2314
+ f"root {root} is not a vertex of this layer. `root` is a "
2315
+ "vertex index, not a positional index -- note that `edges` "
2316
+ "use the opposite convention when `vertex_index` is given."
2317
+ )
2318
+ return root
2202
2319
  else:
2203
2320
  potential_roots = np.flatnonzero(self.csgraph_binary.sum(axis=1) == 0)
2204
2321
  if len(potential_roots) == 1:
2205
- return int(potential_roots[0])
2322
+ # ``potential_roots`` is positional; the root is a vertex index.
2323
+ return int(self.vertex_index[potential_roots[0]])
2206
2324
  else:
2207
2325
  raise ValueError(
2208
2326
  "No root specified and edges are not consistent with a single root. Please set a valid root."
@@ -2274,6 +2392,16 @@ class SkeletonLayer(GraphLayer):
2274
2392
  edges_positional_new[:, ii]
2275
2393
  ]
2276
2394
 
2395
+ # The edges just changed direction, but ``csgraph``/``csgraph_binary``
2396
+ # were built (by the connected_components call above) from the *old*
2397
+ # orientation. Everything read off the directed graph -- branch points,
2398
+ # end points, segments, cover paths -- would otherwise be computed from
2399
+ # a stale graph that disagrees with ``edges_positional``. Drop only the
2400
+ # edge-derived caches: the DAG cache holds root/parent state that the
2401
+ # callers set around this method.
2402
+ self._csgraph = None
2403
+ self._csgraph_binary = None
2404
+
2277
2405
  def reroot(self, new_root: int, as_positional=False) -> Self:
2278
2406
  """Reroot to a new index. Important: that this will reset any inherited properties from an unmasked skeleton!
2279
2407
 
@@ -2366,35 +2494,30 @@ class SkeletonLayer(GraphLayer):
2366
2494
  Parameters
2367
2495
  ----------
2368
2496
  vertices : Optional[np.ndarray]
2369
- The vertices to get the distance from the root for. If None, all vertices are used.
2497
+ The vertices to get the distance from the root for. If None, every
2498
+ vertex of this layer is used, in this layer's positional order, so
2499
+ the result lines up 1:1 with ``self.vertices``.
2370
2500
  as_positional : bool
2371
2501
  If True, the vertices are treated as positional indices. If False, they are treated as vertex features.
2372
2502
 
2373
2503
  Returns
2374
2504
  -------
2375
2505
  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(
2506
+ The distance from the root for each vertex. One value per requested
2507
+ vertex of *this* layer, so on a masked skeleton ``distance_to_root()``
2508
+ always equals ``distance_to_root(np.arange(n), as_positional=True)``
2509
+ and ``distance_to_root(self.vertex_index)``.
2510
+ """
2511
+ # The dijkstra runs on the base graph, so the selection must be in base
2512
+ # positional space -- which is not this layer's space when masked.
2513
+ base_positional = self._vertices_to_base_positional(vertices, as_positional)
2514
+ if self._dag_cache.base_distance_to_root is None:
2515
+ self._dag_cache.base_distance_to_root = sparse.csgraph.dijkstra(
2392
2516
  self.base_csgraph,
2393
2517
  directed=False,
2394
2518
  indices=self.base_root_positional,
2395
- )
2396
- self._dag_cache.distance_to_root = dtr.flatten()
2397
- return dtr[vertices]
2519
+ ).flatten()
2520
+ return self._dag_cache.base_distance_to_root[base_positional]
2398
2521
 
2399
2522
  def hops_to_root(
2400
2523
  self,
@@ -2406,26 +2529,29 @@ class SkeletonLayer(GraphLayer):
2406
2529
  Parameters
2407
2530
  ----------
2408
2531
  vertices : Optional[np.ndarray]
2409
- The vertices to get the distance from the root for. If None, all vertices are used.
2532
+ The vertices to get the distance from the root for. If None, every
2533
+ vertex of this layer is used, in this layer's positional order, so
2534
+ the result lines up 1:1 with ``self.vertices``.
2410
2535
  as_positional : bool
2411
2536
  If True, the vertices are treated as positional indices. If False, they are treated as vertex features.
2412
2537
 
2413
2538
  Returns
2414
2539
  -------
2415
2540
  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(
2541
+ The hop count from the root for each vertex. One value per requested
2542
+ vertex of *this* layer, so on a masked skeleton ``hops_to_root()``
2543
+ always equals ``hops_to_root(np.arange(n), as_positional=True)`` and
2544
+ ``hops_to_root(self.vertex_index)``.
2545
+ """
2546
+ # Same base-space contract as distance_to_root; see that method.
2547
+ base_positional = self._vertices_to_base_positional(vertices, as_positional)
2548
+ if self._dag_cache.base_hops_to_root is None:
2549
+ self._dag_cache.base_hops_to_root = sparse.csgraph.dijkstra(
2423
2550
  self.base_csgraph_binary,
2424
2551
  directed=False,
2425
2552
  indices=self.base_root_positional,
2426
- )
2427
- self._dag_cache.hops_to_root = htr
2428
- return htr[vertices]
2553
+ ).flatten()
2554
+ return self._dag_cache.base_hops_to_root[base_positional]
2429
2555
 
2430
2556
  def child_vertices(self, vertices=None, as_positional=False) -> dict:
2431
2557
  """Get mapping from vertices to their child nodes.
@@ -2581,7 +2707,7 @@ class SkeletonLayer(GraphLayer):
2581
2707
  A list of cover paths, each path is a list of vertex indices, ordered as the typical `cover_paths` method.
2582
2708
  """
2583
2709
  sources, as_positional = self._vertices_to_positional(sources, as_positional)
2584
- cps = gf.compute_cover_paths(
2710
+ cps = gf.build_cover_paths(
2585
2711
  sources,
2586
2712
  self.parent_node_array,
2587
2713
  self.distance_to_root(as_positional=True),
@@ -3327,8 +3453,9 @@ class PointCloudLayer(PointMixin):
3327
3453
  if self._cell.skeleton is None:
3328
3454
  raise ValueError("Cell does not have a Skeleton object.")
3329
3455
 
3330
- if vertices is None:
3331
- vertices = self.vertex_index
3456
+ # Do not pre-fill ``vertices`` here: it would hand vertex ids to a call
3457
+ # that may be reading them as positional indices. ``map_index_to_layer``
3458
+ # already resolves None correctly for *both* index spaces.
3332
3459
  skel_idx = self.map_index_to_layer(
3333
3460
  layer=SKEL_LAYER_NAME, source_index=vertices, as_positional=as_positional
3334
3461
  )
@@ -3418,6 +3545,7 @@ class MeshLayer(FaceMixin, PointMixin):
3418
3545
  spatial_columns: Optional[list] = None,
3419
3546
  *,
3420
3547
  vertex_index: Optional[Union[str, np.ndarray]] = None,
3548
+ faces_as_positional: Optional[bool] = None,
3421
3549
  features: Optional[Union[dict, pd.DataFrame]] = None,
3422
3550
  morphsync: MorphSync = None,
3423
3551
  linkage: Optional[Link] = None,
@@ -3435,8 +3563,9 @@ class MeshLayer(FaceMixin, PointMixin):
3435
3563
  self._cell = None
3436
3564
 
3437
3565
  if not existing:
3438
- if vertex_index:
3439
- faces = self._map_faces_to_index(faces, vertices.index)
3566
+ faces = self._index_connectivity(
3567
+ faces, vertices, faces_as_positional, vertex_index, "faces"
3568
+ )
3440
3569
  self._morphsync.add_mesh(
3441
3570
  mesh=(vertices, faces),
3442
3571
  name=self.layer_name,
@@ -1077,13 +1077,15 @@ def import_legacy_meshwork(
1077
1077
 
1078
1078
  def _process_pcg_skel_import(cell: Cell) -> Cell:
1079
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.
1080
1083
  cell.skeleton.add_feature(
1081
1084
  cell.skeleton.map_annotations_to_feature(
1082
1085
  "compartment",
1083
1086
  distance_threshold=0,
1084
1087
  agg={"compartment": ("compartment", "mean")},
1085
- ),
1086
- "compartment",
1088
+ )
1087
1089
  )
1088
1090
  if "segment_properties" in cell.annotations:
1089
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:
@@ -981,7 +981,31 @@ def plot_points(
981
981
  # Single color string
982
982
  scatter_kws["color"] = colors
983
983
  else:
984
- scatter_kws["c"] = colors
984
+ colors_arr = np.asarray(colors)
985
+ single_rgba = (
986
+ colors_arr.ndim == 1
987
+ and colors_arr.shape[0] in (3, 4)
988
+ and np.issubdtype(colors_arr.dtype, np.number)
989
+ and colors_arr.shape[0] != len(points_proj)
990
+ )
991
+ if single_rgba:
992
+ # One bare RGB/RGBA tuple means "this exact color", but as `c`
993
+ # it is ambiguous with one scalar value per point, and
994
+ # matplotlib warns. A 2-D single row is its documented way to
995
+ # say the former. Reached via root_marker, whose color is read
996
+ # off the root vertex as a single RGBA.
997
+ colors_arr = colors_arr.reshape(1, -1)
998
+ if (
999
+ colors_arr.ndim == 2
1000
+ and colors_arr.shape[1] in (3, 4)
1001
+ and np.issubdtype(colors_arr.dtype, np.number)
1002
+ ):
1003
+ # Explicit RGB/RGBA values are not colormapped, so a cmap set
1004
+ # from a string palette above would be silently ignored --
1005
+ # matplotlib says so out loud. Drop it and its norm.
1006
+ for key in ("cmap", "vmin", "vmax"):
1007
+ scatter_kws.pop(key, None)
1008
+ scatter_kws["c"] = colors_arr
985
1009
 
986
1010
  ax.scatter(
987
1011
  x=points_proj[:, 0],
@@ -1514,6 +1538,53 @@ def plot_cell_multiview(
1514
1538
  return axes
1515
1539
 
1516
1540
 
1541
+ _MIN_PANEL_INCHES = 0.5
1542
+
1543
+
1544
+ def _pad_degenerate_axes(
1545
+ bounds_min: np.ndarray,
1546
+ bounds_max: np.ndarray,
1547
+ fraction: float = 0.02,
1548
+ ) -> Tuple[np.ndarray, np.ndarray, List[int]]:
1549
+ """Expand any zero-extent axis so a flat cell is still renderable.
1550
+
1551
+ A cell that is perfectly flat along one axis -- a planar reconstruction, a
1552
+ 2D-derived skeleton stored with z=0, or a synthetic test skeleton -- has a
1553
+ zero extent there. Panel sizes are derived from the extents, so a zero
1554
+ extent yields a zero-size panel and an empty figure, and matplotlib warns
1555
+ when asked to set identical axis limits. Padding the degenerate axis to a
1556
+ small fraction of the largest extent keeps the panel a thin strip, which is
1557
+ an honest depiction of flat data.
1558
+
1559
+ Parameters
1560
+ ----------
1561
+ bounds_min, bounds_max : np.ndarray
1562
+ Per-axis data bounds.
1563
+ fraction : float
1564
+ Width to give a degenerate axis, as a fraction of the largest
1565
+ non-degenerate extent. If every axis is degenerate (a single point),
1566
+ one unit is used.
1567
+
1568
+ Returns
1569
+ -------
1570
+ Tuple[np.ndarray, np.ndarray, List[int]]
1571
+ Padded ``(bounds_min, bounds_max)`` and the indices of the axes padded.
1572
+ """
1573
+ bounds_min = np.array(bounds_min, dtype=float)
1574
+ bounds_max = np.array(bounds_max, dtype=float)
1575
+ extents = bounds_max - bounds_min
1576
+ degenerate = np.flatnonzero(extents <= 0)
1577
+ if len(degenerate) == 0:
1578
+ return bounds_min, bounds_max, []
1579
+ largest = float(extents.max())
1580
+ pad = fraction * largest if largest > 0 else 1.0
1581
+ for i in degenerate:
1582
+ center = (bounds_min[i] + bounds_max[i]) / 2
1583
+ bounds_min[i] = center - pad / 2
1584
+ bounds_max[i] = center + pad / 2
1585
+ return bounds_min, bounds_max, [int(i) for i in degenerate]
1586
+
1587
+
1517
1588
  def single_panel_figure(
1518
1589
  data_bounds_min: np.ndarray,
1519
1590
  data_bounds_max: np.ndarray,
@@ -1548,8 +1619,12 @@ def single_panel_figure(
1548
1619
  >>> fig, ax = create_single_panel_figure(bounds_min, bounds_max, 10)
1549
1620
  >>> # Creates 10" x 5" figure with 10 units per inch
1550
1621
  """
1551
- data_bounds_min = np.asarray(data_bounds_min)
1552
- data_bounds_max = np.asarray(data_bounds_max)
1622
+ # Pad a flat axis before deriving sizes, so the panel is not zero-sized and
1623
+ # matplotlib is never asked for identical limits. The clamp below already
1624
+ # reports degenerate input, so this does not warn separately.
1625
+ data_bounds_min, data_bounds_max, _ = _pad_degenerate_axes(
1626
+ data_bounds_min, data_bounds_max
1627
+ )
1553
1628
 
1554
1629
  # Calculate data extents
1555
1630
  data_width = data_bounds_max[0] - data_bounds_min[0]
@@ -1562,7 +1637,7 @@ def single_panel_figure(
1562
1637
  # Clamp degenerate dimensions so the figure is still renderable and
1563
1638
  # compatible with peers in a lineup or panel. We warn — the data is
1564
1639
  # degenerate, and the caller likely wants to know.
1565
- _MIN_INCHES = 0.5
1640
+ _MIN_INCHES = _MIN_PANEL_INCHES
1566
1641
  if fig_width < _MIN_INCHES or fig_height < _MIN_INCHES:
1567
1642
  warnings.warn(
1568
1643
  f"single_panel_figure received degenerate bounds "
@@ -1641,18 +1716,28 @@ def multi_panel_figure(
1641
1716
  >>> fig, axes_dict = create_multi_panel_figure(bounds_min, bounds_max, 10, "side_by_side")
1642
1717
  >>> xy_ax, zy_ax = axes_dict["xy"], axes_dict["zy"]
1643
1718
  """
1644
- data_bounds_min = np.asarray(data_bounds_min)
1645
- data_bounds_max = np.asarray(data_bounds_max)
1719
+ data_bounds_min, data_bounds_max, degenerate = _pad_degenerate_axes(
1720
+ data_bounds_min, data_bounds_max
1721
+ )
1722
+ if degenerate:
1723
+ axis_names = ", ".join("xyz"[i] for i in degenerate)
1724
+ warnings.warn(
1725
+ f"multi_panel_figure received a degenerate {axis_names} extent "
1726
+ f"(0 units); padding it so the panel remains visible. The cell is "
1727
+ f"flat along {'these axes' if len(degenerate) > 1 else 'this axis'}.",
1728
+ stacklevel=2,
1729
+ )
1646
1730
 
1647
1731
  # Calculate data extents for each dimension
1648
1732
  x_extent = data_bounds_max[0] - data_bounds_min[0]
1649
1733
  y_extent = data_bounds_max[1] - data_bounds_min[1]
1650
1734
  z_extent = data_bounds_max[2] - data_bounds_min[2]
1651
1735
 
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
1736
+ # Convert to sizes in inches. Clamp as single_panel_figure does, so a panel
1737
+ # of a thin or flat cell is still large enough to see.
1738
+ x_inches = max(x_extent / units_per_inch, _MIN_PANEL_INCHES)
1739
+ y_inches = max(y_extent / units_per_inch, _MIN_PANEL_INCHES)
1740
+ z_inches = max(z_extent / units_per_inch, _MIN_PANEL_INCHES)
1656
1741
 
1657
1742
  if layout == "side_by_side":
1658
1743
  # xy | zy layout
@@ -1,7 +1,6 @@
1
1
  from enum import IntEnum
2
2
  from typing import TYPE_CHECKING, Literal, Optional, Tuple, Union
3
3
 
4
- import fastremap
5
4
  import numpy as np
6
5
  import pandas as pd
7
6
 
@@ -310,13 +309,10 @@ def load_cell_from_client(
310
309
  l2_df = l2_df.reset_index()
311
310
 
312
311
  if restore_graph:
313
- l2_graph = client.chunkedgraph.level2_chunk_graph(root_id)
314
- l2_map = {v: k for k, v in l2_df["l2_id"].to_dict().items()}
315
-
316
- edges = fastremap.remap(
317
- l2_graph,
318
- l2_map,
319
- )
312
+ # The chunkedgraph gives edges as l2_id pairs, which is what the graph
313
+ # is indexed by, so hand them over as-is rather than round-tripping
314
+ # them through positional indices and straight back again.
315
+ edges = client.chunkedgraph.level2_chunk_graph(root_id)
320
316
  else:
321
317
  edges = []
322
318
 
@@ -335,6 +331,7 @@ def load_cell_from_client(
335
331
  spatial_columns=l2_spatial_columns,
336
332
  edges=edges,
337
333
  vertex_index="l2_id",
334
+ edges_as_positional=False,
338
335
  )
339
336
  .add_skeleton(
340
337
  vertices=np.array(sk["vertices"]),
@@ -79,7 +79,10 @@ def process_vertices(
79
79
  )
80
80
  spatial_columns = vertices.columns
81
81
  else:
82
- if vertex_index:
82
+ # Only a string names a column to hold out of the implicit features; an
83
+ # array-like vertex_index supplies the ids directly. Plain truthiness
84
+ # raises on an array of more than one element.
85
+ if isinstance(vertex_index, str):
83
86
  implicit_feature_columns = list(
84
87
  vertices.columns[
85
88
  ~vertices.columns.isin(spatial_columns + [vertex_index])
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