Skip to content

Commit d4b2be8

Browse files
committed
refactor: seperate io functions from batch ones
1 parent ec60b5c commit d4b2be8

5 files changed

Lines changed: 153 additions & 151 deletions

File tree

tests/test_cli.py

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@
66
import pytest
77
from PIL import Image
88

9-
from vesskel._batch import (
10-
_load_image,
11-
_sanitize_for_csv,
12-
_save_radius,
13-
_save_skeleton,
14-
_write_csv,
9+
from vesskel._io import (
10+
load_image,
11+
sanitize_for_csv,
12+
save_radius,
13+
save_skeleton,
14+
write_csv,
1515
)
1616
from vesskel.cli import _discover_input_paths
1717
from vesskel.config import CONFIG_SCHEMA_VERSION, PipelineConfig
1818

1919
HEAVY_MODULES = frozenset(
20-
{"numpy", "PIL", "PIL.Image", "vesskel.pipeline", "vesskel._batch"}
20+
{"numpy", "PIL", "PIL.Image", "vesskel.pipeline", "vesskel._batch", "vesskel._io"}
2121
)
2222

2323

@@ -116,27 +116,27 @@ class TestLoadImage:
116116
def test_load_png(self, tmp_path):
117117
path = tmp_path / "test.png"
118118
Image.fromarray(np.zeros((3, 3), dtype=np.uint8)).save(path)
119-
arr = _load_image(path)
119+
arr = load_image(path)
120120
assert arr.shape == (3, 3)
121121

122122
def test_load_jpg(self, tmp_path):
123123
path = tmp_path / "test.jpg"
124124
Image.fromarray(np.ones((4, 4), dtype=np.uint8) * 128).save(path)
125-
arr = _load_image(path)
125+
arr = load_image(path)
126126
assert arr.shape == (4, 4)
127127

128128
def test_load_npy(self, tmp_path):
129129
path = tmp_path / "test.npy"
130130
np.save(path, np.ones((5, 5), dtype=np.uint8))
131-
arr = _load_image(path)
131+
arr = load_image(path)
132132
assert arr.shape == (5, 5)
133133

134134
def test_load_rgb_collapses_to_grayscale(self, tmp_path):
135135
path = tmp_path / "rgb.png"
136136
rgb = np.zeros((8, 8, 3), dtype=np.uint8)
137137
rgb[2:6, 2:6, 0] = 255
138138
Image.fromarray(rgb).save(path)
139-
arr = _load_image(path)
139+
arr = load_image(path)
140140
assert arr.ndim == 2
141141
assert arr.shape == (8, 8)
142142
assert arr[4, 4] == 255
@@ -147,19 +147,19 @@ def test_load_rgba_collapses_to_grayscale(self, tmp_path):
147147
rgba = np.zeros((6, 6, 4), dtype=np.uint8)
148148
rgba[:, :, :3] = 100
149149
Image.fromarray(rgba).save(path)
150-
arr = _load_image(path)
150+
arr = load_image(path)
151151
assert arr.ndim == 2
152152

153153
def test_load_2d_passes_through(self, tmp_path):
154154
path = tmp_path / "gray.png"
155155
Image.fromarray(np.eye(10, dtype=np.uint8) * 255).save(path)
156-
arr = _load_image(path)
156+
arr = load_image(path)
157157
assert arr.ndim == 2
158158

159159
def test_load_3d_npy(self, tmp_path):
160160
path = tmp_path / "vol.npy"
161161
np.save(path, np.ones((3, 4, 5), dtype=np.uint8))
162-
arr = _load_image(path)
162+
arr = load_image(path)
163163
assert arr.shape == (3, 4, 5)
164164

165165
def test_load_invalid_dimension_raises(self, tmp_path):
@@ -168,40 +168,40 @@ def test_load_invalid_dimension_raises(self, tmp_path):
168168
with pytest.raises(
169169
ValueError, match=r"Expected 2D or 3D image, got shape=\(2, 2, 2, 2\)"
170170
):
171-
_load_image(path)
171+
load_image(path)
172172

173173

174174
class TestSanitizeForCsv:
175175
def test_numpy_int(self):
176-
assert _sanitize_for_csv(np.int64(42)) == 42
176+
assert sanitize_for_csv(np.int64(42)) == 42
177177

178178
def test_numpy_float(self):
179-
result = _sanitize_for_csv(np.float64(3.14))
179+
result = sanitize_for_csv(np.float64(3.14))
180180
assert isinstance(result, float)
181181
assert result == 3.14
182182

183183
def test_python_int_passes_through(self):
184-
assert _sanitize_for_csv(42) == 42
184+
assert sanitize_for_csv(42) == 42
185185

186186
def test_python_str_passes_through(self):
187-
assert _sanitize_for_csv("hello") == "hello"
187+
assert sanitize_for_csv("hello") == "hello"
188188

189189
def test_none_passes_through(self):
190-
assert _sanitize_for_csv(None) is None
190+
assert sanitize_for_csv(None) is None
191191

192192
def test_bool_passes_through(self):
193-
assert _sanitize_for_csv(True) is True
194-
assert _sanitize_for_csv(False) is False
193+
assert sanitize_for_csv(True) is True
194+
assert sanitize_for_csv(False) is False
195195

196196
def test_list_passes_through(self):
197-
assert _sanitize_for_csv([1, "a"]) == [1, "a"]
197+
assert sanitize_for_csv([1, "a"]) == [1, "a"]
198198

199199

200200
class TestWriteCsv:
201201
def test_basic_write(self, tmp_path):
202202
path = tmp_path / "out.csv"
203203
rows = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
204-
_write_csv(path, rows)
204+
write_csv(path, rows)
205205
with path.open() as f:
206206
reader = csv.DictReader(f)
207207
data = list(reader)
@@ -211,22 +211,22 @@ def test_basic_write(self, tmp_path):
211211

212212
def test_empty_rows_writes_nothing(self, tmp_path):
213213
path = tmp_path / "empty.csv"
214-
_write_csv(path, [])
214+
write_csv(path, [])
215215
assert not path.exists()
216216

217217

218218
class TestSaveSkeleton:
219219
def test_save_npy(self, tmp_path):
220220
path = tmp_path / "skel"
221221
skeleton = np.eye(10, dtype=np.uint8)
222-
_save_skeleton(path, skeleton, npy=True, png=False)
222+
save_skeleton(path, skeleton, npy=True, png=False)
223223
loaded = np.load(path.with_suffix(".npy"))
224224
assert np.array_equal(loaded, skeleton)
225225

226226
def test_save_png_2d(self, tmp_path):
227227
path = tmp_path / "skel"
228228
skeleton = np.eye(8, dtype=np.uint8)
229-
_save_skeleton(path, skeleton, npy=False, png=True)
229+
save_skeleton(path, skeleton, npy=False, png=True)
230230
loaded = np.array(Image.open(path.with_suffix(".png")))
231231
assert loaded.shape == (8, 8)
232232
assert np.array_equal(loaded > 0, skeleton > 0)
@@ -235,12 +235,12 @@ def test_save_png_3d_raises(self, tmp_path):
235235
path = tmp_path / "skel"
236236
skeleton = np.eye(4, dtype=np.uint8).reshape(4, 1, 4)
237237
with pytest.raises(ValueError, match="PNG skeleton output"):
238-
_save_skeleton(path, skeleton, npy=False, png=True)
238+
save_skeleton(path, skeleton, npy=False, png=True)
239239

240240
def test_save_both_formats(self, tmp_path):
241241
path = tmp_path / "skel"
242242
skeleton = np.eye(6, dtype=np.uint8)
243-
_save_skeleton(path, skeleton, npy=True, png=True)
243+
save_skeleton(path, skeleton, npy=True, png=True)
244244
assert path.with_suffix(".npy").exists()
245245
assert path.with_suffix(".png").exists()
246246
npy_loaded = np.load(path.with_suffix(".npy"))
@@ -249,14 +249,14 @@ def test_save_both_formats(self, tmp_path):
249249

250250
def test_save_neither_does_nothing(self, tmp_path):
251251
path = tmp_path / "skel"
252-
_save_skeleton(path, np.eye(3, dtype=np.uint8), npy=False, png=False)
252+
save_skeleton(path, np.eye(3, dtype=np.uint8), npy=False, png=False)
253253
assert not path.with_suffix(".npy").exists()
254254
assert not path.with_suffix(".png").exists()
255255

256256
def test_npy_output_is_uint8(self, tmp_path):
257257
path = tmp_path / "skel"
258258
skeleton = np.ones((4, 4), dtype=np.int32)
259-
_save_skeleton(path, skeleton, npy=True, png=False)
259+
save_skeleton(path, skeleton, npy=True, png=False)
260260
loaded = np.load(path.with_suffix(".npy"))
261261
assert loaded.dtype == np.uint8
262262

@@ -265,14 +265,14 @@ class TestSaveRadius:
265265
def test_save_and_load(self, tmp_path):
266266
path = tmp_path / "radius"
267267
radius = np.random.default_rng(7).random((4, 4))
268-
_save_radius(path, radius)
268+
save_radius(path, radius)
269269
loaded = np.load(path.with_suffix(".npy"))
270270
np.testing.assert_array_equal(loaded, radius)
271271

272272
def test_saved_as_float64(self, tmp_path):
273273
path = tmp_path / "radius"
274274
radius = np.array([[1.5, 2.5], [3.5, 4.5]], dtype=np.float64)
275-
_save_radius(path, radius)
275+
save_radius(path, radius)
276276
loaded = np.load(path.with_suffix(".npy"))
277277
assert loaded.dtype == np.float64
278278

vesskel/_batch.py

Lines changed: 4 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,24 @@
1-
"""Batch I/O helpers and worker function for multiprocessing."""
1+
"""Batch worker for multiprocessing."""
22

33
from __future__ import annotations
44

5-
import csv
65
from pathlib import Path
7-
from typing import Iterable
8-
9-
import itk
10-
import numpy as np
11-
from PIL import Image
126

7+
from vesskel._io import load_image, save_analysis_outputs
138
from vesskel.config import PipelineConfig
149
from vesskel.pipeline import analyze_binary_image
1510

1611

17-
def _load_image(path: Path) -> np.ndarray:
18-
suffix = path.suffix.lower()
19-
if suffix == ".npy":
20-
arr = np.load(path)
21-
elif suffix == ".mhd":
22-
arr = np.asarray(itk.imread(str(path)))
23-
else:
24-
with Image.open(path) as im:
25-
arr = np.asarray(im)
26-
27-
if arr.ndim == 0:
28-
raise ValueError("Scalar input is not supported")
29-
30-
if arr.ndim == 3 and arr.shape[-1] in (3, 4):
31-
arr = np.max(arr[..., :3], axis=-1)
32-
33-
if arr.ndim not in (2, 3):
34-
raise ValueError(f"Expected 2D or 3D image, got shape={arr.shape}")
35-
36-
return arr
37-
38-
39-
def _sanitize_for_csv(value: object) -> object:
40-
if isinstance(value, (np.generic,)):
41-
return value.item()
42-
return value
43-
44-
45-
def _write_csv(path: Path, rows: Iterable[dict[str, object]]) -> None:
46-
rows = list(rows)
47-
if not rows:
48-
return
49-
50-
fieldnames = sorted({key for row in rows for key in row.keys()})
51-
with path.open("w", newline="") as f:
52-
writer = csv.DictWriter(f, fieldnames=fieldnames)
53-
writer.writeheader()
54-
for row in rows:
55-
writer.writerow({k: _sanitize_for_csv(v) for k, v in row.items()})
56-
57-
58-
def _save_skeleton(
59-
path: Path,
60-
skeleton: np.ndarray,
61-
*,
62-
npy: bool = True,
63-
png: bool = False,
64-
) -> None:
65-
if npy:
66-
np.save(path.with_suffix(".npy"), skeleton.astype(np.uint8))
67-
if png:
68-
if skeleton.ndim != 2:
69-
raise ValueError("PNG skeleton output is only supported for 2D images")
70-
img = Image.fromarray((skeleton > 0).astype(np.uint8) * 255)
71-
img.save(path.with_suffix(".png"))
72-
73-
74-
def _save_radius(path: Path, radius_matrix: np.ndarray) -> None:
75-
np.save(path.with_suffix(".npy"), radius_matrix.astype(np.float64))
76-
77-
7812
def process_one(
7913
in_path: Path,
8014
safe_name: str,
8115
out_dir: Path,
8216
config: PipelineConfig,
8317
) -> dict[str, object]:
8418
"""Load, analyse, save one image. Returns summary row for agg CSV."""
85-
image = _load_image(in_path)
19+
image = load_image(in_path)
8620
result = analyze_binary_image(image=image, base_name=in_path.stem, config=config)
8721

88-
image_out_dir = out_dir / safe_name
89-
image_out_dir.mkdir(parents=True, exist_ok=True)
90-
91-
if config.output.write_skeleton_npy or config.output.write_skeleton_png:
92-
_save_skeleton(
93-
image_out_dir / f"{safe_name}_skeleton",
94-
result.skeleton,
95-
npy=config.output.write_skeleton_npy,
96-
png=config.output.write_skeleton_png,
97-
)
98-
99-
if config.output.write_radius and result.radius_matrix is not None:
100-
_save_radius(image_out_dir / f"{safe_name}_radius", result.radius_matrix)
101-
102-
if config.output.write_branch_csv and result.branch_records:
103-
_write_csv(image_out_dir / f"{safe_name}_branches.csv", result.branch_records)
104-
105-
if config.output.write_node_csv and result.node_records:
106-
_write_csv(image_out_dir / f"{safe_name}_nodes.csv", result.node_records)
22+
save_analysis_outputs(out_dir, safe_name, result, config.output)
10723

10824
return {"image": in_path.name, **result.summary_features}

0 commit comments

Comments
 (0)