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 +13 -0
- ramify/_io.py +107 -0
- ramify/centerline.py +356 -0
- ramify/partition.py +215 -0
- ramify/width.py +264 -0
- ramify-1.1.0.dist-info/METADATA +183 -0
- ramify-1.1.0.dist-info/RECORD +9 -0
- ramify-1.1.0.dist-info/WHEEL +4 -0
- ramify-1.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+

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

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

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

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

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

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

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

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

|
|
182
|
+
|
|
183
|
+
Only the widths are shown because on this shape the partitioning didn't change.
|
|
@@ -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,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.
|