ramify 1.3.0__tar.gz → 2.0.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-2.0.0/PKG-INFO +270 -0
- ramify-2.0.0/README.md +245 -0
- ramify-2.0.0/assets/abstract.png +0 -0
- ramify-2.0.0/assets/level.png +0 -0
- ramify-2.0.0/assets/open_boundary.png +0 -0
- ramify-2.0.0/assets/partition_methods.png +0 -0
- ramify-2.0.0/assets/quickstart.png +0 -0
- ramify-2.0.0/assets/single_shape.png +0 -0
- ramify-2.0.0/assets/tips_options.png +0 -0
- ramify-2.0.0/assets/widths_regions.png +0 -0
- ramify-2.0.0/assets/widths_stations.png +0 -0
- ramify-2.0.0/branch/data/__init__.py +34 -0
- {ramify-1.3.0 → ramify-2.0.0}/pyproject.toml +6 -4
- ramify-2.0.0/ramify/__init__.py +14 -0
- ramify-2.0.0/ramify/_io.py +250 -0
- ramify-2.0.0/ramify/_skeleton.py +185 -0
- ramify-2.0.0/ramify/centerline.py +124 -0
- ramify-2.0.0/ramify/data/mask.tif +0 -0
- ramify-2.0.0/ramify/data/root.tif +0 -0
- ramify-2.0.0/ramify/data/tips.tif +0 -0
- ramify-2.0.0/ramify/partition.py +575 -0
- ramify-2.0.0/ramify/regions.py +240 -0
- ramify-2.0.0/ramify/width.py +359 -0
- ramify-2.0.0/scripts/gen_images.py +401 -0
- ramify-2.0.0/scripts/progress_example.py +110 -0
- ramify-2.0.0/tests/conftest.py +27 -0
- ramify-2.0.0/tests/test_centerline.py +108 -0
- ramify-2.0.0/tests/test_io.py +48 -0
- ramify-2.0.0/tests/test_partition.py +160 -0
- ramify-2.0.0/tests/test_regions.py +297 -0
- ramify-2.0.0/tests/test_width.py +120 -0
- {ramify-1.3.0 → ramify-2.0.0}/uv.lock +51 -4
- ramify-1.3.0/PKG-INFO +0 -183
- ramify-1.3.0/README.md +0 -160
- ramify-1.3.0/assets/abstract.png +0 -0
- ramify-1.3.0/assets/extract_centerlines_auto.png +0 -0
- ramify-1.3.0/assets/extract_centerlines_tips.png +0 -0
- ramify-1.3.0/assets/interpolate_widths_domain.png +0 -0
- ramify-1.3.0/assets/interpolate_widths_nearest.png +0 -0
- ramify-1.3.0/assets/open_boundary.png +0 -0
- ramify-1.3.0/assets/partition_by_nearest.png +0 -0
- ramify-1.3.0/assets/partition_by_priority.png +0 -0
- ramify-1.3.0/assets/subdivide_regions.png +0 -0
- ramify-1.3.0/ramify/__init__.py +0 -12
- ramify-1.3.0/ramify/_io.py +0 -107
- ramify-1.3.0/ramify/centerline.py +0 -356
- ramify-1.3.0/ramify/partition.py +0 -215
- ramify-1.3.0/ramify/width.py +0 -275
- ramify-1.3.0/scripts/gen_images.py +0 -232
- ramify-1.3.0/scripts/progress_example.py +0 -124
- {ramify-1.3.0 → ramify-2.0.0}/.github/workflows/publish.yml +0 -0
- {ramify-1.3.0 → ramify-2.0.0}/.gitignore +0 -0
- {ramify-1.3.0 → ramify-2.0.0}/LICENSE +0 -0
- /ramify-1.3.0/ramify/data/mask.tif → /ramify-2.0.0/branch/data/example.tif +0 -0
- {ramify-1.3.0/ramify → ramify-2.0.0/branch}/data/root.tif +0 -0
- {ramify-1.3.0/ramify → ramify-2.0.0/branch}/data/tips.tif +0 -0
- {ramify-1.3.0 → ramify-2.0.0}/ramify/data/__init__.py +0 -0
ramify-2.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ramify
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Segment and measure branching shapes: centerlines, hierarchical space partitioning, and width
|
|
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: affine
|
|
12
|
+
Requires-Dist: geopandas
|
|
13
|
+
Requires-Dist: numpy
|
|
14
|
+
Requires-Dist: pandas
|
|
15
|
+
Requires-Dist: rasterio
|
|
16
|
+
Requires-Dist: rioxarray
|
|
17
|
+
Requires-Dist: scikit-image
|
|
18
|
+
Requires-Dist: scipy
|
|
19
|
+
Requires-Dist: shapely>=2.0
|
|
20
|
+
Requires-Dist: xarray
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: matplotlib; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# ramify
|
|
27
|
+
|
|
28
|
+
Split a branched shape into its branches, and measure the length, area, and width of each one.
|
|
29
|
+
|
|
30
|
+
`ramify` takes a mask of a branching shape, such as a river network, a
|
|
31
|
+
floodplain, a glacier, a root system, or a leaf's veins, and divides it into one
|
|
32
|
+
region per branch. Each region gets its own centerline, so each branch can be
|
|
33
|
+
measured separately. For a shape with no branches, see [Single shapes](#single-shapes).
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install ramify
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quickstart
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import ramify
|
|
47
|
+
from ramify.data import load
|
|
48
|
+
|
|
49
|
+
mask, root, tips = load() # bundled toy dataset
|
|
50
|
+
|
|
51
|
+
labels, net, lines = ramify.partition_priority(mask, root, tips=tips)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`lines` has one row per branch, with its centerline, length, and area:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
len(lines) # 28
|
|
58
|
+
lines[["region_id", "length", "area"]].head()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
region_id length area
|
|
63
|
+
1 3356.8 395885.7
|
|
64
|
+
2 2538.2 282902.2
|
|
65
|
+
3 1519.3 59523.3
|
|
66
|
+
4 934.4 29133.2
|
|
67
|
+
5 1081.2 42812.4
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
To get width along each branch, place stations along the centerlines:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
widths, stations = ramify.width_regions_stations(labels, lines, spacing=50)
|
|
74
|
+
stations[["region_id", "station_id", "length", "area", "width"]].head()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
region_id station_id length area width
|
|
79
|
+
1 1 49.4 9844.2 199.4
|
|
80
|
+
1 2 51.3 6762.5 131.8
|
|
81
|
+
1 3 50.6 6842.5 135.3
|
|
82
|
+
1 4 49.1 11601.3 236.5
|
|
83
|
+
1 5 51.2 12419.9 242.7
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+

|
|
87
|
+
|
|
88
|
+
## Partitioning
|
|
89
|
+
|
|
90
|
+
### Inputs
|
|
91
|
+
|
|
92
|
+
- **`mask`**: 2-D array, shape pixels `> 0`. `np.ndarray` (set `pixel_size=`)
|
|
93
|
+
or a georeferenced `xr.DataArray`; outputs match the input type.
|
|
94
|
+
- **`root`**: `(row, col)` of the network's outlet, the downstream end.
|
|
95
|
+
- **Tips**, one of:
|
|
96
|
+
- `tips=[(row, col), ...]`: the branch ends you want. Only these branches
|
|
97
|
+
are kept.
|
|
98
|
+
- `min_length=`: use every end of the shape's skeleton, after pruning side
|
|
99
|
+
branches shorter than this (map units).
|
|
100
|
+
- neither: every skeleton end becomes a tip. Noisy boundaries produce many
|
|
101
|
+
short spurious branches, so on real data use one of the options above.
|
|
102
|
+
|
|
103
|
+

|
|
104
|
+
|
|
105
|
+
Roots and tips can often be derived automatically: glacier branch tips
|
|
106
|
+
([Kienholz et al., 2014](https://tc.copernicus.org/articles/8/503/2014/tc-8-503-2014.pdf)),
|
|
107
|
+
channel initiation points, or the lowest point on the boundary as the root.
|
|
108
|
+
They can also be digitized by hand in GIS software.
|
|
109
|
+
|
|
110
|
+
### Outputs
|
|
111
|
+
|
|
112
|
+
- **`labels`**: a raster assigning every pixel of the shape to a branch.
|
|
113
|
+
Region 1 is the mainstem. Ids are ordered by branch size, and a branch is
|
|
114
|
+
always numbered before its tributaries.
|
|
115
|
+
- **`lines`**: one centerline per region, traced through the region's final
|
|
116
|
+
pixels, with `length` and `area`. **Use these to measure branches.**
|
|
117
|
+
- **`net`**: the network used to build the partition, one row per segment,
|
|
118
|
+
traced through the whole shape before it was split. It records how the
|
|
119
|
+
shape was split and why paths are ordered as they are. It is not for
|
|
120
|
+
measuring.
|
|
121
|
+
|
|
122
|
+
## Widths
|
|
123
|
+
|
|
124
|
+
`width_regions_stations` places stations along each centerline, every
|
|
125
|
+
`spacing` map units (or `n_stations` per region). Each station gets the part
|
|
126
|
+
of its region closest to it (its cell). The station's width is the cell's
|
|
127
|
+
area divided by the centerline length inside it.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
widths, stations = ramify.width_regions_stations(labels, lines, spacing=50)
|
|
131
|
+
widths, stations = ramify.width_regions_stations(labels, lines, n_stations=20)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`widths` is a raster of each pixel's station width. `stations` is a
|
|
135
|
+
`GeoDataFrame` with one row per station: `region_id`, `station_id`,
|
|
136
|
+
`distance` along the line, `area`, `length`, `width`, the cell polygon
|
|
137
|
+
(`geometry`), the station point (`station`), and the piece of centerline in
|
|
138
|
+
the cell (`centerline`).
|
|
139
|
+
|
|
140
|
+

|
|
141
|
+
|
|
142
|
+
## Options
|
|
143
|
+
|
|
144
|
+
### Partition method
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
labels, net, lines = ramify.partition_priority(mask, root, tips=tips)
|
|
148
|
+
labels, net, lines = ramify.partition_nearest(mask, root, tips=tips)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- **`partition_priority`**: branches claim space in order, biggest first,
|
|
152
|
+
each out to its local half-width. Wide branches take more space at
|
|
153
|
+
junctions, and tributaries do not cut into the mainstem.
|
|
154
|
+
- **`partition_nearest`**: every pixel goes to the nearest branch centerline,
|
|
155
|
+
with no ordering and no width limit. It is simpler, but at junctions
|
|
156
|
+
tributaries cut into the mainstem.
|
|
157
|
+
|
|
158
|
+

|
|
159
|
+
|
|
160
|
+
### Mainstem choice (`path_by`)
|
|
161
|
+
|
|
162
|
+
At each junction, the path continues into one upstream branch and the others
|
|
163
|
+
start new paths. `path_by` picks which branch continues the path:
|
|
164
|
+
|
|
165
|
+
- `"area"` (default): the branch with the most shape upstream of it.
|
|
166
|
+
- `"length"`: the branch leading to the farthest tip (Hack's mainstem).
|
|
167
|
+
- `"strahler"`: the branch with the higher Strahler order; ties go to the
|
|
168
|
+
longest.
|
|
169
|
+
|
|
170
|
+
On the bundled dataset all three pick the same mainstem. They diverge when a
|
|
171
|
+
short, wide branch competes against a long, narrow one.
|
|
172
|
+
|
|
173
|
+
### Level
|
|
174
|
+
|
|
175
|
+
`level="path"` (default) gives one region per path. `level="segment"` splits
|
|
176
|
+
each path's region further, one region per segment (the piece between two
|
|
177
|
+
junctions, or between a junction and a tip). `labels` and `lines` are then
|
|
178
|
+
keyed by `segment_id`.
|
|
179
|
+
|
|
180
|
+

|
|
181
|
+
|
|
182
|
+
### Width method
|
|
183
|
+
|
|
184
|
+
Instead of stations, width can be measured exactly on the centerline (twice
|
|
185
|
+
the distance to the nearest wall) and spread across the region:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
w = ramify.width_regions_interpolate(labels, lines) # laplace
|
|
189
|
+
w = ramify.width_regions_interpolate(labels, lines, method="nearest")
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- `method="laplace"` (default) spreads the centerline widths smoothly.
|
|
193
|
+
- `method="nearest"` copies the nearest centerline value. It is faster and
|
|
194
|
+
piecewise constant.
|
|
195
|
+
|
|
196
|
+
Boundaries between regions are not treated as walls, so widths don't shrink
|
|
197
|
+
at junctions.
|
|
198
|
+
|
|
199
|
+

|
|
200
|
+
|
|
201
|
+
### Open boundaries
|
|
202
|
+
|
|
203
|
+
Open boundaries do not apply to stations: `width_regions_stations` does not
|
|
204
|
+
use wall distances and has no `open_boundary`.
|
|
205
|
+
|
|
206
|
+
Branch sizes, claiming distances, and interpolated widths all depend on the
|
|
207
|
+
distance from each pixel to the shape's wall. By default every edge of the
|
|
208
|
+
mask is a wall. Where part of the edge is not a real wall, such as where the
|
|
209
|
+
shape is cut off by open water or the edge of the data, pass `open_boundary`:
|
|
210
|
+
a mask on the same grid marking the open area. Distances are then measured only to the
|
|
211
|
+
remaining real walls.
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
import numpy as np
|
|
215
|
+
|
|
216
|
+
mask_bool = np.asarray(mask.values) == 1
|
|
217
|
+
rr, cc = np.ogrid[:mask_bool.shape[0], :mask_bool.shape[1]]
|
|
218
|
+
open_boundary = (~mask_bool) & (rr >= root[0] - 10) & (cc <= root[1] + 20)
|
|
219
|
+
|
|
220
|
+
labels, net, lines = ramify.partition_priority(mask, root, tips=tips,
|
|
221
|
+
open_boundary=open_boundary)
|
|
222
|
+
w = ramify.width_regions_interpolate(labels, lines, open_boundary=open_boundary)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Pass it to both steps so they measure against the same walls. Mark the open
|
|
226
|
+
area with some depth, not a thin strip: distances are measured through it, so a
|
|
227
|
+
one-pixel strip only moves the wall by one pixel.
|
|
228
|
+
|
|
229
|
+

|
|
230
|
+
|
|
231
|
+
## Single shapes
|
|
232
|
+
|
|
233
|
+
For a shape with no branches, or one region pulled out of a partition, use
|
|
234
|
+
the underlying functions directly:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
shape = labels == 3 # one region, pulled out of a partition
|
|
238
|
+
|
|
239
|
+
line = ramify.centerline(shape) # LineString
|
|
240
|
+
w, stations = ramify.width_stations(shape, line, spacing=50)
|
|
241
|
+
w = ramify.width_interpolate(shape, line)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`centerline` returns the longest path through the shape's skeleton. Give
|
|
245
|
+
`root=` to fix one end, or `root=` and `tip=` to fix both.
|
|
246
|
+
|
|
247
|
+

|
|
248
|
+
|
|
249
|
+
## Conventions
|
|
250
|
+
|
|
251
|
+
- **Coordinates.** Georeferenced input: geometry is in the input's CRS. Plain
|
|
252
|
+
arrays: `x = col * pixel_size`, `y = row * pixel_size` (y increases
|
|
253
|
+
downward), vertices at pixel centers.
|
|
254
|
+
- **Units.** Lengths, widths, `spacing`, `min_length` in map units; areas in
|
|
255
|
+
map units squared.
|
|
256
|
+
- **Points.** `root`, `tip`, `tips` are `(row, col)` pixel indices.
|
|
257
|
+
- **Orientation.** Every line runs downstream to upstream, starting at the
|
|
258
|
+
root end, wherever a root is known (`net`, `lines`, and `centerline` given
|
|
259
|
+
`root`). `centerline(shape)` called with neither `root` nor `tip` has no
|
|
260
|
+
root to start from; its direction is deterministic but arbitrary.
|
|
261
|
+
- **Output type.** Rasters match the input (`np.ndarray` or `xr.DataArray`);
|
|
262
|
+
NaN outside the shape for widths, 0 outside for labels.
|
|
263
|
+
|
|
264
|
+
## Development
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
git clone https://github.com/avkoehl/ramify.git
|
|
268
|
+
cd ramify
|
|
269
|
+
uv sync --extra dev
|
|
270
|
+
```
|
ramify-2.0.0/README.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# ramify
|
|
2
|
+
|
|
3
|
+
Split a branched shape into its branches, and measure the length, area, and width of each one.
|
|
4
|
+
|
|
5
|
+
`ramify` takes a mask of a branching shape, such as a river network, a
|
|
6
|
+
floodplain, a glacier, a root system, or a leaf's veins, and divides it into one
|
|
7
|
+
region per branch. Each region gets its own centerline, so each branch can be
|
|
8
|
+
measured separately. For a shape with no branches, see [Single shapes](#single-shapes).
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install ramify
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import ramify
|
|
22
|
+
from ramify.data import load
|
|
23
|
+
|
|
24
|
+
mask, root, tips = load() # bundled toy dataset
|
|
25
|
+
|
|
26
|
+
labels, net, lines = ramify.partition_priority(mask, root, tips=tips)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`lines` has one row per branch, with its centerline, length, and area:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
len(lines) # 28
|
|
33
|
+
lines[["region_id", "length", "area"]].head()
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
region_id length area
|
|
38
|
+
1 3356.8 395885.7
|
|
39
|
+
2 2538.2 282902.2
|
|
40
|
+
3 1519.3 59523.3
|
|
41
|
+
4 934.4 29133.2
|
|
42
|
+
5 1081.2 42812.4
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
To get width along each branch, place stations along the centerlines:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
widths, stations = ramify.width_regions_stations(labels, lines, spacing=50)
|
|
49
|
+
stations[["region_id", "station_id", "length", "area", "width"]].head()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
region_id station_id length area width
|
|
54
|
+
1 1 49.4 9844.2 199.4
|
|
55
|
+
1 2 51.3 6762.5 131.8
|
|
56
|
+
1 3 50.6 6842.5 135.3
|
|
57
|
+
1 4 49.1 11601.3 236.5
|
|
58
|
+
1 5 51.2 12419.9 242.7
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+

|
|
62
|
+
|
|
63
|
+
## Partitioning
|
|
64
|
+
|
|
65
|
+
### Inputs
|
|
66
|
+
|
|
67
|
+
- **`mask`**: 2-D array, shape pixels `> 0`. `np.ndarray` (set `pixel_size=`)
|
|
68
|
+
or a georeferenced `xr.DataArray`; outputs match the input type.
|
|
69
|
+
- **`root`**: `(row, col)` of the network's outlet, the downstream end.
|
|
70
|
+
- **Tips**, one of:
|
|
71
|
+
- `tips=[(row, col), ...]`: the branch ends you want. Only these branches
|
|
72
|
+
are kept.
|
|
73
|
+
- `min_length=`: use every end of the shape's skeleton, after pruning side
|
|
74
|
+
branches shorter than this (map units).
|
|
75
|
+
- neither: every skeleton end becomes a tip. Noisy boundaries produce many
|
|
76
|
+
short spurious branches, so on real data use one of the options above.
|
|
77
|
+
|
|
78
|
+

|
|
79
|
+
|
|
80
|
+
Roots and tips can often be derived automatically: glacier branch tips
|
|
81
|
+
([Kienholz et al., 2014](https://tc.copernicus.org/articles/8/503/2014/tc-8-503-2014.pdf)),
|
|
82
|
+
channel initiation points, or the lowest point on the boundary as the root.
|
|
83
|
+
They can also be digitized by hand in GIS software.
|
|
84
|
+
|
|
85
|
+
### Outputs
|
|
86
|
+
|
|
87
|
+
- **`labels`**: a raster assigning every pixel of the shape to a branch.
|
|
88
|
+
Region 1 is the mainstem. Ids are ordered by branch size, and a branch is
|
|
89
|
+
always numbered before its tributaries.
|
|
90
|
+
- **`lines`**: one centerline per region, traced through the region's final
|
|
91
|
+
pixels, with `length` and `area`. **Use these to measure branches.**
|
|
92
|
+
- **`net`**: the network used to build the partition, one row per segment,
|
|
93
|
+
traced through the whole shape before it was split. It records how the
|
|
94
|
+
shape was split and why paths are ordered as they are. It is not for
|
|
95
|
+
measuring.
|
|
96
|
+
|
|
97
|
+
## Widths
|
|
98
|
+
|
|
99
|
+
`width_regions_stations` places stations along each centerline, every
|
|
100
|
+
`spacing` map units (or `n_stations` per region). Each station gets the part
|
|
101
|
+
of its region closest to it (its cell). The station's width is the cell's
|
|
102
|
+
area divided by the centerline length inside it.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
widths, stations = ramify.width_regions_stations(labels, lines, spacing=50)
|
|
106
|
+
widths, stations = ramify.width_regions_stations(labels, lines, n_stations=20)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`widths` is a raster of each pixel's station width. `stations` is a
|
|
110
|
+
`GeoDataFrame` with one row per station: `region_id`, `station_id`,
|
|
111
|
+
`distance` along the line, `area`, `length`, `width`, the cell polygon
|
|
112
|
+
(`geometry`), the station point (`station`), and the piece of centerline in
|
|
113
|
+
the cell (`centerline`).
|
|
114
|
+
|
|
115
|
+

|
|
116
|
+
|
|
117
|
+
## Options
|
|
118
|
+
|
|
119
|
+
### Partition method
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
labels, net, lines = ramify.partition_priority(mask, root, tips=tips)
|
|
123
|
+
labels, net, lines = ramify.partition_nearest(mask, root, tips=tips)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- **`partition_priority`**: branches claim space in order, biggest first,
|
|
127
|
+
each out to its local half-width. Wide branches take more space at
|
|
128
|
+
junctions, and tributaries do not cut into the mainstem.
|
|
129
|
+
- **`partition_nearest`**: every pixel goes to the nearest branch centerline,
|
|
130
|
+
with no ordering and no width limit. It is simpler, but at junctions
|
|
131
|
+
tributaries cut into the mainstem.
|
|
132
|
+
|
|
133
|
+

|
|
134
|
+
|
|
135
|
+
### Mainstem choice (`path_by`)
|
|
136
|
+
|
|
137
|
+
At each junction, the path continues into one upstream branch and the others
|
|
138
|
+
start new paths. `path_by` picks which branch continues the path:
|
|
139
|
+
|
|
140
|
+
- `"area"` (default): the branch with the most shape upstream of it.
|
|
141
|
+
- `"length"`: the branch leading to the farthest tip (Hack's mainstem).
|
|
142
|
+
- `"strahler"`: the branch with the higher Strahler order; ties go to the
|
|
143
|
+
longest.
|
|
144
|
+
|
|
145
|
+
On the bundled dataset all three pick the same mainstem. They diverge when a
|
|
146
|
+
short, wide branch competes against a long, narrow one.
|
|
147
|
+
|
|
148
|
+
### Level
|
|
149
|
+
|
|
150
|
+
`level="path"` (default) gives one region per path. `level="segment"` splits
|
|
151
|
+
each path's region further, one region per segment (the piece between two
|
|
152
|
+
junctions, or between a junction and a tip). `labels` and `lines` are then
|
|
153
|
+
keyed by `segment_id`.
|
|
154
|
+
|
|
155
|
+

|
|
156
|
+
|
|
157
|
+
### Width method
|
|
158
|
+
|
|
159
|
+
Instead of stations, width can be measured exactly on the centerline (twice
|
|
160
|
+
the distance to the nearest wall) and spread across the region:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
w = ramify.width_regions_interpolate(labels, lines) # laplace
|
|
164
|
+
w = ramify.width_regions_interpolate(labels, lines, method="nearest")
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- `method="laplace"` (default) spreads the centerline widths smoothly.
|
|
168
|
+
- `method="nearest"` copies the nearest centerline value. It is faster and
|
|
169
|
+
piecewise constant.
|
|
170
|
+
|
|
171
|
+
Boundaries between regions are not treated as walls, so widths don't shrink
|
|
172
|
+
at junctions.
|
|
173
|
+
|
|
174
|
+

|
|
175
|
+
|
|
176
|
+
### Open boundaries
|
|
177
|
+
|
|
178
|
+
Open boundaries do not apply to stations: `width_regions_stations` does not
|
|
179
|
+
use wall distances and has no `open_boundary`.
|
|
180
|
+
|
|
181
|
+
Branch sizes, claiming distances, and interpolated widths all depend on the
|
|
182
|
+
distance from each pixel to the shape's wall. By default every edge of the
|
|
183
|
+
mask is a wall. Where part of the edge is not a real wall, such as where the
|
|
184
|
+
shape is cut off by open water or the edge of the data, pass `open_boundary`:
|
|
185
|
+
a mask on the same grid marking the open area. Distances are then measured only to the
|
|
186
|
+
remaining real walls.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
import numpy as np
|
|
190
|
+
|
|
191
|
+
mask_bool = np.asarray(mask.values) == 1
|
|
192
|
+
rr, cc = np.ogrid[:mask_bool.shape[0], :mask_bool.shape[1]]
|
|
193
|
+
open_boundary = (~mask_bool) & (rr >= root[0] - 10) & (cc <= root[1] + 20)
|
|
194
|
+
|
|
195
|
+
labels, net, lines = ramify.partition_priority(mask, root, tips=tips,
|
|
196
|
+
open_boundary=open_boundary)
|
|
197
|
+
w = ramify.width_regions_interpolate(labels, lines, open_boundary=open_boundary)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Pass it to both steps so they measure against the same walls. Mark the open
|
|
201
|
+
area with some depth, not a thin strip: distances are measured through it, so a
|
|
202
|
+
one-pixel strip only moves the wall by one pixel.
|
|
203
|
+
|
|
204
|
+

|
|
205
|
+
|
|
206
|
+
## Single shapes
|
|
207
|
+
|
|
208
|
+
For a shape with no branches, or one region pulled out of a partition, use
|
|
209
|
+
the underlying functions directly:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
shape = labels == 3 # one region, pulled out of a partition
|
|
213
|
+
|
|
214
|
+
line = ramify.centerline(shape) # LineString
|
|
215
|
+
w, stations = ramify.width_stations(shape, line, spacing=50)
|
|
216
|
+
w = ramify.width_interpolate(shape, line)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`centerline` returns the longest path through the shape's skeleton. Give
|
|
220
|
+
`root=` to fix one end, or `root=` and `tip=` to fix both.
|
|
221
|
+
|
|
222
|
+

|
|
223
|
+
|
|
224
|
+
## Conventions
|
|
225
|
+
|
|
226
|
+
- **Coordinates.** Georeferenced input: geometry is in the input's CRS. Plain
|
|
227
|
+
arrays: `x = col * pixel_size`, `y = row * pixel_size` (y increases
|
|
228
|
+
downward), vertices at pixel centers.
|
|
229
|
+
- **Units.** Lengths, widths, `spacing`, `min_length` in map units; areas in
|
|
230
|
+
map units squared.
|
|
231
|
+
- **Points.** `root`, `tip`, `tips` are `(row, col)` pixel indices.
|
|
232
|
+
- **Orientation.** Every line runs downstream to upstream, starting at the
|
|
233
|
+
root end, wherever a root is known (`net`, `lines`, and `centerline` given
|
|
234
|
+
`root`). `centerline(shape)` called with neither `root` nor `tip` has no
|
|
235
|
+
root to start from; its direction is deterministic but arbitrary.
|
|
236
|
+
- **Output type.** Rasters match the input (`np.ndarray` or `xr.DataArray`);
|
|
237
|
+
NaN outside the shape for widths, 0 outside for labels.
|
|
238
|
+
|
|
239
|
+
## Development
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
git clone https://github.com/avkoehl/ramify.git
|
|
243
|
+
cd ramify
|
|
244
|
+
uv sync --extra dev
|
|
245
|
+
```
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
_HERE = Path(__file__).parent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load():
|
|
9
|
+
# returns (mask, root, tips): mask as xr.DataArray if rioxarray is
|
|
10
|
+
# available (falls back to numpy), root as (row, col), tips as list of
|
|
11
|
+
# (row, col)
|
|
12
|
+
try:
|
|
13
|
+
import rioxarray
|
|
14
|
+
|
|
15
|
+
mask = rioxarray.open_rasterio(_HERE / "example.tif").squeeze(drop=True)
|
|
16
|
+
root_arr = rioxarray.open_rasterio(_HERE / "root.tif").squeeze(drop=True).values
|
|
17
|
+
tips_arr = rioxarray.open_rasterio(_HERE / "tips.tif").squeeze(drop=True).values
|
|
18
|
+
except ImportError:
|
|
19
|
+
import tifffile
|
|
20
|
+
|
|
21
|
+
mask = tifffile.imread(_HERE / "example.tif")
|
|
22
|
+
root_arr = tifffile.imread(_HERE / "root.tif")
|
|
23
|
+
tips_arr = tifffile.imread(_HERE / "tips.tif")
|
|
24
|
+
|
|
25
|
+
rows, cols = np.nonzero(root_arr > 0)
|
|
26
|
+
if len(rows) != 1:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
f"root.tif must contain exactly 1 nonzero pixel, found {len(rows)}"
|
|
29
|
+
)
|
|
30
|
+
root = (int(rows[0]), int(cols[0]))
|
|
31
|
+
|
|
32
|
+
rows, cols = np.nonzero(tips_arr > 0)
|
|
33
|
+
tips = list(zip(rows.tolist(), cols.tolist()))
|
|
34
|
+
return mask, root, tips
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ramify"
|
|
7
|
-
version = "
|
|
8
|
-
description = "Segment and measure
|
|
7
|
+
version = "2.0.0"
|
|
8
|
+
description = "Segment and measure branching shapes: centerlines, hierarchical space partitioning, and width"
|
|
9
9
|
keywords = [
|
|
10
10
|
"centerline",
|
|
11
11
|
"skeleton",
|
|
@@ -27,11 +27,12 @@ classifiers = [
|
|
|
27
27
|
]
|
|
28
28
|
dependencies = [
|
|
29
29
|
"numpy",
|
|
30
|
+
"pandas",
|
|
30
31
|
"scipy",
|
|
31
32
|
"scikit-image",
|
|
32
|
-
"
|
|
33
|
+
"affine",
|
|
33
34
|
"rasterio",
|
|
34
|
-
"shapely",
|
|
35
|
+
"shapely>=2.0",
|
|
35
36
|
"geopandas",
|
|
36
37
|
"xarray",
|
|
37
38
|
"rioxarray",
|
|
@@ -40,6 +41,7 @@ dependencies = [
|
|
|
40
41
|
[project.optional-dependencies]
|
|
41
42
|
dev = [
|
|
42
43
|
"matplotlib",
|
|
44
|
+
"pytest",
|
|
43
45
|
]
|
|
44
46
|
|
|
45
47
|
[tool.hatch.build.targets.wheel]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .centerline import centerline
|
|
2
|
+
from .partition import partition_priority, partition_nearest
|
|
3
|
+
from .width import width_interpolate, width_stations
|
|
4
|
+
from .regions import width_regions_interpolate, width_regions_stations
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"centerline",
|
|
8
|
+
"partition_priority",
|
|
9
|
+
"partition_nearest",
|
|
10
|
+
"width_interpolate",
|
|
11
|
+
"width_stations",
|
|
12
|
+
"width_regions_interpolate",
|
|
13
|
+
"width_regions_stations",
|
|
14
|
+
]
|