ftw-fbp 0.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.
- fbp/__init__.py +35 -0
- fbp/core/__init__.py +1 -0
- fbp/core/fast_morph.py +169 -0
- fbp/core/merge.py +119 -0
- fbp/core/postprocess.py +121 -0
- fbp/core/regions.py +150 -0
- fbp/core/surfaces.py +36 -0
- fbp/methods/__init__.py +41 -0
- fbp/methods/base.py +115 -0
- fbp/methods/boundaryvote.py +113 -0
- fbp/methods/components.py +53 -0
- fbp/methods/ftp.py +48 -0
- fbp/methods/gmm.py +49 -0
- fbp/methods/iterh.py +75 -0
- fbp/methods/owt.py +116 -0
- fbp/methods/swst.py +64 -0
- fbp/methods/thresholds.py +39 -0
- fbp/methods/ucm.py +48 -0
- fbp/windowed.py +187 -0
- ftw_fbp-0.1.0.dist-info/METADATA +143 -0
- ftw_fbp-0.1.0.dist-info/RECORD +24 -0
- ftw_fbp-0.1.0.dist-info/WHEEL +5 -0
- ftw_fbp-0.1.0.dist-info/licenses/LICENSE +21 -0
- ftw_fbp-0.1.0.dist-info/top_level.txt +1 -0
fbp/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Field boundary prediction post-processing algorithms."""
|
|
2
|
+
|
|
3
|
+
from fbp.methods import (
|
|
4
|
+
FTW_2E,
|
|
5
|
+
BoundaryVote,
|
|
6
|
+
ClosedComponents,
|
|
7
|
+
Components,
|
|
8
|
+
ExtentBoundaryThreshold,
|
|
9
|
+
FTPWatershed,
|
|
10
|
+
GaussianMixture,
|
|
11
|
+
IterativeWatershed,
|
|
12
|
+
Method,
|
|
13
|
+
OrientedWatershed,
|
|
14
|
+
StochasticWatershed,
|
|
15
|
+
UltrametricContours,
|
|
16
|
+
ftp_pipeline,
|
|
17
|
+
)
|
|
18
|
+
from fbp.windowed import run_windowed
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"FTW_2E",
|
|
22
|
+
"BoundaryVote",
|
|
23
|
+
"ClosedComponents",
|
|
24
|
+
"Components",
|
|
25
|
+
"ExtentBoundaryThreshold",
|
|
26
|
+
"FTPWatershed",
|
|
27
|
+
"GaussianMixture",
|
|
28
|
+
"IterativeWatershed",
|
|
29
|
+
"Method",
|
|
30
|
+
"OrientedWatershed",
|
|
31
|
+
"StochasticWatershed",
|
|
32
|
+
"UltrametricContours",
|
|
33
|
+
"ftp_pipeline",
|
|
34
|
+
"run_windowed",
|
|
35
|
+
]
|
fbp/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared geometry primitives every post-processing method is built from."""
|
fbp/core/fast_morph.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Integer versions of the two scikit-image calls that dominate BoundaryVote's runtime.
|
|
2
|
+
|
|
3
|
+
On 5,120 x 5,120 production windows, ``h_maxima`` and ``watershed`` take 92% of
|
|
4
|
+
BoundaryVote's time. When the surface is on the 1/255 grid of the uint8 probability
|
|
5
|
+
rasters, both can run on integer levels.
|
|
6
|
+
|
|
7
|
+
``h_maxima_itk`` uses ITK grayscale reconstruction by dilation. It gives the same
|
|
8
|
+
result as scikit-image, checked on 5k and 9k windows, and runs about 10x faster on one
|
|
9
|
+
thread. ``watershed_bucket`` floods with a bucket queue in O(N) under scikit-image's
|
|
10
|
+
rules. Marker pixels of equal cost leave the queue in scan order rather than heap
|
|
11
|
+
order, so the lines move only at exact ties (99.99% of pixels agree).
|
|
12
|
+
|
|
13
|
+
Both reject surfaces that are not on the 1/255 grid. ``quantize_prob`` rounds a float
|
|
14
|
+
softmax onto it, which is what storing it as uint8 does. ``Method(fast=True)`` does
|
|
15
|
+
both.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import SimpleITK as sitk # noqa: N813 (the package's own conventional alias)
|
|
22
|
+
from numba import njit
|
|
23
|
+
except ImportError as e:
|
|
24
|
+
raise ImportError(
|
|
25
|
+
"fast=True needs the fast backend: pip install 'ftw-fbp[fast]'"
|
|
26
|
+
) from e
|
|
27
|
+
from skimage.morphology._util import _offsets_to_raveled_neighbors
|
|
28
|
+
|
|
29
|
+
LEVELS = 255.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def quantize_prob(prob: np.ndarray) -> np.ndarray:
|
|
33
|
+
"""Softmax rounded to 1/255 steps per class, as the uint8 production rasters do."""
|
|
34
|
+
return (np.rint(prob.astype(np.float32) * LEVELS) / LEVELS).astype(np.float32)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _to_levels(x: np.ndarray, name: str) -> np.ndarray:
|
|
38
|
+
u = np.rint(x * LEVELS)
|
|
39
|
+
if np.abs(x * LEVELS - u).max() > 0.25: # fp16 error is ~0.06 levels
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"{name} is not on the 1/255 grid; quantize it with quantize_prob"
|
|
42
|
+
)
|
|
43
|
+
return u.astype(np.uint8)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def h_maxima_itk(image: np.ndarray, h: float, footprint: None = None) -> np.ndarray:
|
|
47
|
+
"""``skimage.morphology.h_maxima`` for a [0, 1] surface on the 1/255 grid."""
|
|
48
|
+
if footprint is not None:
|
|
49
|
+
raise ValueError("only the default (full) footprint is supported")
|
|
50
|
+
img = _to_levels(image, "h_maxima input")
|
|
51
|
+
h_lv = int(np.ceil(h * LEVELS - 1e-9)) # residue >= h <=> levels >= ceil(h*255)
|
|
52
|
+
if h_lv > int(img.max()) - int(img.min()):
|
|
53
|
+
return np.zeros(img.shape, np.uint8)
|
|
54
|
+
marker = np.clip(img.astype(np.int16) - h_lv, 0, 255).astype(np.uint8)
|
|
55
|
+
f = sitk.ReconstructionByDilationImageFilter()
|
|
56
|
+
f.SetFullyConnected(True)
|
|
57
|
+
# GetArrayFromImage (a copy): a view of the temporary result can be freed under us.
|
|
58
|
+
rec = sitk.GetArrayFromImage(
|
|
59
|
+
f.Execute(sitk.GetImageFromArray(marker), sitk.GetImageFromArray(img))
|
|
60
|
+
)
|
|
61
|
+
return ((img.astype(np.int16) - rec) >= h_lv).astype(np.uint8)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@njit(cache=True)
|
|
65
|
+
def _bucket_kernel(img, locs, structure, mask, output): # noqa: ANN001, ANN202, PLR0912, PLR0915
|
|
66
|
+
cap = max(1024, 4 * locs.shape[0])
|
|
67
|
+
ix = np.empty(cap, np.int64)
|
|
68
|
+
sr = np.empty(cap, np.int64)
|
|
69
|
+
nx = np.full(cap, -1, np.int64)
|
|
70
|
+
head = np.full(256, -1, np.int64)
|
|
71
|
+
tail = np.full(256, -1, np.int64)
|
|
72
|
+
n = 0
|
|
73
|
+
for k in range(locs.shape[0]):
|
|
74
|
+
v = img[locs[k]]
|
|
75
|
+
ix[n] = locs[k]
|
|
76
|
+
sr[n] = locs[k]
|
|
77
|
+
nx[n] = -1
|
|
78
|
+
if head[v] < 0:
|
|
79
|
+
head[v] = n
|
|
80
|
+
else:
|
|
81
|
+
nx[tail[v]] = n
|
|
82
|
+
tail[v] = n
|
|
83
|
+
n += 1
|
|
84
|
+
cur = 0
|
|
85
|
+
while cur < 256:
|
|
86
|
+
e = head[cur]
|
|
87
|
+
if e < 0:
|
|
88
|
+
cur += 1
|
|
89
|
+
continue
|
|
90
|
+
head[cur] = nx[e]
|
|
91
|
+
if head[cur] < 0:
|
|
92
|
+
tail[cur] = -1
|
|
93
|
+
ei, es = ix[e], sr[e]
|
|
94
|
+
if output[ei] and ei != es:
|
|
95
|
+
continue
|
|
96
|
+
lab = output[es]
|
|
97
|
+
diff = False
|
|
98
|
+
if not mask[ei]:
|
|
99
|
+
diff = True
|
|
100
|
+
else:
|
|
101
|
+
for q in range(structure.shape[0]):
|
|
102
|
+
nb = structure[q] + ei
|
|
103
|
+
if mask[nb]:
|
|
104
|
+
nl = output[nb]
|
|
105
|
+
if nl != 0 and nl != lab: # noqa: PLR1714 (numba: keep it scalar)
|
|
106
|
+
mask[ei] = False
|
|
107
|
+
diff = True
|
|
108
|
+
break
|
|
109
|
+
if not diff:
|
|
110
|
+
output[ei] = lab
|
|
111
|
+
for q in range(structure.shape[0]):
|
|
112
|
+
nb = structure[q] + ei
|
|
113
|
+
if not mask[nb] or output[nb]:
|
|
114
|
+
continue
|
|
115
|
+
v = img[nb]
|
|
116
|
+
if v < cur: # noqa: PLR1730 skimage: cost >= the popping pixel's cost
|
|
117
|
+
v = cur
|
|
118
|
+
if n == cap:
|
|
119
|
+
cap *= 2
|
|
120
|
+
a = np.empty(cap, np.int64)
|
|
121
|
+
a[:n] = ix[:n]
|
|
122
|
+
ix = a
|
|
123
|
+
a = np.empty(cap, np.int64)
|
|
124
|
+
a[:n] = sr[:n]
|
|
125
|
+
sr = a
|
|
126
|
+
a = np.full(cap, -1, np.int64)
|
|
127
|
+
a[:n] = nx[:n]
|
|
128
|
+
nx = a
|
|
129
|
+
ix[n] = nb
|
|
130
|
+
sr[n] = es
|
|
131
|
+
nx[n] = -1
|
|
132
|
+
if head[v] < 0:
|
|
133
|
+
head[v] = n
|
|
134
|
+
else:
|
|
135
|
+
nx[tail[v]] = n
|
|
136
|
+
tail[v] = n
|
|
137
|
+
n += 1
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def watershed_bucket(
|
|
141
|
+
image: np.ndarray,
|
|
142
|
+
markers: np.ndarray | None = None,
|
|
143
|
+
mask: np.ndarray | None = None,
|
|
144
|
+
watershed_line: bool = True,
|
|
145
|
+
connectivity: int = 1,
|
|
146
|
+
) -> np.ndarray:
|
|
147
|
+
"""Skimage's ``watershed(image, markers, mask=mask, watershed_line=True)``.
|
|
148
|
+
|
|
149
|
+
For ``image = -surface`` with ``surface`` on the 1/255 grid.
|
|
150
|
+
"""
|
|
151
|
+
if not watershed_line or connectivity != 1:
|
|
152
|
+
raise ValueError("only watershed_line=True, connectivity=1 is implemented")
|
|
153
|
+
if markers is None or mask is None:
|
|
154
|
+
raise ValueError("markers and mask are required")
|
|
155
|
+
lv = _to_levels(image + 1.0, "watershed input") # -(1 - pb) + 1 = pb
|
|
156
|
+
img = np.pad(lv, 1)
|
|
157
|
+
msk = np.pad(mask.astype(bool), 1).ravel()
|
|
158
|
+
out = np.pad(markers.astype(np.int32), 1)
|
|
159
|
+
st = _offsets_to_raveled_neighbors(
|
|
160
|
+
img.shape, np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], bool), center=(1, 1)
|
|
161
|
+
)
|
|
162
|
+
_bucket_kernel(
|
|
163
|
+
img.ravel(),
|
|
164
|
+
np.flatnonzero(out).astype(np.int64),
|
|
165
|
+
st.astype(np.int64),
|
|
166
|
+
msk,
|
|
167
|
+
out.ravel(),
|
|
168
|
+
)
|
|
169
|
+
return out[1:-1, 1:-1].copy()
|
fbp/core/merge.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Union-find line merging and RAG-based hierarchical region merging."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy.ndimage import label as ndi_label
|
|
5
|
+
from skimage import graph
|
|
6
|
+
|
|
7
|
+
from fbp.core.regions import lines_from_labels, neighbours4, relabel_dense
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnionFind:
|
|
11
|
+
"""Disjoint-set over labels 0..n, path-compressed, lower label wins."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, n: int) -> None:
|
|
14
|
+
"""Start with every label its own group."""
|
|
15
|
+
self.parent = np.arange(n + 1)
|
|
16
|
+
|
|
17
|
+
def find(self, a: int) -> int:
|
|
18
|
+
"""Group representative of ``a``."""
|
|
19
|
+
while self.parent[a] != a:
|
|
20
|
+
self.parent[a] = self.parent[self.parent[a]]
|
|
21
|
+
a = self.parent[a]
|
|
22
|
+
return a
|
|
23
|
+
|
|
24
|
+
def union(self, a: int, b: int) -> None:
|
|
25
|
+
"""Merge the groups of ``a`` and ``b``."""
|
|
26
|
+
ra, rb = self.find(a), self.find(b)
|
|
27
|
+
if ra != rb:
|
|
28
|
+
self.parent[max(ra, rb)] = min(ra, rb)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def weight_boundary(g: graph.RAG, src: int, dst: int, n: int) -> dict[str, int | float]:
|
|
32
|
+
"""Count-weighted mean boundary probability after merging src into dst."""
|
|
33
|
+
default = {"weight": 0.0, "count": 0}
|
|
34
|
+
cs, cd = g[src].get(n, default)["count"], g[dst].get(n, default)["count"]
|
|
35
|
+
ws, wd = g[src].get(n, default)["weight"], g[dst].get(n, default)["weight"]
|
|
36
|
+
count = cs + cd
|
|
37
|
+
return {"count": count, "weight": (cs * ws + cd * wd) / count}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def merge_boundary(_g: graph.RAG, _src: int, _dst: int) -> None:
|
|
41
|
+
"""No-op merge callback: ``weight_boundary`` carries all the state we keep."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def merge_by_lines(
|
|
45
|
+
inst: np.ndarray,
|
|
46
|
+
r: np.ndarray,
|
|
47
|
+
c: np.ndarray,
|
|
48
|
+
pairs: np.ndarray,
|
|
49
|
+
keep: np.ndarray,
|
|
50
|
+
uniq: np.ndarray,
|
|
51
|
+
) -> np.ndarray:
|
|
52
|
+
"""Union unsupported line pairs and fill the junctions that fully merged.
|
|
53
|
+
|
|
54
|
+
``keep`` is a boolean per entry of ``uniq`` (the sorted, unique
|
|
55
|
+
``a * base + b`` pair keys of ``pairs``): the union-find merges every pair
|
|
56
|
+
whose key is *not* kept, then a line pixel is filled only when every
|
|
57
|
+
region it touches ended up in the same merged group, so a kept split
|
|
58
|
+
always stays a gap and two parcels are never adjacent.
|
|
59
|
+
"""
|
|
60
|
+
valid = pairs[:, 0] > 0
|
|
61
|
+
base = int(inst.max()) + 1
|
|
62
|
+
uf = UnionFind(int(inst.max()))
|
|
63
|
+
for k, k_ok in zip(uniq, keep, strict=True):
|
|
64
|
+
if not k_ok:
|
|
65
|
+
uf.union(int(k // base), int(k % base))
|
|
66
|
+
lut = np.array([uf.find(i) for i in range(int(inst.max()) + 1)], dtype=np.int32)
|
|
67
|
+
merged = lut[inst]
|
|
68
|
+
p = np.pad(inst, 1)
|
|
69
|
+
nbm = lut[neighbours4(p, r, c)]
|
|
70
|
+
mx = nbm.max(axis=1)
|
|
71
|
+
cand = np.where(
|
|
72
|
+
valid & (mx > 0) & ((nbm == mx[:, None]) | (nbm == 0)).all(axis=1), mx, 0
|
|
73
|
+
)
|
|
74
|
+
cand_img = np.zeros_like(merged)
|
|
75
|
+
cand_img[r, c] = cand
|
|
76
|
+
q = np.pad(cand_img, 1)
|
|
77
|
+
nbc = neighbours4(q, r, c)
|
|
78
|
+
conflict = ((nbc != 0) & (nbc != cand[:, None])).any(axis=1)
|
|
79
|
+
keep_px = (cand > 0) & ~conflict
|
|
80
|
+
merged[r[keep_px], c[keep_px]] = cand[keep_px]
|
|
81
|
+
dense, _ = ndi_label(merged > 0)
|
|
82
|
+
return dense.astype(np.int32, copy=False)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def merge_hierarchy(
|
|
86
|
+
over: np.ndarray,
|
|
87
|
+
edge_surface: np.ndarray,
|
|
88
|
+
fg: np.ndarray,
|
|
89
|
+
level: float,
|
|
90
|
+
weights: dict[tuple[int, int], tuple[float, int]] | None = None,
|
|
91
|
+
) -> np.ndarray:
|
|
92
|
+
"""RAG-boundary hierarchical merge shared by ``ucm`` and ``owt``.
|
|
93
|
+
|
|
94
|
+
Builds ``rag_boundary(over, edge_surface)`` with the background node
|
|
95
|
+
dropped, optionally overrides specific edge weights (owt's oriented
|
|
96
|
+
signal), then greedily merges below ``level`` and relabels densely.
|
|
97
|
+
"""
|
|
98
|
+
if over.max() <= 1:
|
|
99
|
+
return over.astype(np.int32, copy=False)
|
|
100
|
+
rag = graph.rag_boundary(over, edge_surface.astype(np.float64), connectivity=1)
|
|
101
|
+
if 0 in rag:
|
|
102
|
+
rag.remove_node(0) # background is not a region to merge into
|
|
103
|
+
if weights:
|
|
104
|
+
for (a, b), (w, n_px) in weights.items():
|
|
105
|
+
if rag.has_edge(a, b):
|
|
106
|
+
rag[a][b]["weight"] = w
|
|
107
|
+
rag[a][b]["count"] = n_px
|
|
108
|
+
merged = graph.merge_hierarchical(
|
|
109
|
+
over,
|
|
110
|
+
rag,
|
|
111
|
+
thresh=level,
|
|
112
|
+
rag_copy=False,
|
|
113
|
+
in_place_merge=True,
|
|
114
|
+
merge_func=merge_boundary,
|
|
115
|
+
weight_func=weight_boundary,
|
|
116
|
+
).astype(np.int32, copy=False)
|
|
117
|
+
merged[~fg] = 0
|
|
118
|
+
merged[fg] += 1 # merge_hierarchical may hand out label 0
|
|
119
|
+
return relabel_dense(lines_from_labels(merged, fg))
|
fbp/core/postprocess.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Foreground rules and the fixed-order cleanup steps applied after a split."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy.ndimage import binary_dilation
|
|
5
|
+
from scipy.ndimage import label as ndi_label
|
|
6
|
+
from skimage.filters import threshold_otsu
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def foreground(seg: np.ndarray, prob: np.ndarray, rule: str | float) -> np.ndarray:
|
|
10
|
+
"""Foreground mask for a ``Method.foreground`` rule.
|
|
11
|
+
|
|
12
|
+
A number keeps pixels whose field probability is above it.
|
|
13
|
+
"""
|
|
14
|
+
if isinstance(rule, int | float):
|
|
15
|
+
return prob[1] > rule
|
|
16
|
+
if rule == "argmax":
|
|
17
|
+
return seg == 1
|
|
18
|
+
if rule == "not_background":
|
|
19
|
+
return seg != 0
|
|
20
|
+
if rule == "otsu":
|
|
21
|
+
pf = prob[1]
|
|
22
|
+
if float(pf.max() - pf.min()) < 1e-3:
|
|
23
|
+
return seg == 1
|
|
24
|
+
return pf > threshold_otsu(pf)
|
|
25
|
+
raise ValueError(f"unknown foreground rule {rule!r}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def drop_low_field(inst: np.ndarray, pf: np.ndarray, min_mean: float) -> np.ndarray:
|
|
29
|
+
"""Drop parcels whose mean field probability is under ``min_mean``."""
|
|
30
|
+
labels = inst.ravel()
|
|
31
|
+
total = np.bincount(labels, weights=pf.ravel().astype(np.float64))
|
|
32
|
+
mean = total / np.maximum(np.bincount(labels), 1)
|
|
33
|
+
return np.where(mean[inst] >= min_mean, inst, 0).astype(np.int32, copy=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def drop_small(inst: np.ndarray, min_area: int) -> np.ndarray:
|
|
37
|
+
"""Remove parcels below ``min_area`` pixels and relabel."""
|
|
38
|
+
if min_area <= 0:
|
|
39
|
+
return inst
|
|
40
|
+
ids, counts = np.unique(inst[inst > 0], return_counts=True)
|
|
41
|
+
small = ids[counts < min_area]
|
|
42
|
+
if not len(small):
|
|
43
|
+
return inst
|
|
44
|
+
out = inst.copy()
|
|
45
|
+
out[np.isin(out, small)] = 0
|
|
46
|
+
dense, _ = ndi_label(out > 0)
|
|
47
|
+
return dense.astype(np.int32, copy=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fill_holes(inst: np.ndarray, max_area: int) -> np.ndarray:
|
|
51
|
+
"""Assign background holes inside a parcel to that parcel.
|
|
52
|
+
|
|
53
|
+
``max_area < 0`` fills every hole; otherwise only holes under ``max_area``
|
|
54
|
+
pixels. A hole is a background component that touches exactly one parcel and
|
|
55
|
+
not the image border, so the gap at a kept split is never filled.
|
|
56
|
+
"""
|
|
57
|
+
if max_area == 0:
|
|
58
|
+
return inst
|
|
59
|
+
bg, n = ndi_label(inst == 0)
|
|
60
|
+
if n == 0:
|
|
61
|
+
return inst
|
|
62
|
+
border = set(np.unique(np.concatenate([bg[0], bg[-1], bg[:, 0], bg[:, -1]])))
|
|
63
|
+
out = inst.copy()
|
|
64
|
+
p = np.pad(inst, 1)
|
|
65
|
+
for i, size in zip(*np.unique(bg[bg > 0], return_counts=True), strict=True):
|
|
66
|
+
if i in border or (max_area > 0 and size >= max_area):
|
|
67
|
+
continue
|
|
68
|
+
m = bg == i
|
|
69
|
+
r, c = np.nonzero(m)
|
|
70
|
+
nb = np.concatenate(
|
|
71
|
+
[p[r, c + 1], p[r + 2, c + 1], p[r + 1, c], p[r + 1, c + 2]]
|
|
72
|
+
)
|
|
73
|
+
owners = np.unique(nb[nb > 0])
|
|
74
|
+
if len(owners) == 1:
|
|
75
|
+
out[m] = owners[0]
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def merge_small(inst: np.ndarray, min_area: int) -> np.ndarray:
|
|
80
|
+
"""Merge small parcels into their longest-shared-border neighbour.
|
|
81
|
+
|
|
82
|
+
``min_area`` px, instead of deleting them. Parcels are separated by a
|
|
83
|
+
one-pixel gap, so the neighbourhood is taken two pixels out and the gap
|
|
84
|
+
between the pair is filled; otherwise the merged parcel would fall apart
|
|
85
|
+
again when labels are rebuilt. A parcel with no neighbour within that
|
|
86
|
+
distance is left alone.
|
|
87
|
+
"""
|
|
88
|
+
if min_area <= 0:
|
|
89
|
+
return inst
|
|
90
|
+
ids, counts = np.unique(inst[inst > 0], return_counts=True)
|
|
91
|
+
order = [i for _, i in sorted(zip(counts, ids, strict=True)) if _ < min_area]
|
|
92
|
+
if not order:
|
|
93
|
+
return inst
|
|
94
|
+
out = inst.copy()
|
|
95
|
+
cross = np.ones((5, 5), dtype=bool)
|
|
96
|
+
for i in order:
|
|
97
|
+
m = out == i
|
|
98
|
+
if not m.any():
|
|
99
|
+
continue
|
|
100
|
+
near = binary_dilation(m, structure=cross) & ~m
|
|
101
|
+
vals, freq = np.unique(out[near & (out > 0)], return_counts=True)
|
|
102
|
+
if not len(vals):
|
|
103
|
+
continue
|
|
104
|
+
best = int(vals[freq.argmax()])
|
|
105
|
+
# bridge the gap so the pair stays one connected parcel
|
|
106
|
+
gap = (
|
|
107
|
+
binary_dilation(m, structure=np.ones((3, 3), dtype=bool))
|
|
108
|
+
& binary_dilation(out == best, structure=np.ones((3, 3), dtype=bool))
|
|
109
|
+
& (out == 0)
|
|
110
|
+
)
|
|
111
|
+
out[gap] = best
|
|
112
|
+
out[m] = best
|
|
113
|
+
dense, _ = ndi_label(out > 0)
|
|
114
|
+
return dense.astype(np.int32, copy=False)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def apply_post_steps(
|
|
118
|
+
inst: np.ndarray, min_area: int, hole_area: int, merge_area: int
|
|
119
|
+
) -> np.ndarray:
|
|
120
|
+
"""Post-steps in a fixed order: fill holes, merge slivers, drop what is left."""
|
|
121
|
+
return drop_small(merge_small(fill_holes(inst, hole_area), merge_area), min_area)
|
fbp/core/regions.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Watershed regions, connected components, and the label bookkeeping they share."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.ndimage import label as ndi_label
|
|
7
|
+
from skimage.morphology import h_maxima, h_minima, local_maxima, local_minima
|
|
8
|
+
from skimage.segmentation import watershed
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Regions:
|
|
13
|
+
"""One watershed's output plus the marker bookkeeping around it."""
|
|
14
|
+
|
|
15
|
+
labels: np.ndarray # int32, 0 outside fg and on lines (when lines=True)
|
|
16
|
+
lines: np.ndarray # bool, watershed lines inside fg
|
|
17
|
+
markers: np.ndarray # int32 marker image
|
|
18
|
+
n_markers: int
|
|
19
|
+
fallback: bool # True when no marker was found and labels = components(fg)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def components(fg: np.ndarray) -> np.ndarray:
|
|
23
|
+
"""Connected components of the foreground."""
|
|
24
|
+
out, _ = ndi_label(fg)
|
|
25
|
+
return out.astype(np.int32, copy=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def relabel_dense(lab: np.ndarray) -> np.ndarray:
|
|
29
|
+
"""Labels remapped to a dense 0..n range, preserving background."""
|
|
30
|
+
_, dense = np.unique(lab, return_inverse=True)
|
|
31
|
+
dense = dense.reshape(lab.shape).astype(np.int32, copy=False)
|
|
32
|
+
if lab.min() != 0:
|
|
33
|
+
dense += 1
|
|
34
|
+
return dense
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def lines_from_labels(lab: np.ndarray, fg: np.ndarray) -> np.ndarray:
|
|
38
|
+
"""1-px lines between different labels so adjacent regions never touch."""
|
|
39
|
+
p = np.pad(lab, 1)
|
|
40
|
+
c = p[1:-1, 1:-1]
|
|
41
|
+
diff = ((p[:-2, 1:-1] != c) & (p[:-2, 1:-1] > 0)) | (
|
|
42
|
+
(p[1:-1, :-2] != c) & (p[1:-1, :-2] > 0)
|
|
43
|
+
)
|
|
44
|
+
out = lab.copy()
|
|
45
|
+
out[diff & fg & (c > 0)] = 0
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def neighbours4(padded: np.ndarray, r: np.ndarray, c: np.ndarray) -> np.ndarray:
|
|
50
|
+
"""Values at the 4-neighbourhood of ``(r, c)`` in a 1-padded image."""
|
|
51
|
+
return np.stack(
|
|
52
|
+
[
|
|
53
|
+
padded[r, c + 1],
|
|
54
|
+
padded[r + 2, c + 1],
|
|
55
|
+
padded[r + 1, c],
|
|
56
|
+
padded[r + 1, c + 2],
|
|
57
|
+
],
|
|
58
|
+
axis=1,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pairs_at_lines(
|
|
63
|
+
inst: np.ndarray, lines: np.ndarray
|
|
64
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
65
|
+
"""Per line pixel: (rows, cols, (n_px, 2) sorted label pairs).
|
|
66
|
+
|
|
67
|
+
The pair is the two largest distinct positive neighbour labels; pixels
|
|
68
|
+
touching < 2 labels get (0, 0).
|
|
69
|
+
"""
|
|
70
|
+
p = np.pad(inst, 1)
|
|
71
|
+
r, c = np.nonzero(lines)
|
|
72
|
+
nb = neighbours4(p, r, c)
|
|
73
|
+
nb.sort(axis=1)
|
|
74
|
+
first = nb[:, :1]
|
|
75
|
+
distinct = np.concatenate(
|
|
76
|
+
[first, np.where(nb[:, 1:] != nb[:, :-1], nb[:, 1:], 0)], axis=1
|
|
77
|
+
)
|
|
78
|
+
distinct.sort(axis=1)
|
|
79
|
+
top2 = distinct[:, -2:] # two largest distinct (0 if fewer than two)
|
|
80
|
+
top2[top2[:, 0] == 0] = 0
|
|
81
|
+
return r, c, top2
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _morphology(backend: str) -> tuple:
|
|
85
|
+
"""The h-maxima and watershed functions for ``backend``."""
|
|
86
|
+
if backend == "skimage":
|
|
87
|
+
return h_maxima, watershed
|
|
88
|
+
if backend == "fast":
|
|
89
|
+
from fbp.core.fast_morph import h_maxima_itk, watershed_bucket # noqa: PLC0415 (optional [fast] extra)
|
|
90
|
+
|
|
91
|
+
return h_maxima_itk, watershed_bucket
|
|
92
|
+
raise ValueError(backend)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def watershed_regions( # noqa: PLR0913 (keyword-only flags)
|
|
96
|
+
surface: np.ndarray,
|
|
97
|
+
fg: np.ndarray,
|
|
98
|
+
h: float,
|
|
99
|
+
*,
|
|
100
|
+
lines: bool = True,
|
|
101
|
+
mode: str = "max",
|
|
102
|
+
backend: str = "skimage",
|
|
103
|
+
) -> Regions:
|
|
104
|
+
"""Watershed inside ``fg``, seeded at ``surface``'s own extrema.
|
|
105
|
+
|
|
106
|
+
``backend="fast"`` runs the h-maxima and the watershed on integer levels
|
|
107
|
+
(``fbp.core.fast_morph``). It applies to ``mode="max"`` only and needs the
|
|
108
|
+
surface on the 1/255 grid.
|
|
109
|
+
|
|
110
|
+
``mode="max"`` (every other caller) seeds on maxima: ``h > 0`` seeds
|
|
111
|
+
``h``-maxima at least ``h`` deep, ``h == 0`` seeds every regional maximum,
|
|
112
|
+
and outside ``fg`` is masked to 0 for the seed search (harmless for the
|
|
113
|
+
non-negative distance/probability surfaces every ``max`` caller uses,
|
|
114
|
+
since 0 is already their own floor).
|
|
115
|
+
|
|
116
|
+
``mode="min"`` (owt only) seeds on minima instead, matching
|
|
117
|
+
``skimage.morphology.h_minima``/``local_minima`` directly. It masks
|
|
118
|
+
outside ``fg`` by *substituting* ``surface.max() + 1`` there rather than
|
|
119
|
+
multiplying by ``fg``: h_maxima/h_minima's floating-point tolerance scales
|
|
120
|
+
with each pixel's own magnitude, so zeroing every masked-out pixel would
|
|
121
|
+
change the in-``fg`` pixels' tolerance too when negated into a maxima
|
|
122
|
+
search, and does occasionally flip which of two nearly-tied real pixels
|
|
123
|
+
counts as the seed on real patches (see the owt port report).
|
|
124
|
+
|
|
125
|
+
Falls back to plain connected components of ``fg`` when no marker
|
|
126
|
+
survives.
|
|
127
|
+
"""
|
|
128
|
+
z = np.zeros(fg.shape, dtype=np.int32)
|
|
129
|
+
if not fg.any():
|
|
130
|
+
return Regions(z, np.zeros(fg.shape, dtype=bool), z, 0, True)
|
|
131
|
+
if mode == "max":
|
|
132
|
+
hmax, ws = _morphology(backend)
|
|
133
|
+
seed = surface * fg
|
|
134
|
+
peaks = (hmax(seed, h=h) & fg) if h > 0 else (local_maxima(seed) & fg)
|
|
135
|
+
flood_surface = -surface
|
|
136
|
+
elif mode == "min":
|
|
137
|
+
ws = watershed
|
|
138
|
+
seed = np.where(fg, surface, surface.max() + 1.0).astype(surface.dtype)
|
|
139
|
+
peaks = (h_minima(seed, h=h) & fg) if h > 0 else (local_minima(seed) & fg)
|
|
140
|
+
flood_surface = surface
|
|
141
|
+
else:
|
|
142
|
+
raise ValueError(mode)
|
|
143
|
+
markers, n = ndi_label(peaks)
|
|
144
|
+
markers = markers.astype(np.int32, copy=False)
|
|
145
|
+
if n == 0:
|
|
146
|
+
return Regions(components(fg), np.zeros(fg.shape, dtype=bool), markers, 0, True)
|
|
147
|
+
labels = ws(flood_surface, markers=markers, mask=fg, watershed_line=lines)
|
|
148
|
+
labels = labels.astype(np.int32, copy=False)
|
|
149
|
+
line_mask = (fg & (labels == 0)) if lines else np.zeros(fg.shape, dtype=bool)
|
|
150
|
+
return Regions(labels, line_mask, markers, int(n), False)
|
fbp/core/surfaces.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The scalar surfaces a split rule seeds its watershed on."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy.ndimage import convolve, distance_transform_edt
|
|
5
|
+
|
|
6
|
+
N_ORIENT = 8
|
|
7
|
+
LINE_PX = 5 # length of the oriented averaging line, in pixels
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def boundary_distance(seg: np.ndarray) -> np.ndarray:
|
|
11
|
+
"""EDT of (seg != boundary), the FTP pipeline's watershed surface."""
|
|
12
|
+
return distance_transform_edt(seg != 2).astype(np.float32, copy=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def boundary_prob(prob: np.ndarray) -> np.ndarray:
|
|
16
|
+
"""1 - p_boundary, high away from a predicted boundary."""
|
|
17
|
+
return (1.0 - prob[2]).astype(np.float32, copy=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def line_kernel(theta: float, length: int) -> np.ndarray:
|
|
21
|
+
"""Normalised line of ``length`` px through the kernel centre at ``theta``."""
|
|
22
|
+
k = np.zeros((length, length), dtype=np.float32)
|
|
23
|
+
c = length // 2
|
|
24
|
+
for t in np.linspace(-c, c, 4 * length + 1):
|
|
25
|
+
r = int(round(c - t * np.sin(theta)))
|
|
26
|
+
q = int(round(c + t * np.cos(theta)))
|
|
27
|
+
k[r, q] = 1.0
|
|
28
|
+
return k / k.sum()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def oriented_signal(pb: np.ndarray, length: int = LINE_PX) -> np.ndarray:
|
|
32
|
+
"""``(N_ORIENT, H, W)`` mean boundary probability along a line per orientation."""
|
|
33
|
+
thetas = np.arange(N_ORIENT) * np.pi / N_ORIENT
|
|
34
|
+
return np.stack(
|
|
35
|
+
[convolve(pb, line_kernel(t, length), mode="nearest") for t in thetas]
|
|
36
|
+
)
|
fbp/methods/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Methods that turn (background, field, boundary) predictions into field instances."""
|
|
2
|
+
|
|
3
|
+
from fbp.methods.base import Method
|
|
4
|
+
from fbp.methods.boundaryvote import BoundaryVote
|
|
5
|
+
from fbp.methods.components import ClosedComponents, Components
|
|
6
|
+
from fbp.methods.ftp import FTPWatershed, ftp_pipeline
|
|
7
|
+
from fbp.methods.gmm import GaussianMixture
|
|
8
|
+
from fbp.methods.iterh import IterativeWatershed
|
|
9
|
+
from fbp.methods.owt import OrientedWatershed
|
|
10
|
+
from fbp.methods.swst import StochasticWatershed
|
|
11
|
+
from fbp.methods.thresholds import ExtentBoundaryThreshold
|
|
12
|
+
from fbp.methods.ucm import UltrametricContours
|
|
13
|
+
|
|
14
|
+
# The setting used for the FTW Global Data 2nd Edition, on 2.5 m probabilities.
|
|
15
|
+
FTW_2E = BoundaryVote(
|
|
16
|
+
foreground="not_background",
|
|
17
|
+
surface="boundary_probability",
|
|
18
|
+
seed_depth=0.01,
|
|
19
|
+
min_line_support=0.5,
|
|
20
|
+
min_mean_field_probability=0.25,
|
|
21
|
+
refill_field_probability=0.10,
|
|
22
|
+
refill_min_uncovered=0.02,
|
|
23
|
+
min_area_m2=900,
|
|
24
|
+
fast=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"FTW_2E",
|
|
29
|
+
"BoundaryVote",
|
|
30
|
+
"ClosedComponents",
|
|
31
|
+
"Components",
|
|
32
|
+
"ExtentBoundaryThreshold",
|
|
33
|
+
"FTPWatershed",
|
|
34
|
+
"GaussianMixture",
|
|
35
|
+
"IterativeWatershed",
|
|
36
|
+
"Method",
|
|
37
|
+
"OrientedWatershed",
|
|
38
|
+
"StochasticWatershed",
|
|
39
|
+
"UltrametricContours",
|
|
40
|
+
"ftp_pipeline",
|
|
41
|
+
]
|