datamint 1.5.5__py3-none-any.whl → 1.6.2__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.

Potentially problematic release.


This version of datamint might be problematic. Click here for more details.

@@ -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 _numpy_to_bytesio_png(seg_imgs: np.ndarray) -> Generator[BinaryIO, None, None]:
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
- seg_img (np.ndarray): The segmentation image with dimensions (height, width, #frames).
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
- seg_imgs = seg_imgs.astype(np.uint8)
34
- for i in range(seg_imgs.shape[2]):
35
- img = seg_imgs[:, :, i]
36
- img = Image.fromarray(img).convert('L')
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
- img.save(img_bytes, format='PNG')
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
- segs_imgs = file_path # (#frames, height, width) or (height, width)
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
- segs_imgs = segs_imgs.transpose(1, 0, 2) if segs_imgs.ndim == 3 else segs_imgs.transpose(1, 0)
52
- nframes = segs_imgs.shape[2] if segs_imgs.ndim == 3 else 1
53
- fios = AnnotationAPIHandler._numpy_to_bytesio_png(segs_imgs)
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
- # The if is correct. The image is already in a different shape than nifty images.
60
- segs_imgs = segs_imgs.transpose(1, 0, 2) if segs_imgs.ndim == 3 else segs_imgs.transpose(1, 0)
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
- if transpose_segmentation:
66
- with Image.open(file_path) as img:
67
- segs_imgs = np.array(img).transpose(1, 0)
68
- fios = AnnotationAPIHandler._numpy_to_bytesio_png(segs_imgs)
69
- else:
70
- fios = (open(file_path, 'rb') for _ in range(1))
71
- nframes = 1
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: Optional[str | dict[int, str]] = None,
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 or a dictionary mapping pixel values to names.
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
- img = np.array(Image.open(fio))
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(uniq_vals) == 1 and uniq_vals[0] == 0:
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
- fio.seek(0)
133
- # TODO: Optimize this. It is not necessary to open the image twice.
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
- segnames = AnnotationAPIHandler._get_segmentation_names(uniq_vals, names=name)
136
- segs_generator = AnnotationAPIHandler._split_segmentations(img, uniq_vals, fio)
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] | None = None,
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
- form.add_field('segmentation_map', json.dumps(name), content_type='application/json')
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 | None = None,
252
- fio: IO | None = None,
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 is not None and (file_path.endswith('.nii') or file_path.endswith('.nii.gz')):
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
- if file_path is not None:
289
- # Handle volume upload
290
- if upload_volume:
291
- if frame_index is not None:
292
- _LOGGER.warning("frame_index parameter ignored when upload_volume=True")
293
-
294
- return await self._upload_volume_segmentation_async(
295
- resource_id=resource_id,
296
- file_path=file_path,
297
- name=name,
298
- imported_from=imported_from,
299
- author_email=author_email,
300
- worklist_id=worklist_id,
301
- model_id=model_id,
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
- if frame_index is None:
310
- frame_index = list(range(nframes))
311
-
312
- annotids = []
313
- for fidx, f in zip(frame_index, fios):
314
- frame_annotids = await self._upload_single_frame_segmentation_async(
315
- resource_id=resource_id,
316
- frame_index=fidx,
317
- fio=f,
318
- name=name,
319
- imported_from=imported_from,
320
- author_email=author_email,
321
- discard_empty_segmentations=discard_empty_segmentations,
322
- worklist_id=worklist_id,
323
- model_id=model_id
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=frame_index,
336
- fio=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
- raise ValueError("Either file_path or fio must be provided")
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: Optional[str | dict[int, str]] = None,
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 must have the shape (height, width, #frames) or (height, width).
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 (Optional[Union[str, Dict[int, str]]]): The name of the segmentation or a dictionary mapping pixel values to names.
368
- example: {1: 'Femur', 2: 'Tibia'}. For NIfTI files, only string names are supported.
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: The segmentation unique id.
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._upload_segmentations_async(
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
- loop = asyncio.get_event_loop()
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 len(frame_index) != nframes:
421
- raise ValueError("Do not provide frame_index for images of multiple frames.")
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._upload_segmentations_async(resource_id,
427
- fio=f,
428
- name=name,
429
- frame_index=fidx,
430
- imported_from=imported_from,
431
- author_email=author_email,
432
- discard_empty_segmentations=discard_empty_segmentations,
433
- worklist_id=worklist_id,
434
- model_id=model_id)
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 (Optional[str]): The annotation type. See :class:`~datamint.dto.annotation_dto.AnnotationType`.
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 _get_segmentation_names(uniq_vals: np.ndarray,
966
- names: Optional[str | dict[int, str]] = None
967
- ) -> list[str]:
968
- uniq_vals = uniq_vals[uniq_vals != 0]
969
- if names is None:
970
- names = 'seg'
971
- if isinstance(names, str):
972
- if len(uniq_vals) == 1:
973
- return [names]
974
- return [f'{names}_{v}' for v in uniq_vals]
975
- if isinstance(names, dict):
976
- for v in uniq_vals:
977
- new_name = names.get(v, names.get('default', None))
978
- if new_name is None:
979
- raise ValueError(f"Value {v} not found in names dictionary." +
980
- f" Provide a name for {v} or use 'default' key to provide a prefix.")
981
- return [names.get(v, names.get('default', '')+'_'+str(v)) for v in uniq_vals]
982
- raise ValueError("names must be a string or a dictionary.")
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 _split_segmentations(img: np.ndarray,
986
- uniq_vals: np.ndarray,
987
- f: IO,
988
- ) -> Generator[BytesIO, None, None]:
989
- # remove zero from uniq_vals
990
- uniq_vals = uniq_vals[uniq_vals != 0]
991
-
992
- for v in uniq_vals:
993
- img_v = (img == v).astype(np.uint8)
994
-
995
- f = BytesIO()
996
- Image.fromarray(img_v*255).convert('RGB').save(f, format='PNG')
997
- f.seek(0)
998
- yield f
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):
@@ -18,7 +18,7 @@ import json
18
18
  from typing import Any, TypeAlias, Literal
19
19
  import logging
20
20
  from enum import Enum
21
- from datamint.utils.dicom_utils import pixel_to_patient
21
+ from medimgkit.dicom_utils import pixel_to_patient
22
22
  import pydicom
23
23
  import numpy as np
24
24
 
@@ -6,8 +6,8 @@ from requests.exceptions import HTTPError
6
6
  import logging
7
7
  import asyncio
8
8
  import aiohttp
9
- from datamint.utils.dicom_utils import anonymize_dicom, to_bytesio, is_dicom
10
- from datamint.utils import dicom_utils
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: