ramify 2.0.0__tar.gz → 2.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.
Files changed (46) hide show
  1. {ramify-2.0.0 → ramify-2.1.0}/PKG-INFO +1 -1
  2. ramify-2.1.0/assets/abstract.png +0 -0
  3. {ramify-2.0.0 → ramify-2.1.0}/assets/level.png +0 -0
  4. ramify-2.1.0/assets/open_boundary.png +0 -0
  5. ramify-2.1.0/assets/quickstart.png +0 -0
  6. ramify-2.1.0/assets/tips_options.png +0 -0
  7. ramify-2.1.0/assets/widths_regions.png +0 -0
  8. ramify-2.1.0/assets/widths_stations.png +0 -0
  9. {ramify-2.0.0 → ramify-2.1.0}/pyproject.toml +1 -1
  10. {ramify-2.0.0 → ramify-2.1.0}/ramify/_skeleton.py +36 -0
  11. {ramify-2.0.0 → ramify-2.1.0}/ramify/centerline.py +10 -1
  12. {ramify-2.0.0 → ramify-2.1.0}/ramify/partition.py +21 -0
  13. {ramify-2.0.0 → ramify-2.1.0}/scripts/gen_images.py +2 -2
  14. ramify-2.1.0/tests/conftest.py +73 -0
  15. {ramify-2.0.0 → ramify-2.1.0}/tests/test_centerline.py +58 -0
  16. {ramify-2.0.0 → ramify-2.1.0}/tests/test_partition.py +62 -0
  17. {ramify-2.0.0 → ramify-2.1.0}/uv.lock +1 -1
  18. ramify-2.0.0/assets/abstract.png +0 -0
  19. ramify-2.0.0/assets/open_boundary.png +0 -0
  20. ramify-2.0.0/assets/quickstart.png +0 -0
  21. ramify-2.0.0/assets/tips_options.png +0 -0
  22. ramify-2.0.0/assets/widths_regions.png +0 -0
  23. ramify-2.0.0/assets/widths_stations.png +0 -0
  24. ramify-2.0.0/tests/conftest.py +0 -27
  25. {ramify-2.0.0 → ramify-2.1.0}/.github/workflows/publish.yml +0 -0
  26. {ramify-2.0.0 → ramify-2.1.0}/.gitignore +0 -0
  27. {ramify-2.0.0 → ramify-2.1.0}/LICENSE +0 -0
  28. {ramify-2.0.0 → ramify-2.1.0}/README.md +0 -0
  29. {ramify-2.0.0 → ramify-2.1.0}/assets/partition_methods.png +0 -0
  30. {ramify-2.0.0 → ramify-2.1.0}/assets/single_shape.png +0 -0
  31. {ramify-2.0.0 → ramify-2.1.0}/branch/data/__init__.py +0 -0
  32. {ramify-2.0.0 → ramify-2.1.0}/branch/data/example.tif +0 -0
  33. {ramify-2.0.0 → ramify-2.1.0}/branch/data/root.tif +0 -0
  34. {ramify-2.0.0 → ramify-2.1.0}/branch/data/tips.tif +0 -0
  35. {ramify-2.0.0 → ramify-2.1.0}/ramify/__init__.py +0 -0
  36. {ramify-2.0.0 → ramify-2.1.0}/ramify/_io.py +0 -0
  37. {ramify-2.0.0 → ramify-2.1.0}/ramify/data/__init__.py +0 -0
  38. {ramify-2.0.0 → ramify-2.1.0}/ramify/data/mask.tif +0 -0
  39. {ramify-2.0.0 → ramify-2.1.0}/ramify/data/root.tif +0 -0
  40. {ramify-2.0.0 → ramify-2.1.0}/ramify/data/tips.tif +0 -0
  41. {ramify-2.0.0 → ramify-2.1.0}/ramify/regions.py +0 -0
  42. {ramify-2.0.0 → ramify-2.1.0}/ramify/width.py +0 -0
  43. {ramify-2.0.0 → ramify-2.1.0}/scripts/progress_example.py +0 -0
  44. {ramify-2.0.0 → ramify-2.1.0}/tests/test_io.py +0 -0
  45. {ramify-2.0.0 → ramify-2.1.0}/tests/test_regions.py +0 -0
  46. {ramify-2.0.0 → ramify-2.1.0}/tests/test_width.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ramify
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Segment and measure branching shapes: centerlines, hierarchical space partitioning, and width
5
5
  License-File: LICENSE
6
6
  Keywords: centerline,geomorphology,medial-axis,morphometry,raster,river,segmentation,skeleton,valley,width
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ramify"
7
- version = "2.0.0"
7
+ version = "2.1.0"
8
8
  description = "Segment and measure branching shapes: centerlines, hierarchical space partitioning, and width"
9
9
  keywords = [
10
10
  "centerline",
@@ -5,6 +5,7 @@ partition network construction.
5
5
  import heapq
6
6
 
7
7
  import numpy as np
8
+ from skimage.draw import line as draw_line
8
9
  from skimage.morphology import skeletonize
9
10
  from skimage.graph import MCP_Geometric
10
11
 
@@ -183,3 +184,38 @@ def prune_short_leaves(nodes, threshold_fn):
183
184
  if not removed_any:
184
185
  break
185
186
  return kept
187
+
188
+
189
+ def disk_cut(path_nodes, half_width, mask_bool, fill=False):
190
+ """Replace the start of `path_nodes` with one straight chord.
191
+
192
+ Near an end, the skeleton forks toward the shape's corners (or runs past a
193
+ point placed on the side of a pointed end), so a path snapped to it swerves
194
+ before reaching the trunk. Every path node whose inscribed disk (radius =
195
+ local half-width, pixel units) reaches the anchor `path_nodes[0]` lies in
196
+ the same end blob; cut to the farthest such node and join it to the anchor
197
+ directly. The chord stays inside that disk, hence inside the shape -- up to
198
+ the slack, so each candidate chord is also rasterized and checked against
199
+ the mask, falling back to nearer nodes and finally the uncut path.
200
+
201
+ `fill=False` joins anchor and node as two consecutive vertices (enough for
202
+ a LineString); `fill=True` puts the chord's rasterized pixels in between,
203
+ keeping the path 8-connected for callers that work on pixel lists.
204
+ """
205
+ anchor = np.array(path_nodes[0], dtype=float)
206
+ pts = np.array(path_nodes, dtype=float)
207
+ d = np.hypot(*(pts - anchor).T)
208
+ r = half_width[pts[:, 0].astype(int), pts[:, 1].astype(int)]
209
+ slack = np.maximum(1.0, 0.1 * r)
210
+ passing = np.nonzero(d <= r + slack)[0]
211
+
212
+ for i in passing[::-1]:
213
+ if i <= 1:
214
+ break # already adjacent to the anchor: nothing to cut
215
+ rr, cc = draw_line(*path_nodes[0], *path_nodes[i])
216
+ if mask_bool[rr, cc].all():
217
+ if fill:
218
+ chord = list(zip(rr.tolist(), cc.tolist()))
219
+ return chord[:-1] + list(path_nodes[i:])
220
+ return [path_nodes[0]] + list(path_nodes[i:])
221
+ return list(path_nodes)
@@ -13,6 +13,7 @@ from ._skeleton import (
13
13
  endpoints,
14
14
  tree_path,
15
15
  prune_short_leaves,
16
+ disk_cut,
16
17
  )
17
18
 
18
19
 
@@ -25,7 +26,10 @@ def centerline(mask, root=None, tip=None, pixel_size=None) -> LineString:
25
26
  - `tip` without `root`: raises `ValueError`.
26
27
 
27
28
  `root` / `tip` are `(row, col)` pixel indices. Orientation: starts at
28
- `root` when given; otherwise deterministic but unspecified.
29
+ `root` when given; otherwise deterministic but unspecified. Near a given
30
+ `root` / `tip`, the line leaves the skeleton and runs straight to the
31
+ point from the farthest skeleton pixel whose inscribed disk reaches it,
32
+ so it doesn't swerve into the forks a skeleton makes at a shape's end.
29
33
 
30
34
  This is the *longest* path through the shape, deliberately not the
31
35
  heaviest (widest) one that `partition_priority`/`partition_nearest` would
@@ -109,6 +113,11 @@ def centerline(mask, root=None, tip=None, pixel_size=None) -> LineString:
109
113
 
110
114
  path_nodes = list(reversed(tree_path(parent1, b, stop=a)))
111
115
 
116
+ if root is not None:
117
+ path_nodes = disk_cut(path_nodes, half_width, mask_bool)
118
+ if tip is not None:
119
+ path_nodes = disk_cut(path_nodes[::-1], half_width, mask_bool)[::-1]
120
+
112
121
  rows = np.array([n[0] for n in path_nodes])
113
122
  cols = np.array([n[1] for n in path_nodes])
114
123
  xs, ys = grid.to_xy(rows, cols)
@@ -28,6 +28,7 @@ from ._skeleton import (
28
28
  prune_short_leaves,
29
29
  path_length,
30
30
  path_weight,
31
+ disk_cut,
31
32
  )
32
33
  from .centerline import centerline
33
34
 
@@ -153,10 +154,30 @@ def _build_network(mask_bool, root, tips, min_length, path_by, open_boundary, pi
153
154
  n = parent[n]
154
155
 
155
156
  segments = break_into_segments(kept, parent, tip_nodes, root)
157
+ segments = _cut_end_segments(
158
+ segments, root, set(tip_points), half_width_pixels(mask_bool, None), mask_bool
159
+ )
156
160
  edt = local_half_width(mask_bool, open_boundary, pixel_size)
157
161
  return _annotate(segments, edt, pixel_size, path_by, root)
158
162
 
159
163
 
164
+ def _cut_end_segments(segments, root, anchors, half_width, mask_bool):
165
+ # Straighten the network where it meets the root and each given tip (see
166
+ # `disk_cut`). Each cut is confined to the one segment touching that
167
+ # point: a segment's other end is a junction, so the tree's topology and
168
+ # shared junction pixels are untouched. Auto-detected tips are skeleton
169
+ # endpoints already and have nothing to cut toward. Segments run
170
+ # upstream -> downstream, so the root is at seg[-1] and a tip at seg[0].
171
+ out = []
172
+ for seg in segments:
173
+ if seg[-1] == root:
174
+ seg = disk_cut(seg[::-1], half_width, mask_bool, fill=True)[::-1]
175
+ if seg[0] in anchors:
176
+ seg = disk_cut(seg, half_width, mask_bool, fill=True)
177
+ out.append(seg)
178
+ return out
179
+
180
+
160
181
  def _annotate(segments, edt, pixel_size, path_by, root):
161
182
  n = len(segments)
162
183
  start_of = {seg[0]: i for i, seg in enumerate(segments)}
@@ -265,13 +265,13 @@ save("quickstart.png", fig, tight=False)
265
265
 
266
266
  with warnings.catch_warnings():
267
267
  warnings.simplefilter("ignore")
268
- _, net_ml, _ = ramify.partition_priority(mask, root, min_length=15.0)
268
+ _, net_ml, _ = ramify.partition_priority(mask, root, min_length=100.0)
269
269
  _, net_all, _ = ramify.partition_priority(mask, root)
270
270
 
271
271
  fig, axes = plt.subplots(1, 3, figsize=(16, 6))
272
272
  for ax, net_i, title in [
273
273
  (axes[0], net, f"tips=tips ({len(tips)} tips)"),
274
- (axes[1], net_ml, f"min_length=15 ({net_ml['path_id'].nunique()} paths)"),
274
+ (axes[1], net_ml, f"min_length=100 ({net_ml['path_id'].nunique()} paths)"),
275
275
  (axes[2], net_all, f"neither ({net_all['path_id'].nunique()} paths)"),
276
276
  ]:
277
277
  ax.imshow(mask_arr, cmap="gray_r", alpha=0.25)
@@ -0,0 +1,73 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+
5
+ @pytest.fixture
6
+ def straight_bar():
7
+ """A 1-pixel-wide horizontal bar, root at one end, tip at the other."""
8
+ mask = np.zeros((5, 20), dtype=np.uint8)
9
+ mask[2, 1:19] = 1
10
+ return mask, (2, 1), (2, 18)
11
+
12
+
13
+ @pytest.fixture
14
+ def stubbed_bar():
15
+ """A long horizontal bar with a short stub off the middle -- the stub
16
+ should be trimmed away when choosing endpoints without an explicit tip."""
17
+ mask = np.zeros((10, 20), dtype=np.uint8)
18
+ mask[5, 1:19] = 1
19
+ mask[3:5, 10] = 1
20
+ return mask
21
+
22
+
23
+ @pytest.fixture
24
+ def toy_dataset():
25
+ from ramify.data import load
26
+
27
+ return load()
28
+
29
+
30
+ @pytest.fixture
31
+ def flared_bar():
32
+ """A thick horizontal bar whose left end flares into a fishtail, so the
33
+ skeleton forks into a Y there. Root sits on the flared end's edge."""
34
+ from skimage.draw import polygon
35
+
36
+ mask = np.zeros((40, 70), dtype=np.uint8)
37
+ rr, cc = polygon([4, 12, 12, 28, 28, 36], [4, 24, 66, 66, 24, 4])
38
+ mask[rr, cc] = 1
39
+ return mask, (20, 4), (20, 65)
40
+
41
+
42
+ @pytest.fixture
43
+ def end_deviation():
44
+ """How far the first `length` of a line strays from the straight chord
45
+ over that stretch: ~0 for a clean end, several pixels for a swerve into a
46
+ skeleton fork arm or a sideways snap."""
47
+ from shapely.geometry import LineString, Point
48
+
49
+ def fn(line, length=15.0):
50
+ chord = LineString([line.coords[0], line.interpolate(length).coords[0]])
51
+ return max(
52
+ chord.distance(Point(p))
53
+ for p in line.coords
54
+ if line.project(Point(p)) <= length
55
+ )
56
+
57
+ return fn
58
+
59
+
60
+ @pytest.fixture
61
+ def inside_mask():
62
+ """Whether every pixel a (pixel_size=1, numpy-grid) line crosses is in `mask`."""
63
+ from skimage.draw import line as draw_line
64
+
65
+ def fn(line, mask):
66
+ pix = [(int(y - 0.5), int(x - 0.5)) for x, y in line.coords]
67
+ for (r0, c0), (r1, c1) in zip(pix[:-1], pix[1:]):
68
+ rr, cc = draw_line(r0, c0, r1, c1)
69
+ if not mask[rr, cc].all():
70
+ return False
71
+ return True
72
+
73
+ return fn
@@ -106,3 +106,61 @@ def test_output_is_plain_ndarray_free(toy_dataset):
106
106
  mask_np = (np.asarray(mask.values) == 1).astype(np.uint8)
107
107
  assert isinstance(ramify.centerline(mask, root=root), LineString)
108
108
  assert isinstance(ramify.centerline(mask_np, root=root), LineString)
109
+
110
+
111
+ def test_root_end_skips_skeleton_fork(flared_bar, end_deviation, inside_mask):
112
+ mask, root, _ = flared_bar
113
+ line = ramify.centerline(mask, root=root)
114
+ assert line.coords[0] == (root[1] + 0.5, root[0] + 0.5)
115
+ assert end_deviation(line) < 1.0 # uncut path swerves ~5 px into a fork arm
116
+ assert inside_mask(line, mask)
117
+
118
+
119
+ def test_tip_end_skips_skeleton_fork(flared_bar, end_deviation, inside_mask):
120
+ mask, tip, root = flared_bar # flared end as the tip this time
121
+ line = ramify.centerline(mask, root=root, tip=tip)
122
+ assert line.coords[-1] == (tip[1] + 0.5, tip[0] + 0.5)
123
+ assert end_deviation(LineString(line.coords[::-1])) < 1.0
124
+ assert inside_mask(line, mask)
125
+
126
+
127
+ def test_root_end_has_no_sideways_snap(toy_dataset, end_deviation, inside_mask):
128
+ # the bundled root sits on the side of a pointed end; the skeleton runs
129
+ # past it toward the point, so the uncut path makes a right-angle turn
130
+ mask, root, _ = toy_dataset
131
+ mask_np = (np.asarray(mask.values) == 1).astype(np.uint8)
132
+ line = ramify.centerline(mask_np, root=root)
133
+ assert end_deviation(line) < 1.0 # ~4.8 px before the cut
134
+ assert inside_mask(line, mask_np)
135
+
136
+
137
+ def test_disk_cut_rejects_chord_leaving_mask():
138
+ from ramify._skeleton import disk_cut
139
+
140
+ # a path that detours around a background pixel; every node's disk is
141
+ # large enough to reach the anchor, but the straight chord to the far
142
+ # nodes crosses the hole, so the cut must fall back to a nearer node
143
+ mask = np.ones((5, 7), dtype=bool)
144
+ mask[2, 3] = False
145
+ half_width = np.full(mask.shape, 10.0)
146
+ path = [(2, 1), (2, 2), (1, 3), (2, 4), (2, 5)]
147
+ assert disk_cut(path, half_width, mask) == [(2, 1), (1, 3), (2, 4), (2, 5)]
148
+
149
+
150
+ def test_disk_cut_leaves_path_when_no_disk_reaches_anchor():
151
+ from ramify._skeleton import disk_cut
152
+
153
+ mask = np.ones((5, 7), dtype=bool)
154
+ half_width = np.zeros(mask.shape)
155
+ path = [(2, 0), (2, 1), (2, 2), (1, 3), (2, 4)]
156
+ assert disk_cut(path, half_width, mask) == path
157
+
158
+
159
+ def test_disk_cut_fill_keeps_path_pixel_connected():
160
+ from ramify._skeleton import disk_cut
161
+
162
+ mask = np.ones((7, 9), dtype=bool)
163
+ half_width = np.full(mask.shape, 10.0)
164
+ path = [(3, 0), (4, 1), (4, 2), (3, 3), (3, 4), (3, 5)]
165
+ assert disk_cut(path, half_width, mask) == [(3, 0), (3, 5)]
166
+ assert disk_cut(path, half_width, mask, fill=True) == [(3, c) for c in range(6)]
@@ -158,3 +158,65 @@ def test_length_path_by_uses_max_not_sum():
158
158
  assert by_head[(0, 5)]["path_id"] == trunk_path
159
159
  assert by_head[(0, 15)]["path_id"] == trunk_path # longest single route wins
160
160
  assert by_head[(3, 5)]["path_id"] != trunk_path
161
+
162
+
163
+ def test_net_root_end_has_no_sideways_snap(toy_dataset, end_deviation):
164
+ # same straightening as centerline(): the bundled root sits beside the
165
+ # skeleton, which the uncut network meets at a right angle
166
+ mask, root, tips = toy_dataset
167
+ mask_np = (np.asarray(mask.values) == 1).astype(np.uint8)
168
+ _, net, _ = ramify.partition_priority(mask_np, root, tips=tips)
169
+ outlet = net[net["downstream_segment_id"].isna()].geometry.iloc[0]
170
+ assert outlet.coords[0] == (root[1] + 0.5, root[0] + 0.5)
171
+ assert end_deviation(outlet) < 1.0 # ~4.8 px before the cut
172
+
173
+
174
+ def test_net_tip_end_skips_skeleton_fork(flared_bar, end_deviation):
175
+ from shapely.geometry import LineString
176
+
177
+ mask, tip, root = flared_bar # flared end as the (given) tip
178
+ _, net, _ = ramify.partition_priority(mask, root, tips=[tip])
179
+ (line,) = net.geometry # no junctions: one segment, cut at both ends
180
+ assert line.coords[-1] == (tip[1] + 0.5, tip[0] + 0.5)
181
+ assert end_deviation(LineString(line.coords[::-1])) < 1.0
182
+
183
+
184
+ def _network_is_intact(df, mask_bool):
185
+ by_id = dict(zip(df["segment_id"], df["pixels"]))
186
+ for seg_id, down, pixels in zip(df["segment_id"], df["downstream_segment_id"], df["pixels"]):
187
+ steps = zip(pixels[:-1], pixels[1:])
188
+ if not all(max(abs(a[0] - b[0]), abs(a[1] - b[1])) == 1 for a, b in steps):
189
+ return False
190
+ if not all(mask_bool[r, c] for r, c in pixels):
191
+ return False
192
+ # segments run upstream -> downstream, sharing the junction pixel
193
+ if not pd.isna(down) and pixels[-1] != by_id[int(down)][0]:
194
+ return False
195
+ return True
196
+
197
+
198
+ def test_end_cut_stops_at_junction_near_root():
199
+ from skimage.draw import polygon
200
+ from ramify.partition import _build_network
201
+
202
+ # flared end with a thin tributary joining inside the root's end disk:
203
+ # the root cut must stay on the short root segment, not swallow the
204
+ # junction
205
+ mask = np.zeros((50, 70), dtype=np.uint8)
206
+ rr, cc = polygon([14, 22, 22, 38, 38, 46], [4, 24, 66, 66, 24, 4])
207
+ mask[rr, cc] = 1
208
+ mask[1:20, 14:16] = 1
209
+ root, tips = (30, 4), [(30, 65), (1, 14)]
210
+ df = _build_network(mask > 0, root, tips, None, "area", None, 1.0)
211
+ assert len(df) == 3
212
+ assert (df["downstream_segment_id"].isna()).sum() == 1
213
+ assert _network_is_intact(df, mask > 0)
214
+
215
+
216
+ def test_end_cuts_keep_toy_network_intact(toy_dataset):
217
+ from ramify.partition import _build_network
218
+
219
+ mask, root, tips = toy_dataset
220
+ mask_bool = np.asarray(mask.values) == 1
221
+ df = _build_network(mask_bool, root, tips, None, "area", None, 1.0)
222
+ assert _network_is_intact(df, mask_bool)
@@ -735,7 +735,7 @@ wheels = [
735
735
 
736
736
  [[package]]
737
737
  name = "ramify"
738
- version = "2.0.0"
738
+ version = "2.1.0"
739
739
  source = { editable = "." }
740
740
  dependencies = [
741
741
  { name = "affine" },
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -1,27 +0,0 @@
1
- import numpy as np
2
- import pytest
3
-
4
-
5
- @pytest.fixture
6
- def straight_bar():
7
- """A 1-pixel-wide horizontal bar, root at one end, tip at the other."""
8
- mask = np.zeros((5, 20), dtype=np.uint8)
9
- mask[2, 1:19] = 1
10
- return mask, (2, 1), (2, 18)
11
-
12
-
13
- @pytest.fixture
14
- def stubbed_bar():
15
- """A long horizontal bar with a short stub off the middle -- the stub
16
- should be trimmed away when choosing endpoints without an explicit tip."""
17
- mask = np.zeros((10, 20), dtype=np.uint8)
18
- mask[5, 1:19] = 1
19
- mask[3:5, 10] = 1
20
- return mask
21
-
22
-
23
- @pytest.fixture
24
- def toy_dataset():
25
- from ramify.data import load
26
-
27
- return load()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes