stouputils 1.12.1__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.
- stouputils/__init__.py +40 -0
- stouputils/__init__.pyi +14 -0
- stouputils/__main__.py +81 -0
- stouputils/_deprecated.py +37 -0
- stouputils/_deprecated.pyi +12 -0
- stouputils/all_doctests.py +160 -0
- stouputils/all_doctests.pyi +46 -0
- stouputils/applications/__init__.py +22 -0
- stouputils/applications/__init__.pyi +2 -0
- stouputils/applications/automatic_docs.py +634 -0
- stouputils/applications/automatic_docs.pyi +106 -0
- stouputils/applications/upscaler/__init__.py +39 -0
- stouputils/applications/upscaler/__init__.pyi +3 -0
- stouputils/applications/upscaler/config.py +128 -0
- stouputils/applications/upscaler/config.pyi +18 -0
- stouputils/applications/upscaler/image.py +247 -0
- stouputils/applications/upscaler/image.pyi +109 -0
- stouputils/applications/upscaler/video.py +287 -0
- stouputils/applications/upscaler/video.pyi +60 -0
- stouputils/archive.py +344 -0
- stouputils/archive.pyi +67 -0
- stouputils/backup.py +488 -0
- stouputils/backup.pyi +109 -0
- stouputils/collections.py +244 -0
- stouputils/collections.pyi +86 -0
- stouputils/continuous_delivery/__init__.py +27 -0
- stouputils/continuous_delivery/__init__.pyi +5 -0
- stouputils/continuous_delivery/cd_utils.py +243 -0
- stouputils/continuous_delivery/cd_utils.pyi +129 -0
- stouputils/continuous_delivery/github.py +522 -0
- stouputils/continuous_delivery/github.pyi +162 -0
- stouputils/continuous_delivery/pypi.py +91 -0
- stouputils/continuous_delivery/pypi.pyi +43 -0
- stouputils/continuous_delivery/pyproject.py +147 -0
- stouputils/continuous_delivery/pyproject.pyi +67 -0
- stouputils/continuous_delivery/stubs.py +86 -0
- stouputils/continuous_delivery/stubs.pyi +39 -0
- stouputils/ctx.py +408 -0
- stouputils/ctx.pyi +211 -0
- stouputils/data_science/config/get.py +51 -0
- stouputils/data_science/config/set.py +125 -0
- stouputils/data_science/data_processing/image/__init__.py +66 -0
- stouputils/data_science/data_processing/image/auto_contrast.py +79 -0
- stouputils/data_science/data_processing/image/axis_flip.py +58 -0
- stouputils/data_science/data_processing/image/bias_field_correction.py +74 -0
- stouputils/data_science/data_processing/image/binary_threshold.py +73 -0
- stouputils/data_science/data_processing/image/blur.py +59 -0
- stouputils/data_science/data_processing/image/brightness.py +54 -0
- stouputils/data_science/data_processing/image/canny.py +110 -0
- stouputils/data_science/data_processing/image/clahe.py +92 -0
- stouputils/data_science/data_processing/image/common.py +30 -0
- stouputils/data_science/data_processing/image/contrast.py +53 -0
- stouputils/data_science/data_processing/image/curvature_flow_filter.py +74 -0
- stouputils/data_science/data_processing/image/denoise.py +378 -0
- stouputils/data_science/data_processing/image/histogram_equalization.py +123 -0
- stouputils/data_science/data_processing/image/invert.py +64 -0
- stouputils/data_science/data_processing/image/laplacian.py +60 -0
- stouputils/data_science/data_processing/image/median_blur.py +52 -0
- stouputils/data_science/data_processing/image/noise.py +59 -0
- stouputils/data_science/data_processing/image/normalize.py +65 -0
- stouputils/data_science/data_processing/image/random_erase.py +66 -0
- stouputils/data_science/data_processing/image/resize.py +69 -0
- stouputils/data_science/data_processing/image/rotation.py +80 -0
- stouputils/data_science/data_processing/image/salt_pepper.py +68 -0
- stouputils/data_science/data_processing/image/sharpening.py +55 -0
- stouputils/data_science/data_processing/image/shearing.py +64 -0
- stouputils/data_science/data_processing/image/threshold.py +64 -0
- stouputils/data_science/data_processing/image/translation.py +71 -0
- stouputils/data_science/data_processing/image/zoom.py +83 -0
- stouputils/data_science/data_processing/image_augmentation.py +118 -0
- stouputils/data_science/data_processing/image_preprocess.py +183 -0
- stouputils/data_science/data_processing/prosthesis_detection.py +359 -0
- stouputils/data_science/data_processing/technique.py +481 -0
- stouputils/data_science/dataset/__init__.py +45 -0
- stouputils/data_science/dataset/dataset.py +292 -0
- stouputils/data_science/dataset/dataset_loader.py +135 -0
- stouputils/data_science/dataset/grouping_strategy.py +296 -0
- stouputils/data_science/dataset/image_loader.py +100 -0
- stouputils/data_science/dataset/xy_tuple.py +696 -0
- stouputils/data_science/metric_dictionnary.py +106 -0
- stouputils/data_science/metric_utils.py +847 -0
- stouputils/data_science/mlflow_utils.py +206 -0
- stouputils/data_science/models/abstract_model.py +149 -0
- stouputils/data_science/models/all.py +85 -0
- stouputils/data_science/models/base_keras.py +765 -0
- stouputils/data_science/models/keras/all.py +38 -0
- stouputils/data_science/models/keras/convnext.py +62 -0
- stouputils/data_science/models/keras/densenet.py +50 -0
- stouputils/data_science/models/keras/efficientnet.py +60 -0
- stouputils/data_science/models/keras/mobilenet.py +56 -0
- stouputils/data_science/models/keras/resnet.py +52 -0
- stouputils/data_science/models/keras/squeezenet.py +233 -0
- stouputils/data_science/models/keras/vgg.py +42 -0
- stouputils/data_science/models/keras/xception.py +38 -0
- stouputils/data_science/models/keras_utils/callbacks/__init__.py +20 -0
- stouputils/data_science/models/keras_utils/callbacks/colored_progress_bar.py +219 -0
- stouputils/data_science/models/keras_utils/callbacks/learning_rate_finder.py +148 -0
- stouputils/data_science/models/keras_utils/callbacks/model_checkpoint_v2.py +31 -0
- stouputils/data_science/models/keras_utils/callbacks/progressive_unfreezing.py +249 -0
- stouputils/data_science/models/keras_utils/callbacks/warmup_scheduler.py +66 -0
- stouputils/data_science/models/keras_utils/losses/__init__.py +12 -0
- stouputils/data_science/models/keras_utils/losses/next_generation_loss.py +56 -0
- stouputils/data_science/models/keras_utils/visualizations.py +416 -0
- stouputils/data_science/models/model_interface.py +939 -0
- stouputils/data_science/models/sandbox.py +116 -0
- stouputils/data_science/range_tuple.py +234 -0
- stouputils/data_science/scripts/augment_dataset.py +77 -0
- stouputils/data_science/scripts/exhaustive_process.py +133 -0
- stouputils/data_science/scripts/preprocess_dataset.py +70 -0
- stouputils/data_science/scripts/routine.py +168 -0
- stouputils/data_science/utils.py +285 -0
- stouputils/decorators.py +595 -0
- stouputils/decorators.pyi +242 -0
- stouputils/image.py +441 -0
- stouputils/image.pyi +172 -0
- stouputils/installer/__init__.py +18 -0
- stouputils/installer/__init__.pyi +5 -0
- stouputils/installer/common.py +67 -0
- stouputils/installer/common.pyi +39 -0
- stouputils/installer/downloader.py +101 -0
- stouputils/installer/downloader.pyi +24 -0
- stouputils/installer/linux.py +144 -0
- stouputils/installer/linux.pyi +39 -0
- stouputils/installer/main.py +223 -0
- stouputils/installer/main.pyi +57 -0
- stouputils/installer/windows.py +136 -0
- stouputils/installer/windows.pyi +31 -0
- stouputils/io.py +486 -0
- stouputils/io.pyi +213 -0
- stouputils/parallel.py +453 -0
- stouputils/parallel.pyi +211 -0
- stouputils/print.py +527 -0
- stouputils/print.pyi +146 -0
- stouputils/py.typed +1 -0
- stouputils-1.12.1.dist-info/METADATA +179 -0
- stouputils-1.12.1.dist-info/RECORD +138 -0
- stouputils-1.12.1.dist-info/WHEEL +4 -0
- stouputils-1.12.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from .ctx import MeasureTime as MeasureTime, Muffle as Muffle
|
|
2
|
+
from .print import error as error, progress as progress, warning as warning
|
|
3
|
+
from collections.abc import Callable as Callable
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
def measure_time(func: Callable[..., Any] | None = None, *, printer: Callable[..., None] = ..., message: str = '', perf_counter: bool = True, is_generator: bool = False) -> Callable[..., Any]:
|
|
8
|
+
''' Decorator that will measure the execution time of a function and print it with the given print function
|
|
9
|
+
|
|
10
|
+
\tArgs:
|
|
11
|
+
\t\tfunc\t\t\t(Callable[..., Any] | None): Function to decorate
|
|
12
|
+
\t\tprinter\t\t\t(Callable):\tFunction to use to print the execution time (e.g. debug, info, warning, error, etc.)
|
|
13
|
+
\t\tmessage\t\t\t(str):\t\tMessage to display with the execution time (e.g. "Execution time of Something"),
|
|
14
|
+
\t\t\tdefaults to "Execution time of {func.__name__}"
|
|
15
|
+
\t\tperf_counter\t(bool):\t\tWhether to use time.perf_counter_ns or time.time_ns
|
|
16
|
+
\t\t\tdefaults to True (use time.perf_counter_ns)
|
|
17
|
+
\t\tis_generator\t(bool):\t\tWhether the function is a generator or not (default: False)
|
|
18
|
+
\t\t\tWhen True, the decorator will yield from the function instead of returning it.
|
|
19
|
+
|
|
20
|
+
\tReturns:
|
|
21
|
+
\t\tCallable: Decorator to measure the time of the function.
|
|
22
|
+
|
|
23
|
+
\tExamples:
|
|
24
|
+
\t\t.. code-block:: python
|
|
25
|
+
|
|
26
|
+
\t\t\t> @measure_time(printer=info)
|
|
27
|
+
\t\t\t> def test():
|
|
28
|
+
\t\t\t> pass
|
|
29
|
+
\t\t\t> test() # [INFO HH:MM:SS] Execution time of test: 0.000ms (400ns)
|
|
30
|
+
\t'''
|
|
31
|
+
|
|
32
|
+
class LogLevels(Enum):
|
|
33
|
+
""" Log level for the errors in the decorator handle_error() """
|
|
34
|
+
NONE = 0
|
|
35
|
+
WARNING = 1
|
|
36
|
+
WARNING_TRACEBACK = 2
|
|
37
|
+
ERROR_TRACEBACK = 3
|
|
38
|
+
RAISE_EXCEPTION = 4
|
|
39
|
+
|
|
40
|
+
force_raise_exception: bool
|
|
41
|
+
|
|
42
|
+
def handle_error(func: Callable[..., Any] | None = None, *, exceptions: tuple[type[BaseException], ...] | type[BaseException] = ..., message: str = '', error_log: LogLevels = ..., sleep_time: float = 0.0) -> Callable[..., Any]:
|
|
43
|
+
''' Decorator that handle an error with different log levels.
|
|
44
|
+
|
|
45
|
+
\tArgs:
|
|
46
|
+
\t\tfunc (Callable[..., Any] | None): \tFunction to decorate
|
|
47
|
+
\t\texceptions\t(tuple[type[BaseException]], ...):\tExceptions to handle
|
|
48
|
+
\t\tmessage\t\t(str):\t\t\t\t\t\t\t\tMessage to display with the error. (e.g. "Error during something")
|
|
49
|
+
\t\terror_log\t(LogLevels):\t\t\t\t\t\tLog level for the errors
|
|
50
|
+
\t\t\tLogLevels.NONE:\t\t\t\t\tNone
|
|
51
|
+
\t\t\tLogLevels.WARNING:\t\t\t\tShow as warning
|
|
52
|
+
\t\t\tLogLevels.WARNING_TRACEBACK:\tShow as warning with traceback
|
|
53
|
+
\t\t\tLogLevels.ERROR_TRACEBACK:\t\tShow as error with traceback
|
|
54
|
+
\t\t\tLogLevels.RAISE_EXCEPTION:\t\tRaise exception
|
|
55
|
+
\t\tsleep_time\t(float):\t\t\t\t\t\t\tTime to sleep after the error (e.g. 0.0 to not sleep, 1.0 to sleep for 1 second)
|
|
56
|
+
|
|
57
|
+
\tExamples:
|
|
58
|
+
\t\t>>> @handle_error
|
|
59
|
+
\t\t... def might_fail():
|
|
60
|
+
\t\t... raise ValueError("Let\'s fail")
|
|
61
|
+
|
|
62
|
+
\t\t>>> @handle_error(error_log=LogLevels.WARNING)
|
|
63
|
+
\t\t... def test():
|
|
64
|
+
\t\t... raise ValueError("Let\'s fail")
|
|
65
|
+
\t\t>>> # test()\t# [WARNING HH:MM:SS] Error during test: (ValueError) Let\'s fail
|
|
66
|
+
\t'''
|
|
67
|
+
def timeout(func: Callable[..., Any] | None = None, *, seconds: float = 60.0, message: str = '') -> Callable[..., Any]:
|
|
68
|
+
''' Decorator that raises a TimeoutError if the function runs longer than the specified timeout.
|
|
69
|
+
|
|
70
|
+
\tNote: This decorator uses SIGALRM on Unix systems, which only works in the main thread.
|
|
71
|
+
\tOn Windows or in non-main threads, it will fall back to a polling-based approach.
|
|
72
|
+
|
|
73
|
+
\tArgs:
|
|
74
|
+
\t\tfunc\t\t(Callable[..., Any] | None):\tFunction to apply timeout to
|
|
75
|
+
\t\tseconds\t\t(float):\t\t\t\t\t\tTimeout duration in seconds (default: 60.0)
|
|
76
|
+
\t\tmessage\t\t(str):\t\t\t\t\t\t\tCustom timeout message (default: "Function \'{func_name}\' timed out after {seconds} seconds")
|
|
77
|
+
|
|
78
|
+
\tReturns:
|
|
79
|
+
\t\tCallable[..., Any]: Decorator that enforces timeout on the function
|
|
80
|
+
|
|
81
|
+
\tRaises:
|
|
82
|
+
\t\tTimeoutError: If the function execution exceeds the timeout duration
|
|
83
|
+
|
|
84
|
+
\tExamples:
|
|
85
|
+
\t\t>>> @timeout(seconds=2.0)
|
|
86
|
+
\t\t... def slow_function():
|
|
87
|
+
\t\t... time.sleep(5)
|
|
88
|
+
\t\t>>> slow_function() # Raises TimeoutError after 2 seconds
|
|
89
|
+
\t\tTraceback (most recent call last):
|
|
90
|
+
\t\t\t...
|
|
91
|
+
\t\tTimeoutError: Function \'slow_function\' timed out after 2.0 seconds
|
|
92
|
+
|
|
93
|
+
\t\t>>> @timeout(seconds=1.0, message="Custom timeout message")
|
|
94
|
+
\t\t... def another_slow_function():
|
|
95
|
+
\t\t... time.sleep(3)
|
|
96
|
+
\t\t>>> another_slow_function() # Raises TimeoutError after 1 second
|
|
97
|
+
\t\tTraceback (most recent call last):
|
|
98
|
+
\t\t\t...
|
|
99
|
+
\t\tTimeoutError: Custom timeout message
|
|
100
|
+
\t'''
|
|
101
|
+
def retry(func: Callable[..., Any] | None = None, *, exceptions: tuple[type[BaseException], ...] | type[BaseException] = ..., max_attempts: int = 10, delay: float = 1.0, backoff: float = 1.0, message: str = '') -> Callable[..., Any]:
|
|
102
|
+
''' Decorator that retries a function when specific exceptions are raised.
|
|
103
|
+
|
|
104
|
+
\tArgs:
|
|
105
|
+
\t\tfunc\t\t\t(Callable[..., Any] | None):\t\tFunction to retry
|
|
106
|
+
\t\texceptions\t\t(tuple[type[BaseException], ...]):\tExceptions to catch and retry on
|
|
107
|
+
\t\tmax_attempts\t(int | None):\t\t\t\t\t\tMaximum number of attempts (None for infinite retries)
|
|
108
|
+
\t\tdelay\t\t\t(float):\t\t\t\t\t\t\tInitial delay in seconds between retries (default: 1.0)
|
|
109
|
+
\t\tbackoff\t\t\t(float):\t\t\t\t\t\t\tMultiplier for delay after each retry (default: 1.0 for constant delay)
|
|
110
|
+
\t\tmessage\t\t\t(str):\t\t\t\t\t\t\t\tCustom message to display before ", retrying" (default: "{ExceptionName} encountered while running {func_name}")
|
|
111
|
+
|
|
112
|
+
\tReturns:
|
|
113
|
+
\t\tCallable[..., Any]: Decorator that retries the function on specified exceptions
|
|
114
|
+
|
|
115
|
+
\tExamples:
|
|
116
|
+
\t\t>>> import os
|
|
117
|
+
\t\t>>> @retry(exceptions=PermissionError, max_attempts=3, delay=0.1)
|
|
118
|
+
\t\t... def write_file():
|
|
119
|
+
\t\t... with open("test.txt", "w") as f:
|
|
120
|
+
\t\t... f.write("test")
|
|
121
|
+
|
|
122
|
+
\t\t>>> @retry(exceptions=(OSError, IOError), delay=0.5, backoff=2.0)
|
|
123
|
+
\t\t... def network_call():
|
|
124
|
+
\t\t... pass
|
|
125
|
+
|
|
126
|
+
\t\t>>> @retry(max_attempts=5, delay=1.0)
|
|
127
|
+
\t\t... def might_fail():
|
|
128
|
+
\t\t... pass
|
|
129
|
+
\t'''
|
|
130
|
+
def simple_cache(func: Callable[..., Any] | None = None, *, method: Literal['str', 'pickle'] = 'str') -> Callable[..., Any]:
|
|
131
|
+
''' Decorator that caches the result of a function based on its arguments.
|
|
132
|
+
|
|
133
|
+
\tThe str method is often faster than the pickle method (by a little) but not as accurate with complex objects.
|
|
134
|
+
|
|
135
|
+
\tArgs:
|
|
136
|
+
\t\tfunc (Callable[..., Any] | None): Function to cache
|
|
137
|
+
\t\tmethod (Literal["str", "pickle"]): The method to use for caching.
|
|
138
|
+
\tReturns:
|
|
139
|
+
\t\tCallable[..., Any]: A decorator that caches the result of a function.
|
|
140
|
+
\tExamples:
|
|
141
|
+
\t\t>>> @simple_cache
|
|
142
|
+
\t\t... def test1(a: int, b: int) -> int:
|
|
143
|
+
\t\t... return a + b
|
|
144
|
+
|
|
145
|
+
\t\t>>> @simple_cache(method="str")
|
|
146
|
+
\t\t... def test2(a: int, b: int) -> int:
|
|
147
|
+
\t\t... return a + b
|
|
148
|
+
\t\t>>> test2(1, 2)
|
|
149
|
+
\t\t3
|
|
150
|
+
\t\t>>> test2(1, 2)
|
|
151
|
+
\t\t3
|
|
152
|
+
\t\t>>> test2(3, 4)
|
|
153
|
+
\t\t7
|
|
154
|
+
\t'''
|
|
155
|
+
def abstract(func: Callable[..., Any] | None = None, *, error_log: LogLevels = ...) -> Callable[..., Any]:
|
|
156
|
+
""" Decorator that marks a function as abstract.
|
|
157
|
+
|
|
158
|
+
\tContrary to the abstractmethod decorator from the abc module that raises a TypeError
|
|
159
|
+
\twhen you try to instantiate a class that has abstract methods, this decorator raises
|
|
160
|
+
\ta NotImplementedError ONLY when the decorated function is called, indicating that the function
|
|
161
|
+
\tmust be implemented by a subclass.
|
|
162
|
+
|
|
163
|
+
\tArgs:
|
|
164
|
+
\t\tfunc (Callable[..., Any] | None): The function to mark as abstract
|
|
165
|
+
\t\terror_log (LogLevels): Log level for the error handling
|
|
166
|
+
\t\t\tLogLevels.NONE: None
|
|
167
|
+
\t\t\tLogLevels.WARNING: Show as warning
|
|
168
|
+
\t\t\tLogLevels.WARNING_TRACEBACK: Show as warning with traceback
|
|
169
|
+
\t\t\tLogLevels.ERROR_TRACEBACK: Show as error with traceback
|
|
170
|
+
\t\t\tLogLevels.RAISE_EXCEPTION: Raise exception
|
|
171
|
+
|
|
172
|
+
\tReturns:
|
|
173
|
+
\t\tCallable[..., Any]: Decorator that raises NotImplementedError when called
|
|
174
|
+
|
|
175
|
+
\tExamples:
|
|
176
|
+
\t\t>>> class Base:
|
|
177
|
+
\t\t... @abstract
|
|
178
|
+
\t\t... def method(self):
|
|
179
|
+
\t\t... pass
|
|
180
|
+
\t\t>>> Base().method()
|
|
181
|
+
\t\tTraceback (most recent call last):
|
|
182
|
+
\t\t\t...
|
|
183
|
+
\t\tNotImplementedError: Function 'method' is abstract and must be implemented by a subclass
|
|
184
|
+
\t"""
|
|
185
|
+
def deprecated(func: Callable[..., Any] | None = None, *, message: str = '', version: str = '', error_log: LogLevels = ...) -> Callable[..., Any]:
|
|
186
|
+
''' Decorator that marks a function as deprecated.
|
|
187
|
+
|
|
188
|
+
\tArgs:
|
|
189
|
+
\t\tfunc (Callable[..., Any] | None): Function to mark as deprecated
|
|
190
|
+
\t\tmessage (str): Additional message to display with the deprecation warning
|
|
191
|
+
\t\tversion (str): Version since when the function is deprecated (e.g. "v1.2.0")
|
|
192
|
+
\t\terror_log (LogLevels): Log level for the deprecation warning
|
|
193
|
+
\t\t\tLogLevels.NONE: None
|
|
194
|
+
\t\t\tLogLevels.WARNING: Show as warning
|
|
195
|
+
\t\t\tLogLevels.WARNING_TRACEBACK: Show as warning with traceback
|
|
196
|
+
\t\t\tLogLevels.ERROR_TRACEBACK: Show as error with traceback
|
|
197
|
+
\t\t\tLogLevels.RAISE_EXCEPTION: Raise exception
|
|
198
|
+
\tReturns:
|
|
199
|
+
\t\tCallable[..., Any]: Decorator that marks a function as deprecated
|
|
200
|
+
|
|
201
|
+
\tExamples:
|
|
202
|
+
\t\t>>> @deprecated
|
|
203
|
+
\t\t... def old_function():
|
|
204
|
+
\t\t... pass
|
|
205
|
+
|
|
206
|
+
\t\t>>> @deprecated(message="Use \'new_function()\' instead", error_log=LogLevels.WARNING)
|
|
207
|
+
\t\t... def another_old_function():
|
|
208
|
+
\t\t... pass
|
|
209
|
+
\t'''
|
|
210
|
+
def silent(func: Callable[..., Any] | None = None, *, mute_stderr: bool = False) -> Callable[..., Any]:
|
|
211
|
+
''' Decorator that makes a function silent (disable stdout, and stderr if specified).
|
|
212
|
+
|
|
213
|
+
\tAlternative to stouputils.ctx.Muffle.
|
|
214
|
+
|
|
215
|
+
\tArgs:
|
|
216
|
+
\t\tfunc\t\t\t(Callable[..., Any] | None):\tFunction to make silent
|
|
217
|
+
\t\tmute_stderr\t\t(bool):\t\t\t\t\t\t\tWhether to mute stderr or not
|
|
218
|
+
|
|
219
|
+
\tExamples:
|
|
220
|
+
\t\t>>> @silent
|
|
221
|
+
\t\t... def test():
|
|
222
|
+
\t\t... print("Hello, world!")
|
|
223
|
+
\t\t>>> test()
|
|
224
|
+
|
|
225
|
+
\t\t>>> @silent(mute_stderr=True)
|
|
226
|
+
\t\t... def test2():
|
|
227
|
+
\t\t... print("Hello, world!")
|
|
228
|
+
\t\t>>> test2()
|
|
229
|
+
|
|
230
|
+
\t\t>>> silent(print)("Hello, world!")
|
|
231
|
+
\t'''
|
|
232
|
+
def _get_func_name(func: Callable[..., Any]) -> str:
|
|
233
|
+
''' Get the name of a function, returns "<unknown>" if the name cannot be retrieved. '''
|
|
234
|
+
def _get_wrapper_name(decorator_name: str, func: Callable[..., Any]) -> str:
|
|
235
|
+
''' Get a descriptive name for a wrapper function.
|
|
236
|
+
|
|
237
|
+
\tArgs:
|
|
238
|
+
\t\tdecorator_name\t(str):\t\t\t\t\tName of the decorator
|
|
239
|
+
\t\tfunc\t\t\t(Callable[..., Any]):\tFunction being decorated
|
|
240
|
+
\tReturns:
|
|
241
|
+
\t\tstr: Combined name for the wrapper function (e.g., "stouputils.decorators.handle_error@function_name")
|
|
242
|
+
\t'''
|
stouputils/image.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides little utilities for image processing.
|
|
3
|
+
|
|
4
|
+
- image_resize: Resize an image while preserving its aspect ratio by default.
|
|
5
|
+
- auto_crop: Automatically crop an image to remove zero/uniform regions.
|
|
6
|
+
- numpy_to_gif: Generate a '.gif' file from a 3D numpy array for visualization.
|
|
7
|
+
- numpy_to_obj: Generate a '.obj' file from a 3D numpy array using marching cubes.
|
|
8
|
+
|
|
9
|
+
See stouputils.data_science.data_processing for lots more image processing utilities.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# Imports
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
16
|
+
|
|
17
|
+
from .io import super_open
|
|
18
|
+
from .print import debug, info
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import numpy as np
|
|
22
|
+
from numpy.typing import NDArray
|
|
23
|
+
from PIL import Image
|
|
24
|
+
|
|
25
|
+
PIL_Image_or_NDArray = TypeVar("PIL_Image_or_NDArray", bound="Image.Image | NDArray[np.number]")
|
|
26
|
+
|
|
27
|
+
# Functions
|
|
28
|
+
def image_resize[PIL_Image_or_NDArray](
|
|
29
|
+
image: PIL_Image_or_NDArray,
|
|
30
|
+
max_result_size: int,
|
|
31
|
+
resampling: "Image.Resampling | None" = None,
|
|
32
|
+
min_or_max: Callable[[int, int], int] = max,
|
|
33
|
+
return_type: type[PIL_Image_or_NDArray] | str = "same",
|
|
34
|
+
keep_aspect_ratio: bool = True,
|
|
35
|
+
) -> Any:
|
|
36
|
+
""" Resize an image while preserving its aspect ratio by default.
|
|
37
|
+
Scales the image so that its largest dimension equals max_result_size.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
image (Image.Image | np.ndarray): The image to resize.
|
|
41
|
+
max_result_size (int): Maximum size for the largest dimension.
|
|
42
|
+
resampling (Image.Resampling | None): PIL resampling filter to use (default: Image.Resampling.LANCZOS).
|
|
43
|
+
min_or_max (Callable): Function to use to get the minimum or maximum of the two ratios.
|
|
44
|
+
return_type (type | str): Type of the return value (Image.Image, np.ndarray, or "same" to match input type).
|
|
45
|
+
keep_aspect_ratio (bool): Whether to keep the aspect ratio.
|
|
46
|
+
Returns:
|
|
47
|
+
Image.Image | NDArray[np.number]: The resized image with preserved aspect ratio.
|
|
48
|
+
Examples:
|
|
49
|
+
>>> # Test with (height x width x channels) numpy array
|
|
50
|
+
>>> import numpy as np
|
|
51
|
+
>>> array = np.random.randint(0, 255, (100, 50, 3), dtype=np.uint8)
|
|
52
|
+
>>> image_resize(array, 100).shape
|
|
53
|
+
(100, 50, 3)
|
|
54
|
+
>>> image_resize(array, 100, min_or_max=max).shape
|
|
55
|
+
(100, 50, 3)
|
|
56
|
+
>>> image_resize(array, 100, min_or_max=min).shape
|
|
57
|
+
(200, 100, 3)
|
|
58
|
+
|
|
59
|
+
>>> # Test with PIL Image
|
|
60
|
+
>>> from PIL import Image
|
|
61
|
+
>>> pil_image: Image.Image = Image.new('RGB', (200, 100))
|
|
62
|
+
>>> image_resize(pil_image, 50).size
|
|
63
|
+
(50, 25)
|
|
64
|
+
>>> # Test with different return types
|
|
65
|
+
>>> resized_array = image_resize(array, 50, return_type=np.ndarray)
|
|
66
|
+
>>> isinstance(resized_array, np.ndarray)
|
|
67
|
+
True
|
|
68
|
+
>>> resized_array.shape
|
|
69
|
+
(50, 25, 3)
|
|
70
|
+
>>> # Test with different resampling methods
|
|
71
|
+
>>> image_resize(pil_image, 50, resampling=Image.Resampling.NEAREST).size
|
|
72
|
+
(50, 25)
|
|
73
|
+
"""
|
|
74
|
+
# Imports
|
|
75
|
+
import numpy as np
|
|
76
|
+
from PIL import Image
|
|
77
|
+
|
|
78
|
+
# Set default resampling method if not provided
|
|
79
|
+
if resampling is None:
|
|
80
|
+
resampling = Image.Resampling.LANCZOS
|
|
81
|
+
|
|
82
|
+
# Store original type for later conversion
|
|
83
|
+
original_was_pil: bool = isinstance(image, Image.Image)
|
|
84
|
+
|
|
85
|
+
# Convert numpy array to PIL Image if needed
|
|
86
|
+
if not original_was_pil:
|
|
87
|
+
image = Image.fromarray(image)
|
|
88
|
+
|
|
89
|
+
if keep_aspect_ratio:
|
|
90
|
+
|
|
91
|
+
# Get original image dimensions
|
|
92
|
+
width: int = image.size[0]
|
|
93
|
+
height: int = image.size[1]
|
|
94
|
+
|
|
95
|
+
# Determine which dimension to use for scaling based on min_or_max function
|
|
96
|
+
max_dimension: int = min_or_max(width, height)
|
|
97
|
+
|
|
98
|
+
# Calculate scaling factor
|
|
99
|
+
scale: float = max_result_size / max_dimension
|
|
100
|
+
|
|
101
|
+
# Calculate new dimensions while preserving aspect ratio
|
|
102
|
+
new_width: int = int(width * scale)
|
|
103
|
+
new_height: int = int(height * scale)
|
|
104
|
+
|
|
105
|
+
# Resize the image with the calculated dimensions
|
|
106
|
+
new_image: Image.Image = image.resize((new_width, new_height), resampling)
|
|
107
|
+
else:
|
|
108
|
+
# If not keeping aspect ratio, resize to square with max_result_size
|
|
109
|
+
new_image: Image.Image = image.resize((max_result_size, max_result_size), resampling)
|
|
110
|
+
|
|
111
|
+
# Return the image in the requested format
|
|
112
|
+
if return_type == "same":
|
|
113
|
+
# Return same type as input
|
|
114
|
+
if original_was_pil:
|
|
115
|
+
return new_image
|
|
116
|
+
else:
|
|
117
|
+
return np.array(new_image)
|
|
118
|
+
elif return_type != Image.Image:
|
|
119
|
+
return np.array(new_image)
|
|
120
|
+
else:
|
|
121
|
+
return new_image
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def auto_crop[PIL_Image_or_NDArray](
|
|
125
|
+
image: PIL_Image_or_NDArray,
|
|
126
|
+
mask: "NDArray[np.bool_] | None" = None,
|
|
127
|
+
threshold: int | float | Callable[["NDArray[np.number]"], int | float] | None = None,
|
|
128
|
+
return_type: type[PIL_Image_or_NDArray] | str = "same",
|
|
129
|
+
contiguous: bool = True,
|
|
130
|
+
) -> Any:
|
|
131
|
+
""" Automatically crop an image to remove zero or uniform regions.
|
|
132
|
+
|
|
133
|
+
This function crops the image to keep only the region where pixels are non-zero
|
|
134
|
+
(or above a threshold). It can work with a mask or directly analyze the image.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
image (Image.Image | NDArray): The image to crop.
|
|
138
|
+
mask (NDArray[bool] | None): Optional binary mask indicating regions to keep.
|
|
139
|
+
threshold (int | float | Callable): Threshold value or function (default: np.min).
|
|
140
|
+
return_type (type | str): Type of the return value (Image.Image, NDArray[np.number], or "same" to match input type).
|
|
141
|
+
contiguous (bool): If True (default), crop to bounding box. If False, remove entire rows/columns with no content.
|
|
142
|
+
Returns:
|
|
143
|
+
Image.Image | NDArray[np.number]: The cropped image.
|
|
144
|
+
|
|
145
|
+
Examples:
|
|
146
|
+
>>> # Test with numpy array with zeros on edges
|
|
147
|
+
>>> import numpy as np
|
|
148
|
+
>>> array = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
149
|
+
>>> array[20:80, 30:70] = 255 # White rectangle in center
|
|
150
|
+
>>> cropped = auto_crop(array, return_type=np.ndarray)
|
|
151
|
+
>>> cropped.shape
|
|
152
|
+
(60, 40, 3)
|
|
153
|
+
|
|
154
|
+
>>> # Test with custom mask
|
|
155
|
+
>>> mask = np.zeros((100, 100), dtype=bool)
|
|
156
|
+
>>> mask[10:90, 10:90] = True
|
|
157
|
+
>>> cropped_with_mask = auto_crop(array, mask=mask, return_type=np.ndarray)
|
|
158
|
+
>>> cropped_with_mask.shape
|
|
159
|
+
(80, 80, 3)
|
|
160
|
+
|
|
161
|
+
>>> # Test with PIL Image
|
|
162
|
+
>>> from PIL import Image
|
|
163
|
+
>>> pil_image = Image.new('RGB', (100, 100), (0, 0, 0))
|
|
164
|
+
>>> from PIL import ImageDraw
|
|
165
|
+
>>> draw = ImageDraw.Draw(pil_image)
|
|
166
|
+
>>> draw.rectangle([25, 25, 75, 75], fill=(255, 255, 255))
|
|
167
|
+
>>> cropped_pil = auto_crop(pil_image)
|
|
168
|
+
>>> cropped_pil.size
|
|
169
|
+
(51, 51)
|
|
170
|
+
|
|
171
|
+
>>> # Test with threshold
|
|
172
|
+
>>> array_gray = np.ones((100, 100), dtype=np.uint8) * 10
|
|
173
|
+
>>> array_gray[20:80, 30:70] = 255
|
|
174
|
+
>>> cropped_threshold = auto_crop(array_gray, threshold=50, return_type=np.ndarray)
|
|
175
|
+
>>> cropped_threshold.shape
|
|
176
|
+
(60, 40)
|
|
177
|
+
|
|
178
|
+
>>> # Test with callable threshold (using lambda to avoid min value)
|
|
179
|
+
>>> array_gray2 = np.ones((100, 100), dtype=np.uint8) * 10
|
|
180
|
+
>>> array_gray2[20:80, 30:70] = 255
|
|
181
|
+
>>> cropped_max = auto_crop(array_gray2, threshold=lambda x: 50, return_type=np.ndarray)
|
|
182
|
+
>>> cropped_max.shape
|
|
183
|
+
(60, 40)
|
|
184
|
+
|
|
185
|
+
>>> # Test with non-contiguous crop
|
|
186
|
+
>>> array_sparse = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
187
|
+
>>> array_sparse[10, 10] = 255
|
|
188
|
+
>>> array_sparse[50, 50] = 255
|
|
189
|
+
>>> array_sparse[90, 90] = 255
|
|
190
|
+
>>> cropped_contiguous = auto_crop(array_sparse, contiguous=True, return_type=np.ndarray)
|
|
191
|
+
>>> cropped_contiguous.shape # Bounding box from (10,10) to (90,90)
|
|
192
|
+
(81, 81, 3)
|
|
193
|
+
>>> cropped_non_contiguous = auto_crop(array_sparse, contiguous=False, return_type=np.ndarray)
|
|
194
|
+
>>> cropped_non_contiguous.shape # Only rows/cols 10, 50, 90
|
|
195
|
+
(3, 3, 3)
|
|
196
|
+
|
|
197
|
+
>>> # Test with 3D crop on depth dimension
|
|
198
|
+
>>> array_3d = np.zeros((50, 50, 10), dtype=np.uint8)
|
|
199
|
+
>>> array_3d[10:40, 10:40, 2:8] = 255 # Content only in depth slices 2-7
|
|
200
|
+
>>> cropped_3d = auto_crop(array_3d, contiguous=True, return_type=np.ndarray)
|
|
201
|
+
>>> cropped_3d.shape # Should crop all 3 dimensions
|
|
202
|
+
(30, 30, 6)
|
|
203
|
+
"""
|
|
204
|
+
# Imports
|
|
205
|
+
import numpy as np
|
|
206
|
+
from PIL import Image
|
|
207
|
+
|
|
208
|
+
# Convert to numpy array and store original type
|
|
209
|
+
original_was_pil: bool = isinstance(image, Image.Image)
|
|
210
|
+
image_array: NDArray[np.number] = np.array(image) if original_was_pil else image
|
|
211
|
+
|
|
212
|
+
# Create mask if not provided
|
|
213
|
+
if mask is None:
|
|
214
|
+
if threshold is None:
|
|
215
|
+
threshold = cast(Callable[["NDArray[np.number]"], int | float], np.min)
|
|
216
|
+
threshold_value: int | float = threshold(image_array) if callable(threshold) else threshold
|
|
217
|
+
# Create a 2D mask for both 2D and 3D arrays
|
|
218
|
+
if image_array.ndim == 2:
|
|
219
|
+
mask = image_array > threshold_value
|
|
220
|
+
else: # 3D array
|
|
221
|
+
mask = np.any(image_array > threshold_value, axis=2)
|
|
222
|
+
|
|
223
|
+
# Find rows, columns, and depth with content
|
|
224
|
+
rows_with_content: NDArray[np.bool_] = np.any(mask, axis=1)
|
|
225
|
+
cols_with_content: NDArray[np.bool_] = np.any(mask, axis=0)
|
|
226
|
+
|
|
227
|
+
# For 3D arrays, also find which depth slices have content
|
|
228
|
+
depth_with_content: NDArray[np.bool_] | None = None
|
|
229
|
+
if image_array.ndim == 3:
|
|
230
|
+
# Create a 1D mask for depth dimension
|
|
231
|
+
depth_with_content = np.any(image_array > (threshold(image_array) if callable(threshold) else threshold if threshold is not None else np.min(image_array)), axis=(0, 1))
|
|
232
|
+
|
|
233
|
+
# Return original if no content found
|
|
234
|
+
if not (np.any(rows_with_content) and np.any(cols_with_content)):
|
|
235
|
+
return image_array if return_type != Image.Image else (image if original_was_pil else Image.fromarray(image_array))
|
|
236
|
+
|
|
237
|
+
# Crop based on contiguous parameter
|
|
238
|
+
if contiguous:
|
|
239
|
+
row_idx, col_idx = np.where(rows_with_content)[0], np.where(cols_with_content)[0]
|
|
240
|
+
if image_array.ndim == 3 and depth_with_content is not None and np.any(depth_with_content):
|
|
241
|
+
depth_idx = np.where(depth_with_content)[0]
|
|
242
|
+
cropped_array: NDArray[np.number] = image_array[row_idx[0]:row_idx[-1]+1, col_idx[0]:col_idx[-1]+1, depth_idx[0]:depth_idx[-1]+1]
|
|
243
|
+
else:
|
|
244
|
+
cropped_array: NDArray[np.number] = image_array[row_idx[0]:row_idx[-1]+1, col_idx[0]:col_idx[-1]+1]
|
|
245
|
+
else:
|
|
246
|
+
if image_array.ndim == 3 and depth_with_content is not None:
|
|
247
|
+
# np.ix_ needs index arrays, not boolean arrays
|
|
248
|
+
row_indices = np.where(rows_with_content)[0]
|
|
249
|
+
col_indices = np.where(cols_with_content)[0]
|
|
250
|
+
depth_indices = np.where(depth_with_content)[0]
|
|
251
|
+
ix = np.ix_(row_indices, col_indices, depth_indices)
|
|
252
|
+
else:
|
|
253
|
+
row_indices = np.where(rows_with_content)[0]
|
|
254
|
+
col_indices = np.where(cols_with_content)[0]
|
|
255
|
+
ix = np.ix_(row_indices, col_indices)
|
|
256
|
+
cropped_array = image_array[ix]
|
|
257
|
+
|
|
258
|
+
# Return in requested format
|
|
259
|
+
if return_type == "same":
|
|
260
|
+
return Image.fromarray(cropped_array) if original_was_pil else cropped_array
|
|
261
|
+
return cropped_array if return_type != Image.Image else Image.fromarray(cropped_array)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def numpy_to_gif(
|
|
265
|
+
path: str,
|
|
266
|
+
array: "NDArray[np.integer | np.floating | np.bool_]",
|
|
267
|
+
duration: int = 100,
|
|
268
|
+
loop: int = 0,
|
|
269
|
+
mkdir: bool = True,
|
|
270
|
+
**kwargs: Any
|
|
271
|
+
) -> None:
|
|
272
|
+
""" Generate a '.gif' file from a numpy array for 3D/4D visualization.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
path (str): Path to the output .gif file.
|
|
276
|
+
array (NDArray): Numpy array to be dumped (must be 3D or 4D).
|
|
277
|
+
3D: (depth, height, width) - e.g. (64, 1024, 1024)
|
|
278
|
+
4D: (depth, height, width, channels) - e.g. (50, 64, 1024, 3)
|
|
279
|
+
duration (int): Duration between frames in milliseconds.
|
|
280
|
+
loop (int): Number of loops (0 = infinite).
|
|
281
|
+
mkdir (bool): Create the directory if it does not exist.
|
|
282
|
+
**kwargs (Any): Additional keyword arguments for PIL.Image.save().
|
|
283
|
+
|
|
284
|
+
Examples:
|
|
285
|
+
|
|
286
|
+
.. code-block:: python
|
|
287
|
+
|
|
288
|
+
> # 3D array example
|
|
289
|
+
> array = np.random.randint(0, 256, (10, 100, 100), dtype=np.uint8)
|
|
290
|
+
> numpy_to_gif("output_10_frames_100x100.gif", array, duration=200, loop=0)
|
|
291
|
+
|
|
292
|
+
> # 4D array example (batch of 3D images)
|
|
293
|
+
> array_4d = np.random.randint(0, 256, (5, 10, 100, 3), dtype=np.uint8)
|
|
294
|
+
> numpy_to_gif("output_50_frames_100x100.gif", array_4d, duration=200)
|
|
295
|
+
|
|
296
|
+
> total_duration = 1000 # 1 second
|
|
297
|
+
> numpy_to_gif("output_1s.gif", array, duration=total_duration // len(array))
|
|
298
|
+
"""
|
|
299
|
+
# Imports
|
|
300
|
+
import numpy as np
|
|
301
|
+
from PIL import Image
|
|
302
|
+
|
|
303
|
+
# Assertions
|
|
304
|
+
assert array.ndim in (3, 4), f"The input array must be 3D or 4D, got shape {array.shape} instead."
|
|
305
|
+
if array.ndim == 4:
|
|
306
|
+
assert array.shape[-1] in (1, 3), f"For 4D arrays, the last dimension must be 1 or 3 (channels), got shape {array.shape} instead."
|
|
307
|
+
|
|
308
|
+
# Create directory if needed
|
|
309
|
+
if mkdir:
|
|
310
|
+
dirname: str = os.path.dirname(path)
|
|
311
|
+
if dirname != "":
|
|
312
|
+
os.makedirs(dirname, exist_ok=True)
|
|
313
|
+
|
|
314
|
+
# Normalize array if outside [0-255] range to [0-1]
|
|
315
|
+
array = array.astype(np.float32)
|
|
316
|
+
mini, maxi = np.min(array), np.max(array)
|
|
317
|
+
if mini < 0 or maxi > 255:
|
|
318
|
+
array = ((array - mini) / (maxi - mini + 1e-8))
|
|
319
|
+
|
|
320
|
+
# Scale to [0-255] if in [0-1] range
|
|
321
|
+
mini, maxi = np.min(array), np.max(array)
|
|
322
|
+
if mini >= 0.0 and maxi <= 1.0:
|
|
323
|
+
array = (array * 255)
|
|
324
|
+
|
|
325
|
+
# Ensure array is uint8 for PIL compatibility
|
|
326
|
+
array = array.astype(np.uint8)
|
|
327
|
+
|
|
328
|
+
# Convert each slice to PIL Image
|
|
329
|
+
pil_images: list[Image.Image] = [
|
|
330
|
+
Image.fromarray(z_slice)
|
|
331
|
+
for z_slice in array
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
# Save as GIF
|
|
335
|
+
pil_images[0].save(
|
|
336
|
+
path,
|
|
337
|
+
save_all=True,
|
|
338
|
+
append_images=pil_images[1:],
|
|
339
|
+
duration=duration,
|
|
340
|
+
loop=loop,
|
|
341
|
+
**kwargs
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def numpy_to_obj(
|
|
346
|
+
path: str,
|
|
347
|
+
array: "NDArray[np.integer | np.floating | np.bool_]",
|
|
348
|
+
threshold: float = 0.5,
|
|
349
|
+
step_size: int = 1,
|
|
350
|
+
pad_array: bool = True,
|
|
351
|
+
verbose: int = 0
|
|
352
|
+
) -> None:
|
|
353
|
+
""" Generate a '.obj' file from a numpy array for 3D visualization using marching cubes.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
path (str): Path to the output .obj file.
|
|
357
|
+
array (NDArray): Numpy array to be dumped (must be 3D).
|
|
358
|
+
threshold (float): Threshold level for marching cubes (0.5 for binary data).
|
|
359
|
+
step_size (int): Step size for marching cubes (higher = simpler mesh, faster generation).
|
|
360
|
+
pad_array (bool): If True, pad array with zeros to ensure closed volumes for border cells.
|
|
361
|
+
verbose (int): Verbosity level (0 = no output, 1 = some output, 2 = full output).
|
|
362
|
+
|
|
363
|
+
Examples:
|
|
364
|
+
|
|
365
|
+
.. code-block:: python
|
|
366
|
+
|
|
367
|
+
> array = np.random.rand(64, 64, 64) > 0.5 # Binary volume
|
|
368
|
+
> numpy_to_obj("output_mesh.obj", array, threshold=0.5, step_size=2, pad_array=True, verbose=1)
|
|
369
|
+
|
|
370
|
+
> array = my_3d_data # Some 3D numpy array (e.g. human lung scan)
|
|
371
|
+
> numpy_to_obj("output_mesh.obj", array, threshold=0.3)
|
|
372
|
+
"""
|
|
373
|
+
# Imports
|
|
374
|
+
import numpy as np
|
|
375
|
+
from numpy.typing import NDArray
|
|
376
|
+
from skimage import measure
|
|
377
|
+
|
|
378
|
+
# Assertions
|
|
379
|
+
assert array.ndim == 3, f"The input array must be 3D, got shape {array.shape} instead."
|
|
380
|
+
assert step_size > 0, f"Step size must be positive, got {step_size}."
|
|
381
|
+
if verbose > 1:
|
|
382
|
+
debug(
|
|
383
|
+
f"Generating 3D mesh from array of shape {array.shape}, "
|
|
384
|
+
f"threshold={threshold}, step_size={step_size}, pad_array={pad_array}, "
|
|
385
|
+
f"non-zero voxels={np.count_nonzero(array):,}"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Convert to float for marching cubes, if needed
|
|
389
|
+
volume: NDArray[np.floating] = array.astype(np.float32)
|
|
390
|
+
if np.issubdtype(array.dtype, np.bool_):
|
|
391
|
+
threshold = 0.5
|
|
392
|
+
elif np.issubdtype(array.dtype, np.integer):
|
|
393
|
+
# For integer arrays, normalize to 0-1 range
|
|
394
|
+
array = array.astype(np.float32)
|
|
395
|
+
min_val, max_val = np.min(array), np.max(array)
|
|
396
|
+
if min_val != max_val:
|
|
397
|
+
volume = (array - min_val) / (max_val - min_val)
|
|
398
|
+
|
|
399
|
+
# Pad array with zeros to ensure closed volumes for border cells
|
|
400
|
+
if pad_array:
|
|
401
|
+
volume = np.pad(volume, pad_width=step_size, mode='constant', constant_values=0.0)
|
|
402
|
+
|
|
403
|
+
# Apply marching cubes algorithm to extract mesh
|
|
404
|
+
verts, faces, _, _ = cast(
|
|
405
|
+
tuple[NDArray[np.floating], NDArray[np.integer], NDArray[np.floating], NDArray[np.floating]],
|
|
406
|
+
measure.marching_cubes(volume, level=threshold, step_size=step_size, allow_degenerate=False) # type: ignore
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# Shift vertices back by step_size to account for padding
|
|
410
|
+
if pad_array:
|
|
411
|
+
verts = verts - step_size
|
|
412
|
+
|
|
413
|
+
if verbose > 1:
|
|
414
|
+
debug(f"Generated mesh with {len(verts):,} vertices and {len(faces):,} faces")
|
|
415
|
+
if step_size > 1:
|
|
416
|
+
debug(f"Mesh complexity reduced by ~{step_size ** 3}x compared to step_size=1")
|
|
417
|
+
|
|
418
|
+
# Build content using list for better performance
|
|
419
|
+
content_lines: list[str] = [
|
|
420
|
+
"# OBJ file generated from 3D numpy array",
|
|
421
|
+
f"# Array shape: {array.shape}",
|
|
422
|
+
f"# Threshold: {threshold}",
|
|
423
|
+
f"# Step size: {step_size}",
|
|
424
|
+
f"# Vertices: {len(verts)}",
|
|
425
|
+
f"# Faces: {len(faces)}",
|
|
426
|
+
""
|
|
427
|
+
]
|
|
428
|
+
|
|
429
|
+
# Add vertices
|
|
430
|
+
content_lines.extend(f"v {a:.6f} {b:.6f} {c:.6f}" for a, b, c in verts)
|
|
431
|
+
|
|
432
|
+
# Add faces (OBJ format is 1-indexed, simple format without normals)
|
|
433
|
+
content_lines.extend(f"f {a+1} {b+1} {c+1}" for a, b, c in faces)
|
|
434
|
+
|
|
435
|
+
# Write to .obj file
|
|
436
|
+
with super_open(path, "w") as f:
|
|
437
|
+
f.write("\n".join(content_lines) + "\n")
|
|
438
|
+
|
|
439
|
+
if verbose > 0:
|
|
440
|
+
info(f"Successfully exported 3D mesh to: '{path}'")
|
|
441
|
+
|