otsu2D 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.
- otsu2D/__init__.py +40 -0
- otsu2D/core.py +429 -0
- otsu2D/runner.py +173 -0
- otsu2d-0.1.0.dist-info/METADATA +78 -0
- otsu2d-0.1.0.dist-info/RECORD +9 -0
- otsu2d-0.1.0.dist-info/WHEEL +5 -0
- otsu2d-0.1.0.dist-info/entry_points.txt +2 -0
- otsu2d-0.1.0.dist-info/licenses/LICENSE +22 -0
- otsu2d-0.1.0.dist-info/top_level.txt +1 -0
otsu2D/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local 2D Otsu Thresholding for 3D Images
|
|
3
|
+
==========================================
|
|
4
|
+
|
|
5
|
+
A high-performance implementation of local 2D Otsu thresholding for 3D grayscale images
|
|
6
|
+
using Numba acceleration.
|
|
7
|
+
|
|
8
|
+
Main Functions
|
|
9
|
+
--------------
|
|
10
|
+
getBinary : Apply local 2D Otsu thresholding to get binary mask
|
|
11
|
+
getThreshold : Compute 2D Otsu threshold for 1D intensity values and 1D local mean values
|
|
12
|
+
|
|
13
|
+
Example
|
|
14
|
+
-------
|
|
15
|
+
>>> import numpy as np
|
|
16
|
+
>>> from modifiedOtsu import getBinary
|
|
17
|
+
>>>
|
|
18
|
+
>>> # Create sample 3D image
|
|
19
|
+
>>> img = np.random.randint(0, 256, size=(50, 50, 50), dtype=np.uint8)
|
|
20
|
+
>>>
|
|
21
|
+
>>> # Get binary image
|
|
22
|
+
>>> binary = getBinary(img, window_size=(3, 3, 3))
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .core import getBinary, getThreshold
|
|
26
|
+
|
|
27
|
+
# Package metadata
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
__author__ = "John Rick Manzanares"
|
|
30
|
+
__email__ = "jdolormanzanares@impan.pl"
|
|
31
|
+
|
|
32
|
+
# Define what gets imported with "from Otsu2D import *"
|
|
33
|
+
__all__ = [
|
|
34
|
+
"getBinary",
|
|
35
|
+
"getThreshold",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# Optional: Add convenience functions or constants
|
|
39
|
+
DEFAULT_WINDOW_SIZE = (3, 3, 3)
|
|
40
|
+
DEFAULT_MEAN_WINDOW_SIZE = (3, 3, 3)
|
otsu2D/core.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core implementation of local 2D Otsu thresholding.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numba import njit, prange
|
|
7
|
+
from typing import Tuple
|
|
8
|
+
|
|
9
|
+
# ============================================================================
|
|
10
|
+
# Helper Functions (Internal)
|
|
11
|
+
# ============================================================================
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@njit
|
|
15
|
+
def _padReflect(img: np.ndarray, pad_width: Tuple[int, int, int]) -> np.ndarray:
|
|
16
|
+
"""
|
|
17
|
+
Manual reflect padding for 3D arrays (Numba-compatible).
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
img : np.ndarray
|
|
22
|
+
3D input array
|
|
23
|
+
pad_width : tuple of int
|
|
24
|
+
Padding size for each dimension (z_pad, y_pad, x_pad)
|
|
25
|
+
|
|
26
|
+
Returns
|
|
27
|
+
-------
|
|
28
|
+
np.ndarray
|
|
29
|
+
Padded array with reflect mode
|
|
30
|
+
"""
|
|
31
|
+
z_pad, y_pad, x_pad = pad_width
|
|
32
|
+
z_max, y_max, x_max = img.shape
|
|
33
|
+
|
|
34
|
+
padded = np.zeros(
|
|
35
|
+
(z_max + 2 * z_pad, y_max + 2 * y_pad, x_max + 2 * x_pad), dtype=img.dtype
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Copy center
|
|
39
|
+
padded[z_pad : z_pad + z_max, y_pad : y_pad + y_max, x_pad : x_pad + x_max] = img
|
|
40
|
+
|
|
41
|
+
# Reflect padding for Z
|
|
42
|
+
for i in range(z_pad):
|
|
43
|
+
padded[z_pad - 1 - i, y_pad : y_pad + y_max, x_pad : x_pad + x_max] = img[
|
|
44
|
+
i, :, :
|
|
45
|
+
]
|
|
46
|
+
padded[z_pad + z_max + i, y_pad : y_pad + y_max, x_pad : x_pad + x_max] = img[
|
|
47
|
+
z_max - 1 - i, :, :
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Reflect padding for Y
|
|
51
|
+
for j in range(y_pad):
|
|
52
|
+
padded[:, y_pad - 1 - j, x_pad : x_pad + x_max] = padded[
|
|
53
|
+
:, y_pad + j, x_pad : x_pad + x_max
|
|
54
|
+
]
|
|
55
|
+
padded[:, y_pad + y_max + j, x_pad : x_pad + x_max] = padded[
|
|
56
|
+
:, y_pad + y_max - 1 - j, x_pad : x_pad + x_max
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# Reflect padding for X
|
|
60
|
+
for k in range(x_pad):
|
|
61
|
+
padded[:, :, x_pad - 1 - k] = padded[:, :, x_pad + k]
|
|
62
|
+
padded[:, :, x_pad + x_max + k] = padded[:, :, x_pad + x_max - 1 - k]
|
|
63
|
+
|
|
64
|
+
return padded
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@njit
|
|
68
|
+
def _getBinary(
|
|
69
|
+
img: np.ndarray,
|
|
70
|
+
window_size: Tuple[int, int, int],
|
|
71
|
+
mean_window_size: Tuple[int, int, int],
|
|
72
|
+
) -> np.ndarray:
|
|
73
|
+
"""
|
|
74
|
+
Internal Numba-compiled implementation.
|
|
75
|
+
No validation - assumes inputs are correct.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
localMean = getLocalMean(img, mean_window_size)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
z_win, y_win, x_win = window_size
|
|
82
|
+
z_pad, y_pad, x_pad = z_win // 2, y_win // 2, x_win // 2
|
|
83
|
+
|
|
84
|
+
padded = _padReflect(img, (z_pad, y_pad, x_pad))
|
|
85
|
+
paddedLocalMean = _padReflect(localMean, (z_pad, y_pad, x_pad))
|
|
86
|
+
binary = np.zeros_like(img, dtype=np.int32)
|
|
87
|
+
z_max, y_max, x_max = img.shape
|
|
88
|
+
|
|
89
|
+
for z in prange(z_max):
|
|
90
|
+
for y in range(y_max):
|
|
91
|
+
for x in range(x_max):
|
|
92
|
+
windowIntensity = padded[
|
|
93
|
+
z : z + z_win, y : y + y_win, x : x + x_win
|
|
94
|
+
].ravel()
|
|
95
|
+
windowMean = paddedLocalMean[
|
|
96
|
+
z : z + z_win, y : y + y_win, x : x + x_win
|
|
97
|
+
].ravel()
|
|
98
|
+
s_real, t_real = getThreshold(windowIntensity, windowMean)
|
|
99
|
+
|
|
100
|
+
# Element-wise comparison at each voxel
|
|
101
|
+
condition1 = img[z, y, x] > s_real
|
|
102
|
+
condition2 = localMean[z, y, x] > t_real
|
|
103
|
+
|
|
104
|
+
binary[z, y, x] = condition1 & condition2
|
|
105
|
+
|
|
106
|
+
return binary.astype(np.uint8) * 255
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@njit
|
|
110
|
+
def getLocalMean(
|
|
111
|
+
image: np.ndarray, window_size: Tuple[int, int, int] = (3, 3, 3)
|
|
112
|
+
) -> np.ndarray:
|
|
113
|
+
"""
|
|
114
|
+
Compute local mean for a 3D image using integral image (fast) without padding.
|
|
115
|
+
Only voxels where the full window fits are computed.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
image : np.ndarray
|
|
120
|
+
3D input image (z, y, x)
|
|
121
|
+
window_size : int
|
|
122
|
+
Neighborhood size for local mean calculation (should be odd)
|
|
123
|
+
|
|
124
|
+
Returns
|
|
125
|
+
-------
|
|
126
|
+
np.ndarray
|
|
127
|
+
Local mean image, same shape as input, with zeros at boundaries
|
|
128
|
+
where the full window does not fit. dtype=np.uint16
|
|
129
|
+
"""
|
|
130
|
+
# z_max, y_max, x_max = image.shape
|
|
131
|
+
ws_z, ws_y, ws_x = window_size
|
|
132
|
+
pad = (ws_z // 2, ws_y // 2, ws_x // 2)
|
|
133
|
+
padded = _padReflect(image, pad)
|
|
134
|
+
|
|
135
|
+
z_max, y_max, x_max = padded.shape
|
|
136
|
+
|
|
137
|
+
# Compute integral image
|
|
138
|
+
integral = np.zeros((z_max, y_max, x_max), dtype=np.float64)
|
|
139
|
+
for z in range(z_max):
|
|
140
|
+
for y in range(y_max):
|
|
141
|
+
for x in range(x_max):
|
|
142
|
+
val = padded[z, y, x]
|
|
143
|
+
if z > 0:
|
|
144
|
+
val += integral[z - 1, y, x]
|
|
145
|
+
if y > 0:
|
|
146
|
+
val += integral[z, y - 1, x]
|
|
147
|
+
if x > 0:
|
|
148
|
+
val += integral[z, y, x - 1]
|
|
149
|
+
if z > 0 and y > 0:
|
|
150
|
+
val -= integral[z - 1, y - 1, x]
|
|
151
|
+
if z > 0 and x > 0:
|
|
152
|
+
val -= integral[z - 1, y, x - 1]
|
|
153
|
+
if y > 0 and x > 0:
|
|
154
|
+
val -= integral[z, y - 1, x - 1]
|
|
155
|
+
if z > 0 and y > 0 and x > 0:
|
|
156
|
+
val += integral[z - 1, y - 1, x - 1]
|
|
157
|
+
integral[z, y, x] = val
|
|
158
|
+
|
|
159
|
+
# Compute local mean
|
|
160
|
+
local_mean = np.zeros_like(image, dtype=np.float32)
|
|
161
|
+
z0, y0, x0 = pad
|
|
162
|
+
|
|
163
|
+
# Only compute where the full window fits
|
|
164
|
+
for z in range(ws_z - 1, z_max - z0):
|
|
165
|
+
for y in range(ws_y - 1, y_max - y0):
|
|
166
|
+
for x in range(ws_x - 1, x_max - x0):
|
|
167
|
+
z1, y1, x1 = z - z0, y - y0, x - x0
|
|
168
|
+
z2, y2, x2 = z + z0, y + y0, x + x0
|
|
169
|
+
|
|
170
|
+
s = integral[z2, y2, x2]
|
|
171
|
+
if z1 > 0:
|
|
172
|
+
s -= integral[z1 - 1, y2, x2]
|
|
173
|
+
if y1 > 0:
|
|
174
|
+
s -= integral[z2, y1 - 1, x2]
|
|
175
|
+
if x1 > 0:
|
|
176
|
+
s -= integral[z2, y2, x1 - 1]
|
|
177
|
+
if z1 > 0 and y1 > 0:
|
|
178
|
+
s += integral[z1 - 1, y1 - 1, x2]
|
|
179
|
+
if z1 > 0 and x1 > 0:
|
|
180
|
+
s += integral[z1 - 1, y2, x1 - 1]
|
|
181
|
+
if y1 > 0 and x1 > 0:
|
|
182
|
+
s += integral[z2, y1 - 1, x1 - 1]
|
|
183
|
+
if z1 > 0 and y1 > 0 and x1 > 0:
|
|
184
|
+
s -= integral[z1 - 1, y1 - 1, x1 - 1]
|
|
185
|
+
|
|
186
|
+
local_mean[z - z0, y - y0, x - x0] = s / (ws_z * ws_y * ws_x)
|
|
187
|
+
|
|
188
|
+
return local_mean.astype(np.uint16)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@njit
|
|
192
|
+
def cumSum(arr, axis=None):
|
|
193
|
+
"""
|
|
194
|
+
Compute cumulative sum manually (Numba-compatible).
|
|
195
|
+
Supports axis=None, axis=0, and axis=1 (like np.cumsum).
|
|
196
|
+
"""
|
|
197
|
+
rows, cols = arr.shape
|
|
198
|
+
out = np.zeros_like(arr)
|
|
199
|
+
if axis is None:
|
|
200
|
+
# Flattened cumulative sum
|
|
201
|
+
flat = arr.ravel()
|
|
202
|
+
out_flat = out.ravel()
|
|
203
|
+
total = 0.0
|
|
204
|
+
for i in range(flat.size):
|
|
205
|
+
total += flat[i]
|
|
206
|
+
out_flat[i] = total
|
|
207
|
+
return out
|
|
208
|
+
elif axis == 0:
|
|
209
|
+
# Cumulative sum along rows (down each column)
|
|
210
|
+
for j in range(cols):
|
|
211
|
+
total = 0.0
|
|
212
|
+
for i in range(rows):
|
|
213
|
+
total += arr[i, j]
|
|
214
|
+
out[i, j] = total
|
|
215
|
+
return out
|
|
216
|
+
elif axis == 1:
|
|
217
|
+
# Cumulative sum along columns (across each row)
|
|
218
|
+
for i in range(rows):
|
|
219
|
+
total = 0.0
|
|
220
|
+
for j in range(cols):
|
|
221
|
+
total += arr[i, j]
|
|
222
|
+
out[i, j] = total
|
|
223
|
+
return out
|
|
224
|
+
else:
|
|
225
|
+
raise ValueError("axis must be None, 0, or 1")
|
|
226
|
+
|
|
227
|
+
def _validate_window_size(name, window_size):
|
|
228
|
+
"""
|
|
229
|
+
Validate that a window size argument is a tuple or list of three positive odd integers.
|
|
230
|
+
|
|
231
|
+
This function checks that the provided `window_size`:
|
|
232
|
+
- Is a tuple or list.
|
|
233
|
+
- Has exactly three elements corresponding to (z, y, x).
|
|
234
|
+
- Contains only integer values.
|
|
235
|
+
- Contains only positive values.
|
|
236
|
+
- Contains only odd values (since even sizes cannot be centered).
|
|
237
|
+
|
|
238
|
+
If any of these conditions are not met, a ValueError is raised with a descriptive
|
|
239
|
+
message that includes the variable name for easier debugging.
|
|
240
|
+
|
|
241
|
+
Parameters
|
|
242
|
+
----------
|
|
243
|
+
name : str
|
|
244
|
+
The name of the variable being validated (used in error messages).
|
|
245
|
+
window_size : tuple or list of int
|
|
246
|
+
The window size to validate. Must contain exactly three positive odd integers.
|
|
247
|
+
|
|
248
|
+
Raises
|
|
249
|
+
------
|
|
250
|
+
ValueError
|
|
251
|
+
If `window_size` is not a tuple or list of three positive odd integers.
|
|
252
|
+
"""
|
|
253
|
+
if not isinstance(window_size, (tuple, list)):
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"{name} must be a tuple or list, got {type(window_size)}"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if len(window_size) != 3:
|
|
259
|
+
raise ValueError(
|
|
260
|
+
f"{name} must have 3 elements (z, y, x), got {len(window_size)}"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if not all(isinstance(w, (int, np.integer)) for w in window_size):
|
|
264
|
+
raise ValueError(f"{name} elements must be integers, got {window_size}")
|
|
265
|
+
|
|
266
|
+
if any(w <= 0 for w in window_size):
|
|
267
|
+
raise ValueError(f"{name} elements must be positive, got {window_size}")
|
|
268
|
+
|
|
269
|
+
if any(w % 2 == 0 for w in window_size):
|
|
270
|
+
z_win, y_win, x_win = window_size
|
|
271
|
+
raise ValueError(
|
|
272
|
+
f"{name} elements should be odd numbers for centered windows, "
|
|
273
|
+
f"got {window_size}. Consider using "
|
|
274
|
+
f"({z_win+1 if z_win%2==0 else z_win}, "
|
|
275
|
+
f"{y_win+1 if y_win%2==0 else y_win}, "
|
|
276
|
+
f"{x_win+1 if x_win%2==0 else x_win}) instead."
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# ============================================================================
|
|
280
|
+
# Public API
|
|
281
|
+
# ============================================================================
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@njit
|
|
285
|
+
def getThreshold(intensity_values: np.ndarray, local_mean_values: np.ndarray):
|
|
286
|
+
"""
|
|
287
|
+
Compute 2D Otsu threshold for a local neighborhood.
|
|
288
|
+
|
|
289
|
+
Parameters:
|
|
290
|
+
intensity_values (1D array): Intensity values in the neighborhood
|
|
291
|
+
local_mean_values (1D array): Local mean values in the neighborhood
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
s_real (int): Optimal intensity threshold
|
|
295
|
+
t_real (int): Optimal local mean threshold
|
|
296
|
+
"""
|
|
297
|
+
# Build local 2D histogram
|
|
298
|
+
g_min, g_max = intensity_values.min(), intensity_values.max()
|
|
299
|
+
m_min, m_max = local_mean_values.min(), local_mean_values.max()
|
|
300
|
+
|
|
301
|
+
# Handle degenerate cases
|
|
302
|
+
if g_min == g_max or m_min == m_max:
|
|
303
|
+
return g_min, m_min
|
|
304
|
+
|
|
305
|
+
g_range = g_max - g_min + 1
|
|
306
|
+
m_range = m_max - m_min + 1
|
|
307
|
+
|
|
308
|
+
# Build histogram
|
|
309
|
+
hist_2d = np.zeros((g_range, m_range), dtype=np.uint32)
|
|
310
|
+
flat_g = intensity_values - g_min
|
|
311
|
+
flat_m = local_mean_values - m_min
|
|
312
|
+
|
|
313
|
+
for i in range(len(flat_g)):
|
|
314
|
+
hist_2d[flat_g[i], flat_m[i]] += 1
|
|
315
|
+
|
|
316
|
+
p = hist_2d.astype(np.float64) / hist_2d.sum()
|
|
317
|
+
|
|
318
|
+
# Global means for this neighborhood
|
|
319
|
+
i_vals = (np.arange(g_range) + g_min)[:, None]
|
|
320
|
+
j_vals = (np.arange(m_range) + m_min)[None, :]
|
|
321
|
+
|
|
322
|
+
mu_T0 = np.sum(i_vals * p)
|
|
323
|
+
mu_T1 = np.sum(j_vals * p)
|
|
324
|
+
|
|
325
|
+
# Cumulative sums
|
|
326
|
+
P_cum = cumSum(cumSum(p, axis=0), axis=1)
|
|
327
|
+
mu_i_cum = cumSum(cumSum(i_vals * p, axis=0), axis=1)
|
|
328
|
+
mu_j_cum = cumSum(cumSum(j_vals * p, axis=0), axis=1)
|
|
329
|
+
|
|
330
|
+
max_trace = 0
|
|
331
|
+
best_s = best_t = 0
|
|
332
|
+
|
|
333
|
+
for s in range(g_range - 1):
|
|
334
|
+
for t in range(m_range - 1):
|
|
335
|
+
P0 = P_cum[s, t]
|
|
336
|
+
|
|
337
|
+
if P0 <= 0 or P0 >= 1.0:
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
mu_i = mu_i_cum[s, t]
|
|
341
|
+
mu_j = mu_j_cum[s, t]
|
|
342
|
+
|
|
343
|
+
numerator = (mu_i - P0 * mu_T0) ** 2 + (mu_j - P0 * mu_T1) ** 2
|
|
344
|
+
denominator = P0 * (1 - P0)
|
|
345
|
+
trace_SB = numerator / denominator
|
|
346
|
+
|
|
347
|
+
if trace_SB > max_trace:
|
|
348
|
+
max_trace = trace_SB
|
|
349
|
+
best_s, best_t = s, t
|
|
350
|
+
|
|
351
|
+
s_real = best_s + g_min
|
|
352
|
+
t_real = best_t + m_min
|
|
353
|
+
|
|
354
|
+
return s_real, t_real
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def getBinary(
|
|
358
|
+
img: np.ndarray,
|
|
359
|
+
window_size: Tuple[int, int, int] = (3, 3, 3),
|
|
360
|
+
mean_window_size: Tuple[int, int, int] = (3, 3, 3),
|
|
361
|
+
) -> np.ndarray:
|
|
362
|
+
"""
|
|
363
|
+
Apply local 2D Otsu thresholding to a 3D grayscale image.
|
|
364
|
+
|
|
365
|
+
For each pixel, computes an optimal threshold based on the intensity
|
|
366
|
+
distribution in a local neighborhood window, then either returns the
|
|
367
|
+
threshold values or a binary mask.
|
|
368
|
+
|
|
369
|
+
Parameters
|
|
370
|
+
----------
|
|
371
|
+
img : np.ndarray
|
|
372
|
+
3D input image with integer dtype (e.g., uint8, uint16, int32).
|
|
373
|
+
Shape should be (z, y, x).
|
|
374
|
+
window_size : tuple of int
|
|
375
|
+
Local neighborhood size as (z_size, y_size, x_size).
|
|
376
|
+
Should be positive odd integers for centered windows.
|
|
377
|
+
delta : float, default=0.2
|
|
378
|
+
Contrast parameter for Otsu thresholding. Lower values are more
|
|
379
|
+
permissive, higher values require stronger contrast.
|
|
380
|
+
|
|
381
|
+
Returns
|
|
382
|
+
-------
|
|
383
|
+
np.ndarray
|
|
384
|
+
If binarize=True: Binary mask with same shape as input (dtype=uint8)
|
|
385
|
+
If binarize=False: Threshold map with same shape as input (dtype=int32)
|
|
386
|
+
|
|
387
|
+
Raises
|
|
388
|
+
------
|
|
389
|
+
ValueError
|
|
390
|
+
If input validation fails (wrong dimensions, dtype, window_size, etc.)
|
|
391
|
+
|
|
392
|
+
Examples
|
|
393
|
+
--------
|
|
394
|
+
>>> import numpy as np
|
|
395
|
+
>>> from Otsu2D import getBinary
|
|
396
|
+
>>>
|
|
397
|
+
>>> # Create sample 3D grayscale image
|
|
398
|
+
>>> img = np.random.randint(0, 256, size=(50, 50, 50), dtype=np.uint8)
|
|
399
|
+
>>>
|
|
400
|
+
>>> # Get binary mask
|
|
401
|
+
>>> mask = getBinary(img, window_size=(5, 5, 5), delta=0.2, binarize=True)
|
|
402
|
+
>>> print(mask.shape, mask.dtype)
|
|
403
|
+
(50, 50, 50) uint8
|
|
404
|
+
>>>
|
|
405
|
+
>>> # Get threshold values
|
|
406
|
+
>>> thresholds = getBinary(img, window_size=(5, 5, 5), delta=0.2, binarize=False)
|
|
407
|
+
>>> print(thresholds.shape, thresholds.dtype)
|
|
408
|
+
(50, 50, 50) int32
|
|
409
|
+
|
|
410
|
+
Notes
|
|
411
|
+
-----
|
|
412
|
+
- Uses reflect padding at image boundaries
|
|
413
|
+
- Parallelized using Numba for high performance
|
|
414
|
+
- Input must be integer dtype (float images should be converted first)
|
|
415
|
+
"""
|
|
416
|
+
# Validation
|
|
417
|
+
if img.ndim != 3:
|
|
418
|
+
raise ValueError(
|
|
419
|
+
f"Expected 3D image, got {img.ndim}D array with shape {img.shape}"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
if img.size == 0:
|
|
423
|
+
raise ValueError("Input image is empty")
|
|
424
|
+
|
|
425
|
+
_validate_window_size("window_size", window_size)
|
|
426
|
+
_validate_window_size("mean_window_size", mean_window_size)
|
|
427
|
+
|
|
428
|
+
# Call Numba implementation
|
|
429
|
+
return _getBinary(img, window_size, mean_window_size)
|
otsu2D/runner.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# Command Line Runner for local 2D Otsu Thresholding
|
|
3
|
+
# ============================================================================
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import numpy as np
|
|
7
|
+
import os
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
from otsu2D import getBinary
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_image(image_path: str) -> np.ndarray:
|
|
13
|
+
"""
|
|
14
|
+
Load a 3D image from a file (.npy or .tif/.tiff).
|
|
15
|
+
"""
|
|
16
|
+
if not os.path.exists(image_path):
|
|
17
|
+
raise FileNotFoundError(f"Image file not found: {image_path}")
|
|
18
|
+
|
|
19
|
+
ext = os.path.splitext(image_path)[1].lower()
|
|
20
|
+
|
|
21
|
+
if ext == ".npy":
|
|
22
|
+
img = np.load(image_path)
|
|
23
|
+
elif ext in [".tif", ".tiff"]:
|
|
24
|
+
try:
|
|
25
|
+
import tifffile
|
|
26
|
+
except ImportError:
|
|
27
|
+
raise ImportError(
|
|
28
|
+
"tifffile is required to read TIFF images. "
|
|
29
|
+
"Install it via 'pip install tifffile'."
|
|
30
|
+
)
|
|
31
|
+
img = tifffile.imread(image_path)
|
|
32
|
+
else:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"Unsupported file format: {ext}. Use .npy or .tif/.tiff files."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if img.ndim != 3:
|
|
38
|
+
raise ValueError(f"Expected a 3D image, got shape {img.shape}")
|
|
39
|
+
|
|
40
|
+
if not np.issubdtype(img.dtype, np.integer):
|
|
41
|
+
img = img.astype(np.uint8)
|
|
42
|
+
|
|
43
|
+
return img
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def save_output(binary: np.ndarray, image_path: str, outdir: str, fmt: str):
|
|
47
|
+
"""
|
|
48
|
+
Save binary arrays to the specified directory and format.
|
|
49
|
+
Filenames follow: {input_filename}_{threshold/binary}.{format}
|
|
50
|
+
"""
|
|
51
|
+
os.makedirs(outdir, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
# Extract base name (without extension)
|
|
54
|
+
base_name = os.path.splitext(os.path.basename(image_path))[0]
|
|
55
|
+
|
|
56
|
+
bin_path = os.path.join(outdir, f"{base_name}_binary.{fmt}")
|
|
57
|
+
|
|
58
|
+
if fmt == "npy":
|
|
59
|
+
np.save(bin_path, binary)
|
|
60
|
+
print(f"Saved NPY files:\n {th_path}\n {bin_path}")
|
|
61
|
+
|
|
62
|
+
elif fmt in ["tif", "tiff"]:
|
|
63
|
+
try:
|
|
64
|
+
import tifffile
|
|
65
|
+
except ImportError:
|
|
66
|
+
print("tifffile not installed — cannot save as TIFF. Saving as NPY instead.")
|
|
67
|
+
np.save(bin_path.replace(".tiff", ".npy"), binary)
|
|
68
|
+
return
|
|
69
|
+
tifffile.imwrite(bin_path, binary.astype(np.uint8))
|
|
70
|
+
print(f"Saved TIFF files:\n {bin_path}")
|
|
71
|
+
|
|
72
|
+
else:
|
|
73
|
+
raise ValueError(f"Unsupported format: {fmt}. Choose 'npy' or 'tiff'.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def show_results(img: np.ndarray, binary: np.ndarray, z_index: int = None):
|
|
77
|
+
"""
|
|
78
|
+
Show a chosen or middle slice for binary output.
|
|
79
|
+
"""
|
|
80
|
+
if z_index is None:
|
|
81
|
+
z_index = img.shape[0] // 2
|
|
82
|
+
|
|
83
|
+
if z_index < 0 or z_index >= img.shape[0]:
|
|
84
|
+
raise ValueError(f"Invalid z-index {z_index}. Must be in range [0, {img.shape[0]-1}]")
|
|
85
|
+
|
|
86
|
+
fig, axes = plt.subplots(1, 2, figsize=(12, 6))
|
|
87
|
+
axes[0].imshow(img[z_index], cmap="gray")
|
|
88
|
+
axes[0].set_title(f"Original (z={z_index})")
|
|
89
|
+
axes[1].imshow(binary[z_index], cmap="gray")
|
|
90
|
+
axes[1].set_title("Binary Mask")
|
|
91
|
+
for ax in axes:
|
|
92
|
+
ax.axis("off")
|
|
93
|
+
|
|
94
|
+
plt.tight_layout()
|
|
95
|
+
plt.show()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main():
|
|
99
|
+
parser = argparse.ArgumentParser(
|
|
100
|
+
description="Run local 2D Otsu Thresholding on a 3D image."
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--image",
|
|
104
|
+
type=str,
|
|
105
|
+
required=True,
|
|
106
|
+
help="Path to a 3D image file (.npy or .tif/.tiff)",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--window",
|
|
110
|
+
type=int,
|
|
111
|
+
nargs=3,
|
|
112
|
+
default=[3, 3, 3],
|
|
113
|
+
help="Intensity window size (z y x). Default: 3 3 3",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--mean_window",
|
|
117
|
+
type=int,
|
|
118
|
+
nargs=3,
|
|
119
|
+
default=[3, 3, 3],
|
|
120
|
+
help="Local window size (z y x). Default: 3 3 3",
|
|
121
|
+
)
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"--outdir",
|
|
124
|
+
type=str,
|
|
125
|
+
default="outputs",
|
|
126
|
+
help="Directory to save outputs (default: ./outputs)",
|
|
127
|
+
)
|
|
128
|
+
parser.add_argument(
|
|
129
|
+
"--format",
|
|
130
|
+
type=str,
|
|
131
|
+
default="npy",
|
|
132
|
+
choices=["npy", "tif", "tiff"],
|
|
133
|
+
help="Output file format (default: npy)",
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--show",
|
|
137
|
+
action="store_true",
|
|
138
|
+
help="Display binary slices using matplotlib",
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
"--slice",
|
|
142
|
+
type=int,
|
|
143
|
+
default=None,
|
|
144
|
+
help="Optional z-index to visualize (default: middle slice)",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
args = parser.parse_args()
|
|
148
|
+
|
|
149
|
+
# Load image
|
|
150
|
+
print(f"Loading image: {args.image}")
|
|
151
|
+
img = load_image(args.image)
|
|
152
|
+
print(f"Image loaded with shape {img.shape} and dtype {img.dtype}")
|
|
153
|
+
|
|
154
|
+
# Run local 2D Otsu Thresholding
|
|
155
|
+
print("Running modified Otsu thresholding...")
|
|
156
|
+
binary = getBinary(img, window_size=tuple(args.window), mean_window_size=tuple(args.mean_window))
|
|
157
|
+
|
|
158
|
+
# Print results
|
|
159
|
+
print("------------------------------------------------------------")
|
|
160
|
+
print(f"Binary mask shape: {binary.shape}")
|
|
161
|
+
|
|
162
|
+
# Save outputs
|
|
163
|
+
save_output(binary, args.image, args.outdir, args.format)
|
|
164
|
+
|
|
165
|
+
# Show outputs if requested
|
|
166
|
+
if args.show:
|
|
167
|
+
show_results(img, binary, args.slice)
|
|
168
|
+
|
|
169
|
+
print("Done.")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
main()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: otsu2D
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Numba-accelerated local 2D Otsu thresholding for 3D images.
|
|
5
|
+
Author-email: John Rick Manzanares <jdolormanzanares@impan.pl>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jhnrckmnznrs/otsu2D
|
|
8
|
+
Project-URL: Issues, https://github.com/jhnrckmnznrs/otsu2D/issues
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: numpy
|
|
13
|
+
Requires-Dist: numba
|
|
14
|
+
Provides-Extra: tiff
|
|
15
|
+
Requires-Dist: tifffile; extra == "tiff"
|
|
16
|
+
Requires-Dist: matplotlib; extra == "tiff"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# 🧠 Local 2D Otsu Thresholding
|
|
20
|
+
|
|
21
|
+
**Local 2D Otsu Thresholding** is a **Numba-accelerated** implementation of a *local* 2D Otsu thresholding method for 3D images.
|
|
22
|
+
|
|
23
|
+
This algorithm adapts the 2D Otsu threshold within a sliding 3D window, enabling robust segmentation in datasets with spatially varying intensity distributions.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## ⚙️ Installation
|
|
28
|
+
|
|
29
|
+
Install the latest stable version from PyPI:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install otsu2D
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 🚀 Example Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import numpy as np
|
|
39
|
+
from otsu2D import getBinary
|
|
40
|
+
|
|
41
|
+
# Create sample 3D image
|
|
42
|
+
img = np.random.randint(0, 256, size=(10, 10, 10), dtype=np.uint8)
|
|
43
|
+
|
|
44
|
+
# Get threshold map and binary image
|
|
45
|
+
binary = getBinary(img, window_size=(3, 3, 3), mean_window_size =(3,3,3))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 🖥️ Command Line Usage
|
|
49
|
+
|
|
50
|
+
You can execute the script with custom parameters:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python runner.py --shape 10 10 10 --window 3 3 3 --mean_window 3 3 3
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 📦 Dependencies
|
|
57
|
+
|
|
58
|
+
- NumPy
|
|
59
|
+
- Numba
|
|
60
|
+
- Matplotlib
|
|
61
|
+
|
|
62
|
+
Install dependencies with:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install numpy numba matplotlib
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 📜 License
|
|
69
|
+
|
|
70
|
+
This project is licensed under the MIT License. See the LICENSE file for details.
|
|
71
|
+
|
|
72
|
+
## 🤝 Contributing
|
|
73
|
+
|
|
74
|
+
Contributions are welcome! If you'd like to fix a bug, add a feature, or improve performance, please open a pull request or contact the maintainers.
|
|
75
|
+
|
|
76
|
+
## 💬 Contact
|
|
77
|
+
|
|
78
|
+
For questions, issues, or feedback, open an issue on GitHub.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
otsu2D/__init__.py,sha256=S42dZ0oQtaV9DfkhczsUUp0PzkubutoBSY8FiS87IK0,1044
|
|
2
|
+
otsu2D/core.py,sha256=h1WiULgAq9P7hlr-ZpxXUIG_fnXac689kXedyTbTRrE,13711
|
|
3
|
+
otsu2D/runner.py,sha256=8Fg7dFFGTf-0OoJPFTSDHvDSWerCI9jo2V7O0Zo4dkg,5018
|
|
4
|
+
otsu2d-0.1.0.dist-info/licenses/LICENSE,sha256=nf6nNGBSlKqD389gsU-_DPce5IOf-lk_MceXNyoDpMg,1078
|
|
5
|
+
otsu2d-0.1.0.dist-info/METADATA,sha256=s42ciZlPyr2dsJYq8u6gp4hIk1LWJOttll0jpgtMPmA,1959
|
|
6
|
+
otsu2d-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
+
otsu2d-0.1.0.dist-info/entry_points.txt,sha256=2CwJDiPCVVABvTzuwg-STlo0tuHB5eC66pJgQPribRM,47
|
|
8
|
+
otsu2d-0.1.0.dist-info/top_level.txt,sha256=7kMNOiAsJJarPaCNtiNc8tOainJbQj2PPpvkbAFHE-A,7
|
|
9
|
+
otsu2d-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 John Rick Manzanares
|
|
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.
|
|
22
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
otsu2D
|