Skip to content

Commit 7e919b9

Browse files
feat(fill_holes_v2): true multi-label fill holes (#10)
* fix: remove debug code * feat: add morphological closing feature * fix: remove debug profile * redesign: get rid of morphological close, add 1 to labels * fix: reduce "escape" for holes on borders by using 4 connectivty * fix: give an adjustable threshold for how much a hole can poke out * fix: got this mostly working * fix: preserve labels as "filled" if they simply are touching a lot of other labels * fix: ensure bg contacts have non-zero surface contact * perf: reduce peak memory usage * perf: use crackle for low memory representations * feat: make crackle-codec an optional dependency * fix: switch from Sequence to Iterator * docs: add docstring to fill_holes_v2 * refactor: add fill_holes_v1 as alias for fill_holes * fix: trying to replicate exactly hole filling semantics * fix: remove debug code * fix: remove debug code * fix: matched scipy! found a bug in fill_voids? * feat: add merge threshold back in * fix: referencing non-existent edges * fix: incorrect optimization * fix: incorrect threshold direction * feat: allow closing of background on border * ci: update cibuildwheel * fix: bool dtype * fix: return proper dtype * test: check fill_holes_v2 for correctness * install: add scipy to dev requirements * ci: install scipy * perf: add special handling for binary images * refactor: remove unneeded special handling for binary images * docs: describe hole filling algorithm * fix: discard the sentinel * feat: perform real hole filling in 2d * fix: treat zero correctly * fix: make everything work with fixed borders * refactor: remove unreachable branch * fix: don't reprocess an already processed hole * fix: removed explored hole group * fix: type annotation and return type for edge hole * perf: handle large hole groups more efficiently * fix: more suitable variable * fix: better annotations * docs: describe hole filling techniques * fix: ensure holes touch the border properly * fix: don't allow setting return_crackle when crackle is not present * perf: delete remap variable after use * fix: harmonize anisotropy type and generalize to float * fix: handle fill holes on a solid color * fix: fill holes on no color * test: check if fix borders works right * fix: handle fix_borders without skipping over labels that are fully filled * test: check zero image works * docs: correct the documentation
1 parent ab9e756 commit 7e919b9

5 files changed

Lines changed: 484 additions & 5 deletions

File tree

.github/workflows/run_tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
- name: Install dependencies
3333
run: |
3434
python -m pip install --upgrade pip
35-
python -m pip install pytest pybind11 setuptools wheel crackle-codec
35+
python -m pip install pytest pybind11 setuptools wheel crackle-codec scipy
3636
python -m pip install connected-components-3d edt fastremap numpy tqdm fill-voids
3737
3838
- name: Compile

README.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,53 @@ morphed = fastmorph.spherical_close(labels, radius=1, parallel=2, anisotropy=(1,
6464
# The rest support multilabel images.
6565
morphed = fastmorph.spherical_erode(labels, radius=1, parallel=2, anisotropy=(1,1,1))
6666

67+
68+
# Rapid multilabel hole filling. There are two versions that use different techniques
69+
# and have different interfaces for their "aggressive" modes. Both modes fill
70+
# holes appropriately by default.
71+
#
72+
# Generally speaking, fill_holes_v2 will be much faster. v2 uses a
73+
# mostly linear time contact graph analysis. v1 analyzes a sequence
74+
# of binary images. v2 exhibits much better scaling behavior and supports
75+
# returning the filled and hole labels as CrackleArray compressed objects
76+
# to save memory.
77+
#
78+
# The main advantage of v1 is that it includes a morphological closure mode
79+
# that operates on voxels for closing small holes. The downside is that this
80+
# can modify the surface of the object.
81+
#
82+
# v2 allows merging holes that are less than 100% closed, but if this
83+
# threshold is set too high, holes won't be closed. If it is too low,
84+
# improper merging can occur.
85+
#
86+
# In both methods, objects that contact the sides or more than one side
87+
# (in the case of fix_borders) cannot be merged.
88+
89+
filled_labels, hole_labels = fastmorph.fill_holes_v2(labels)
90+
# requires: pip install crackle-codec
91+
# returns as compressed CrackleArrays that have speedy access to labels
92+
# in the compressed state (often hundreds of times smaller than the full array)
93+
filled_labels, hole_labels = fastmorph.fill_holes_v2(labels, return_crackle=True)
94+
95+
# fix_borders runs hole filling for each object on the edge to reduce edge contacts
96+
filled_labels, hole_labels = fastmorph.fill_holes_v2(labels, fix_borders=True)
97+
98+
# merge_threshold (range 0.0 - 1.0) controls how much surface area can be
99+
# "exposed" for a hole to still be filled. The default (1.0) means a hole
100+
# must be perfectly sealed (typical for hole filling algorithms).
101+
filled_labels, hole_labels = fastmorph.fill_holes_v2(labels, merge_threshold=0.97)
102+
67103
# Note: for boolean images, this function will directly call fill_voids
68104
# and return a scalar for ct
69105
# For integer images, more processing will be done to deal with multiple labels.
70106
# A dict of { label: num_voxels_filled } for integer images will be returned.
71107
# Note that for multilabel images, by default, if a label is totally enclosed by another,
72108
# a FillError will be raised. If remove_enclosed is True, the label will be overwritten.
73-
filled_labels, ct = fastmorph.fill_holes(labels, return_fill_count=True, remove_enclosed=False)
109+
filled_labels, ct = fastmorph.fill_holes_v1(labels, return_fill_count=True, remove_enclosed=False)
74110

75111
# If the holes in your segmentation are imperfectly sealed, consider
76112
# using the following options.
77-
filled_labels = fastmorph.fill_holes(
113+
filled_labels = fastmorph.fill_holes_v1(
78114
labels,
79115
# runs 2d fill on the sides of the cube for each binary image
80116
fix_borders=True,

automated_test.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import time
2+
13
import pytest
24
import numpy as np
35
import fastmorph
6+
import scipy.ndimage
47

58
def test_spherical_dilate():
69
labels = np.zeros((10,10,10), dtype=bool)
@@ -109,6 +112,88 @@ def test_complex_fill():
109112

110113
assert np.all(res == ans)
111114

115+
def test_fill_holes_v2():
116+
labels = np.zeros((10,10,10), dtype=np.uint64)
117+
filled, holes = fastmorph.fill_holes_v2(labels)
118+
assert not np.any(filled)
119+
assert not np.any(holes)
120+
121+
labels = np.ones((10,10,10), dtype=np.uint8)
122+
labels[:,:,:5] = 2
123+
124+
labels[5,5,2] = 0
125+
labels[5,5,7] = 0
126+
127+
assert np.count_nonzero(labels) == 998
128+
filled, holes = fastmorph.fill_holes_v2(labels)
129+
assert np.count_nonzero(filled) == 1000
130+
assert list(np.unique(filled)) == [1,2]
131+
132+
assert filled[5,5,2] == 2
133+
assert filled[5,5,7] == 1
134+
135+
labels = np.ones((10,10,10), dtype=np.uint32)
136+
labels[5,5,2] = 777
137+
138+
filled, holes = fastmorph.fill_holes_v2(labels, return_crackle=True)
139+
assert set(holes.labels()) == set([0,777])
140+
141+
labels = np.ones((10,10,10), dtype=bool)
142+
labels[5,5,2] = 0
143+
144+
res, holes = fastmorph.fill_holes_v2(labels)
145+
assert np.all(res)
146+
assert not np.any(holes)
147+
148+
def test_fill_v2_fix_borders():
149+
labels = np.ones([100,100,100], dtype=np.uint8)
150+
labels[40:60,40:60,:] = 2
151+
labels[40:60,:,40:60] = 2
152+
labels[:,40:60,40:60] = 2
153+
154+
filled, holes = fastmorph.fill_holes_v2(
155+
labels,
156+
fix_borders=False,
157+
)
158+
assert np.all(filled == labels)
159+
160+
filled, holes = fastmorph.fill_holes_v2(
161+
labels,
162+
fix_borders=True,
163+
)
164+
assert np.all(filled == 1)
165+
assert np.count_nonzero(holes) == 20*20*100*3 - 20*20*20*2
166+
167+
labels = np.ones([100,100,100], dtype=np.uint8)
168+
labels[40:60,40:60,0] = 2
169+
labels[40:60,0,40:60] = 2
170+
labels[0,40:60,40:60] = 2
171+
172+
filled, holes = fastmorph.fill_holes_v2(
173+
labels,
174+
fix_borders=True,
175+
)
176+
177+
assert np.all(filled == 1)
178+
assert np.count_nonzero(holes) == 20*20*3
179+
180+
def test_fill_holes_v2_data():
181+
import crackle
182+
labels = crackle.load("connectomics.npy.ckl.gz")
183+
184+
s = time.time()
185+
filled, holes = fastmorph.fill_holes_v2(labels)
186+
print(f"{time.time() - s:.3f}")
187+
188+
uniq = np.unique(filled)
189+
for lbl in uniq[:10]:
190+
print(lbl)
191+
if lbl == 0:
192+
continue
193+
binary_image_fm = filled == lbl
194+
binary_image_scipy = scipy.ndimage.binary_fill_holes(labels == lbl)
195+
assert np.all(binary_image_fm == binary_image_scipy)
196+
112197
def test_spherical_open_close_run():
113198
labels = np.zeros((10,10,10), dtype=bool)
114199
res = fastmorph.spherical_open(labels, radius=1)

0 commit comments

Comments
 (0)