ramify 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ name: Publish to PyPI
2
+ on:
3
+ release:
4
+ types: [published]
5
+
6
+ jobs:
7
+ publish:
8
+ runs-on: ubuntu-latest
9
+ permissions:
10
+ id-token: write
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v5
14
+ - run: uv build
15
+ - run: uv publish --trusted-publishing automatic
@@ -0,0 +1,53 @@
1
+ # Project Directory Structure
2
+ *.pdf
3
+ cache/
4
+ data/
5
+ run.py
6
+ output
7
+
8
+ profile_run.py
9
+
10
+ *.gpkg-wal
11
+ *.gpkg-shm
12
+ *.tif.aux.xml
13
+
14
+ # vim
15
+ [._]*.s[a-v][a-z]
16
+ [._]*.sw[a-p]
17
+ [._]s[a-rt-v][a-z]
18
+ [._]ss[a-gi-z]
19
+ [._]sw[a-p]
20
+
21
+ # snakemake
22
+ .snakemake/
23
+
24
+ # R
25
+ *.rds
26
+ .RData
27
+ .Rhistory
28
+
29
+
30
+ # Python
31
+ __pycache__/
32
+ *.py[cod]
33
+ .python-version
34
+ .ipynb_checkpoints
35
+
36
+
37
+ # Operating System Metadata
38
+ ._*
39
+ .DocumentRevisions-V100
40
+ .fseventsd
41
+ .Spotlight-V100
42
+ .TemporaryItems
43
+ .Trashes
44
+ .VolumeIcon.icns
45
+ .com.apple.timemachine.donotpresent
46
+ .AppleDB
47
+ .AppleDesktop
48
+ .DS_Store
49
+ .AppleDouble
50
+ .LSOverride
51
+ Network Trash Folder
52
+ Temporary Items
53
+ .apdisk
ramify-1.1.0/LICENSE ADDED
@@ -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.
ramify-1.1.0/PKG-INFO ADDED
@@ -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.
ramify-1.1.0/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # ramify
2
+
3
+ Characterize binary branching shapes (e.g. rivers, floodplains, glaciers,
4
+ roots, veins...). Given a shape mask, a root point, and (optionally) branch
5
+ tips, `ramify` extracts a topology-aware centerline network, decomposes it into
6
+ hierarchically ordered paths, allocates every pixel of the shape to its path,
7
+ and estimates local width everywhere.
8
+
9
+ ![graphical abstract](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/abstract.png)
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install ramify
15
+ ```
16
+
17
+ Development (clone, then sync with dev extras):
18
+
19
+ ```bash
20
+ git clone https://github.com/avkoehl/ramify.git
21
+ cd ramify
22
+ uv sync --extra dev
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ import ramify
29
+ from ramify.data import load
30
+
31
+ mask, root, tips = load() # bundled toy dataset
32
+
33
+ net = ramify.extract(mask, root, tips=tips) # centerline network of ordered paths
34
+ regions = ramify.allocate(mask, net.rasterize(by="path"))
35
+ widths = ramify.region_widths(mask, net.rasterize(), regions)
36
+
37
+ net.segments # DataFrame: segment_id, path_id, strahler,
38
+ # length, weight, downstream_segment_id
39
+ regions # labeled raster: each pixel -> its path
40
+ widths # float raster: local width everywhere
41
+ ```
42
+
43
+ Inputs are `np.ndarray` (with `pixel_size=`) or georeferenced `xr.DataArray`;
44
+ outputs match the input type. `root` and `tips` are `(row, col)` pixel coordinates.
45
+
46
+ ## Components
47
+
48
+ Each individual component is presented below.
49
+
50
+ ### Centerlines
51
+
52
+ ```python
53
+ net = ramify.extract(mask, root, tips=tips)
54
+ ```
55
+
56
+ Skeletonizes the mask, routes from each tip to the root (pruning everything else),
57
+ and decomposes the network into ordered paths — `path_id == 1` is the mainstem.
58
+
59
+
60
+ ![extract with tips](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/extract_tips.png)
61
+
62
+ ```python
63
+ net = ramify.extract(mask, root)
64
+ ```
65
+
66
+ Without tips, every skeleton endpoint becomes a tip.
67
+
68
+ ![extract auto tips](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/extract_auto.png)
69
+
70
+ Tips and root can often be derived automatically — glacier branch tips
71
+ [Kienholz et al.,
72
+ 2014](https://tc.copernicus.org/articles/8/503/2014/tc-8-503-2014.pdf), channel
73
+ initiation points, or the lowest point on the boundary as the root — or simply
74
+ digitized in GIS software.
75
+
76
+
77
+ ### Partitioning
78
+
79
+ ```python
80
+ regions = ramify.allocate(mask, net.rasterize(by="path"))
81
+ ```
82
+
83
+ Assigns every pixel to a path: paths claim territory in priority order, each limited
84
+ by the local shape radius, so wide branches claim proportionally more space at junctions.
85
+
86
+ ![allocate](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/allocate.png)
87
+
88
+ ```python
89
+ regions = ramify.voronoi(mask, net.rasterize(by="path"))
90
+ ```
91
+
92
+ Nearest-centerline partition — no ordering, no radius limits.
93
+
94
+ ![voronoi](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/voronoi.png)
95
+
96
+ ```python
97
+ seg_regions = ramify.subdivide(regions, net)
98
+ ```
99
+
100
+ Subdivides each path's territory further: within a territory, every pixel goes to
101
+ its nearest centerline segment of that same path.
102
+
103
+ ![subdivide](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/subdivide.png)
104
+
105
+ ### Widths
106
+
107
+ Exact widths (twice the distance to the boundary) are taken at the centerline and
108
+ interpolated across the shape. That interpolation runs either over the whole shape
109
+ or independently within each region, which keeps junction-zone pixels from
110
+ averaging between a branch and its mainstem:
111
+
112
+ ```python
113
+ w = ramify.widths(mask, net.rasterize())
114
+ w = ramify.region_widths(mask, net.rasterize(), regions)
115
+ ```
116
+
117
+ ![widths domain](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/widths_domain.png)
118
+
119
+ Either call also takes `method="nearest"`, which gives each pixel the width of its
120
+ nearest centerline pixel instead of diffusing smoothly from it — piecewise
121
+ constant, and much faster:
122
+
123
+ ```python
124
+ w = ramify.widths(mask, net.rasterize(), method="nearest")
125
+ ```
126
+
127
+ ![widths nearest](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/widths_nearest.png)
128
+
129
+ ## Open boundaries
130
+
131
+ Everything above measures local half-width as the distance from each pixel to the
132
+ shape's boundary, and that half-width drives three things: which branch is the
133
+ mainstem, how far each path claims territory, and the width field. By default
134
+ every boundary pixel is treated as a **wall**. Sometimes part of the boundary is
135
+ not a real wall — the shape is truncated by open water, the data extent, or
136
+ another medium — and treating it as one makes the half-width collapse to zero
137
+ there.
138
+
139
+ Pass `open_boundary`: a binary mask, on the same grid as the shape, marking the
140
+ non-wall (void) pixels. Distances are then measured only to the remaining real
141
+ walls. It is optional — omitted, every boundary is a wall (the behaviour above) —
142
+ and accepted by `extract`, `allocate`, `widths`, and `region_widths`. Give it to
143
+ every step, so all three stages measure against the same walls:
144
+
145
+ ```python
146
+ net = ramify.extract(mask, root, tips=tips, open_boundary=open_boundary)
147
+ regions = ramify.allocate(mask, net.rasterize(by="path"), open_boundary=open_boundary)
148
+ widths = ramify.region_widths(mask, net.rasterize(), regions,
149
+ open_boundary=open_boundary)
150
+ ```
151
+
152
+ Below, the same mask, root, and tips are reused, but the void past the outlet is
153
+ marked open (shaded red), and the outlet `widths` no longer taper to the cut edge.
154
+ Mark a region with depth rather than a thin skin along the boundary: distances are
155
+ measured *through* the open void, so a one-pixel rind would only push the wall out
156
+ by one pixel.
157
+
158
+ ![open boundary](https://raw.githubusercontent.com/avkoehl/ramify/main/assets/open_boundary.png)
159
+
160
+ Only the widths are shown because on this shape the partitioning didn't change.
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ramify"
7
+ version = "1.1.0"
8
+ description = "Segment and measure binary branching shapes: centerline extraction, hierarchical space partitioning, and width interpolation"
9
+ keywords = [
10
+ "centerline",
11
+ "skeleton",
12
+ "medial-axis",
13
+ "segmentation",
14
+ "raster",
15
+ "geomorphology",
16
+ "river",
17
+ "valley",
18
+ "morphometry",
19
+ "width",
20
+ ]
21
+ readme = "README.md"
22
+ requires-python = ">=3.12"
23
+ classifiers = [
24
+ "Programming Language :: Python :: 3",
25
+ "License :: OSI Approved :: MIT License",
26
+ "Topic :: Scientific/Engineering :: GIS",
27
+ ]
28
+ dependencies = [
29
+ "numpy",
30
+ "scipy",
31
+ "scikit-image",
32
+ "networkx",
33
+ "rasterio",
34
+ "shapely",
35
+ "geopandas",
36
+ "xarray",
37
+ "rioxarray",
38
+ ]
39
+
40
+ [project.optional-dependencies]
41
+ dev = [
42
+ "matplotlib",
43
+ ]
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["ramify"]
47
+
48
+ [tool.hatch.build.targets.wheel.shared-data]
49
+ "ramify/data" = "ramify/data"
@@ -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
+ ]
@@ -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