otsu2D 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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
+
otsu2d-0.1.0/PKG-INFO ADDED
@@ -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.
otsu2d-0.1.0/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # 🧠 Local 2D Otsu Thresholding
2
+
3
+ **Local 2D Otsu Thresholding** is a **Numba-accelerated** implementation of a *local* 2D Otsu thresholding method for 3D images.
4
+
5
+ This algorithm adapts the 2D Otsu threshold within a sliding 3D window, enabling robust segmentation in datasets with spatially varying intensity distributions.
6
+
7
+ ---
8
+
9
+ ## ⚙️ Installation
10
+
11
+ Install the latest stable version from PyPI:
12
+
13
+ ```bash
14
+ pip install otsu2D
15
+ ```
16
+
17
+ ## 🚀 Example Usage
18
+
19
+ ```python
20
+ import numpy as np
21
+ from otsu2D import getBinary
22
+
23
+ # Create sample 3D image
24
+ img = np.random.randint(0, 256, size=(10, 10, 10), dtype=np.uint8)
25
+
26
+ # Get threshold map and binary image
27
+ binary = getBinary(img, window_size=(3, 3, 3), mean_window_size =(3,3,3))
28
+ ```
29
+
30
+ ## 🖥️ Command Line Usage
31
+
32
+ You can execute the script with custom parameters:
33
+
34
+ ```bash
35
+ python runner.py --shape 10 10 10 --window 3 3 3 --mean_window 3 3 3
36
+ ```
37
+
38
+ ## 📦 Dependencies
39
+
40
+ - NumPy
41
+ - Numba
42
+ - Matplotlib
43
+
44
+ Install dependencies with:
45
+
46
+ ```bash
47
+ pip install numpy numba matplotlib
48
+ ```
49
+
50
+ ## 📜 License
51
+
52
+ This project is licensed under the MIT License. See the LICENSE file for details.
53
+
54
+ ## 🤝 Contributing
55
+
56
+ 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.
57
+
58
+ ## 💬 Contact
59
+
60
+ For questions, issues, or feedback, open an issue on GitHub.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools >= 77.0.3", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "otsu2D"
7
+ version = "0.1.0"
8
+ description = "Numba-accelerated local 2D Otsu thresholding for 3D images."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICEN[CS]E*"]
12
+ authors = [
13
+ {name="John Rick Manzanares", email="jdolormanzanares@impan.pl"}
14
+ ]
15
+ dependencies = [
16
+ "numpy",
17
+ "numba"
18
+ ]
19
+ requires-python = ">=3.9"
20
+
21
+ [project.optional-dependencies]
22
+ tiff = ["tifffile", "matplotlib"]
23
+
24
+ [project.scripts]
25
+ otsu-2d = "otsu2D.runner:main"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/jhnrckmnznrs/otsu2D"
29
+ Issues = "https://github.com/jhnrckmnznrs/otsu2D/issues"
otsu2d-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)
@@ -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)
@@ -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,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/otsu2D/__init__.py
5
+ src/otsu2D/core.py
6
+ src/otsu2D/runner.py
7
+ src/otsu2D.egg-info/PKG-INFO
8
+ src/otsu2D.egg-info/SOURCES.txt
9
+ src/otsu2D.egg-info/dependency_links.txt
10
+ src/otsu2D.egg-info/entry_points.txt
11
+ src/otsu2D.egg-info/requires.txt
12
+ src/otsu2D.egg-info/top_level.txt
13
+ tests/test_functional.py
14
+ tests/test_validation.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ otsu-2d = otsu2D.runner:main
@@ -0,0 +1,6 @@
1
+ numpy
2
+ numba
3
+
4
+ [tiff]
5
+ tifffile
6
+ matplotlib
@@ -0,0 +1 @@
1
+ otsu2D
@@ -0,0 +1,28 @@
1
+ """
2
+ Optional functional correctness test for getMask.
3
+ Checks internal consistency without assuming exact threshold values.
4
+ """
5
+
6
+ import numpy as np
7
+ from otsu2D.core import getMask # Adjust based on your package name
8
+
9
+
10
+ def test_getBinary_consistency():
11
+ """Test that binary mask corresponds correctly to threshold map."""
12
+ # Small synthetic 3D image
13
+ img = np.random.randint(0, 256, size=(5, 5, 5), dtype=np.uint8)
14
+
15
+ # Run getMask
16
+ binary_mask = getMask(img, window_size=(3, 3, 3), mean_window_size=(3,3,3))
17
+
18
+ # Check shapes
19
+ assert binary_mask.shape == img.shape, "Binary mask shape mismatch"
20
+
21
+ # Check types
22
+ assert binary_mask.dtype == bool, "Binary mask should be boolean"
23
+
24
+ print("✓ getMask consistency functional test passed")
25
+
26
+
27
+ if __name__ == "__main__":
28
+ test_getBinary_consistency()
@@ -0,0 +1,159 @@
1
+ """
2
+ Input validation tests for getBinary
3
+ """
4
+
5
+ import numpy as np
6
+ from otsu2D.core import getBinary
7
+
8
+ # Color codes for terminal output
9
+ GREEN = "\033[92m"
10
+ RED = "\033[91m"
11
+ YELLOW = "\033[93m"
12
+ RESET = "\033[0m"
13
+
14
+
15
+ def print_test_result(test_name: str, passed: bool, error_msg: str = ""):
16
+ """Print formatted test result"""
17
+ if passed:
18
+ print(f"{GREEN}✓ PASS{RESET}: {test_name}")
19
+ else:
20
+ print(f"{RED}✗ FAIL{RESET}: {test_name}")
21
+ if error_msg:
22
+ print(f" {YELLOW}Expected error not raised or wrong error message{RESET}")
23
+ print(f" Got: {error_msg}")
24
+
25
+
26
+ def test_validation():
27
+ """Run all validation tests"""
28
+ print("=" * 70)
29
+ print("TESTING INPUT VALIDATION FOR getBinary")
30
+ print("=" * 70)
31
+
32
+ total_tests = 0
33
+ passed_tests = 0
34
+
35
+ # Test 1: Valid input (should NOT raise error)
36
+ print("\n--- Test 1: Valid Input ---")
37
+ total_tests += 1
38
+ try:
39
+ img = np.random.randint(0, 256, size=(4, 4, 4), dtype=np.uint8)
40
+ result = getBinary(img, (3, 3, 3), (3,3,3))
41
+ print_test_result("Valid input should work", True)
42
+ passed_tests += 1
43
+ except Exception as e:
44
+ print_test_result("Valid input should work", False, str(e))
45
+
46
+ # Test 2: Wrong number of dimensions (2D)
47
+ print("\n--- Test 2: 2D Image (should fail) ---")
48
+ total_tests += 1
49
+ try:
50
+ img_2d = np.random.randint(0, 256, size=(4, 4), dtype=np.uint8)
51
+ result = getBinary(img, (3, 3, 3), (3,3,3))
52
+ print_test_result("Should reject 2D image", False, "No error raised")
53
+ except ValueError as e:
54
+ if "3D" in str(e) and "2D" in str(e):
55
+ print_test_result("Should reject 2D image", True)
56
+ passed_tests += 1
57
+ else:
58
+ print_test_result("Should reject 2D image", False, str(e))
59
+ except Exception as e:
60
+ print_test_result("Should reject 2D image", False, f"Wrong exception: {e}")
61
+
62
+ # Test 3: Wrong number of dimensions (4D)
63
+ print("\n--- Test 3: 4D Image (should fail) ---")
64
+ total_tests += 1
65
+ try:
66
+ img_4d = np.random.randint(0, 256, size=(5, 4, 4, 4), dtype=np.uint8)
67
+ result = getBinary(img, (3, 3, 3), (3,3,3))
68
+ print_test_result("Should reject 4D image", False, "No error raised")
69
+ except ValueError as e:
70
+ if "3D" in str(e) and "4D" in str(e):
71
+ print_test_result("Should reject 4D image", True)
72
+ passed_tests += 1
73
+ else:
74
+ print_test_result("Should reject 4D image", False, str(e))
75
+ except Exception as e:
76
+ print_test_result("Should reject 4D image", False, f"Wrong exception: {e}")
77
+
78
+ # Test 4: Float dtype (should fail)
79
+ print("\n--- Test 4: Float Image (should fail) ---")
80
+ total_tests += 1
81
+ try:
82
+ img_float = np.random.rand(4, 4, 4).astype(np.float32)
83
+ result = getBinary(img, (3, 3, 3), (3,3,3))
84
+ print_test_result("Should reject float dtype", False, "No error raised")
85
+ except ValueError as e:
86
+ if "integer" in str(e).lower() and "float" in str(e).lower():
87
+ print_test_result("Should reject float dtype", True)
88
+ passed_tests += 1
89
+ else:
90
+ print_test_result("Should reject float dtype", False, str(e))
91
+ except Exception as e:
92
+ print_test_result("Should reject float dtype", False, f"Wrong exception: {e}")
93
+
94
+ # Test 5: Float64 dtype (should fail)
95
+ print("\n--- Test 5: Float64 Image (should fail) ---")
96
+ total_tests += 1
97
+ try:
98
+ img_float64 = np.random.rand(4, 4, 4)
99
+ result = getBinary(img, (3, 3, 3), (3,3,3))
100
+ print_test_result("Should reject float64 dtype", False, "No error raised")
101
+ except ValueError as e:
102
+ if "integer" in str(e).lower():
103
+ print_test_result("Should reject float64 dtype", True)
104
+ passed_tests += 1
105
+ else:
106
+ print_test_result("Should reject float64 dtype", False, str(e))
107
+ except Exception as e:
108
+ print_test_result("Should reject float64 dtype", False, f"Wrong exception: {e}")
109
+
110
+ # Test 6: Negative value in window_size
111
+ total_tests += 1
112
+ try:
113
+ img = np.random.randint(0, 256, size=(4, 4, 4), dtype=np.uint8)
114
+ getBinary(img, (3, -3, 3), (3,3,3))
115
+ print_test_result(
116
+ "Negative window_size value should fail", False, "No error raised"
117
+ )
118
+ except ValueError as e:
119
+ if "positive" in str(e).lower():
120
+ print_test_result("Negative window_size value should fail", True)
121
+ passed_tests += 1
122
+ else:
123
+ print_test_result("Negative window_size value should fail", False, str(e))
124
+ except Exception as e:
125
+ print_test_result(
126
+ "Negative window_size value should fail", False, f"Wrong exception: {e}"
127
+ )
128
+
129
+ # Test 7: Negative in mean_window_size
130
+ total_tests += 1
131
+ try:
132
+ img = np.random.randint(0, 256, size=(4, 4, 4), dtype=np.uint8)
133
+ getBinary(img, (3, 3, 3), (3,-3,3))
134
+ print_test_result("Negative mean_window_size should fail", False, "No error raised")
135
+ except ValueError as e:
136
+ if "non-negative" in str(e).lower() or "positive" in str(e).lower():
137
+ print_test_result("Negative mean_window_size should fail", True)
138
+ passed_tests += 1
139
+ else:
140
+ print_test_result("Negative mean_window_size should fail", False, str(e))
141
+ except Exception as e:
142
+ print_test_result("Negative mean_window_size should fail", False, f"Wrong exception: {e}")
143
+
144
+ # Summary
145
+ print("\n" + "=" * 70)
146
+ print(f"SUMMARY: {passed_tests}/{total_tests} tests passed")
147
+ print("=" * 70)
148
+
149
+ if passed_tests == total_tests:
150
+ print(f"{GREEN}All tests passed! ✓{RESET}")
151
+ else:
152
+ print(f"{RED}{total_tests - passed_tests} test(s) failed! ✗{RESET}")
153
+
154
+ return passed_tests, total_tests
155
+
156
+
157
+ # ===== MAIN ENTRY POINT =====
158
+ if __name__ == "__main__":
159
+ test_validation()