Skip to content

Commit cb3f8a1

Browse files
committed
Finishing implementation of slicing a VolumeDataset
1 parent 5a917b5 commit cb3f8a1

3 files changed

Lines changed: 123 additions & 23 deletions

File tree

datamint/dataset/annotation_processor.py

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,12 @@ def __init__(
5353
seglabel2code: dict[str, int],
5454
image_labels_set: list[str],
5555
image_lcodes: dict[str, dict[str, int]],
56+
allow_external_annotations: bool = False,
5657
):
5758
self.seglabel2code = seglabel2code
5859
self.image_labels_set = image_labels_set
5960
self.image_lcodes = image_lcodes
61+
self.allow_external_annotations = allow_external_annotations
6062

6163
def collate_frame_segmentations(self,
6264
fr_anns: Sequence['Annotation'],
@@ -67,7 +69,15 @@ def collate_frame_segmentations(self,
6769
for ann in fr_anns:
6870
try:
6971
seg = self.load_segmentation_data(ann)
70-
seg_code_i = self.seglabel2code.get(ann.identifier, 0)
72+
seg_code_i = self.seglabel2code.get(ann.identifier)
73+
if seg_code_i is None:
74+
if self.allow_external_annotations and ann.identifier:
75+
seg_code_i = max(self.seglabel2code.values(), default=0) + 1
76+
self.seglabel2code[ann.identifier] = seg_code_i
77+
_LOGGER.info(f"Dynamically added segmentation label '{ann.identifier}' with code {seg_code_i}")
78+
else:
79+
raise ValueError(f"Unknown segmentation label '{ann.identifier}' and external annotations are not allowed")
80+
7181
if seg_code != -1 and seg_code != seg_code_i:
7282
raise ValueError(f"Conflicting segmentation codes for frame annotations: "
7383
f"{seg_code} vs {seg_code_i}")
@@ -145,8 +155,17 @@ def load_image_segmentations(self,
145155
author = ann.created_by or ann.created_by_model or "unknown"
146156

147157
try:
158+
seg_code = self.seglabel2code.get(ann.identifier)
159+
if seg_code is None:
160+
if self.allow_external_annotations:
161+
seg_code = max(self.seglabel2code.values(), default=0) + 1
162+
self.seglabel2code[ann.identifier] = seg_code
163+
_LOGGER.info(f"Dynamically added segmentation label '{ann.identifier}' with code {seg_code}")
164+
else:
165+
raise ValueError(f"Segmentation annotation {ann.id} has unknown identifier "
166+
f"{ann.identifier} with no corresponding code in {self.seglabel2code=}")
148167
seg = self.load_segmentation_data(ann)
149-
seg_code = self.seglabel2code.get(ann.identifier, 0)
168+
150169
# seg shape: (#slices, H, W)
151170
except Exception as e:
152171
_LOGGER.error(f"Failed to load segmentation for annotation {ann.id}: {e}")
@@ -331,9 +350,20 @@ def convert_image_labels(
331350
Returns:
332351
Dict of annotator_id -> one-hot tensor of shape (num_labels,).
333352
"""
334-
labels_ret_size = (len(self.image_labels_set),)
335353
label2code = self.image_lcodes.get('multilabel', {})
336354

355+
# If allow_external_annotations, first pass to discover unknown labels
356+
if self.allow_external_annotations:
357+
for ann in annotations:
358+
if ann.annotation_type != 'label':
359+
continue
360+
if ann.identifier not in label2code:
361+
new_code = len(self.image_labels_set)
362+
self.image_labels_set.append(ann.identifier)
363+
label2code[ann.identifier] = new_code
364+
_LOGGER.info(f"Dynamically added image label '{ann.identifier}' with code {new_code}")
365+
366+
labels_ret_size = (len(self.image_labels_set),)
337367
labels_by_user: dict[str, torch.Tensor] = {}
338368

339369
for ann in annotations:
@@ -355,44 +385,36 @@ def apply_merge_strategy(
355385
self,
356386
segmentations: dict[str, Tensor],
357387
strategy: MergeStrategy,
358-
output_shape: tuple[int, ...] | None = None,
359388
) -> Tensor: ...
360389

361390
@overload
362391
def apply_merge_strategy(
363392
self,
364393
segmentations: dict[str, np.ndarray],
365394
strategy: MergeStrategy,
366-
output_shape: tuple[int, ...] | None = None,
367395
) -> np.ndarray: ...
368396

369397
def apply_merge_strategy(
370398
self,
371399
segmentations: dict[str, Tensor] | dict[str, np.ndarray],
372400
strategy: MergeStrategy,
373-
output_shape: tuple[int, ...] | None = None,
374401
) -> Tensor | np.ndarray:
375402
"""Merge semantic segmentations from multiple annotators.
376403
377404
Args:
378405
segmentations: Dict of author -> semantic segmentation tensor.
379-
output_shape: Shape for empty result if no segmentations are present.
380406
strategy: Merge strategy ('union', 'intersection', 'mode').
381407
382408
Returns:
383409
Merged tensor if strategy is specified, otherwise original dict.
384410
"""
385411
if len(segmentations) == 0:
386-
if output_shape is None:
387-
raise ValueError("output_shape must be provided when no segmentations are present")
388-
empty_segs = torch.zeros(output_shape, dtype=torch.get_default_dtype())
389-
empty_segs[0] = 1 # background
390-
return empty_segs
412+
raise ValueError("No segmentations to merge")
391413

392414
if isinstance(next(iter(segmentations.values())), np.ndarray):
393415
with torch.no_grad():
394416
segmentations = {author: torch.from_numpy(seg) for author, seg in segmentations.items()}
395-
return self.apply_merge_strategy(segmentations, strategy, output_shape).numpy()
417+
return self.apply_merge_strategy(segmentations, strategy).numpy()
396418

397419
if strategy == 'union':
398420
merged = self._merge_union(segmentations)

datamint/dataset/base.py

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,11 @@
1515
import numpy as np
1616
from datamint.apihandler.dto.annotation_dto import AnnotationType
1717
from datamint.exceptions import DatamintException
18-
from datamint.entities import Annotation
1918
from .annotation_processor import AnnotationProcessor, MergeStrategy
2019
from datamint.entities.annotations.annotation_spec import AnnotationSpec, CategoryAnnotationSpec
2120

2221
if 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:

datamint/dataset/sliced_dataset.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,8 @@ def __getitem__(self, index: int) -> dict[str, Any]:
417417
result['image'] = img
418418
sliced_segs = aug_result['segmentations']
419419

420-
segmentations_processed, seg_labels_out = self._process_segmentations(sliced_segs, seg_labels)
420+
segmentations_processed, seg_labels_out = self._process_segmentations(sliced_segs, seg_labels,
421+
output_shape=img.shape[1:])
421422
# remove temporary dummy dimension: (#instances, 1, DIM1, DIM2) -> (#instances, DIM1, DIM2)
422423
if isinstance(segmentations_processed, (Tensor, np.ndarray)):
423424
segmentations_processed = segmentations_processed.squeeze(1)

0 commit comments

Comments
 (0)