Skip to content

Commit 74fe0e3

Browse files
Merge pull request #7 from scientificcomputing/tests
Add a test suite
2 parents 92dedb3 + 79bf89f commit 74fe0e3

8 files changed

Lines changed: 375 additions & 14 deletions

File tree

.github/workflows/test_conda.yml

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ jobs:
2727
steps:
2828
- uses: actions/checkout@v4
2929

30-
#- name: Install Headless Plotting Libs
31-
# if: runner.os == 'Linux'
32-
# run: |
33-
# sudo apt-get update
34-
# sudo apt-get install -y libosmesa6 libgl1
30+
- name: Install Headless Plotting Lib (Linux)
31+
if: runner.os == 'Linux'
32+
run: |
33+
sudo apt-get update
34+
sudo apt-get install -y libosmesa6 libgl1
3535
3636
- name: Setup conda-forge
3737
uses: conda-incubator/setup-miniconda@v3
@@ -47,14 +47,18 @@ jobs:
4747
- name: Install Missing Tools (Linux/Win)
4848
if: runner.os != 'macOS'
4949
run: |
50-
conda install snakemake mesalib
50+
conda install snakemake mesalib pytest
5151
5252
# 2. Install for macOS (Excludes mesalib)
5353
- name: Install Missing Tools (macOS)
5454
if: runner.os == 'macOS'
5555
run: |
56-
conda install snakemake
57-
56+
conda install snakemake pytest
57+
58+
- name: Run unit tests
59+
run: |
60+
python -m pytest tests
61+
5862
- name: Run snakemake
5963
run: |
6064
snakemake --cores 2 -p --configfile ./config_files/test.yml

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ results
44
results/
55
logs/
66

7+
*.egg-info
8+
79
fTetWild
810
zipped
911
zipped_surf

src/emimesh/process_image_data.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ def mergecells(img, labels):
1414

1515
def ncells(img, ncells, keep_cell_labels=None):
1616
cell_labels, cell_counts = fastremap.unique(img, return_counts=True)
17-
cell_labels = cell_labels[np.argsort(cell_counts)]
18-
if keep_cell_labels is None: cois =[]
19-
cois = set(keep_cell_labels)
17+
cell_labels = cell_labels[np.argsort(cell_counts)][::-1]
18+
if keep_cell_labels is None:
19+
cois = set()
20+
else:
21+
cois = set(keep_cell_labels)
2022
for cid in cell_labels:
2123
if len(cois) >= ncells: break
2224
cois.add(cid)
23-
img = np.where(np.isin(img, cois), img, 0)
25+
img = np.where(np.isin(img, list(cois)), img, 0)
2426
return img
2527

2628
def dilate(img, radius, labels=None):

src/emimesh/utils.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
import fastremap
44

55

6-
def np2pv(arr, resolution, roimask=None):
6+
def np2pv(arr, resolution, roimask=None, as_point_data=False):
7+
dimensions = arr.shape
8+
if not as_point_data: dimensions += + np.array([1, 1, 1])
9+
710
grid = pv.ImageData(
8-
dimensions=arr.shape + np.array((1, 1, 1)), spacing=resolution, origin=(0, 0, 0)
11+
dimensions=dimensions,
12+
spacing=resolution,
13+
origin=(0, 0, 0)
914
)
1015
grid[f"data"] = arr.flatten(order="F")
1116
if roimask is not None:

tests/conftest.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Global pytest configuration and fixtures for EMIMesh testing."""
2+
import pytest
3+
import numpy as np
4+
import pyvista as pv
5+
from pathlib import Path
6+
import tempfile
7+
import shutil
8+
9+
10+
@pytest.fixture
11+
def temp_dir():
12+
"""Create a temporary directory for test files."""
13+
temp_path = Path(tempfile.mkdtemp())
14+
yield temp_path
15+
shutil.rmtree(temp_path)
16+
17+
18+
@pytest.fixture
19+
def sample_image_data():
20+
"""Create sample image data for testing."""
21+
# Create a 3D array with some labeled regions
22+
data = np.zeros((50, 50, 50), dtype=np.uint32)
23+
24+
# Add some labeled cells
25+
data[10:20, 10:20, 10:20] = 1
26+
data[30:40, 30:40, 30:40] = 2
27+
data[15:25, 35:45, 15:25] = 3
28+
29+
return data
30+
31+
32+
@pytest.fixture
33+
def sample_resolution():
34+
"""Sample resolution for testing."""
35+
return [18.0, 18.0, 18.0]
36+
37+
38+
@pytest.fixture
39+
def sample_pyvista_grid(sample_image_data, sample_resolution):
40+
"""Create a sample PyVista grid for testing."""
41+
grid = pv.ImageData(
42+
dimensions=sample_image_data.shape + np.array([1, 1, 1]),
43+
spacing=sample_resolution,
44+
origin=(0, 0, 0)
45+
)
46+
grid["data"] = sample_image_data.flatten(order="F")
47+
return grid
48+
49+
50+
@pytest.fixture
51+
def test_data_dir():
52+
"""Path to test data directory."""
53+
return Path(__file__).parent / "data"

tests/test_extract_surfaces.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Tests for emimesh.extract_surfaces module."""
2+
import numpy as np
3+
import pyvista as pv
4+
5+
from emimesh.extract_surfaces import (
6+
extract_surface, create_balanced_csg_tree,
7+
clean_mesh_nan_points
8+
)
9+
from emimesh.utils import np2pv
10+
11+
12+
class TestExtractSurface:
13+
"""Test surface extraction functionality."""
14+
15+
def test_extract_surface_basic(self):
16+
"""Test basic surface extraction."""
17+
# Create a simple test volume
18+
mask = np.zeros((10, 10, 10), dtype=np.uint32)
19+
mask[2:7, 2:8, 2:8] = 1 # Cube in the center
20+
21+
grid = pv.ImageData(dimensions=mask.shape, spacing=(1,1,1), origin=(0, 0, 0))
22+
print(grid)
23+
print(mask)
24+
result = extract_surface(mask, grid, mesh_reduction_factor=2, taubin_smooth_iter=5)
25+
26+
# Should return a valid mesh
27+
assert isinstance(result, pv.PolyData)
28+
assert result.is_manifold
29+
assert not np.isnan(result.points).any()
30+
31+
32+
def test_extract_surface_too_small(self):
33+
"""Test surface extraction with too small volume."""
34+
mask = np.zeros((10, 10, 10), dtype=np.uint32)
35+
mask[5, 5, 5] = 1 # Single voxel
36+
37+
grid = pv.ImageData(dimensions=(10, 10, 10), spacing=(1, 1, 1))
38+
39+
result = extract_surface(mask, grid, mesh_reduction_factor=10, taubin_smooth_iter=5)
40+
41+
assert result is False
42+
43+
class TestCSGTree:
44+
"""Test CSG tree creation."""
45+
46+
def test_create_balanced_csg_tree_single(self):
47+
"""Test CSG tree creation with single surface."""
48+
surface_files = ["surface1.ply"]
49+
50+
result = create_balanced_csg_tree(surface_files)
51+
52+
assert result == "surface1.ply"
53+
54+
def test_create_balanced_csg_tree_two(self):
55+
"""Test CSG tree creation with two surfaces."""
56+
surface_files = ["surface1.ply", "surface2.ply"]
57+
58+
result = create_balanced_csg_tree(surface_files)
59+
60+
expected = {
61+
"operation": "union",
62+
"left": "surface1.ply",
63+
"right": "surface2.ply"
64+
}
65+
assert result == expected
66+
67+
def test_create_balanced_csg_tree_multiple(self):
68+
"""Test CSG tree creation with multiple surfaces."""
69+
surface_files = ["s1.ply", "s2.ply", "s3.ply", "s4.ply"]
70+
71+
result = create_balanced_csg_tree(surface_files)
72+
73+
# Should be a balanced tree structure
74+
assert result["operation"] == "union"
75+
assert "left" in result
76+
assert "right" in result
77+
78+
# Left and right should each contain 2 surfaces
79+
assert isinstance(result["left"], dict) or isinstance(result["left"], str)
80+
assert isinstance(result["right"], dict) or isinstance(result["right"], str)

tests/test_process_image_data.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Tests for emimesh.process_image_data module."""
2+
import numpy as np
3+
from unittest.mock import patch
4+
from emimesh.process_image_data import (
5+
mergecells, ncells, dilate, erode, smooth, removeislands,
6+
opdict, parse_operations, _parse_to_dict
7+
)
8+
9+
class TestImageOperations:
10+
"""Test individual image processing operations."""
11+
12+
def test_mergecells_basic(self):
13+
"""Test basic cell merging."""
14+
img = np.array([[[1, 1, 2], [1, 3, 2], [4, 4, 4]]], dtype=np.uint32)
15+
labels = [1, 2]
16+
17+
result = mergecells(img, labels)
18+
19+
# All 1s and 2s should become the first label (1)
20+
non_zero_values = result[result > 0]
21+
unique_values = np.unique(non_zero_values)
22+
23+
# Should only have values 1, 3, 4 (1 and 2 merged to 1)
24+
assert set(unique_values) == {1, 3, 4}
25+
assert 3 in result # 3 should remain unchanged
26+
assert 4 in result # 4 should remain unchanged
27+
28+
def test_ncells_basic(self):
29+
"""Test keeping only N largest cells."""
30+
img = np.array([[[1, 1, 2], [1, 3, 2], [4, 4, 4]]], dtype=np.uint32)
31+
32+
result = ncells(img, ncells=2)
33+
34+
# Should keep only background (0) and the two largest cells (1 and 4)
35+
assert np.allclose(np.unique(result), np.array([0, 1,4]))
36+
37+
def test_ncells_with_keep_labels(self):
38+
"""Test keeping specific cells regardless of size."""
39+
img = np.array([[[1, 1, 2], [1, 3, 2], [4, 4, 4]]], dtype=np.uint32)
40+
keep_labels = [2]
41+
42+
result = ncells(img, ncells=1, keep_cell_labels=keep_labels)
43+
44+
# Should only have background (0) and the kept label (2)
45+
assert np.allclose(np.unique(result), np.array([0, 2]))
46+
47+
def test_removeislands_basic(self):
48+
"""Test removing small islands."""
49+
# Create an image with small and large connected components
50+
img = np.zeros((10, 10, 10), dtype=np.uint32)
51+
img[2:4, 2:4, 2:4] = 1 # Small island (8 voxels)
52+
img[6:9, 6:9, 6:9] = 2 # Large island (27 voxels)
53+
54+
result = removeislands(img, minsize=10)
55+
56+
# Small island should be removed, large one should remain
57+
assert 1 not in np.unique(result)
58+
assert 2 in np.unique(result)
59+
60+
61+
class TestOperationDictionary:
62+
"""Test the operation dictionary."""
63+
64+
def test_opdict_contains_all_operations(self):
65+
"""Test that opdict contains all expected operations."""
66+
expected_ops = ["merge", "smooth", "dilate", "erode", "removeislands", "ncells"]
67+
68+
for op in expected_ops:
69+
assert op in opdict
70+
assert callable(opdict[op])
71+
72+
73+
class TestParseOperations:
74+
"""Test operation parsing functionality."""
75+
76+
def test_parse_to_dict_basic(self):
77+
"""Test basic dictionary parsing."""
78+
values = ["key1='value1'", "key2=42", "key3=True"]
79+
80+
result = _parse_to_dict(values)
81+
82+
assert result["key1"] == "value1"
83+
assert result["key2"] == 42
84+
assert result["key3"] is True
85+
86+
def test_parse_to_dict_with_lists(self):
87+
"""Test parsing with list values."""
88+
values = ["labels='[1, 2, 3]'", "radius=5"]
89+
90+
result = _parse_to_dict(values)
91+
92+
assert result["labels"] == [1, 2, 3]
93+
assert result["radius"] == 5
94+
95+
def test_parse_operations_basic(self):
96+
"""Test basic operation parsing."""
97+
ops = [["merge", "labels='[1, 2]'", "radius=5"]]
98+
99+
result = parse_operations(ops)
100+
101+
assert len(result) == 1
102+
assert result[0][0] == "merge"
103+
assert result[0][1]["labels"] == [1, 2]
104+
assert result[0][1]["radius"] == 5
105+
106+
def test_parse_operations_multiple(self):
107+
"""Test parsing multiple operations."""
108+
ops = [
109+
["merge", "labels='[1, 2]'"],
110+
["removeislands", "minsize=100"],
111+
["dilate", "radius=3"]
112+
]
113+
114+
result = parse_operations(ops)
115+
116+
assert len(result) == 3
117+
assert result[0][0] == "merge"
118+
assert result[1][0] == "removeislands"
119+
assert result[2][0] == "dilate"
120+
121+
122+
class TestImageProcessingIntegration:
123+
"""Integration tests for image processing operations."""
124+
125+
def test_dilate_operation(self):
126+
"""Test dilation operation."""
127+
img = np.zeros((10, 10, 10), dtype=np.uint32)
128+
img[4:6, 4:6, 4:6] = 1
129+
130+
# Mock nbmorph.dilate_labels_spherical to avoid dependency
131+
with patch('emimesh.process_image_data.nbmorph') as mock_nbmorph:
132+
mock_nbmorph.dilate_labels_spherical.return_value = img # Return same for simplicity
133+
134+
result = dilate(img, radius=2)
135+
136+
mock_nbmorph.dilate_labels_spherical.assert_called_once_with(img, radius=2)
137+
138+
def test_erode_operation(self):
139+
"""Test erosion operation."""
140+
img = np.ones((10, 10, 10), dtype=np.uint32)
141+
142+
# Mock nbmorph.erode_labels_spherical to avoid dependency
143+
with patch('emimesh.process_image_data.nbmorph') as mock_nbmorph:
144+
mock_nbmorph.erode_labels_spherical.return_value = img # Return same for simplicity
145+
146+
result = erode(img, radius=2)
147+
148+
mock_nbmorph.erode_labels_spherical.assert_called_once_with(img, radius=2)
149+
150+
def test_smooth_operation(self):
151+
"""Test smoothing operation."""
152+
img = np.ones((10, 10, 10), dtype=np.uint32)
153+
154+
# Mock nbmorph.smooth_labels_spherical to avoid dependency
155+
with patch('emimesh.process_image_data.nbmorph') as mock_nbmorph:
156+
mock_nbmorph.smooth_labels_spherical.return_value = img # Return same for simplicity
157+
158+
result = smooth(img, iterations=5, radius=3)
159+
160+
mock_nbmorph.smooth_labels_spherical.assert_called_once_with(
161+
img, radius=3, iterations=5, dilate_radius=3
162+
)

0 commit comments

Comments
 (0)