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.
Files changed (57) hide show
  1. ramify-2.0.0/PKG-INFO +270 -0
  2. ramify-2.0.0/README.md +245 -0
  3. ramify-2.0.0/assets/abstract.png +0 -0
  4. ramify-2.0.0/assets/level.png +0 -0
  5. ramify-2.0.0/assets/open_boundary.png +0 -0
  6. ramify-2.0.0/assets/partition_methods.png +0 -0
  7. ramify-2.0.0/assets/quickstart.png +0 -0
  8. ramify-2.0.0/assets/single_shape.png +0 -0
  9. ramify-2.0.0/assets/tips_options.png +0 -0
  10. ramify-2.0.0/assets/widths_regions.png +0 -0
  11. ramify-2.0.0/assets/widths_stations.png +0 -0
  12. ramify-2.0.0/branch/data/__init__.py +34 -0
  13. {ramify-1.3.0 → ramify-2.0.0}/pyproject.toml +6 -4
  14. ramify-2.0.0/ramify/__init__.py +14 -0
  15. ramify-2.0.0/ramify/_io.py +250 -0
  16. ramify-2.0.0/ramify/_skeleton.py +185 -0
  17. ramify-2.0.0/ramify/centerline.py +124 -0
  18. ramify-2.0.0/ramify/data/mask.tif +0 -0
  19. ramify-2.0.0/ramify/data/root.tif +0 -0
  20. ramify-2.0.0/ramify/data/tips.tif +0 -0
  21. ramify-2.0.0/ramify/partition.py +575 -0
  22. ramify-2.0.0/ramify/regions.py +240 -0
  23. ramify-2.0.0/ramify/width.py +359 -0
  24. ramify-2.0.0/scripts/gen_images.py +401 -0
  25. ramify-2.0.0/scripts/progress_example.py +110 -0
  26. ramify-2.0.0/tests/conftest.py +27 -0
  27. ramify-2.0.0/tests/test_centerline.py +108 -0
  28. ramify-2.0.0/tests/test_io.py +48 -0
  29. ramify-2.0.0/tests/test_partition.py +160 -0
  30. ramify-2.0.0/tests/test_regions.py +297 -0
  31. ramify-2.0.0/tests/test_width.py +120 -0
  32. {ramify-1.3.0 → ramify-2.0.0}/uv.lock +51 -4
  33. ramify-1.3.0/PKG-INFO +0 -183
  34. ramify-1.3.0/README.md +0 -160
  35. ramify-1.3.0/assets/abstract.png +0 -0
  36. ramify-1.3.0/assets/extract_centerlines_auto.png +0 -0
  37. ramify-1.3.0/assets/extract_centerlines_tips.png +0 -0
  38. ramify-1.3.0/assets/interpolate_widths_domain.png +0 -0
  39. ramify-1.3.0/assets/interpolate_widths_nearest.png +0 -0
  40. ramify-1.3.0/assets/open_boundary.png +0 -0
  41. ramify-1.3.0/assets/partition_by_nearest.png +0 -0
  42. ramify-1.3.0/assets/partition_by_priority.png +0 -0
  43. ramify-1.3.0/assets/subdivide_regions.png +0 -0
  44. ramify-1.3.0/ramify/__init__.py +0 -12
  45. ramify-1.3.0/ramify/_io.py +0 -107
  46. ramify-1.3.0/ramify/centerline.py +0 -356
  47. ramify-1.3.0/ramify/partition.py +0 -215
  48. ramify-1.3.0/ramify/width.py +0 -275
  49. ramify-1.3.0/scripts/gen_images.py +0 -232
  50. ramify-1.3.0/scripts/progress_example.py +0 -124
  51. {ramify-1.3.0 → ramify-2.0.0}/.github/workflows/publish.yml +0 -0
  52. {ramify-1.3.0 → ramify-2.0.0}/.gitignore +0 -0
  53. {ramify-1.3.0 → ramify-2.0.0}/LICENSE +0 -0
  54. /ramify-1.3.0/ramify/data/mask.tif → /ramify-2.0.0/branch/data/example.tif +0 -0
  55. {ramify-1.3.0/ramify → ramify-2.0.0/branch}/data/root.tif +0 -0
  56. {ramify-1.3.0/ramify → ramify-2.0.0/branch}/data/tips.tif +0 -0
  57. {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
+ ![graphical abstract](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/abstract.png)
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
+ ![quickstart](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/quickstart.png)
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
+ ![tips options](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/tips_options.png)
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
+ ![station widths](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/widths_stations.png)
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
+ ![partition methods](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/partition_methods.png)
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
+ ![level](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/level.png)
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
+ ![interpolated widths](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/widths_regions.png)
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
+ ![open boundary](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/open_boundary.png)
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
+ ![single shape](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/single_shape.png)
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
+ ![graphical abstract](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/abstract.png)
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
+ ![quickstart](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/quickstart.png)
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
+ ![tips options](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/tips_options.png)
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
+ ![station widths](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/widths_stations.png)
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
+ ![partition methods](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/partition_methods.png)
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
+ ![level](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/level.png)
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
+ ![interpolated widths](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/widths_regions.png)
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
+ ![open boundary](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/open_boundary.png)
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
+ ![single shape](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/single_shape.png)
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
@@ -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 = "1.3.0"
8
- description = "Segment and measure binary branching shapes: centerline extraction, hierarchical space partitioning, and width interpolation"
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
- "networkx",
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
+ ]