datamint 1.5.5__tar.gz → 1.6.2__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.
Potentially problematic release.
This version of datamint might be problematic. Click here for more details.
- {datamint-1.5.5 → datamint-1.6.2}/PKG-INFO +2 -1
- {datamint-1.5.5 → datamint-1.6.2}/datamint/apihandler/annotation_api_handler.py +241 -153
- {datamint-1.5.5 → datamint-1.6.2}/datamint/apihandler/dto/annotation_dto.py +1 -1
- {datamint-1.5.5 → datamint-1.6.2}/datamint/apihandler/root_api_handler.py +4 -2
- {datamint-1.5.5 → datamint-1.6.2}/datamint/client_cmd_tools/datamint_upload.py +101 -42
- {datamint-1.5.5 → datamint-1.6.2}/datamint/dataset/base_dataset.py +2 -2
- {datamint-1.5.5 → datamint-1.6.2}/pyproject.toml +2 -1
- datamint-1.5.5/datamint/utils/dicom_utils.py +0 -707
- datamint-1.5.5/datamint/utils/io_utils.py +0 -187
- {datamint-1.5.5 → datamint-1.6.2}/README.md +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/__init__.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/apihandler/api_handler.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/apihandler/base_api_handler.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/apihandler/exp_api_handler.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/client_cmd_tools/__init__.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/client_cmd_tools/datamint_config.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/configs.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/dataset/__init__.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/dataset/dataset.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/examples/__init__.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/examples/example_projects.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/experiment/__init__.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/experiment/_patcher.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/experiment/experiment.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/logging.yaml +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/utils/logging_utils.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/utils/torchmetrics.py +0 -0
- {datamint-1.5.5 → datamint-1.6.2}/datamint/utils/visualization.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: datamint
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.2
|
|
4
4
|
Summary: A library for interacting with the Datamint API, designed for efficient data management, processing and Deep Learning workflows.
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -19,6 +19,7 @@ Requires-Dist: humanize (>=4.0.0,<5.0.0)
|
|
|
19
19
|
Requires-Dist: lazy-loader (>=0.3.0)
|
|
20
20
|
Requires-Dist: lightning
|
|
21
21
|
Requires-Dist: matplotlib
|
|
22
|
+
Requires-Dist: medimgkit
|
|
22
23
|
Requires-Dist: nest-asyncio (>=1.0.0,<2.0.0)
|
|
23
24
|
Requires-Dist: nibabel (>=4.0.0)
|
|
24
25
|
Requires-Dist: numpy
|
|
@@ -10,32 +10,63 @@ import os
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import aiohttp
|
|
12
12
|
from requests.exceptions import HTTPError
|
|
13
|
-
from deprecated.sphinx import deprecated
|
|
14
13
|
from .dto.annotation_dto import CreateAnnotationDto, LineGeometry, BoxGeometry, CoordinateSystem, AnnotationType
|
|
15
14
|
import pydicom
|
|
16
15
|
import json
|
|
17
16
|
|
|
18
17
|
_LOGGER = logging.getLogger(__name__)
|
|
19
18
|
_USER_LOGGER = logging.getLogger('user_logger')
|
|
19
|
+
MAX_NUMBER_DISTINCT_COLORS = 2048 # Maximum number of distinct colors in a segmentation image
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
class AnnotationAPIHandler(BaseAPIHandler):
|
|
23
23
|
@staticmethod
|
|
24
|
-
def
|
|
24
|
+
def _normalize_segmentation_array(seg_imgs: np.ndarray) -> np.ndarray:
|
|
25
25
|
"""
|
|
26
|
+
Normalize segmentation array to a consistent format.
|
|
27
|
+
|
|
26
28
|
Args:
|
|
27
|
-
|
|
29
|
+
seg_imgs: Input segmentation array in various formats: (height, width, #frames), (height, width), (3, height, width, #frames).
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
np.ndarray: Shape (#channels, height, width, #frames)
|
|
28
33
|
"""
|
|
34
|
+
if seg_imgs.ndim == 4:
|
|
35
|
+
return seg_imgs # .transpose(1, 2, 0, 3)
|
|
29
36
|
|
|
37
|
+
# Handle grayscale segmentations
|
|
30
38
|
if seg_imgs.ndim == 2:
|
|
39
|
+
# Add frame dimension: (height, width) -> (height, width, 1)
|
|
31
40
|
seg_imgs = seg_imgs[..., None]
|
|
41
|
+
if seg_imgs.ndim == 3:
|
|
42
|
+
# (height, width, #frames)
|
|
43
|
+
seg_imgs = seg_imgs[np.newaxis, ...] # Add channel dimension: (1, height, width, #frames)
|
|
44
|
+
|
|
45
|
+
return seg_imgs
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _numpy_to_bytesio_png(seg_imgs: np.ndarray) -> Generator[BinaryIO, None, None]:
|
|
49
|
+
"""
|
|
50
|
+
Convert normalized segmentation images to PNG BytesIO objects.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
seg_imgs: Normalized segmentation array in shape (channels, height, width, frames).
|
|
32
54
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
55
|
+
Yields:
|
|
56
|
+
BinaryIO: PNG image data as BytesIO objects
|
|
57
|
+
"""
|
|
58
|
+
# PIL RGB format is: (height, width, channels)
|
|
59
|
+
if seg_imgs.shape[0] not in [1, 3, 4]:
|
|
60
|
+
raise ValueError(f"Unsupported number of channels: {seg_imgs.shape[0]}. Expected 1 or 3")
|
|
61
|
+
nframes = seg_imgs.shape[3]
|
|
62
|
+
for i in range(nframes):
|
|
63
|
+
img = seg_imgs[:, :, :, i].astype(np.uint8)
|
|
64
|
+
if img.shape[0] == 1:
|
|
65
|
+
pil_img = Image.fromarray(img[0]).convert('RGB')
|
|
66
|
+
else:
|
|
67
|
+
pil_img = Image.fromarray(img.transpose(1, 2, 0))
|
|
37
68
|
img_bytes = BytesIO()
|
|
38
|
-
|
|
69
|
+
pil_img.save(img_bytes, format='PNG')
|
|
39
70
|
img_bytes.seek(0)
|
|
40
71
|
yield img_bytes
|
|
41
72
|
|
|
@@ -46,29 +77,42 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
46
77
|
raise ValueError(f"Unsupported file type: {type(file_path)}")
|
|
47
78
|
|
|
48
79
|
if isinstance(file_path, np.ndarray):
|
|
49
|
-
|
|
80
|
+
normalized_imgs = AnnotationAPIHandler._normalize_segmentation_array(file_path)
|
|
81
|
+
# normalized_imgs shape: (3, height, width, #frames)
|
|
82
|
+
|
|
83
|
+
# Apply transpose if requested
|
|
50
84
|
if transpose_segmentation:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
85
|
+
# (channels, height, width, frames) -> (channels, width, height, frames)
|
|
86
|
+
normalized_imgs = normalized_imgs.transpose(0, 2, 1, 3)
|
|
87
|
+
|
|
88
|
+
nframes = normalized_imgs.shape[3]
|
|
89
|
+
fios = AnnotationAPIHandler._numpy_to_bytesio_png(normalized_imgs)
|
|
90
|
+
|
|
54
91
|
elif file_path.endswith('.nii') or file_path.endswith('.nii.gz'):
|
|
55
92
|
segs_imgs = nib.load(file_path).get_fdata()
|
|
56
93
|
if segs_imgs.ndim != 3 and segs_imgs.ndim != 2:
|
|
57
94
|
raise ValueError(f"Invalid segmentation shape: {segs_imgs.shape}")
|
|
95
|
+
|
|
96
|
+
# Normalize and apply transpose
|
|
97
|
+
normalized_imgs = AnnotationAPIHandler._normalize_segmentation_array(segs_imgs)
|
|
58
98
|
if not transpose_segmentation:
|
|
59
|
-
#
|
|
60
|
-
|
|
99
|
+
# Apply default NIfTI transpose
|
|
100
|
+
# (channels, width, height, frames) -> (channels, height, width, frames)
|
|
101
|
+
normalized_imgs = normalized_imgs.transpose(0, 2, 1, 3)
|
|
102
|
+
|
|
103
|
+
nframes = normalized_imgs.shape[3]
|
|
104
|
+
fios = AnnotationAPIHandler._numpy_to_bytesio_png(normalized_imgs)
|
|
61
105
|
|
|
62
|
-
fios = AnnotationAPIHandler._numpy_to_bytesio_png(segs_imgs)
|
|
63
|
-
nframes = segs_imgs.shape[2] if segs_imgs.ndim == 3 else 1
|
|
64
106
|
elif file_path.endswith('.png'):
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
107
|
+
with Image.open(file_path) as img:
|
|
108
|
+
img_array = np.array(img)
|
|
109
|
+
normalized_imgs = AnnotationAPIHandler._normalize_segmentation_array(img_array)
|
|
110
|
+
|
|
111
|
+
if transpose_segmentation:
|
|
112
|
+
normalized_imgs = normalized_imgs.transpose(0, 2, 1, 3)
|
|
113
|
+
|
|
114
|
+
fios = AnnotationAPIHandler._numpy_to_bytesio_png(normalized_imgs)
|
|
115
|
+
nframes = 1
|
|
72
116
|
else:
|
|
73
117
|
raise ValueError(f"Unsupported file format of '{file_path}'")
|
|
74
118
|
|
|
@@ -91,9 +135,9 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
91
135
|
|
|
92
136
|
async def _upload_single_frame_segmentation_async(self,
|
|
93
137
|
resource_id: str,
|
|
94
|
-
frame_index: int,
|
|
138
|
+
frame_index: int | None,
|
|
95
139
|
fio: IO,
|
|
96
|
-
name:
|
|
140
|
+
name: dict[int, str] | dict[tuple, str],
|
|
97
141
|
imported_from: Optional[str] = None,
|
|
98
142
|
author_email: Optional[str] = None,
|
|
99
143
|
discard_empty_segmentations: bool = True,
|
|
@@ -107,7 +151,8 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
107
151
|
resource_id: The resource unique id.
|
|
108
152
|
frame_index: The frame index for the segmentation.
|
|
109
153
|
fio: File-like object containing the segmentation image.
|
|
110
|
-
name: The name of the segmentation
|
|
154
|
+
name: The name of the segmentation, a dictionary mapping pixel values to names,
|
|
155
|
+
or a dictionary mapping RGB tuples to names.
|
|
111
156
|
imported_from: The imported from value.
|
|
112
157
|
author_email: The author email.
|
|
113
158
|
discard_empty_segmentations: Whether to discard empty segmentations.
|
|
@@ -119,21 +164,29 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
119
164
|
"""
|
|
120
165
|
try:
|
|
121
166
|
try:
|
|
122
|
-
|
|
167
|
+
img_pil = Image.open(fio)
|
|
168
|
+
img_array = np.array(img_pil) # shape: (height, width, channels)
|
|
169
|
+
# Returns a list of (count, color) tuples
|
|
170
|
+
unique_vals = img_pil.getcolors(maxcolors=MAX_NUMBER_DISTINCT_COLORS)
|
|
171
|
+
# convert to list of RGB tuples
|
|
172
|
+
if unique_vals is None:
|
|
173
|
+
raise ValueError(f'Number of unique colors exceeds {MAX_NUMBER_DISTINCT_COLORS}.')
|
|
174
|
+
unique_vals = [color for count, color in unique_vals]
|
|
175
|
+
# Remove black/transparent pixels
|
|
176
|
+
black_pixel = (0, 0, 0)
|
|
177
|
+
unique_vals = [rgb for rgb in unique_vals if rgb != black_pixel]
|
|
123
178
|
|
|
124
|
-
# Check that frame is not empty
|
|
125
|
-
uniq_vals = np.unique(img)
|
|
126
179
|
if discard_empty_segmentations:
|
|
127
|
-
if len(
|
|
128
|
-
msg = f"Discarding empty segmentation for frame {frame_index}"
|
|
180
|
+
if len(unique_vals) == 0:
|
|
181
|
+
msg = f"Discarding empty RGB segmentation for frame {frame_index}"
|
|
129
182
|
_LOGGER.debug(msg)
|
|
130
183
|
_USER_LOGGER.debug(msg)
|
|
131
184
|
return []
|
|
132
|
-
|
|
133
|
-
|
|
185
|
+
segnames = AnnotationAPIHandler._get_segmentation_names_rgb(unique_vals, names=name)
|
|
186
|
+
segs_generator = AnnotationAPIHandler._split_rgb_segmentations(img_array, unique_vals)
|
|
134
187
|
|
|
135
|
-
|
|
136
|
-
|
|
188
|
+
fio.seek(0)
|
|
189
|
+
# TODO: Optimize this. It is not necessary to open the image twice.
|
|
137
190
|
|
|
138
191
|
# Create annotations
|
|
139
192
|
annotations: list[CreateAnnotationDto] = []
|
|
@@ -174,7 +227,6 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
174
227
|
resp = await self._run_request_async(request_params)
|
|
175
228
|
if 'error' in resp:
|
|
176
229
|
raise DatamintException(resp['error'])
|
|
177
|
-
|
|
178
230
|
return annotids
|
|
179
231
|
finally:
|
|
180
232
|
fio.close()
|
|
@@ -184,7 +236,7 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
184
236
|
async def _upload_volume_segmentation_async(self,
|
|
185
237
|
resource_id: str,
|
|
186
238
|
file_path: str | np.ndarray,
|
|
187
|
-
name: str | dict[int, str] |
|
|
239
|
+
name: str | dict[int, str] | dict[tuple, str] | None,
|
|
188
240
|
imported_from: Optional[str] = None,
|
|
189
241
|
author_email: Optional[str] = None,
|
|
190
242
|
worklist_id: Optional[str] = None,
|
|
@@ -210,9 +262,13 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
210
262
|
Raises:
|
|
211
263
|
ValueError: If name is not a string or file format is unsupported for volume upload.
|
|
212
264
|
"""
|
|
213
|
-
if name is None:
|
|
214
|
-
name = 'volume_segmentation'
|
|
215
265
|
|
|
266
|
+
if isinstance(name, str):
|
|
267
|
+
raise NotImplementedError("`name=string` is not supported yet for volume segmentation.")
|
|
268
|
+
if isinstance(name, dict):
|
|
269
|
+
if any(isinstance(k, tuple) for k in name.keys()):
|
|
270
|
+
raise NotImplementedError("For volume segmentations, `name` must be a dictionary with integer keys only.")
|
|
271
|
+
|
|
216
272
|
# Prepare file for upload
|
|
217
273
|
if isinstance(file_path, str):
|
|
218
274
|
if file_path.endswith('.nii') or file_path.endswith('.nii.gz'):
|
|
@@ -225,7 +281,8 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
225
281
|
form.add_field('model_id', model_id) # Add model_id if provided
|
|
226
282
|
if worklist_id is not None:
|
|
227
283
|
form.add_field('annotation_worklist_id', worklist_id)
|
|
228
|
-
|
|
284
|
+
if name is not None:
|
|
285
|
+
form.add_field('segmentation_map', json.dumps(name), content_type='application/json')
|
|
229
286
|
|
|
230
287
|
request_params = dict(
|
|
231
288
|
method='POST',
|
|
@@ -248,9 +305,8 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
248
305
|
async def _upload_segmentations_async(self,
|
|
249
306
|
resource_id: str,
|
|
250
307
|
frame_index: int | None,
|
|
251
|
-
file_path: str | np.ndarray
|
|
252
|
-
|
|
253
|
-
name: Optional[str | dict[int, str]] = None,
|
|
308
|
+
file_path: str | np.ndarray,
|
|
309
|
+
name: dict[int, str] | dict[tuple, str],
|
|
254
310
|
imported_from: Optional[str] = None,
|
|
255
311
|
author_email: Optional[str] = None,
|
|
256
312
|
discard_empty_segmentations: bool = True,
|
|
@@ -266,7 +322,6 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
266
322
|
resource_id: The resource unique id.
|
|
267
323
|
frame_index: The frame index or None for multiple frames.
|
|
268
324
|
file_path: Path to segmentation file or numpy array.
|
|
269
|
-
fio: File-like object containing segmentation data.
|
|
270
325
|
name: The name of the segmentation or mapping of pixel values to names.
|
|
271
326
|
imported_from: The imported from value.
|
|
272
327
|
author_email: The author email.
|
|
@@ -280,60 +335,44 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
280
335
|
List of annotation IDs created.
|
|
281
336
|
"""
|
|
282
337
|
if upload_volume == 'auto':
|
|
283
|
-
if file_path
|
|
338
|
+
if isinstance(file_path, str) and (file_path.endswith('.nii') or file_path.endswith('.nii.gz')):
|
|
284
339
|
upload_volume = True
|
|
285
340
|
else:
|
|
286
341
|
upload_volume = False
|
|
287
342
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
transpose_segmentation=transpose_segmentation
|
|
303
|
-
)
|
|
304
|
-
|
|
305
|
-
# Handle frame-by-frame upload (existing logic)
|
|
306
|
-
nframes, fios = AnnotationAPIHandler._generate_segmentations_ios(
|
|
307
|
-
file_path, transpose_segmentation=transpose_segmentation
|
|
343
|
+
# Handle volume upload
|
|
344
|
+
if upload_volume:
|
|
345
|
+
if frame_index is not None:
|
|
346
|
+
_LOGGER.warning("frame_index parameter ignored when upload_volume=True")
|
|
347
|
+
|
|
348
|
+
return await self._upload_volume_segmentation_async(
|
|
349
|
+
resource_id=resource_id,
|
|
350
|
+
file_path=file_path,
|
|
351
|
+
name=name,
|
|
352
|
+
imported_from=imported_from,
|
|
353
|
+
author_email=author_email,
|
|
354
|
+
worklist_id=worklist_id,
|
|
355
|
+
model_id=model_id,
|
|
356
|
+
transpose_segmentation=transpose_segmentation
|
|
308
357
|
)
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
)
|
|
325
|
-
annotids.extend(frame_annotids)
|
|
326
|
-
return annotids
|
|
327
|
-
|
|
328
|
-
# Handle single file-like object
|
|
329
|
-
if fio is not None:
|
|
330
|
-
if upload_volume:
|
|
331
|
-
raise ValueError("upload_volume=True is not supported when providing fio parameter")
|
|
332
|
-
|
|
333
|
-
return await self._upload_single_frame_segmentation_async(
|
|
358
|
+
|
|
359
|
+
# Handle frame-by-frame upload (existing logic)
|
|
360
|
+
nframes, fios = AnnotationAPIHandler._generate_segmentations_ios(
|
|
361
|
+
file_path, transpose_segmentation=transpose_segmentation
|
|
362
|
+
)
|
|
363
|
+
if frame_index is None:
|
|
364
|
+
frames_indices = list(range(nframes))
|
|
365
|
+
elif isinstance(frame_index, int):
|
|
366
|
+
frames_indices = [frame_index]
|
|
367
|
+
else:
|
|
368
|
+
raise ValueError("frame_index must be an int or None")
|
|
369
|
+
|
|
370
|
+
annotids = []
|
|
371
|
+
for fidx, f in zip(frames_indices, fios):
|
|
372
|
+
frame_annotids = await self._upload_single_frame_segmentation_async(
|
|
334
373
|
resource_id=resource_id,
|
|
335
|
-
frame_index=
|
|
336
|
-
fio=
|
|
374
|
+
frame_index=fidx,
|
|
375
|
+
fio=f,
|
|
337
376
|
name=name,
|
|
338
377
|
imported_from=imported_from,
|
|
339
378
|
author_email=author_email,
|
|
@@ -341,13 +380,30 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
341
380
|
worklist_id=worklist_id,
|
|
342
381
|
model_id=model_id
|
|
343
382
|
)
|
|
383
|
+
annotids.extend(frame_annotids)
|
|
384
|
+
return annotids
|
|
344
385
|
|
|
345
|
-
|
|
386
|
+
@staticmethod
|
|
387
|
+
def standardize_segmentation_names(name: str | dict[int, str] | dict[tuple, str] | None) -> dict[tuple[int, int, int], str]:
|
|
388
|
+
if name is None:
|
|
389
|
+
return {}
|
|
390
|
+
elif isinstance(name, str):
|
|
391
|
+
return {'default': name}
|
|
392
|
+
elif isinstance(name, dict):
|
|
393
|
+
name = {
|
|
394
|
+
tuple(k) if isinstance(k, (list, tuple)) else k if isinstance(k, str) else (k, k, k): v
|
|
395
|
+
for k, v in name.items()
|
|
396
|
+
}
|
|
397
|
+
if 'default' not in name:
|
|
398
|
+
name['default'] = None
|
|
399
|
+
return name
|
|
400
|
+
else:
|
|
401
|
+
raise ValueError("Invalid name format")
|
|
346
402
|
|
|
347
403
|
def upload_segmentations(self,
|
|
348
404
|
resource_id: str,
|
|
349
405
|
file_path: str | np.ndarray,
|
|
350
|
-
name:
|
|
406
|
+
name: str | dict[int, str] | dict[tuple, str] | None = None,
|
|
351
407
|
frame_index: int | list[int] | None = None,
|
|
352
408
|
imported_from: Optional[str] = None,
|
|
353
409
|
author_email: Optional[str] = None,
|
|
@@ -362,27 +418,41 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
362
418
|
Args:
|
|
363
419
|
resource_id (str): The resource unique id.
|
|
364
420
|
file_path (str|np.ndarray): The path to the segmentation file or a numpy array.
|
|
365
|
-
If a numpy array is provided, it
|
|
421
|
+
If a numpy array is provided, it can have the shape:
|
|
422
|
+
- (height, width, #frames) or (height, width) for grayscale segmentations
|
|
423
|
+
- (3, height, width, #frames) for RGB segmentations
|
|
366
424
|
For NIfTI files (.nii/.nii.gz), the entire volume is uploaded as a single segmentation.
|
|
367
|
-
name
|
|
368
|
-
|
|
425
|
+
name: The name of the segmentation.
|
|
426
|
+
Can be:
|
|
427
|
+
- str: Single name for all segmentations
|
|
428
|
+
- dict[int, str]: Mapping pixel values to names for grayscale segmentations
|
|
429
|
+
- dict[tuple[int, int, int], str]: Mapping RGB tuples to names for RGB segmentations
|
|
430
|
+
Example: {(255, 0, 0): 'Red_Region', (0, 255, 0): 'Green_Region'}
|
|
369
431
|
frame_index (int | list[int]): The frame index of the segmentation.
|
|
370
432
|
If a list, it must have the same length as the number of frames in the segmentation.
|
|
371
433
|
If None, it is assumed that the segmentations are in sequential order starting from 0.
|
|
372
434
|
This parameter is ignored for NIfTI files as they are treated as volume segmentations.
|
|
373
435
|
discard_empty_segmentations (bool): Whether to discard empty segmentations or not.
|
|
374
|
-
This is ignored for NIfTI files.
|
|
375
436
|
|
|
376
437
|
Returns:
|
|
377
|
-
str:
|
|
438
|
+
List[str]: List of segmentation unique ids.
|
|
378
439
|
|
|
379
440
|
Raises:
|
|
380
441
|
ResourceNotFoundError: If the resource does not exists or the segmentation is invalid.
|
|
381
442
|
|
|
382
443
|
Example:
|
|
444
|
+
>>> # Grayscale segmentation
|
|
383
445
|
>>> api_handler.upload_segmentation(resource_id, 'path/to/segmentation.png', 'SegmentationName')
|
|
446
|
+
>>>
|
|
447
|
+
>>> # RGB segmentation with numpy array
|
|
448
|
+
>>> seg_data = np.random.randint(0, 3, size=(3, 2140, 1760, 1), dtype=np.uint8)
|
|
449
|
+
>>> rgb_names = {(1, 0, 0): 'Red_Region', (0, 1, 0): 'Green_Region', (0, 0, 1): 'Blue_Region'}
|
|
450
|
+
>>> api_handler.upload_segmentation(resource_id, seg_data, rgb_names)
|
|
451
|
+
>>>
|
|
452
|
+
>>> # Volume segmentation
|
|
384
453
|
>>> api_handler.upload_segmentation(resource_id, 'path/to/segmentation.nii.gz', 'VolumeSegmentation')
|
|
385
454
|
"""
|
|
455
|
+
|
|
386
456
|
if isinstance(file_path, str) and not os.path.exists(file_path):
|
|
387
457
|
raise FileNotFoundError(f"File {file_path} not found.")
|
|
388
458
|
|
|
@@ -392,48 +462,46 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
392
462
|
if frame_index is not None:
|
|
393
463
|
raise ValueError("Do not provide frame_index for NIfTI segmentations.")
|
|
394
464
|
loop = asyncio.get_event_loop()
|
|
395
|
-
task = self.
|
|
465
|
+
task = self._upload_volume_segmentation_async(
|
|
396
466
|
resource_id=resource_id,
|
|
397
|
-
frame_index=None,
|
|
398
467
|
file_path=file_path,
|
|
399
468
|
name=name,
|
|
400
469
|
imported_from=imported_from,
|
|
401
470
|
author_email=author_email,
|
|
402
|
-
discard_empty_segmentations=False,
|
|
403
471
|
worklist_id=worklist_id,
|
|
404
472
|
model_id=model_id,
|
|
405
|
-
transpose_segmentation=transpose_segmentation
|
|
406
|
-
upload_volume=True
|
|
473
|
+
transpose_segmentation=transpose_segmentation
|
|
407
474
|
)
|
|
408
475
|
return loop.run_until_complete(task)
|
|
409
476
|
# All other file types are converted to multiple PNGs and uploaded frame by frame.
|
|
410
|
-
if isinstance(frame_index, int):
|
|
411
|
-
frame_index = [frame_index]
|
|
412
477
|
|
|
413
|
-
|
|
478
|
+
name = AnnotationAPIHandler.standardize_segmentation_names(name)
|
|
479
|
+
|
|
414
480
|
to_run = []
|
|
415
481
|
# Generate IOs for the segmentations.
|
|
416
482
|
nframes, fios = AnnotationAPIHandler._generate_segmentations_ios(file_path,
|
|
417
483
|
transpose_segmentation=transpose_segmentation)
|
|
418
484
|
if frame_index is None:
|
|
419
485
|
frame_index = list(range(nframes))
|
|
420
|
-
elif
|
|
421
|
-
|
|
422
|
-
|
|
486
|
+
elif isinstance(frame_index, int):
|
|
487
|
+
frame_index = [frame_index]
|
|
488
|
+
if len(frame_index) != nframes:
|
|
489
|
+
raise ValueError(f'Expected {nframes} frame_index values, but got {len(frame_index)}.')
|
|
423
490
|
|
|
424
491
|
# For each frame, create the annotations and upload the segmentations.
|
|
425
492
|
for fidx, f in zip(frame_index, fios):
|
|
426
|
-
task = self.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
493
|
+
task = self._upload_single_frame_segmentation_async(resource_id,
|
|
494
|
+
fio=f,
|
|
495
|
+
name=name,
|
|
496
|
+
frame_index=fidx,
|
|
497
|
+
imported_from=imported_from,
|
|
498
|
+
author_email=author_email,
|
|
499
|
+
discard_empty_segmentations=discard_empty_segmentations,
|
|
500
|
+
worklist_id=worklist_id,
|
|
501
|
+
model_id=model_id)
|
|
435
502
|
to_run.append(task)
|
|
436
503
|
|
|
504
|
+
loop = asyncio.get_event_loop()
|
|
437
505
|
ret = loop.run_until_complete(asyncio.gather(*to_run))
|
|
438
506
|
# merge the results in a single list
|
|
439
507
|
ret = [item for sublist in ret for item in sublist]
|
|
@@ -831,7 +899,7 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
831
899
|
|
|
832
900
|
Args:
|
|
833
901
|
resource_id (Optional[str]): The resource unique id.
|
|
834
|
-
annotation_type (
|
|
902
|
+
annotation_type (AnnotationType | str | None): The annotation type. See :class:`~datamint.dto.annotation_dto.AnnotationType`.
|
|
835
903
|
annotator_email (Optional[str]): The annotator email.
|
|
836
904
|
date_from (Optional[date]): The start date.
|
|
837
905
|
date_to (Optional[date]): The end date.
|
|
@@ -843,7 +911,6 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
843
911
|
Returns:
|
|
844
912
|
Generator[dict, None, None]: A generator of dictionaries with the annotations information.
|
|
845
913
|
"""
|
|
846
|
-
# TODO: create annotation_type enum
|
|
847
914
|
|
|
848
915
|
if annotation_type is not None and isinstance(annotation_type, AnnotationType):
|
|
849
916
|
annotation_type = annotation_type.value
|
|
@@ -962,40 +1029,61 @@ class AnnotationAPIHandler(BaseAPIHandler):
|
|
|
962
1029
|
self._run_request(request_params)
|
|
963
1030
|
|
|
964
1031
|
@staticmethod
|
|
965
|
-
def
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1032
|
+
def _get_segmentation_names_rgb(uniq_rgb_vals: list[tuple[int, int, int]],
|
|
1033
|
+
names: dict[tuple[int, int, int], str]
|
|
1034
|
+
) -> list[str]:
|
|
1035
|
+
"""
|
|
1036
|
+
Generate segmentation names for RGB combinations.
|
|
1037
|
+
|
|
1038
|
+
Args:
|
|
1039
|
+
uniq_rgb_vals: List of unique RGB combinations as (R,G,B) tuples
|
|
1040
|
+
names: Name mapping for RGB combinations
|
|
1041
|
+
|
|
1042
|
+
Returns:
|
|
1043
|
+
List of segmentation names
|
|
1044
|
+
"""
|
|
1045
|
+
result = []
|
|
1046
|
+
for rgb_tuple in uniq_rgb_vals:
|
|
1047
|
+
seg_name = names.get(rgb_tuple, names.get('default', f'seg_{"_".join(map(str, rgb_tuple))}'))
|
|
1048
|
+
if seg_name is None:
|
|
1049
|
+
if rgb_tuple[0] == rgb_tuple[1] and rgb_tuple[1] == rgb_tuple[2]:
|
|
1050
|
+
msg = f"Provide a name for {rgb_tuple} or {rgb_tuple[0]} or use 'default' key."
|
|
1051
|
+
else:
|
|
1052
|
+
msg = f"Provide a name for {rgb_tuple} or use 'default' key."
|
|
1053
|
+
raise ValueError(f"RGB combination {rgb_tuple} not found in names dictionary. " +
|
|
1054
|
+
msg)
|
|
1055
|
+
# If using default prefix, append RGB values
|
|
1056
|
+
# if rgb_tuple not in names and 'default' in names:
|
|
1057
|
+
# seg_name = f"{seg_name}_{'_'.join(map(str, rgb_tuple))}"
|
|
1058
|
+
result.append(seg_name)
|
|
1059
|
+
return result
|
|
983
1060
|
|
|
984
1061
|
@staticmethod
|
|
985
|
-
def
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1062
|
+
def _split_rgb_segmentations(img: np.ndarray,
|
|
1063
|
+
uniq_rgb_vals: list[tuple[int, int, int]]
|
|
1064
|
+
) -> Generator[BytesIO, None, None]:
|
|
1065
|
+
"""
|
|
1066
|
+
Split RGB segmentations into individual binary masks.
|
|
1067
|
+
|
|
1068
|
+
Args:
|
|
1069
|
+
img: RGB image array of shape (height, width, channels)
|
|
1070
|
+
uniq_rgb_vals: List of unique RGB combinations as (R,G,B) tuples
|
|
1071
|
+
|
|
1072
|
+
Yields:
|
|
1073
|
+
BytesIO objects containing individual segmentation masks
|
|
1074
|
+
"""
|
|
1075
|
+
for rgb_tuple in uniq_rgb_vals:
|
|
1076
|
+
# Create binary mask for this RGB combination
|
|
1077
|
+
rgb_array = np.array(rgb_tuple[:3]) # Ensure only R,G,B values
|
|
1078
|
+
mask = np.all(img[:, :, :3] == rgb_array, axis=2)
|
|
1079
|
+
|
|
1080
|
+
# Convert to uint8 and create PNG
|
|
1081
|
+
mask_img = (mask * 255).astype(np.uint8)
|
|
1082
|
+
|
|
1083
|
+
f_out = BytesIO()
|
|
1084
|
+
Image.fromarray(mask_img).convert('L').save(f_out, format='PNG')
|
|
1085
|
+
f_out.seek(0)
|
|
1086
|
+
yield f_out
|
|
999
1087
|
|
|
1000
1088
|
def delete_annotation(self, annotation_id: str | dict):
|
|
1001
1089
|
if isinstance(annotation_id, dict):
|
|
@@ -6,8 +6,8 @@ from requests.exceptions import HTTPError
|
|
|
6
6
|
import logging
|
|
7
7
|
import asyncio
|
|
8
8
|
import aiohttp
|
|
9
|
-
from
|
|
10
|
-
from
|
|
9
|
+
from medimgkit.dicom_utils import anonymize_dicom, to_bytesio, is_dicom
|
|
10
|
+
from medimgkit import dicom_utils
|
|
11
11
|
import pydicom
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from datetime import date
|
|
@@ -447,6 +447,8 @@ class RootAPIHandler(BaseAPIHandler):
|
|
|
447
447
|
for segfiles in segmentation_files]
|
|
448
448
|
|
|
449
449
|
for segfiles in segmentation_files:
|
|
450
|
+
if segfiles is None:
|
|
451
|
+
continue
|
|
450
452
|
if 'files' not in segfiles:
|
|
451
453
|
raise ValueError("segmentation_files must contain a 'files' key with a list of file paths.")
|
|
452
454
|
if 'names' in segfiles:
|