1515import numpy as np
1616from datamint .apihandler .dto .annotation_dto import AnnotationType
1717from datamint .exceptions import DatamintException
18- from datamint .entities import Annotation
1918from .annotation_processor import AnnotationProcessor , MergeStrategy
2019from datamint .entities .annotations .annotation_spec import AnnotationSpec , CategoryAnnotationSpec
2120
2221if TYPE_CHECKING :
23- from datamint .entities import Resource , Project
22+ from datamint .entities import Resource , Project , Annotation
2423
2524_LOGGER = logging .getLogger (__name__ )
2625
@@ -61,10 +60,14 @@ class DatamintBaseDataset(ABC):
6160 exclude_image_label_names: Blacklist of image labels.
6261 include_frame_label_names: Whitelist of frame labels.
6362 exclude_frame_label_names: Blacklist of frame labels.
63+ allow_external_annotations: If True, allow and automatically include annotation
64+ labels that are not part of the project's official schema (e.g., labels
65+ from other projects or legacy annotations). If False, these annotations
66+ will be filtered out.
6467 """
6568
6669 resources : Sequence ['Resource' ]
67- resource_annotations : list [Sequence [Annotation ]]
70+ resource_annotations : list [Sequence [' Annotation' ]]
6871 project : 'Project | None'
6972
7073 def __init__ (
@@ -89,6 +92,7 @@ def __init__(
8992 exclude_image_label_names : list [str ] | None = None ,
9093 include_frame_label_names : list [str ] | None = None ,
9194 exclude_frame_label_names : list [str ] | None = None ,
95+ allow_external_annotations : bool = False ,
9296 ):
9397 from datamint import Api
9498 # Validate mutually exclusive parameters
@@ -155,6 +159,7 @@ def __init__(
155159 self .exclude_image_label_names = exclude_image_label_names
156160 self .include_frame_label_names = include_frame_label_names
157161 self .exclude_frame_label_names = exclude_frame_label_names
162+ self .allow_external_annotations = allow_external_annotations
158163
159164 # Internal state
160165 self ._logged_uint16_conversion = False
@@ -164,7 +169,7 @@ def __init__(
164169
165170 def _extract_image_labels (
166171 self ,
167- annotations : Sequence [Annotation ],
172+ annotations : Sequence [' Annotation' ],
168173 ) -> dict [str , torch .Tensor ]:
169174 """Extract image-level label annotations.
170175
@@ -263,12 +268,51 @@ def _setup_labels(self) -> None:
263268 self .seglabel_list , self .seglabel2code = self ._process_segmentation_group (
264269 worklist_schema ['segmentation_group' ]
265270 )
271+
272+ if self .allow_external_annotations :
273+ self ._augment_labels_from_annotations ()
266274 else :
267275 _LOGGER .info ("No project provided; inferring labels from annotations." )
268276 self .frame_lsets , self .frame_lcodes = self ._infer_labels_set (framed = True )
269277 self .image_lsets , self .image_lcodes = self ._infer_labels_set (framed = False )
270278 self .seglabel_list , self .seglabel2code = self ._infer_segmentation_group ()
271279
280+ def _augment_labels_from_annotations (self ) -> None :
281+ """Augment project-defined label sets with identifiers found in actual annotations.
282+
283+ Scans resource annotations for identifiers not present in the project's
284+ annotations_specs and adds them to the corresponding label/segmentation mappings.
285+ """
286+ inferred_frame_lsets , inferred_frame_lcodes = self ._infer_labels_set (framed = True )
287+ inferred_image_lsets , inferred_image_lcodes = self ._infer_labels_set (framed = False )
288+ inferred_seglabel_list , _ = self ._infer_segmentation_group ()
289+
290+ # Augment frame labels
291+ for kind in ('multilabel' , 'multiclass' ):
292+ existing = set (self .frame_lsets [kind ])
293+ new_labels = sorted ([label for label in inferred_frame_lsets [kind ] if label not in existing ])
294+ for label in new_labels :
295+ _LOGGER .info (f"Allowing external frame label '{ label } ' not in project specs." )
296+ self .frame_lsets [kind ].append (label )
297+ self .frame_lcodes [kind ] = self .__build_label_codemap (self .frame_lsets [kind ])
298+
299+ # Augment image labels
300+ for kind in ('multilabel' , 'multiclass' ):
301+ existing = set (self .image_lsets [kind ])
302+ new_labels = sorted ([label for label in inferred_image_lsets [kind ] if label not in existing ])
303+ for label in new_labels :
304+ _LOGGER .info (f"Allowing external image label '{ label } ' not in project specs." )
305+ self .image_lsets [kind ].append (label )
306+ self .image_lcodes [kind ] = self .__build_label_codemap (self .image_lsets [kind ])
307+
308+ # Augment segmentation labels
309+ existing_segs = set (self .seglabel_list )
310+ new_segs = sorted ([label for label in inferred_seglabel_list if label not in existing_segs ])
311+ for label in new_segs :
312+ _LOGGER .info (f"Allowing external segmentation label '{ label } ' not in project specs." )
313+ self .seglabel_list .append (label )
314+ self .seglabel2code [label ] = len (self .seglabel_list ) # 1-based code
315+
272316 def _setup_annotation_processor (self ) -> None :
273317 """Initialize the annotation processor.
274318
@@ -279,6 +323,7 @@ def _setup_annotation_processor(self) -> None:
279323 seglabel2code = self .seglabel2code ,
280324 image_labels_set = self .image_labels_set ,
281325 image_lcodes = self .image_lcodes ,
326+ allow_external_annotations = self .allow_external_annotations ,
282327 )
283328
284329 def _apply_annotation_filters (self ) -> None :
@@ -312,11 +357,11 @@ def _filter_unannotated(self) -> None:
312357 self .resources = filtered_resources
313358 self .resource_annotations = filtered_annotations
314359
315- def _filter_annotations (self , annotations : Sequence [Annotation ]) -> list [Annotation ]:
360+ def _filter_annotations (self , annotations : Sequence [' Annotation' ]) -> list [' Annotation' ]:
316361 """Filter annotations based on include/exclude settings."""
317362 return [ann for ann in annotations if self ._should_include_annotation (ann )]
318363
319- def _should_include_annotation (self , ann : Annotation ) -> bool :
364+ def _should_include_annotation (self , ann : ' Annotation' ) -> bool :
320365 """Check if annotation should be included."""
321366 # Check annotator
322367 annotator = ann .created_by
@@ -331,6 +376,12 @@ def _should_include_annotation(self, ann: Annotation) -> bool:
331376 return self ._should_include_image_label (ann .identifier )
332377 else : # frame-level
333378 return self ._should_include_frame_label (ann .identifier )
379+ elif ann .annotation_type == 'category' :
380+ if not self .allow_external_annotations :
381+ lsets = self .image_lsets if ann .frame_index is None else self .frame_lsets
382+ valid_identifiers = {ident for ident , _ in lsets .get ('multiclass' , [])}
383+ if ann .identifier not in valid_identifiers :
384+ return False
334385
335386 return True
336387
@@ -342,20 +393,26 @@ def _should_include_annotator(self, annotator_id: str) -> bool:
342393 return True
343394
344395 def _should_include_segmentation (self , name : str ) -> bool :
396+ if not self .allow_external_annotations and name not in self .segmentation_labels_set :
397+ return False
345398 if self .include_segmentation_names is not None :
346399 return name in self .include_segmentation_names
347400 if self .exclude_segmentation_names is not None :
348401 return name not in self .exclude_segmentation_names
349402 return True
350403
351404 def _should_include_image_label (self , name : str ) -> bool :
405+ if not self .allow_external_annotations and name not in self .image_labels_set :
406+ return False
352407 if self .include_image_label_names is not None :
353408 return name in self .include_image_label_names
354409 if self .exclude_image_label_names is not None :
355410 return name not in self .exclude_image_label_names
356411 return True
357412
358413 def _should_include_frame_label (self , name : str ) -> bool :
414+ if not self .allow_external_annotations and name not in self .frame_labels_set :
415+ return False
359416 if self .include_frame_label_names is not None :
360417 return name in self .include_frame_label_names
361418 if self .exclude_frame_label_names is not None :
@@ -505,7 +562,16 @@ def _preprocess_image_array(self, img: np.ndarray) -> np.ndarray:
505562
506563 def _process_segmentations (self ,
507564 segmentations : dict ,
508- seg_labels : dict ) -> tuple [Tensor | np .ndarray | dict , dict | None ]:
565+ seg_labels : dict ,
566+ output_shape : tuple | None = None ) -> tuple [Tensor | np .ndarray | dict , dict | None ]:
567+ """
568+ Process segmentations by optionally converting to semantic format and applying merge strategy.
569+
570+ Args:
571+ segmentations: Dict of annotator_id -> segmentation array (num_instances, depth, H, W).
572+ seg_labels: Dict of annotator_id -> list of label names corresponding to each instance in the segmentation array.
573+ output_shape: Fallback output shape to use when outputting as semantic segmentation and no segmentations are present to infer from. Should NOT have the number of classes dimension. Example: (depth, H, W)
574+ """
509575 # segmentations['author'] shape: (#instances, depth, H, W)
510576 if self .return_as_semantic_segmentation :
511577 sem_segs = {}
@@ -518,12 +584,22 @@ def _process_segmentations(self,
518584 _LOGGER .debug (
519585 f'Converted to semantic segmentation. Shapes: { [segmentations [a ].shape for a in segmentations ]} ' )
520586 if self .semantic_seg_merge_strategy :
521- if segmentations :
587+ if len ( segmentations ) > 0 :
522588 segmentations = self .annotation_processor .apply_merge_strategy (
523589 segmentations ,
524590 strategy = self .semantic_seg_merge_strategy
525591 )
526592 _LOGGER .debug (f"Merged segmentation shape: { segmentations .shape } " )
593+ else :
594+ if output_shape is None :
595+ raise ValueError ("output_shape must be provided when no segmentations are present"
596+ " to infer shape from." )
597+ # Create empty semantic segmentation with just background class
598+ segmentations = torch .zeros ((len (self .segmentation_labels_set ), * output_shape ),
599+ dtype = torch .get_default_dtype ())
600+ segmentations [0 ] = 1 # background
601+ _LOGGER .debug ("No segmentations found. "
602+ f"Created empty semantic segmentation with shape: { segmentations .shape } " )
527603
528604 # In semantic format, we don't need `seg_labels`, as the label info is at the new dimension (axis=0) of the semantic segmentation array.
529605 seg_labels = None
@@ -560,7 +636,8 @@ def __getitem__(self, index: int) -> dict[str, Any]:
560636 _LOGGER .debug (
561637 f"Applied albumentations transform. Image shape: { img .shape } and segs shape: { [segmentations [a ].shape for a in segmentations ]} " )
562638
563- segmentations , seg_labels = self ._process_segmentations (segmentations , seg_labels )
639+ segmentations , seg_labels = self ._process_segmentations (segmentations , seg_labels ,
640+ output_shape = img .shape [1 :])
564641
565642 result ['segmentations' ] = segmentations
566643 if seg_labels :
0 commit comments