ramify 1.1.0__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.
- ramify-1.1.0/.github/workflows/publish.yml +15 -0
- ramify-1.1.0/.gitignore +53 -0
- ramify-1.1.0/LICENSE +21 -0
- ramify-1.1.0/PKG-INFO +183 -0
- ramify-1.1.0/README.md +160 -0
- ramify-1.1.0/assets/abstract.png +0 -0
- ramify-1.1.0/assets/allocate.png +0 -0
- ramify-1.1.0/assets/extract_auto.png +0 -0
- ramify-1.1.0/assets/extract_tips.png +0 -0
- ramify-1.1.0/assets/open_boundary.png +0 -0
- ramify-1.1.0/assets/subdivide.png +0 -0
- ramify-1.1.0/assets/voronoi.png +0 -0
- ramify-1.1.0/assets/widths_domain.png +0 -0
- ramify-1.1.0/assets/widths_nearest.png +0 -0
- ramify-1.1.0/pyproject.toml +49 -0
- ramify-1.1.0/ramify/__init__.py +13 -0
- ramify-1.1.0/ramify/_io.py +107 -0
- ramify-1.1.0/ramify/centerline.py +356 -0
- ramify-1.1.0/ramify/partition.py +215 -0
- ramify-1.1.0/ramify/width.py +264 -0
- ramify-1.1.0/scripts/gen_images.py +232 -0
- ramify-1.1.0/scripts/progress_example.py +124 -0
- ramify-1.1.0/uv.lock +1004 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
on:
|
|
3
|
+
release:
|
|
4
|
+
types: [published]
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
publish:
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
permissions:
|
|
10
|
+
id-token: write
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v5
|
|
14
|
+
- run: uv build
|
|
15
|
+
- run: uv publish --trusted-publishing automatic
|
ramify-1.1.0/.gitignore
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Project Directory Structure
|
|
2
|
+
*.pdf
|
|
3
|
+
cache/
|
|
4
|
+
data/
|
|
5
|
+
run.py
|
|
6
|
+
output
|
|
7
|
+
|
|
8
|
+
profile_run.py
|
|
9
|
+
|
|
10
|
+
*.gpkg-wal
|
|
11
|
+
*.gpkg-shm
|
|
12
|
+
*.tif.aux.xml
|
|
13
|
+
|
|
14
|
+
# vim
|
|
15
|
+
[._]*.s[a-v][a-z]
|
|
16
|
+
[._]*.sw[a-p]
|
|
17
|
+
[._]s[a-rt-v][a-z]
|
|
18
|
+
[._]ss[a-gi-z]
|
|
19
|
+
[._]sw[a-p]
|
|
20
|
+
|
|
21
|
+
# snakemake
|
|
22
|
+
.snakemake/
|
|
23
|
+
|
|
24
|
+
# R
|
|
25
|
+
*.rds
|
|
26
|
+
.RData
|
|
27
|
+
.Rhistory
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Python
|
|
31
|
+
__pycache__/
|
|
32
|
+
*.py[cod]
|
|
33
|
+
.python-version
|
|
34
|
+
.ipynb_checkpoints
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Operating System Metadata
|
|
38
|
+
._*
|
|
39
|
+
.DocumentRevisions-V100
|
|
40
|
+
.fseventsd
|
|
41
|
+
.Spotlight-V100
|
|
42
|
+
.TemporaryItems
|
|
43
|
+
.Trashes
|
|
44
|
+
.VolumeIcon.icns
|
|
45
|
+
.com.apple.timemachine.donotpresent
|
|
46
|
+
.AppleDB
|
|
47
|
+
.AppleDesktop
|
|
48
|
+
.DS_Store
|
|
49
|
+
.AppleDouble
|
|
50
|
+
.LSOverride
|
|
51
|
+
Network Trash Folder
|
|
52
|
+
Temporary Items
|
|
53
|
+
.apdisk
|
ramify-1.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arthur Koehl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ramify-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ramify
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Segment and measure binary branching shapes: centerline extraction, hierarchical space partitioning, and width interpolation
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Keywords: centerline,geomorphology,medial-axis,morphometry,raster,river,segmentation,skeleton,valley,width
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Requires-Dist: geopandas
|
|
12
|
+
Requires-Dist: networkx
|
|
13
|
+
Requires-Dist: numpy
|
|
14
|
+
Requires-Dist: rasterio
|
|
15
|
+
Requires-Dist: rioxarray
|
|
16
|
+
Requires-Dist: scikit-image
|
|
17
|
+
Requires-Dist: scipy
|
|
18
|
+
Requires-Dist: shapely
|
|
19
|
+
Requires-Dist: xarray
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: matplotlib; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# ramify
|
|
25
|
+
|
|
26
|
+
Characterize binary branching shapes (e.g. rivers, floodplains, glaciers,
|
|
27
|
+
roots, veins...). Given a shape mask, a root point, and (optionally) branch
|
|
28
|
+
tips, `ramify` extracts a topology-aware centerline network, decomposes it into
|
|
29
|
+
hierarchically ordered paths, allocates every pixel of the shape to its path,
|
|
30
|
+
and estimates local width everywhere.
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install ramify
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Development (clone, then sync with dev extras):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/avkoehl/ramify.git
|
|
44
|
+
cd ramify
|
|
45
|
+
uv sync --extra dev
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import ramify
|
|
52
|
+
from ramify.data import load
|
|
53
|
+
|
|
54
|
+
mask, root, tips = load() # bundled toy dataset
|
|
55
|
+
|
|
56
|
+
net = ramify.extract(mask, root, tips=tips) # centerline network of ordered paths
|
|
57
|
+
regions = ramify.allocate(mask, net.rasterize(by="path"))
|
|
58
|
+
widths = ramify.region_widths(mask, net.rasterize(), regions)
|
|
59
|
+
|
|
60
|
+
net.segments # DataFrame: segment_id, path_id, strahler,
|
|
61
|
+
# length, weight, downstream_segment_id
|
|
62
|
+
regions # labeled raster: each pixel -> its path
|
|
63
|
+
widths # float raster: local width everywhere
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Inputs are `np.ndarray` (with `pixel_size=`) or georeferenced `xr.DataArray`;
|
|
67
|
+
outputs match the input type. `root` and `tips` are `(row, col)` pixel coordinates.
|
|
68
|
+
|
|
69
|
+
## Components
|
|
70
|
+
|
|
71
|
+
Each individual component is presented below.
|
|
72
|
+
|
|
73
|
+
### Centerlines
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
net = ramify.extract(mask, root, tips=tips)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Skeletonizes the mask, routes from each tip to the root (pruning everything else),
|
|
80
|
+
and decomposes the network into ordered paths — `path_id == 1` is the mainstem.
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+

|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
net = ramify.extract(mask, root)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Without tips, every skeleton endpoint becomes a tip.
|
|
90
|
+
|
|
91
|
+

|
|
92
|
+
|
|
93
|
+
Tips and root can often be derived automatically — glacier branch tips
|
|
94
|
+
[Kienholz et al.,
|
|
95
|
+
2014](https://tc.copernicus.org/articles/8/503/2014/tc-8-503-2014.pdf), channel
|
|
96
|
+
initiation points, or the lowest point on the boundary as the root — or simply
|
|
97
|
+
digitized in GIS software.
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
### Partitioning
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
regions = ramify.allocate(mask, net.rasterize(by="path"))
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Assigns every pixel to a path: paths claim territory in priority order, each limited
|
|
107
|
+
by the local shape radius, so wide branches claim proportionally more space at junctions.
|
|
108
|
+
|
|
109
|
+

|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
regions = ramify.voronoi(mask, net.rasterize(by="path"))
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Nearest-centerline partition — no ordering, no radius limits.
|
|
116
|
+
|
|
117
|
+

|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
seg_regions = ramify.subdivide(regions, net)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Subdivides each path's territory further: within a territory, every pixel goes to
|
|
124
|
+
its nearest centerline segment of that same path.
|
|
125
|
+
|
|
126
|
+

|
|
127
|
+
|
|
128
|
+
### Widths
|
|
129
|
+
|
|
130
|
+
Exact widths (twice the distance to the boundary) are taken at the centerline and
|
|
131
|
+
interpolated across the shape. That interpolation runs either over the whole shape
|
|
132
|
+
or independently within each region, which keeps junction-zone pixels from
|
|
133
|
+
averaging between a branch and its mainstem:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
w = ramify.widths(mask, net.rasterize())
|
|
137
|
+
w = ramify.region_widths(mask, net.rasterize(), regions)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+

|
|
141
|
+
|
|
142
|
+
Either call also takes `method="nearest"`, which gives each pixel the width of its
|
|
143
|
+
nearest centerline pixel instead of diffusing smoothly from it — piecewise
|
|
144
|
+
constant, and much faster:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
w = ramify.widths(mask, net.rasterize(), method="nearest")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+

|
|
151
|
+
|
|
152
|
+
## Open boundaries
|
|
153
|
+
|
|
154
|
+
Everything above measures local half-width as the distance from each pixel to the
|
|
155
|
+
shape's boundary, and that half-width drives three things: which branch is the
|
|
156
|
+
mainstem, how far each path claims territory, and the width field. By default
|
|
157
|
+
every boundary pixel is treated as a **wall**. Sometimes part of the boundary is
|
|
158
|
+
not a real wall — the shape is truncated by open water, the data extent, or
|
|
159
|
+
another medium — and treating it as one makes the half-width collapse to zero
|
|
160
|
+
there.
|
|
161
|
+
|
|
162
|
+
Pass `open_boundary`: a binary mask, on the same grid as the shape, marking the
|
|
163
|
+
non-wall (void) pixels. Distances are then measured only to the remaining real
|
|
164
|
+
walls. It is optional — omitted, every boundary is a wall (the behaviour above) —
|
|
165
|
+
and accepted by `extract`, `allocate`, `widths`, and `region_widths`. Give it to
|
|
166
|
+
every step, so all three stages measure against the same walls:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
net = ramify.extract(mask, root, tips=tips, open_boundary=open_boundary)
|
|
170
|
+
regions = ramify.allocate(mask, net.rasterize(by="path"), open_boundary=open_boundary)
|
|
171
|
+
widths = ramify.region_widths(mask, net.rasterize(), regions,
|
|
172
|
+
open_boundary=open_boundary)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Below, the same mask, root, and tips are reused, but the void past the outlet is
|
|
176
|
+
marked open (shaded red), and the outlet `widths` no longer taper to the cut edge.
|
|
177
|
+
Mark a region with depth rather than a thin skin along the boundary: distances are
|
|
178
|
+
measured *through* the open void, so a one-pixel rind would only push the wall out
|
|
179
|
+
by one pixel.
|
|
180
|
+
|
|
181
|
+

|
|
182
|
+
|
|
183
|
+
Only the widths are shown because on this shape the partitioning didn't change.
|
ramify-1.1.0/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# ramify
|
|
2
|
+
|
|
3
|
+
Characterize binary branching shapes (e.g. rivers, floodplains, glaciers,
|
|
4
|
+
roots, veins...). Given a shape mask, a root point, and (optionally) branch
|
|
5
|
+
tips, `ramify` extracts a topology-aware centerline network, decomposes it into
|
|
6
|
+
hierarchically ordered paths, allocates every pixel of the shape to its path,
|
|
7
|
+
and estimates local width everywhere.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install ramify
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Development (clone, then sync with dev extras):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
git clone https://github.com/avkoehl/ramify.git
|
|
21
|
+
cd ramify
|
|
22
|
+
uv sync --extra dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import ramify
|
|
29
|
+
from ramify.data import load
|
|
30
|
+
|
|
31
|
+
mask, root, tips = load() # bundled toy dataset
|
|
32
|
+
|
|
33
|
+
net = ramify.extract(mask, root, tips=tips) # centerline network of ordered paths
|
|
34
|
+
regions = ramify.allocate(mask, net.rasterize(by="path"))
|
|
35
|
+
widths = ramify.region_widths(mask, net.rasterize(), regions)
|
|
36
|
+
|
|
37
|
+
net.segments # DataFrame: segment_id, path_id, strahler,
|
|
38
|
+
# length, weight, downstream_segment_id
|
|
39
|
+
regions # labeled raster: each pixel -> its path
|
|
40
|
+
widths # float raster: local width everywhere
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Inputs are `np.ndarray` (with `pixel_size=`) or georeferenced `xr.DataArray`;
|
|
44
|
+
outputs match the input type. `root` and `tips` are `(row, col)` pixel coordinates.
|
|
45
|
+
|
|
46
|
+
## Components
|
|
47
|
+
|
|
48
|
+
Each individual component is presented below.
|
|
49
|
+
|
|
50
|
+
### Centerlines
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
net = ramify.extract(mask, root, tips=tips)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Skeletonizes the mask, routes from each tip to the root (pruning everything else),
|
|
57
|
+
and decomposes the network into ordered paths — `path_id == 1` is the mainstem.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+

|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
net = ramify.extract(mask, root)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Without tips, every skeleton endpoint becomes a tip.
|
|
67
|
+
|
|
68
|
+

|
|
69
|
+
|
|
70
|
+
Tips and root can often be derived automatically — glacier branch tips
|
|
71
|
+
[Kienholz et al.,
|
|
72
|
+
2014](https://tc.copernicus.org/articles/8/503/2014/tc-8-503-2014.pdf), channel
|
|
73
|
+
initiation points, or the lowest point on the boundary as the root — or simply
|
|
74
|
+
digitized in GIS software.
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
### Partitioning
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
regions = ramify.allocate(mask, net.rasterize(by="path"))
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Assigns every pixel to a path: paths claim territory in priority order, each limited
|
|
84
|
+
by the local shape radius, so wide branches claim proportionally more space at junctions.
|
|
85
|
+
|
|
86
|
+

|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
regions = ramify.voronoi(mask, net.rasterize(by="path"))
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Nearest-centerline partition — no ordering, no radius limits.
|
|
93
|
+
|
|
94
|
+

|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
seg_regions = ramify.subdivide(regions, net)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Subdivides each path's territory further: within a territory, every pixel goes to
|
|
101
|
+
its nearest centerline segment of that same path.
|
|
102
|
+
|
|
103
|
+

|
|
104
|
+
|
|
105
|
+
### Widths
|
|
106
|
+
|
|
107
|
+
Exact widths (twice the distance to the boundary) are taken at the centerline and
|
|
108
|
+
interpolated across the shape. That interpolation runs either over the whole shape
|
|
109
|
+
or independently within each region, which keeps junction-zone pixels from
|
|
110
|
+
averaging between a branch and its mainstem:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
w = ramify.widths(mask, net.rasterize())
|
|
114
|
+
w = ramify.region_widths(mask, net.rasterize(), regions)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+

|
|
118
|
+
|
|
119
|
+
Either call also takes `method="nearest"`, which gives each pixel the width of its
|
|
120
|
+
nearest centerline pixel instead of diffusing smoothly from it — piecewise
|
|
121
|
+
constant, and much faster:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
w = ramify.widths(mask, net.rasterize(), method="nearest")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+

|
|
128
|
+
|
|
129
|
+
## Open boundaries
|
|
130
|
+
|
|
131
|
+
Everything above measures local half-width as the distance from each pixel to the
|
|
132
|
+
shape's boundary, and that half-width drives three things: which branch is the
|
|
133
|
+
mainstem, how far each path claims territory, and the width field. By default
|
|
134
|
+
every boundary pixel is treated as a **wall**. Sometimes part of the boundary is
|
|
135
|
+
not a real wall — the shape is truncated by open water, the data extent, or
|
|
136
|
+
another medium — and treating it as one makes the half-width collapse to zero
|
|
137
|
+
there.
|
|
138
|
+
|
|
139
|
+
Pass `open_boundary`: a binary mask, on the same grid as the shape, marking the
|
|
140
|
+
non-wall (void) pixels. Distances are then measured only to the remaining real
|
|
141
|
+
walls. It is optional — omitted, every boundary is a wall (the behaviour above) —
|
|
142
|
+
and accepted by `extract`, `allocate`, `widths`, and `region_widths`. Give it to
|
|
143
|
+
every step, so all three stages measure against the same walls:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
net = ramify.extract(mask, root, tips=tips, open_boundary=open_boundary)
|
|
147
|
+
regions = ramify.allocate(mask, net.rasterize(by="path"), open_boundary=open_boundary)
|
|
148
|
+
widths = ramify.region_widths(mask, net.rasterize(), regions,
|
|
149
|
+
open_boundary=open_boundary)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Below, the same mask, root, and tips are reused, but the void past the outlet is
|
|
153
|
+
marked open (shaded red), and the outlet `widths` no longer taper to the cut edge.
|
|
154
|
+
Mark a region with depth rather than a thin skin along the boundary: distances are
|
|
155
|
+
measured *through* the open void, so a one-pixel rind would only push the wall out
|
|
156
|
+
by one pixel.
|
|
157
|
+
|
|
158
|
+

|
|
159
|
+
|
|
160
|
+
Only the widths are shown because on this shape the partitioning didn't change.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ramify"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Segment and measure binary branching shapes: centerline extraction, hierarchical space partitioning, and width interpolation"
|
|
9
|
+
keywords = [
|
|
10
|
+
"centerline",
|
|
11
|
+
"skeleton",
|
|
12
|
+
"medial-axis",
|
|
13
|
+
"segmentation",
|
|
14
|
+
"raster",
|
|
15
|
+
"geomorphology",
|
|
16
|
+
"river",
|
|
17
|
+
"valley",
|
|
18
|
+
"morphometry",
|
|
19
|
+
"width",
|
|
20
|
+
]
|
|
21
|
+
readme = "README.md"
|
|
22
|
+
requires-python = ">=3.12"
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"numpy",
|
|
30
|
+
"scipy",
|
|
31
|
+
"scikit-image",
|
|
32
|
+
"networkx",
|
|
33
|
+
"rasterio",
|
|
34
|
+
"shapely",
|
|
35
|
+
"geopandas",
|
|
36
|
+
"xarray",
|
|
37
|
+
"rioxarray",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
dev = [
|
|
42
|
+
"matplotlib",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["ramify"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel.shared-data]
|
|
49
|
+
"ramify/data" = "ramify/data"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .centerline import extract, Network
|
|
2
|
+
from .partition import allocate, voronoi, subdivide
|
|
3
|
+
from .width import widths, region_widths
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"extract",
|
|
7
|
+
"Network",
|
|
8
|
+
"allocate",
|
|
9
|
+
"voronoi",
|
|
10
|
+
"subdivide",
|
|
11
|
+
"widths",
|
|
12
|
+
"region_widths",
|
|
13
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# _io.py
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class GridMeta:
|
|
8
|
+
"""Everything needed to reconstruct an xr.DataArray from a numpy result."""
|
|
9
|
+
|
|
10
|
+
coords: dict
|
|
11
|
+
dims: tuple
|
|
12
|
+
crs: object | None
|
|
13
|
+
transform: object | None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def unwrap(arr, pixel_size=None):
|
|
17
|
+
"""
|
|
18
|
+
Accept np.ndarray or xr.DataArray.
|
|
19
|
+
Returns (values: np.ndarray, pixel_size: float, meta: GridMeta | None).
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
import xarray as xr
|
|
23
|
+
|
|
24
|
+
is_xr = isinstance(arr, xr.DataArray)
|
|
25
|
+
except ImportError:
|
|
26
|
+
is_xr = False
|
|
27
|
+
|
|
28
|
+
if not is_xr:
|
|
29
|
+
return np.asarray(arr), float(pixel_size or 1.0), None
|
|
30
|
+
|
|
31
|
+
meta = GridMeta(
|
|
32
|
+
coords=arr.coords,
|
|
33
|
+
dims=arr.dims,
|
|
34
|
+
crs=getattr(arr.rio, "crs", None) if hasattr(arr, "rio") else None,
|
|
35
|
+
transform=arr.rio.transform() if hasattr(arr, "rio") else None,
|
|
36
|
+
)
|
|
37
|
+
if pixel_size is None and meta.transform is not None:
|
|
38
|
+
pixel_size = abs(
|
|
39
|
+
meta.transform.a
|
|
40
|
+
) # from affine transform, assumes square pixels
|
|
41
|
+
return arr.values, float(pixel_size or 1.0), meta
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def wrap(values, meta):
|
|
45
|
+
"""np.ndarray + meta -> xr.DataArray; passthrough if meta is None."""
|
|
46
|
+
if meta is None:
|
|
47
|
+
return values
|
|
48
|
+
import xarray as xr
|
|
49
|
+
|
|
50
|
+
out = xr.DataArray(values, coords=meta.coords, dims=meta.dims)
|
|
51
|
+
if meta.crs is not None:
|
|
52
|
+
out.rio.write_crs(meta.crs, inplace=True)
|
|
53
|
+
out.rio.write_transform(meta.transform, inplace=True)
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def region_groups(label_arr, mask_bool=None):
|
|
58
|
+
"""Group a labeled raster's pixels by label, in one pass.
|
|
59
|
+
|
|
60
|
+
Yields ``(label, flat_idx)`` per positive label, ascending, where
|
|
61
|
+
``flat_idx`` indexes the raveled grid; ``mask_bool`` optionally restricts
|
|
62
|
+
which pixels count. This replaces the ``label_arr == label`` scan a
|
|
63
|
+
per-region loop would otherwise do, which costs the whole grid once per
|
|
64
|
+
region. The sort is stable, so each group's indices come out ascending --
|
|
65
|
+
callers rely on that for ``searchsorted`` neighbour lookups and for
|
|
66
|
+
reading off a bounding box.
|
|
67
|
+
"""
|
|
68
|
+
positive = label_arr > 0
|
|
69
|
+
flat = np.flatnonzero(positive if mask_bool is None else positive & mask_bool)
|
|
70
|
+
if flat.size == 0:
|
|
71
|
+
return
|
|
72
|
+
labels = label_arr.ravel()[flat]
|
|
73
|
+
order = np.argsort(labels, kind="stable")
|
|
74
|
+
flat = flat[order]
|
|
75
|
+
labels = labels[order]
|
|
76
|
+
starts = np.flatnonzero(np.r_[True, labels[1:] != labels[:-1]])
|
|
77
|
+
for lo, hi in zip(starts, np.append(starts[1:], flat.size)):
|
|
78
|
+
yield int(labels[lo]), flat[lo:hi]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def edt_field(mask_bool, open_boundary):
|
|
82
|
+
"""Foreground array for the local-half-width distance transform.
|
|
83
|
+
|
|
84
|
+
``distance_transform_edt`` of the returned array gives, at each shape pixel,
|
|
85
|
+
the distance to the nearest *wall*. By default (``open_boundary is None``)
|
|
86
|
+
every non-shape pixel is a wall -- the original behaviour. When
|
|
87
|
+
``open_boundary`` is given, its truthy pixels are treated as void (open,
|
|
88
|
+
not a wall): they join the shape as foreground, so half-widths are measured
|
|
89
|
+
only to the remaining real walls. Accepts np.ndarray or xr.DataArray.
|
|
90
|
+
"""
|
|
91
|
+
if open_boundary is None:
|
|
92
|
+
return mask_bool
|
|
93
|
+
open_arr, _, _ = unwrap(open_boundary)
|
|
94
|
+
open_bool = np.asarray(open_arr) > 0
|
|
95
|
+
if open_bool.shape != mask_bool.shape:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"open_boundary shape {open_bool.shape} does not match "
|
|
98
|
+
f"mask shape {mask_bool.shape}"
|
|
99
|
+
)
|
|
100
|
+
field = mask_bool | open_bool
|
|
101
|
+
if field.all():
|
|
102
|
+
import warnings
|
|
103
|
+
|
|
104
|
+
warnings.warn(
|
|
105
|
+
"open_boundary leaves no wall pixels; local half-widths will be zero"
|
|
106
|
+
)
|
|
107
|
+
return field
|