ramify 1.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
ramify/_io.py ADDED
@@ -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
ramify/centerline.py ADDED
@@ -0,0 +1,356 @@
1
+ import heapq
2
+ from dataclasses import dataclass, field
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ from scipy.ndimage import distance_transform_edt
7
+ from skimage.morphology import skeletonize
8
+ from skimage.graph import MCP_Geometric
9
+
10
+ from ._io import unwrap, wrap, edt_field, GridMeta
11
+
12
+ SQRT2 = np.sqrt(2.0)
13
+ _OFFSETS = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
14
+
15
+ Pixel = tuple[int, int]
16
+
17
+
18
+ @dataclass
19
+ class Network:
20
+ segments: pd.DataFrame # segment_id, path_id, strahler, length, weight,
21
+ # downstream_segment_id, pixels
22
+ shape: tuple[int, int]
23
+ pixel_size: float
24
+ root: Pixel
25
+ tips: list[Pixel]
26
+ _meta: GridMeta | None = field(default=None, repr=False)
27
+
28
+ def rasterize(self, by=None):
29
+ if by is not None and by not in ("path", "segment"):
30
+ raise ValueError(f"by must be None, 'path', or 'segment', got {by!r}")
31
+
32
+ flat, counts = self._pixel_index()
33
+ if by is None:
34
+ arr = np.zeros(self.shape, dtype=np.uint8)
35
+ arr.ravel()[flat] = 1
36
+ return wrap(arr, self._meta)
37
+
38
+ column = "path_id" if by == "path" else "segment_id"
39
+ values = np.repeat(self.segments[column].to_numpy(), counts)
40
+
41
+ # A junction pixel belongs to several segments, so the writes collide.
42
+ # The old loop wrote paths in descending path_id and let later writes
43
+ # win, which means: lowest path_id takes the pixel (so the mainstem,
44
+ # path_id == 1, wins), and within one path the later row in `segments`
45
+ # takes it. Reproduce that by sorting the collisions together and
46
+ # keeping one winner each, rather than replaying the writes in order --
47
+ # same answer, and no O(paths^2) regrouping to get there.
48
+ path_ids = np.repeat(self.segments["path_id"].to_numpy(), counts)
49
+ seq = np.repeat(np.arange(len(self.segments)), counts)
50
+ order = np.lexsort((-seq, path_ids, flat)) # last key sorts first
51
+ winner = order[np.flatnonzero(
52
+ np.r_[True, flat[order][1:] != flat[order][:-1]]
53
+ )]
54
+
55
+ arr = np.zeros(self.shape, dtype=np.uint32)
56
+ arr.ravel()[flat[winner]] = values[winner]
57
+ return wrap(arr, self._meta)
58
+
59
+ def _pixel_index(self):
60
+ # Flat indices of every segment's pixels, concatenated in `segments`
61
+ # row order, plus each segment's pixel count so the per-segment columns
62
+ # can be np.repeat'd out to match.
63
+ pixels = [np.asarray(p).reshape(-1, 2) for p in self.segments["pixels"]]
64
+ counts = np.fromiter((len(p) for p in pixels), np.intp, len(pixels))
65
+ rc = np.concatenate(pixels) if pixels else np.empty((0, 2), np.intp)
66
+ return rc[:, 0].astype(np.intp) * self.shape[1] + rc[:, 1], counts
67
+
68
+ def to_gdf(self):
69
+ if self._meta is None or self._meta.transform is None:
70
+ raise ValueError(
71
+ "to_gdf requires a georeferenced xr.DataArray input to extract()"
72
+ )
73
+ import geopandas as gpd
74
+ import rasterio.transform
75
+ from shapely.geometry import LineString
76
+
77
+ keep, geoms = [], []
78
+ for i, pixels in enumerate(self.segments["pixels"]):
79
+ if len(pixels) < 2:
80
+ continue
81
+ rows, cols = zip(*pixels)
82
+ xs, ys = rasterio.transform.xy(self._meta.transform, rows, cols)
83
+ geoms.append(LineString(zip(xs, ys)))
84
+ keep.append(i)
85
+
86
+ gdf = self.segments.iloc[keep].drop(columns=["pixels"]).copy()
87
+ gdf["geometry"] = geoms
88
+ return gpd.GeoDataFrame(gdf, geometry="geometry", crs=self._meta.crs)
89
+
90
+
91
+ def extract(mask, root, tips=None, path_by="area", pixel_size=None,
92
+ open_boundary=None) -> Network:
93
+ if path_by not in ("area", "length", "strahler"):
94
+ raise ValueError(
95
+ f"path_by must be 'area', 'length', or 'strahler', got {path_by!r}"
96
+ )
97
+
98
+ mask_arr, px, meta = unwrap(mask, pixel_size)
99
+ mask_bool = mask_arr == 1
100
+ root = (int(root[0]), int(root[1]))
101
+ if not mask_bool[root]:
102
+ raise ValueError(f"root {root} is not inside the mask")
103
+
104
+ # 1. skeletonize
105
+ nodes = _skeleton_nodes(mask_bool)
106
+ if not nodes:
107
+ raise ValueError("skeletonization produced no pixels")
108
+
109
+ # 2-3. trace root and provided tips onto the skeleton (mask-constrained)
110
+ points = [root] + ([tuple(map(int, t)) for t in tips] if tips else [])
111
+ traces = _snap_paths(points, nodes, mask_bool)
112
+ if traces[0] is None:
113
+ raise ValueError(f"root {root} cannot reach the skeleton within the mask")
114
+ snapped_tips = []
115
+ if tips:
116
+ for t, tr in zip(points[1:], traces[1:]):
117
+ if tr is None:
118
+ raise ValueError(f"tip {t} cannot reach the skeleton within the mask")
119
+ snapped_tips = points[1:]
120
+ for tr in traces:
121
+ nodes.update(tr)
122
+
123
+ # 4-5a. shortest-path tree from root (guarantees the result is a tree,
124
+ # even when mask holes create skeleton loops)
125
+ parent, dist = _dijkstra_tree(nodes, root)
126
+
127
+ # 5b. resolve tips
128
+ if tips:
129
+ for t in snapped_tips:
130
+ if t not in parent:
131
+ raise ValueError(f"tip {t} is not connected to the root")
132
+ tip_nodes = snapped_tips
133
+ else:
134
+ tip_nodes = [n for n in _endpoints(nodes) if n != root and n in parent]
135
+ if not tip_nodes:
136
+ raise ValueError(
137
+ "no tips found: skeleton has no endpoints reachable from root"
138
+ )
139
+
140
+ # 5c. keep only pixels on some tip -> root path
141
+ kept = set()
142
+ for t in tip_nodes:
143
+ n = t
144
+ while n is not None and n not in kept:
145
+ kept.add(n)
146
+ n = parent[n]
147
+
148
+ # 6. orient and break into segments at tips and junctions
149
+ segments = _to_segments(kept, parent, tip_nodes, root)
150
+
151
+ # 7. annotate
152
+ edt = distance_transform_edt(edt_field(mask_bool, open_boundary)) * px
153
+ df = _annotate(segments, edt, px, path_by, root)
154
+
155
+ return Network(
156
+ segments=df,
157
+ shape=mask_bool.shape,
158
+ pixel_size=px,
159
+ root=root,
160
+ tips=[t for t in tip_nodes],
161
+ _meta=meta,
162
+ )
163
+
164
+
165
+ # -- skeleton and snapping ---------------------------------------------------
166
+
167
+
168
+ def _skeleton_nodes(mask_bool) -> set:
169
+ skel = skeletonize(mask_bool)
170
+ rows, cols = np.nonzero(skel)
171
+ return set(zip(rows.tolist(), cols.tolist()))
172
+
173
+
174
+ def _snap_paths(points, nodes, mask_bool):
175
+ # least-cost path from the skeleton to each point, constrained to the mask;
176
+ # returns list of pixel-paths (or None if unreachable), aligned with points
177
+ penalty = np.where(mask_bool, 1.0, np.inf)
178
+ mcp = MCP_Geometric(penalty)
179
+ # only points off the skeleton need tracing; pass them as ends so the flood
180
+ # stops once they are reached (they sit on/near the skeleton) instead of
181
+ # filling the whole array. Costs and tracebacks for the reached ends are
182
+ # identical to the full-flood result.
183
+ ends = [list(p) for p in points if p not in nodes]
184
+ if ends:
185
+ mcp.find_costs(starts=[list(n) for n in nodes], ends=ends)
186
+
187
+ out = []
188
+ for p in points:
189
+ if p in nodes:
190
+ out.append([p])
191
+ continue
192
+ try:
193
+ path = mcp.traceback(list(p))
194
+ except ValueError:
195
+ out.append(None)
196
+ continue
197
+ out.append([(int(r), int(c)) for r, c in path] if path else None)
198
+ return out
199
+
200
+
201
+ # -- tree construction -------------------------------------------------------
202
+
203
+
204
+ def _dijkstra_tree(nodes, root):
205
+ # parent[n] is the neighbor of n one step closer to root (downstream)
206
+ dist = {root: 0.0}
207
+ parent = {root: None}
208
+ heap = [(0.0, root)]
209
+ while heap:
210
+ d, n = heapq.heappop(heap)
211
+ if d > dist[n]:
212
+ continue
213
+ r, c = n
214
+ for dr, dc in _OFFSETS:
215
+ m = (r + dr, c + dc)
216
+ if m not in nodes:
217
+ continue
218
+ nd = d + (SQRT2 if dr and dc else 1.0)
219
+ if nd < dist.get(m, np.inf):
220
+ dist[m] = nd
221
+ parent[m] = n
222
+ heapq.heappush(heap, (nd, m))
223
+ return parent, dist
224
+
225
+
226
+ def _endpoints(nodes):
227
+ out = []
228
+ for r, c in nodes:
229
+ degree = sum((r + dr, c + dc) in nodes for dr, dc in _OFFSETS)
230
+ if degree == 1:
231
+ out.append((r, c))
232
+ return out
233
+
234
+
235
+ def _to_segments(kept, parent, tip_nodes, root):
236
+ # segments are ordered upstream -> downstream; junction pixels are shared:
237
+ # last pixel of each upstream segment, first pixel of the downstream one
238
+ n_children = {}
239
+ for n in kept:
240
+ p = parent[n]
241
+ if p is not None:
242
+ n_children[p] = n_children.get(p, 0) + 1
243
+
244
+ breakpoints = set(tip_nodes) | {n for n, k in n_children.items() if k > 1}
245
+ stops = breakpoints | {root}
246
+
247
+ segments = []
248
+ for s in breakpoints:
249
+ if s == root:
250
+ continue
251
+ seg = [s]
252
+ cur = parent[s]
253
+ while cur not in stops:
254
+ seg.append(cur)
255
+ cur = parent[cur]
256
+ seg.append(cur)
257
+ segments.append(seg)
258
+ return segments
259
+
260
+
261
+ # -- annotation ---------------------------------------------------------------
262
+
263
+
264
+ def _annotate(segments, edt, pixel_size, path_by, root):
265
+ n = len(segments)
266
+ start_of = {seg[0]: i for i, seg in enumerate(segments)}
267
+ downstream = [start_of.get(seg[-1]) for seg in segments] # None at root
268
+ children = [[] for _ in range(n)]
269
+ for i, d in enumerate(downstream):
270
+ if d is not None:
271
+ children[d].append(i)
272
+
273
+ length = np.array([_seg_length(s, pixel_size) for s in segments])
274
+ weight = np.array([_seg_weight(s, edt, pixel_size) for s in segments])
275
+
276
+ # post-order accumulation (iterative)
277
+ strahler = np.zeros(n, dtype=int)
278
+ sub_length = np.zeros(n)
279
+ sub_weight = np.zeros(n)
280
+ outlets = [i for i, d in enumerate(downstream) if d is None]
281
+ order = []
282
+ stack = list(outlets)
283
+ while stack:
284
+ i = stack.pop()
285
+ order.append(i)
286
+ stack.extend(children[i])
287
+ for i in reversed(order): # leaves first
288
+ if not children[i]:
289
+ strahler[i] = 1
290
+ sub_length[i] = length[i]
291
+ sub_weight[i] = weight[i]
292
+ else:
293
+ orders = strahler[children[i]]
294
+ m = orders.max()
295
+ strahler[i] = m + 1 if (orders == m).sum() > 1 else m
296
+ sub_length[i] = length[i] + sub_length[children[i]].max()
297
+ sub_weight[i] = weight[i] + sub_weight[children[i]].max()
298
+
299
+ key = {
300
+ "area": lambda i: (sub_weight[i],),
301
+ "length": lambda i: (sub_length[i],),
302
+ "strahler": lambda i: (strahler[i], sub_length[i]),
303
+ }[path_by]
304
+
305
+ # heavy-path decomposition: walk upstream from each outlet, continuing
306
+ # along the heaviest child; other children start new paths
307
+ path_id = np.zeros(n, dtype=int)
308
+ next_id = 1
309
+ from collections import deque
310
+
311
+ queue = deque(sorted(outlets, key=key, reverse=True))
312
+ while queue:
313
+ cur = queue.popleft()
314
+ if path_id[cur]:
315
+ continue
316
+ while True:
317
+ path_id[cur] = next_id
318
+ preds = [c for c in children[cur] if not path_id[c]]
319
+ if not preds:
320
+ break
321
+ preds.sort(key=key, reverse=True)
322
+ queue.extend(preds[1:])
323
+ cur = preds[0]
324
+ next_id += 1
325
+
326
+ df = pd.DataFrame(
327
+ {
328
+ "segment_id": np.arange(1, n + 1),
329
+ "path_id": path_id,
330
+ "strahler": strahler,
331
+ "length": length,
332
+ "weight": weight,
333
+ "downstream_segment_id": pd.array(
334
+ [d + 1 if d is not None else pd.NA for d in downstream],
335
+ dtype="Int64",
336
+ ),
337
+ "pixels": segments,
338
+ }
339
+ )
340
+ return df.sort_values(["path_id", "segment_id"], ignore_index=True)
341
+
342
+
343
+ def _seg_length(pixels, pixel_size):
344
+ total = 0.0
345
+ for (r1, c1), (r2, c2) in zip(pixels[:-1], pixels[1:]):
346
+ total += SQRT2 if (r1 != r2 and c1 != c2) else 1.0
347
+ return total * pixel_size
348
+
349
+
350
+ def _seg_weight(pixels, edt, pixel_size):
351
+ # trapezoid rule for integral of distance-to-edge along the segment (~ area/2)
352
+ total = 0.0
353
+ for (r1, c1), (r2, c2) in zip(pixels[:-1], pixels[1:]):
354
+ step = SQRT2 if (r1 != r2 and c1 != c2) else 1.0
355
+ total += 0.5 * (edt[r1, c1] + edt[r2, c2]) * step
356
+ return total * pixel_size
ramify/partition.py ADDED
@@ -0,0 +1,215 @@
1
+ import numpy as np
2
+ from scipy.ndimage import distance_transform_edt
3
+ from scipy.sparse import csr_matrix
4
+ from scipy.sparse.csgraph import dijkstra
5
+ from skimage.segmentation import watershed
6
+
7
+ from ._io import unwrap, wrap, edt_field, region_groups
8
+ from .centerline import Network
9
+
10
+ SQRT2 = np.sqrt(2.0)
11
+ # forward-only neighbour offsets; directed=False makes each bidirectional
12
+ _EDGES = [(0, 1, 1.0), (1, 0, 1.0), (1, 1, SQRT2), (1, -1, SQRT2)]
13
+
14
+
15
+ def allocate(mask, seeds, open_boundary=None, progress=None):
16
+ # Ordered, radius-limited claiming. Each path (seed label) claims the mask
17
+ # pixels within *some* of its seeds' local half-width, measured as a
18
+ # boundary-respecting (geodesic) distance -- so a wide-but-farther seed can
19
+ # reach a pixel a narrow-but-nearer seed cannot. Paths are processed
20
+ # biggest-first (label 1 = mainstem = highest priority) and a pixel is kept
21
+ # by the first (biggest) path to reach it, so wide branches claim
22
+ # proportionally more space at junctions. Unclaimed remainder is
23
+ # watershed-filled so the labels cover the mask completely.
24
+ #
25
+ # Each path's claim is a windowed, radius-bounded Dijkstra (see _reach), so
26
+ # cost is O(tube area) per path rather than O(domain); the mask never
27
+ # changes during the loop, so there is no per-step graph rebuild.
28
+ #
29
+ # `progress`, if given, is called once per path just before its Dijkstra:
30
+ # progress(i, n_paths, label, window_pixel_count). The window area is the
31
+ # honest cost proxy -- a path's seed count is its *length*, which says little
32
+ # about the tube it will claim. Paths run biggest-first, so the early ones are
33
+ # by far the slowest; a bar weighted by window area tracks that, one counting
34
+ # paths does not.
35
+ mask_arr, _, meta = unwrap(mask)
36
+ seed_arr, _, _ = unwrap(seeds)
37
+ mask_bool = mask_arr == 1
38
+ _check(mask_bool, seed_arr)
39
+
40
+ H, W = mask_bool.shape
41
+ radius = distance_transform_edt(edt_field(mask_bool, open_boundary)) # local half-width
42
+ allocation = np.zeros(mask_bool.shape, dtype=np.uint32)
43
+
44
+ # group seed pixels by label once, so each path works from its own coords
45
+ # (and bbox) instead of scanning the full seed array every iteration
46
+ flat = np.flatnonzero(seed_arr)
47
+ labels_flat = seed_arr.ravel()[flat]
48
+ order = np.argsort(labels_flat, kind="stable")
49
+ flat = flat[order]
50
+ labels_sorted = labels_flat[order]
51
+ uniq, starts = np.unique(labels_sorted, return_index=True) # ascending
52
+ bounds = np.append(starts, labels_sorted.size)
53
+
54
+ for i, label in enumerate(uniq): # ascending label == biggest path first
55
+ idx = flat[bounds[i]:bounds[i + 1]]
56
+ rr, cc = idx // W, idx % W
57
+ keep = mask_bool[rr, cc]
58
+ if not keep.any():
59
+ continue
60
+ rr, cc = rr[keep], cc[keep]
61
+ rad = radius[rr, cc]
62
+ R = float(rad.max()) # farthest this path can reach = search bound
63
+
64
+ pad = int(np.ceil(R)) + 1
65
+ r0, r1 = max(int(rr.min()) - pad, 0), min(int(rr.max()) + pad + 1, H)
66
+ c0, c1 = max(int(cc.min()) - pad, 0), min(int(cc.max()) + pad + 1, W)
67
+
68
+ # reported here, not at the top of the loop: the window is the first
69
+ # point where this path's cost is actually known
70
+ if progress is not None:
71
+ progress(i, len(uniq), int(label), (r1 - r0) * (c1 - c0))
72
+
73
+ seed_local = np.stack([rr - r0, cc - c0], axis=1)
74
+ tube = _reach(mask_bool[r0:r1, c0:c1], seed_local, rad, R)
75
+ sub = allocation[r0:r1, c0:c1]
76
+ sub[tube & (sub == 0)] = label # keep only where no bigger path won
77
+
78
+ claimed = allocation > 0
79
+ unclaimed = mask_bool & ~claimed
80
+ if unclaimed.any() and claimed.any():
81
+ allocation = watershed(
82
+ image=distance_transform_edt(~claimed),
83
+ markers=allocation,
84
+ mask=mask_bool,
85
+ ).astype(np.uint32)
86
+
87
+ return wrap(allocation, meta)
88
+
89
+
90
+ def _reach(mask_win, seed_rc, radii, R):
91
+ # Pixels within some seed's local half-width, geodesically, inside the
92
+ # window. A virtual super-source is wired to each seed s with edge weight
93
+ # R - radius(s) (>= 0), so one bounded search from it gives, per pixel q,
94
+ # dist_V(q) = R + min_s ( geodist(q, s) - radius(s) ),
95
+ # and dist_V(q) <= R is exactly "some seed's radius reaches q". The bound
96
+ # (limit=R) keeps the search inside the tube. Metric is octile ({1, sqrt2}),
97
+ # matching centerline._dijkstra_tree.
98
+ h, w = mask_win.shape
99
+ ys, xs = np.nonzero(mask_win)
100
+ M = ys.size
101
+ if M == 0:
102
+ return np.zeros((h, w), dtype=bool)
103
+ ids = np.full((h, w), -1, dtype=np.int64) # pixel -> node id, -1 off-mask
104
+ ids[ys, xs] = np.arange(M)
105
+ V = M # super-source node id
106
+
107
+ rows, cols, wts = [], [], []
108
+ for dr, dc, step in _EDGES: # grid edges between adjacent in-mask pixels
109
+ ny, nx = ys + dr, xs + dc
110
+ ok = (ny >= 0) & (ny < h) & (nx >= 0) & (nx < w)
111
+ nb = np.where(ok, ids[np.clip(ny, 0, h - 1), np.clip(nx, 0, w - 1)], -1)
112
+ keep = nb >= 0
113
+ rows.append(ids[ys[keep], xs[keep]])
114
+ cols.append(nb[keep])
115
+ wts.append(np.full(int(keep.sum()), step))
116
+
117
+ sids = ids[seed_rc[:, 0], seed_rc[:, 1]] # super-source -> each seed
118
+ ok = sids >= 0
119
+ rows.append(np.full(int(ok.sum()), V))
120
+ cols.append(sids[ok])
121
+ wts.append(R - radii[ok])
122
+
123
+ n = M + 1
124
+ graph = csr_matrix(
125
+ (np.concatenate(wts), (np.concatenate(rows), np.concatenate(cols))),
126
+ shape=(n, n),
127
+ )
128
+ dist = dijkstra(graph, directed=False, indices=V, limit=R)
129
+ out = np.zeros((h, w), dtype=bool)
130
+ out[ys, xs] = dist[:M] <= R
131
+ return out
132
+
133
+
134
+ def voronoi(mask, seeds):
135
+ # Nearest-seed partition of the mask: every pixel goes to the seed label
136
+ # it can reach by the shortest within-mask route. No ordering, no radius
137
+ # limits. Use for simple subdivision, e.g. splitting a path's territory
138
+ # by segment: voronoi(regions == path_id, segment_seeds).
139
+ mask_arr, _, meta = unwrap(mask)
140
+ seed_arr, _, _ = unwrap(seeds)
141
+ mask_bool = mask_arr == 1
142
+ _check(mask_bool, seed_arr)
143
+
144
+ markers = np.where(mask_bool, seed_arr, 0).astype(np.int64)
145
+ if not (markers > 0).any():
146
+ return wrap(np.zeros(mask_bool.shape, dtype=np.uint32), meta)
147
+
148
+ out = watershed(
149
+ image=distance_transform_edt(markers == 0),
150
+ markers=markers,
151
+ mask=mask_bool,
152
+ ).astype(np.uint32)
153
+ return wrap(out, meta)
154
+
155
+
156
+ def subdivide(regions, network: Network):
157
+ # Subdivide each path's territory (from allocate) into segment-level
158
+ # territories. Each territory is seeded only by its own path's segments,
159
+ # so neighboring paths' labels (e.g. shared junction pixels) never bleed
160
+ # across boundaries. Pixels in territories whose path has no segments in
161
+ # the table remain 0.
162
+ reg_arr, _, meta = unwrap(regions)
163
+ if reg_arr.shape != network.shape:
164
+ raise ValueError(
165
+ f"regions shape {reg_arr.shape} does not match network grid {network.shape}"
166
+ )
167
+
168
+ # Group the territories once, then work each path inside its own bounding
169
+ # box. Everything below is local: a full-grid pass per path would cost the
170
+ # whole raster ~once per path, and voronoi() runs a distance transform and
171
+ # a watershed, so that is the expensive kind of pass. Cropping is exact
172
+ # here -- the distance transform measures to the nearest seed and every one
173
+ # of this path's seeds is inside its own bbox, and the watershed only ever
174
+ # floods within the territory.
175
+ territories = dict(region_groups(reg_arr))
176
+
177
+ out = np.zeros(network.shape, dtype=np.uint32)
178
+ _, w = network.shape
179
+ for path_id, group in network.segments.groupby("path_id"):
180
+ idx = territories.get(int(path_id))
181
+ if idx is None:
182
+ continue # path swallowed during allocation
183
+ rows, cols = np.divmod(idx, w)
184
+ r0, r1 = int(rows.min()), int(rows.max()) + 1
185
+ c0, c1 = int(cols.min()), int(cols.max()) + 1
186
+
187
+ territory = np.zeros((r1 - r0, c1 - c0), dtype=np.uint8)
188
+ territory[rows - r0, cols - c0] = 1
189
+
190
+ seeds = np.zeros(territory.shape, dtype=np.uint32)
191
+ for _, row in group.iterrows():
192
+ rc = np.asarray(row["pixels"])
193
+ inside = (
194
+ (rc[:, 0] >= r0) & (rc[:, 0] < r1) & (rc[:, 1] >= c0) & (rc[:, 1] < c1)
195
+ )
196
+ rc = rc[inside]
197
+ seeds[rc[:, 0] - r0, rc[:, 1] - c0] = row["segment_id"]
198
+ seeds = np.where(territory == 1, seeds, 0)
199
+ if not (seeds > 0).any():
200
+ continue
201
+
202
+ sub = np.asarray(voronoi(territory, seeds))
203
+ hit = sub > 0
204
+ out[r0:r1, c0:c1][hit] = sub[hit]
205
+
206
+ return wrap(out, meta)
207
+
208
+
209
+ def _check(mask_bool, seed_arr):
210
+ if seed_arr.shape != mask_bool.shape:
211
+ raise ValueError(
212
+ f"seeds shape {seed_arr.shape} does not match mask shape {mask_bool.shape}"
213
+ )
214
+ if (seed_arr[mask_bool] > 0).sum() == 0:
215
+ raise ValueError("no seed pixels found inside the mask")
ramify/width.py ADDED
@@ -0,0 +1,264 @@
1
+ import warnings
2
+
3
+ import numpy as np
4
+ from scipy.ndimage import distance_transform_edt
5
+ from scipy.sparse import csr_matrix
6
+ from scipy.sparse.csgraph import connected_components
7
+ from scipy.sparse.linalg import cg
8
+ from scipy.spatial import cKDTree
9
+
10
+ from ._io import unwrap, wrap, edt_field, region_groups
11
+
12
+ SQRT2 = np.sqrt(2.0)
13
+ # 8-connected stencil; diagonals weighted 1/sqrt(2) for isotropy (and so a pixel
14
+ # attached only diagonally is never isolated)
15
+ _OFFSETS = [
16
+ (-1, 0, 1.0),
17
+ (1, 0, 1.0),
18
+ (0, -1, 1.0),
19
+ (0, 1, 1.0),
20
+ (-1, -1, 1 / SQRT2),
21
+ (-1, 1, 1 / SQRT2),
22
+ (1, -1, 1 / SQRT2),
23
+ (1, 1, 1 / SQRT2),
24
+ ]
25
+
26
+
27
+ def widths(mask, centerline, method="laplace", pixel_size=None, open_boundary=None):
28
+ # Per-pixel width of the shape. Exact widths (2 * distance-to-boundary)
29
+ # are taken at centerline pixels and interpolated across the mask.
30
+ # method="laplace": smooth diffusion (Laplace equation, Dirichlet BCs at
31
+ # the centerline) — continuous fields, best for downstream analysis.
32
+ # method="nearest": each pixel takes the width of its nearest centerline
33
+ # pixel (a Voronoi-style assignment, cf. ramify.voronoi) — piecewise
34
+ # constant, fast, exact at the centerline.
35
+ if method not in ("laplace", "nearest"):
36
+ raise ValueError(f"method must be 'laplace' or 'nearest', got {method!r}")
37
+
38
+ mask_arr, px, meta = unwrap(mask, pixel_size)
39
+ cl_arr, _, _ = unwrap(centerline)
40
+ mask_bool = mask_arr == 1
41
+ cl_bool = (cl_arr > 0) & mask_bool
42
+ if cl_arr.shape != mask_bool.shape:
43
+ raise ValueError(
44
+ f"centerline shape {cl_arr.shape} does not match mask shape {mask_bool.shape}"
45
+ )
46
+ if not cl_bool.any():
47
+ raise ValueError("no centerline pixels found inside the mask")
48
+
49
+ seed_widths = np.where(
50
+ cl_bool, distance_transform_edt(edt_field(mask_bool, open_boundary)) * px * 2.0, 0.0
51
+ )
52
+
53
+ out = np.full(mask_bool.shape, np.nan)
54
+ if method == "nearest":
55
+ out[mask_bool] = _nearest(cl_bool, seed_widths)[mask_bool]
56
+ else:
57
+ idx = np.flatnonzero(mask_bool)
58
+ out.ravel()[idx] = _laplace(
59
+ idx, cl_bool.ravel()[idx], seed_widths.ravel()[idx], mask_bool.shape
60
+ )
61
+ # parts of the mask the centerline cannot reach (a detached blob, an
62
+ # island) have no Dirichlet data at all -- fall back rather than
63
+ # reporting the zero the homogeneous solve would give
64
+ leftover = mask_bool & np.isnan(out)
65
+ if leftover.any():
66
+ warnings.warn(
67
+ f"{int(leftover.sum())} mask pixels are not connected to the "
68
+ "centerline; filled by nearest centerline width"
69
+ )
70
+ out[leftover] = _nearest(cl_bool, seed_widths)[leftover]
71
+
72
+ out = wrap(out, meta)
73
+ if meta is not None:
74
+ try:
75
+ out.rio.write_nodata(np.nan, inplace=True)
76
+ except AttributeError:
77
+ pass
78
+ return out
79
+
80
+
81
+ def region_widths(mask, centerline, regions, method="laplace", pixel_size=None,
82
+ open_boundary=None, progress=None):
83
+ # Like widths(), but interpolated independently within each labeled
84
+ # region (e.g. the output of ramify.allocate), so widths do not diffuse
85
+ # across path boundaries at junctions. Each region is seeded only by the
86
+ # centerline pixels inside it. Regions containing no centerline pixels
87
+ # are filled by nearest-centerline fallback (with a warning) so the
88
+ # output always covers the mask.
89
+ #
90
+ # Regions are solved from their own flat pixel indices (grouped once, up
91
+ # front) rather than by re-scanning the full grid per label, so the cost is
92
+ # O(mask area) in total instead of O(mask area x number of regions).
93
+ #
94
+ # `progress`, if given, is called once per region just before that region is
95
+ # solved: progress(i, n_regions, label, region_pixel_count). Regions are very
96
+ # uneven and the laplace solve costs ~O(n^1.5) in a region's pixel count, so
97
+ # drive a bar off that count -- counting regions makes it race to ~99% and
98
+ # then sit on the few big ones for most of the wall time.
99
+ if method not in ("laplace", "nearest"):
100
+ raise ValueError(f"method must be 'laplace' or 'nearest', got {method!r}")
101
+
102
+ mask_arr, px, meta = unwrap(mask, pixel_size)
103
+ cl_arr, _, _ = unwrap(centerline)
104
+ reg_arr, _, _ = unwrap(regions)
105
+ mask_bool = mask_arr == 1
106
+ cl_bool = (cl_arr > 0) & mask_bool
107
+ if cl_arr.shape != mask_bool.shape or reg_arr.shape != mask_bool.shape:
108
+ raise ValueError("mask, centerline, and regions must share one shape")
109
+ if not cl_bool.any():
110
+ raise ValueError("no centerline pixels found inside the mask")
111
+
112
+ seed_widths = np.where(
113
+ cl_bool, distance_transform_edt(edt_field(mask_bool, open_boundary)) * px * 2.0, 0.0
114
+ )
115
+ cl_flat = cl_bool.ravel()
116
+ seed_flat = seed_widths.ravel()
117
+
118
+ out = np.full(mask_bool.shape, np.nan)
119
+ out_flat = out.ravel()
120
+ groups = list(region_groups(reg_arr, mask_bool)) # views, so the total is free
121
+ for i, (label, idx) in enumerate(groups):
122
+ if progress is not None:
123
+ progress(i, len(groups), label, idx.size)
124
+ is_seed = cl_flat[idx]
125
+ if not is_seed.any():
126
+ continue # filled by fallback below
127
+ seed_vals = seed_flat[idx]
128
+ if method == "laplace":
129
+ out_flat[idx] = _laplace(idx, is_seed, seed_vals, mask_bool.shape)
130
+ else:
131
+ out_flat[idx] = _nearest_flat(idx, is_seed, seed_vals, mask_bool.shape)
132
+
133
+ leftover = mask_bool & np.isnan(out)
134
+ if leftover.any():
135
+ warnings.warn(
136
+ f"{int(leftover.sum())} mask pixels fall in regions with no "
137
+ "centerline pixels (or outside any region); filled by nearest "
138
+ "centerline width"
139
+ )
140
+ fallback = _nearest(cl_bool, seed_widths)
141
+ out[leftover] = fallback[leftover]
142
+
143
+ out = np.where(mask_bool, out, np.nan)
144
+ out = wrap(out, meta)
145
+ if meta is not None:
146
+ try:
147
+ out.rio.write_nodata(np.nan, inplace=True)
148
+ except AttributeError:
149
+ pass
150
+ return out
151
+
152
+
153
+ def _neighbours(qidx, idx, dy, dx, shape):
154
+ # Membership test for one stencil offset: for each query pixel qidx[i], does
155
+ # its neighbour at (dy, dx) belong to the sorted set `idx`? Works purely on
156
+ # flat indices, so nothing the size of the grid (or even of the region's
157
+ # bounding box) is allocated. Returns (has, pos) with idx[pos[i]] the
158
+ # neighbour wherever has[i]. The explicit column check is what stops a
159
+ # dx = -1 step at column 0 from wrapping onto the previous row.
160
+ h, w = shape
161
+ rows, cols = np.divmod(qidx, w)
162
+ nr, nc = rows + dy, cols + dx
163
+ ok = (nr >= 0) & (nr < h) & (nc >= 0) & (nc < w)
164
+ nflat = qidx + (dy * w + dx)
165
+ pos = np.searchsorted(idx, nflat)
166
+ np.clip(pos, 0, idx.size - 1, out=pos)
167
+ return ok & (idx[pos] == nflat), pos
168
+
169
+
170
+ def _nearest(cl_bool, seed_widths):
171
+ # Whole-grid nearest-centerline assignment (used by widths() and as the
172
+ # region_widths fallback); the EDT is linear in grid size.
173
+ _, idx = distance_transform_edt(~cl_bool, return_indices=True)
174
+ return seed_widths[idx[0], idx[1]]
175
+
176
+
177
+ def _nearest_flat(idx, is_seed, seed_vals, shape):
178
+ # Same rule restricted to one region's seeds. A KD-tree over the region's
179
+ # centerline pixels costs O(region size); an EDT here would cost O(grid).
180
+ w = shape[1]
181
+ seed_rc = np.column_stack(np.divmod(idx[is_seed], w))
182
+ all_rc = np.column_stack(np.divmod(idx, w))
183
+ _, nn = cKDTree(seed_rc).query(all_rc, k=1)
184
+ return seed_vals[is_seed][nn]
185
+
186
+
187
+ def _laplace(idx, is_seed, seed_vals, shape):
188
+ # Laplace interpolation over the pixel set `idx` (sorted flat indices) with
189
+ # Dirichlet BCs at the seeds. The seed rows are eliminated rather than
190
+ # carried as identity rows, so the system solved is
191
+ #
192
+ # (D - W_ff) x_f = W_fs s
193
+ #
194
+ # over the free pixels only: symmetric, diagonally dominant, positive
195
+ # definite — which is what cg actually requires, and smaller besides.
196
+ free = ~is_seed
197
+ n_free = int(free.sum())
198
+ out = seed_vals.copy()
199
+ if n_free == 0:
200
+ return out
201
+
202
+ fidx = idx[free]
203
+ fpos = np.cumsum(free) - 1 # position in `idx` -> row in the free system
204
+ local = np.arange(n_free)
205
+
206
+ rows, cols, data = [], [], []
207
+ diag = np.zeros(n_free)
208
+ b = np.zeros(n_free)
209
+ touches_seed = np.zeros(n_free, dtype=bool)
210
+ for dy, dx, wt in _OFFSETS:
211
+ has, pos = _neighbours(fidx, idx, dy, dx, shape)
212
+ p = pos[has]
213
+ diag[has] += wt
214
+ nbr_seed = is_seed[p]
215
+ rows.append(local[has][~nbr_seed])
216
+ cols.append(fpos[p[~nbr_seed]])
217
+ data.append(np.full(int((~nbr_seed).sum()), -wt))
218
+ # each free pixel meets a given offset at most once, but summing over
219
+ # the eight offsets still accumulates, so go through bincount
220
+ b += np.bincount(
221
+ local[has][nbr_seed],
222
+ weights=wt * seed_vals[p[nbr_seed]],
223
+ minlength=n_free,
224
+ )
225
+ touches_seed[local[has][nbr_seed]] = True
226
+
227
+ rows.append(local)
228
+ cols.append(local)
229
+ data.append(diag)
230
+
231
+ A = csr_matrix(
232
+ (np.concatenate(data), (np.concatenate(rows), np.concatenate(cols))),
233
+ shape=(n_free, n_free),
234
+ )
235
+
236
+ x = _solve(A, b)
237
+
238
+ # A pixel set can fall apart into chunks that no seed touches (a region
239
+ # split by a junction, an island). Such a chunk is a singular Neumann block
240
+ # with a zero rhs, and because A is block diagonal cg leaves it at exactly
241
+ # 0.0 -- so the solver silently reports width 0 there. Exact zero is also
242
+ # the only way a *seeded* block can land on 0 (the maximum principle bounds
243
+ # it below by its smallest seed width), which makes it a cheap filter;
244
+ # confirm against the connectivity, then hand the chunk back as NaN for the
245
+ # caller to fill by fallback.
246
+ if (x == 0.0).any():
247
+ n_comp, comp = connected_components(A, directed=False)
248
+ seeded = np.zeros(n_comp, dtype=bool)
249
+ seeded[comp[touches_seed]] = True
250
+ x[~seeded[comp]] = np.nan
251
+
252
+ out[free] = x
253
+ return out
254
+
255
+
256
+ def _solve(A, b):
257
+ # rtol bounds the residual, not the error, and the two diverge as a region
258
+ # grows (A's smallest eigenvalue shrinks): at rtol=1e-4 a 340k-pixel region
259
+ # lands ~1-2 width units off the exact solution. 1e-6 costs ~40% more
260
+ # iterations and pulls that back to ~0.03.
261
+ x, info = cg(A, b, rtol=1e-6)
262
+ if info != 0:
263
+ warnings.warn("conjugate gradient solver did not converge")
264
+ return x
@@ -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
+ ![graphical abstract](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/abstract.png)
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
+ ![extract with tips](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/extract_tips.png)
84
+
85
+ ```python
86
+ net = ramify.extract(mask, root)
87
+ ```
88
+
89
+ Without tips, every skeleton endpoint becomes a tip.
90
+
91
+ ![extract auto tips](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/extract_auto.png)
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
+ ![allocate](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/allocate.png)
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
+ ![voronoi](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/voronoi.png)
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
+ ![subdivide](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/subdivide.png)
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
+ ![widths domain](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/widths_domain.png)
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
+ ![widths nearest](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/widths_nearest.png)
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
+ ![open boundary](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/open_boundary.png)
182
+
183
+ Only the widths are shown because on this shape the partitioning didn't change.
@@ -0,0 +1,9 @@
1
+ ramify/__init__.py,sha256=B5QnMUkVoqKTgz8oYj44B2l8UnzbQ84u3RYEgrVkcdE,262
2
+ ramify/_io.py,sha256=PzSWuvL8-r0tIQ_qDNejhVnkJbujRFTC6qq5mBiHrEU,3656
3
+ ramify/centerline.py,sha256=GSVKY5G2okB6HP60b_AxoYJI1bP__tE1igTnigfa3XY,12350
4
+ ramify/partition.py,sha256=m7lELiuEEc6-R9V8NMqqoEeJYWbkN9TNHkn8PpGxtQ8,9013
5
+ ramify/width.py,sha256=JlCMvWs35QhfOfX1dgQ-mOsB5rWypEyNXsLp7soH2Eg,10586
6
+ ramify-1.1.0.dist-info/METADATA,sha256=phKJ00tF5KsG3h8gBCYA7bv4c0Xiy7733gCxyhcWtgA,6649
7
+ ramify-1.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ ramify-1.1.0.dist-info/licenses/LICENSE,sha256=9OJ32gzj_uZUUO657WiSLpRj7FlcLoj4yZKBiE8V0kA,1069
9
+ ramify-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.