stouputils 1.14.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.
Files changed (140) hide show
  1. stouputils/__init__.py +40 -0
  2. stouputils/__main__.py +86 -0
  3. stouputils/_deprecated.py +37 -0
  4. stouputils/all_doctests.py +160 -0
  5. stouputils/applications/__init__.py +22 -0
  6. stouputils/applications/automatic_docs.py +634 -0
  7. stouputils/applications/upscaler/__init__.py +39 -0
  8. stouputils/applications/upscaler/config.py +128 -0
  9. stouputils/applications/upscaler/image.py +247 -0
  10. stouputils/applications/upscaler/video.py +287 -0
  11. stouputils/archive.py +344 -0
  12. stouputils/backup.py +488 -0
  13. stouputils/collections.py +244 -0
  14. stouputils/continuous_delivery/__init__.py +27 -0
  15. stouputils/continuous_delivery/cd_utils.py +243 -0
  16. stouputils/continuous_delivery/github.py +522 -0
  17. stouputils/continuous_delivery/pypi.py +130 -0
  18. stouputils/continuous_delivery/pyproject.py +147 -0
  19. stouputils/continuous_delivery/stubs.py +86 -0
  20. stouputils/ctx.py +408 -0
  21. stouputils/data_science/config/get.py +51 -0
  22. stouputils/data_science/config/set.py +125 -0
  23. stouputils/data_science/data_processing/image/__init__.py +66 -0
  24. stouputils/data_science/data_processing/image/auto_contrast.py +79 -0
  25. stouputils/data_science/data_processing/image/axis_flip.py +58 -0
  26. stouputils/data_science/data_processing/image/bias_field_correction.py +74 -0
  27. stouputils/data_science/data_processing/image/binary_threshold.py +73 -0
  28. stouputils/data_science/data_processing/image/blur.py +59 -0
  29. stouputils/data_science/data_processing/image/brightness.py +54 -0
  30. stouputils/data_science/data_processing/image/canny.py +110 -0
  31. stouputils/data_science/data_processing/image/clahe.py +92 -0
  32. stouputils/data_science/data_processing/image/common.py +30 -0
  33. stouputils/data_science/data_processing/image/contrast.py +53 -0
  34. stouputils/data_science/data_processing/image/curvature_flow_filter.py +74 -0
  35. stouputils/data_science/data_processing/image/denoise.py +378 -0
  36. stouputils/data_science/data_processing/image/histogram_equalization.py +123 -0
  37. stouputils/data_science/data_processing/image/invert.py +64 -0
  38. stouputils/data_science/data_processing/image/laplacian.py +60 -0
  39. stouputils/data_science/data_processing/image/median_blur.py +52 -0
  40. stouputils/data_science/data_processing/image/noise.py +59 -0
  41. stouputils/data_science/data_processing/image/normalize.py +65 -0
  42. stouputils/data_science/data_processing/image/random_erase.py +66 -0
  43. stouputils/data_science/data_processing/image/resize.py +69 -0
  44. stouputils/data_science/data_processing/image/rotation.py +80 -0
  45. stouputils/data_science/data_processing/image/salt_pepper.py +68 -0
  46. stouputils/data_science/data_processing/image/sharpening.py +55 -0
  47. stouputils/data_science/data_processing/image/shearing.py +64 -0
  48. stouputils/data_science/data_processing/image/threshold.py +64 -0
  49. stouputils/data_science/data_processing/image/translation.py +71 -0
  50. stouputils/data_science/data_processing/image/zoom.py +83 -0
  51. stouputils/data_science/data_processing/image_augmentation.py +118 -0
  52. stouputils/data_science/data_processing/image_preprocess.py +183 -0
  53. stouputils/data_science/data_processing/prosthesis_detection.py +359 -0
  54. stouputils/data_science/data_processing/technique.py +481 -0
  55. stouputils/data_science/dataset/__init__.py +45 -0
  56. stouputils/data_science/dataset/dataset.py +292 -0
  57. stouputils/data_science/dataset/dataset_loader.py +135 -0
  58. stouputils/data_science/dataset/grouping_strategy.py +296 -0
  59. stouputils/data_science/dataset/image_loader.py +100 -0
  60. stouputils/data_science/dataset/xy_tuple.py +696 -0
  61. stouputils/data_science/metric_dictionnary.py +106 -0
  62. stouputils/data_science/metric_utils.py +847 -0
  63. stouputils/data_science/mlflow_utils.py +206 -0
  64. stouputils/data_science/models/abstract_model.py +149 -0
  65. stouputils/data_science/models/all.py +85 -0
  66. stouputils/data_science/models/base_keras.py +765 -0
  67. stouputils/data_science/models/keras/all.py +38 -0
  68. stouputils/data_science/models/keras/convnext.py +62 -0
  69. stouputils/data_science/models/keras/densenet.py +50 -0
  70. stouputils/data_science/models/keras/efficientnet.py +60 -0
  71. stouputils/data_science/models/keras/mobilenet.py +56 -0
  72. stouputils/data_science/models/keras/resnet.py +52 -0
  73. stouputils/data_science/models/keras/squeezenet.py +233 -0
  74. stouputils/data_science/models/keras/vgg.py +42 -0
  75. stouputils/data_science/models/keras/xception.py +38 -0
  76. stouputils/data_science/models/keras_utils/callbacks/__init__.py +20 -0
  77. stouputils/data_science/models/keras_utils/callbacks/colored_progress_bar.py +219 -0
  78. stouputils/data_science/models/keras_utils/callbacks/learning_rate_finder.py +148 -0
  79. stouputils/data_science/models/keras_utils/callbacks/model_checkpoint_v2.py +31 -0
  80. stouputils/data_science/models/keras_utils/callbacks/progressive_unfreezing.py +249 -0
  81. stouputils/data_science/models/keras_utils/callbacks/warmup_scheduler.py +66 -0
  82. stouputils/data_science/models/keras_utils/losses/__init__.py +12 -0
  83. stouputils/data_science/models/keras_utils/losses/next_generation_loss.py +56 -0
  84. stouputils/data_science/models/keras_utils/visualizations.py +416 -0
  85. stouputils/data_science/models/model_interface.py +939 -0
  86. stouputils/data_science/models/sandbox.py +116 -0
  87. stouputils/data_science/range_tuple.py +234 -0
  88. stouputils/data_science/scripts/augment_dataset.py +77 -0
  89. stouputils/data_science/scripts/exhaustive_process.py +133 -0
  90. stouputils/data_science/scripts/preprocess_dataset.py +70 -0
  91. stouputils/data_science/scripts/routine.py +168 -0
  92. stouputils/data_science/utils.py +285 -0
  93. stouputils/decorators.py +605 -0
  94. stouputils/image.py +441 -0
  95. stouputils/installer/__init__.py +18 -0
  96. stouputils/installer/common.py +67 -0
  97. stouputils/installer/downloader.py +101 -0
  98. stouputils/installer/linux.py +144 -0
  99. stouputils/installer/main.py +223 -0
  100. stouputils/installer/windows.py +136 -0
  101. stouputils/io.py +486 -0
  102. stouputils/parallel.py +483 -0
  103. stouputils/print.py +482 -0
  104. stouputils/py.typed +1 -0
  105. stouputils/stouputils/__init__.pyi +15 -0
  106. stouputils/stouputils/_deprecated.pyi +12 -0
  107. stouputils/stouputils/all_doctests.pyi +46 -0
  108. stouputils/stouputils/applications/__init__.pyi +2 -0
  109. stouputils/stouputils/applications/automatic_docs.pyi +106 -0
  110. stouputils/stouputils/applications/upscaler/__init__.pyi +3 -0
  111. stouputils/stouputils/applications/upscaler/config.pyi +18 -0
  112. stouputils/stouputils/applications/upscaler/image.pyi +109 -0
  113. stouputils/stouputils/applications/upscaler/video.pyi +60 -0
  114. stouputils/stouputils/archive.pyi +67 -0
  115. stouputils/stouputils/backup.pyi +109 -0
  116. stouputils/stouputils/collections.pyi +86 -0
  117. stouputils/stouputils/continuous_delivery/__init__.pyi +5 -0
  118. stouputils/stouputils/continuous_delivery/cd_utils.pyi +129 -0
  119. stouputils/stouputils/continuous_delivery/github.pyi +162 -0
  120. stouputils/stouputils/continuous_delivery/pypi.pyi +53 -0
  121. stouputils/stouputils/continuous_delivery/pyproject.pyi +67 -0
  122. stouputils/stouputils/continuous_delivery/stubs.pyi +39 -0
  123. stouputils/stouputils/ctx.pyi +211 -0
  124. stouputils/stouputils/decorators.pyi +252 -0
  125. stouputils/stouputils/image.pyi +172 -0
  126. stouputils/stouputils/installer/__init__.pyi +5 -0
  127. stouputils/stouputils/installer/common.pyi +39 -0
  128. stouputils/stouputils/installer/downloader.pyi +24 -0
  129. stouputils/stouputils/installer/linux.pyi +39 -0
  130. stouputils/stouputils/installer/main.pyi +57 -0
  131. stouputils/stouputils/installer/windows.pyi +31 -0
  132. stouputils/stouputils/io.pyi +213 -0
  133. stouputils/stouputils/parallel.pyi +216 -0
  134. stouputils/stouputils/print.pyi +136 -0
  135. stouputils/stouputils/version_pkg.pyi +15 -0
  136. stouputils/version_pkg.py +189 -0
  137. stouputils-1.14.0.dist-info/METADATA +178 -0
  138. stouputils-1.14.0.dist-info/RECORD +140 -0
  139. stouputils-1.14.0.dist-info/WHEEL +4 -0
  140. stouputils-1.14.0.dist-info/entry_points.txt +3 -0
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
+
@@ -0,0 +1,18 @@
1
+ """ Installer module for stouputils.
2
+
3
+ Provides functions for platform-agnostic installation tasks by dispatching
4
+ to platform-specific implementations (Windows, Linux/macOS).
5
+
6
+ It handles getting installation paths, adding programs to the PATH environment variable,
7
+ and installing programs from local zip files or URLs.
8
+ """
9
+ # ruff: noqa: F403
10
+ # ruff: noqa: F405
11
+
12
+ # Imports
13
+ from .common import *
14
+ from .downloader import *
15
+ from .linux import *
16
+ from .main import *
17
+ from .windows import *
18
+
@@ -0,0 +1,67 @@
1
+ """ Common functions used by the Linux and Windows installers modules. """
2
+ # Imports
3
+ from typing import Literal
4
+
5
+ from ..print import warning
6
+
7
+
8
+ # Functions
9
+ def prompt_for_path(prompt_message: str, default_path: str) -> str:
10
+ """ Prompt the user to override a default path.
11
+
12
+ Args:
13
+ prompt_message (str): The message to display to the user.
14
+ default_path (str): The default path to suggest.
15
+
16
+ Returns:
17
+ str: The path entered by the user, or the default path if they pressed Enter.
18
+ """
19
+ warning(f"{prompt_message}\nPress Enter to use this path, or type a new path to override it: ")
20
+ return input() or default_path
21
+
22
+
23
+ def ask_install_type(ask_global: int, default_local_path: str, default_global_path: str | None) -> Literal["g", "l"]:
24
+ """ Determine the installation type (global 'g' or local 'l') based on user input.
25
+
26
+ Args:
27
+ ask_global (int): 0 = ask, 1 = force global, 2 = force local.
28
+ default_local_path (str): The default local path.
29
+ default_global_path (str | None): The default global path (if applicable).
30
+
31
+ Returns:
32
+ Literal["g", "l"]: 'g' for global install, 'l' for local install.
33
+
34
+ Examples:
35
+ .. code-block:: python
36
+
37
+ > # Ask the user while providing default paths
38
+ > install_choice: str = ask_install_type(0, f"{os.getcwd()}/MyProgram", "C:\\Program Files\\MyProgram")
39
+ g
40
+
41
+ > # Don't ask, force global
42
+ > install_choice: str = ask_install_type(1, ...)
43
+ g
44
+
45
+ > # Don't ask, force local
46
+ > install_choice: str = ask_install_type(2, ...)
47
+ l
48
+ """
49
+ install_choice: str = ""
50
+ if ask_global == 0:
51
+ if default_global_path:
52
+ global_prompt: str = f"(Globally would target '{default_global_path}')"
53
+ else:
54
+ global_prompt: str = "(Global install not well-defined)"
55
+ warning(
56
+ f"Install globally (requires admin/sudo, suggests adding to PATH) or locally? (G/l):\n"
57
+ f"{global_prompt}, locally would be '{default_local_path}')"
58
+ )
59
+ install_choice = input().lower()
60
+ elif ask_global == 1:
61
+ install_choice = "g"
62
+ elif ask_global == 2:
63
+ install_choice = "l"
64
+
65
+ # Default to global unless user explicitly chooses local ('l')
66
+ return 'l' if install_choice == 'l' else 'g'
67
+
@@ -0,0 +1,101 @@
1
+ """ Downloader module for the installer subpackage.
2
+
3
+ Provides functions for downloading and installing programs from URLs.
4
+ It handles platform-specific downloads, checking if programs are already installed,
5
+ and setting up the downloaded programs for use.
6
+
7
+ This module works with the main installer module to provide a complete installation
8
+ solution for programs that need to be downloaded from the internet.
9
+ """
10
+ # Imports
11
+ import os
12
+ import platform
13
+ import subprocess
14
+ import sys
15
+
16
+ from ..print import info, warning
17
+ from .main import install_program
18
+
19
+
20
+ # Functions
21
+ def download_executable(download_urls: dict[str, str], program_name: str, append_to_path: str = "") -> bool:
22
+ """ Ask the user if they want to download the program (ex: waifu2x-ncnn-vulkan).
23
+ If yes, try to download the program from the GitHub releases page.
24
+
25
+ Args:
26
+ download_urls (dict[str, str]): The URLs to download the program from.
27
+ program_name (str): The name of the program to download.
28
+
29
+ Returns:
30
+ bool: True if the program is now ready to use, False otherwise.
31
+ """
32
+ # Ask the user if they want to download the upscaler
33
+ program_url: str = next(iter(download_urls.values())).split("/download/")[0]
34
+ warning(
35
+ f"Program executable not found, would you like to download it automatically from GitHub? (Y/n) :\n"
36
+ f"({program_url})"
37
+ )
38
+
39
+ # Handle the user's response
40
+ if input().lower() == "n":
41
+ info("User declined to download the upscaler.")
42
+ return False
43
+
44
+ # Get the platform
45
+ system: str = platform.system()
46
+ download_url: str = download_urls.get(system, "")
47
+ if not download_url:
48
+ warning(
49
+ f"Unsupported platform: {system}, please download the program manually from the following URL:\n"
50
+ f" {program_url}"
51
+ )
52
+ return False
53
+
54
+ # Download the upscaler
55
+ if not install_program(download_url, program_name=program_name, append_to_path=append_to_path):
56
+ warning("Failed to download the upscaler, please download it manually from the following URL:")
57
+ print(f" {download_url}")
58
+ return False
59
+
60
+ return True
61
+
62
+ def check_executable(
63
+ executable: str,
64
+ executable_help_text: str,
65
+ download_urls: dict[str, str],
66
+ append_to_path: str = ""
67
+ ) -> None:
68
+ """ Check if the executable exists, optionally download it if it doesn't.
69
+
70
+ Args:
71
+ executable (str): The path to the executable.
72
+ executable_help_text (str): The help text to check for in the executable's output.
73
+ download_urls (dict[str, str]): The URLs to download the executable from.
74
+ append_to_path (str): The path to append to the executable's path.
75
+ (ex: "bin" if executables are in the bin folder)
76
+ """
77
+ program_name: str = os.path.basename(executable)
78
+ try_download: bool = True
79
+
80
+ # Run the command, capture output, don't check exit code immediately
81
+ try:
82
+ result: subprocess.CompletedProcess[str] = subprocess.run(
83
+ [executable, "-h"],
84
+ capture_output=True,
85
+ text=True, # Decode stdout/stderr as text
86
+ check=False # Don't raise exception on non-zero exit code
87
+ )
88
+
89
+ # If the command failed (no help text matching), try to download the upscaler
90
+ try_download: bool = executable_help_text.lower() not in result.stdout.lower()
91
+ except FileNotFoundError:
92
+ try_download: bool = True
93
+
94
+ # If the command failed, try to download the upscaler
95
+ if try_download:
96
+ if not download_executable(download_urls, program_name, append_to_path=append_to_path):
97
+ warning(f"'{program_name}' is required but not available. Exiting.")
98
+ else:
99
+ info(f"'{program_name}' downloaded successfully, please restart the script.")
100
+ sys.exit(1)
101
+