From cd2107fdad105f6f180f969a3626edf9ecc8e869 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 2 Feb 2026 15:56:04 -0300 Subject: [PATCH 01/47] Better error handling in BaseApi when streamlining ResourceNotFoundError raising --- datamint/api/base_api.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index 28669a67..82e8bee5 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -82,7 +82,6 @@ def __init__(self, self._aiohttp_connector: aiohttp.TCPConnector | None = None self._aiohttp_session: aiohttp.ClientSession | None = None ensure_asyncio_loop() - @staticmethod def _create_client(config: ApiConfig) -> httpx.Client: @@ -401,12 +400,11 @@ def _check_errors_response_httpx(self, logger.debug("Unable to set message attribute on exception") pass - logger.error(f"HTTP error {response.status_code} for {url}: {error_msg}") status_code = response.status_code - if status_code in (400, 404): - if ' not found' in error_msg.lower() or 'Not Found' in error_msg: - # Will be caught by the caller and properly initialized: - raise ResourceNotFoundError('unknown', {}) + if status_code in (400, 404) and (' not found' in error_msg.lower() or 'Not Found' in error_msg): + # Will be caught by the caller and properly initialized: + raise ResourceNotFoundError('unknown', {}) + logger.error(f"HTTP error {response.status_code} for {url}: {error_msg}") raise return response_json From 66c8f245e527c6b012e82275ed758b45c77e35b2 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 2 Feb 2026 16:29:00 -0300 Subject: [PATCH 02/47] Implement new modular dataset classes: ImageDataset and VolumeDataset; deprecate legacy DatamintBaseDataset and DatamintDataset --- datamint/__init__.py | 14 +- datamint/dataset/__init__.py | 25 +- datamint/dataset/annotation_processor.py | 521 +++++++++++++++++++ datamint/dataset/base.py | 606 +++++++++++++++++++++++ datamint/dataset/base_dataset.py | 4 + datamint/dataset/dataset.py | 8 +- datamint/dataset/image_dataset.py | 216 ++++++++ datamint/dataset/volume_dataset.py | 111 +++++ datamint/entities/project.py | 2 +- 9 files changed, 1500 insertions(+), 7 deletions(-) create mode 100644 datamint/dataset/annotation_processor.py create mode 100644 datamint/dataset/base.py create mode 100644 datamint/dataset/image_dataset.py create mode 100644 datamint/dataset/volume_dataset.py diff --git a/datamint/__init__.py b/datamint/__init__.py index 0a930e62..ea45de34 100644 --- a/datamint/__init__.py +++ b/datamint/__init__.py @@ -5,20 +5,28 @@ import importlib.metadata from typing import TYPE_CHECKING if TYPE_CHECKING: + # Legacy from .dataset.dataset import DatamintDataset as Dataset - from .apihandler.api_handler import APIHandler + from .api.client import Api + # New modular datasets + from .dataset.image_dataset import ImageDataset + from .dataset.volume_dataset import VolumeDataset + else: import lazy_loader as lazy __getattr__, __dir__, __all__ = lazy.attach( __name__, - submodules=['dataset', "dataset.dataset", "apihandler.api_handler"], + submodules=['dataset', "dataset.dataset"], submod_attrs={ + # Legacy exports "dataset.dataset": ["DatamintDataset"], "dataset": ['Dataset'], - "apihandler.api_handler": ["APIHandler"], "api.client": ["Api"], + # New modular dataset classes + "dataset.image_dataset": ["ImageDataset"], + "dataset.volume_dataset": ["VolumeDataset"], }, ) diff --git a/datamint/dataset/__init__.py b/datamint/dataset/__init__.py index 08ac2d16..14a7db11 100644 --- a/datamint/dataset/__init__.py +++ b/datamint/dataset/__init__.py @@ -1 +1,24 @@ -from .dataset import DatamintDataset as Dataset \ No newline at end of file +""" +Datamint Dataset module. + +Provides specialized dataset classes for different medical imaging modalities: +- ImageDataset: 2D images (X-rays, pathology, single-frame DICOM) +- VideoDataset: Temporal sequences (videos, multi-frame DICOM) +- VolumetricDataset: 3D volumes (NIfTI, CT, MRI) + +Use `create_dataset()` for automatic type detection, or instantiate directly. +""" + +# New modular architecture +from .base import DatamintBaseDataset, DatamintDatasetException +from .image_dataset import ImageDataset +from .volume_dataset import VolumeDataset + +__all__ = [ + # Core + 'DatamintBaseDataset', + 'DatamintDatasetException', + # Specialized datasets + 'ImageDataset', + 'VolumeDataset', +] \ No newline at end of file diff --git a/datamint/dataset/annotation_processor.py b/datamint/dataset/annotation_processor.py new file mode 100644 index 00000000..a5cb1d8b --- /dev/null +++ b/datamint/dataset/annotation_processor.py @@ -0,0 +1,521 @@ +""" +AnnotationProcessor - Handles segmentation and label processing. + +This module provides annotation processing classes for different dataset types: +- BaseAnnotationProcessor: Generic processor with shared logic for all dataset types +- ImageAnnotationProcessor: Processor for simple 2D images (no frame/slot concept) +- SequenceAnnotationProcessor: Extended processor for multi-frame/multi-slot data (videos, volumes) + +The class hierarchy ensures that the base class contains only generic logic that works +for any dataset type, while specialized logic is in subclasses. +""" +import logging +from typing import Literal, TYPE_CHECKING +from collections.abc import Iterable, Sequence +from collections import defaultdict + +import numpy as np +import torch +from torch import Tensor +from typing_extensions import overload + +from medimgkit.readers import read_array_normalized + +if TYPE_CHECKING: + from datamint.entities.annotations.annotation import Annotation + +_LOGGER = logging.getLogger(__name__) + +# Type alias for merge strategy +MergeStrategy = Literal['union', 'intersection', 'mode'] + + +class AnnotationProcessor: + """Base processor for annotations - contains only generic shared logic. + + This class provides generic annotation processing that works for any dataset type: + - Loading segmentation data from annotations (raw load, no frame handling) + - Generic merging strategies for semantic segmentations + - Label name conversion utilities + - Annotation filtering utilities + + Subclasses (ImageAnnotationProcessor, SequenceAnnotationProcessor) handle + dataset-specific logic like frame/slot assignment and dimension handling. + + Args: + seglabel2code: Mapping from label name to code. + image_labels_set: List of image-level label names. + image_lcodes: Mapping for image labels. + """ + + def __init__( + self, + seglabel2code: dict[str, int], + image_labels_set: list[str], + image_lcodes: dict[str, dict[str, int]], + ): + self.seglabel2code = seglabel2code + self.image_labels_set = image_labels_set + self.image_lcodes = image_lcodes + + def collate_frame_segmentations(self, + fr_anns: Sequence['Annotation'], + depth: int | None = None) -> tuple[np.ndarray | None, int]: + stacked_seg = None + seg_code = -1 + # sort frame annotations by frame index + for ann in fr_anns: + try: + seg = self.load_segmentation_data(ann) + seg_code_i = self.seglabel2code.get(ann.identifier, 0) + _LOGGER.debug(f'Processing frame annotation {ann.id} at index {ann.frame_index} with shape {seg.shape}') + if seg_code != -1 and seg_code != seg_code_i: + raise ValueError(f"Conflicting segmentation codes for frame annotations: " + f"{seg_code} vs {seg_code_i}") + seg_code = seg_code_i + # seg shape: (1, H, W) + seg = seg[0] # -> (H, W) + if stacked_seg is None: + if depth is None: + depth = ann.resource.get_depth() + stacked_seg = np.zeros((depth, *seg.shape), dtype=bool) + if ann.frame_index is None: + raise ValueError(f"Frame-level annotation {ann.id} missing frame_index") + stacked_seg[ann.frame_index] = seg + except Exception as e: + _LOGGER.error(f"Failed to load segmentation for annotation {ann.id}: {e}") + raise + return stacked_seg, seg_code + + def group_annotations(self, + annotations: Iterable['Annotation'], + by_author: bool = False, + by_identifier: bool = False, + ) -> dict[tuple, list['Annotation']]: + """Group annotations by author and/or identifier. + + Args: + annotations: Iterable of Annotation objects. + by_author: If True, group by author. + by_identifier: If True, group by identifier. + + Returns: + Dict mapping grouping keys to lists of annotations. + """ + + if not by_author and not by_identifier: + raise ValueError("At least one of grouping criteria must be True") + + seg_frame_anns_map = {} + for ann in annotations: + key_parts = [] + if by_author: + author = ann.created_by or "unknown" + key_parts.append(author) + if by_identifier: + identifier = ann.identifier + key_parts.append(identifier) + + key = tuple(key_parts) + if key not in seg_frame_anns_map: + seg_frame_anns_map[key] = [] + seg_frame_anns_map[key].append(ann) + + return seg_frame_anns_map + + def load_image_segmentations(self, + annotations: Iterable['Annotation'] + ) -> tuple[dict[str, list], dict[str, list], dict[str, list]]: + """Load segmentations defined at image scope. + Args: + annotations: Iterable of Annotation objects (segmentation type). + Returns: + Tuple of (segmentations, seg_labels, seg_anns): + - segmentations: dict[author -> list of mask arrays of shape (#slices, H, W)] + - seg_labels: dict[author -> list of int codes] + - seg_anns: dict[author -> list of Annotation objects] + """ + + segmentations = defaultdict(list) + seg_labels = defaultdict(list) + seg_anns = defaultdict(list) + + seg_image_annotations = [ann for ann in annotations + if ann.scope == 'image' and ann.annotation_type == 'segmentation'] + for ann in seg_image_annotations: + author = ann.created_by or ann.created_by_model or "unknown" + + try: + seg = self.load_segmentation_data(ann) + seg_code = self.seglabel2code.get(ann.identifier, 0) + # seg shape: (#slices, H, W) + except Exception as e: + _LOGGER.error(f"Failed to load segmentation for annotation {ann.id}: {e}") + raise + + if ann.frame_index is None: + segmentations[author].append(seg) + else: + raise ValueError(f"unexpected scope/index combo for annotation {ann.id} " + f"(scope={ann.scope}, frame_index={ann.frame_index})") + seg_labels[author].append(seg_code) + seg_anns[author].append(ann) + + return segmentations, seg_labels, seg_anns + + def load_frame_segmentations( + self, + annotations: Iterable['Annotation'], + ) -> tuple[dict[str, list], dict[str, list], dict[str, list]]: + """Load frame-level segmentations + + Args: + annotations: Iterable of Annotation objects (segmentation type). + Returns: + Tuple of (segmentations, seg_labels, seg_metainfos): + - segmentations: dict[author -> list of np.ndarray of shape (#num_instances, #frames, H, W)] + - seg_labels: dict[author -> list of int codes] + - seg_anns: dict[author -> list of list of Annotation objects] + """ + annotations = [ann for ann in annotations + if ann.scope == 'frame' and ann.annotation_type == 'segmentation'] + seg_frame_anns_map = self.group_annotations( + annotations, + by_author=True, + by_identifier=True, + ) + + segmentations = defaultdict(list) + seg_labels = defaultdict(list) + seg_anns = defaultdict(list) + + _LOGGER.debug( + f"Found {len(seg_frame_anns_map)} unique (author, identifier) groups for frame-level segmentations") + + for (author, identifier), fr_anns in seg_frame_anns_map.items(): + stacked_seg, seg_code = self.collate_frame_segmentations(fr_anns) + if stacked_seg is None: + continue + + segmentations[author].append(stacked_seg) + seg_labels[author].append(seg_code) + seg_anns[author].append(fr_anns) + + return segmentations, seg_labels, seg_anns + + def _stack_segmentations(self, + segmentations: dict[str, list[np.ndarray]], + seg_labels: dict[str, list[int]]): + # Stack per-author segmentations + final_segmentations: dict[str, np.ndarray] = {} + final_seg_labels: dict[str, np.ndarray] = {} + for author in segmentations: + _LOGGER.debug(f"Author {author} has {len(segmentations[author])} segmentations to stack") + final_segmentations[author] = np.stack(segmentations[author], axis=0) # (#num_instances, Z, H, W) + final_seg_labels[author] = np.array(seg_labels[author], dtype=np.int32) + + return final_segmentations, final_seg_labels + + def load_segmentations( + self, + annotations: Iterable['Annotation'] + ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray], dict[str, list]]: + """Load segmentations for multi-slot data (videos, volumes). + + Args: + annotations: Iterable of Annotation objects (segmentation type). + + Returns: + Tuple of (segmentations, seg_labels, seg_metainfos): + - segmentations: dict[author -> np.ndarray of shape (#num_instances, depth or #slices or #frames, H, W)] + - seg_labels: dict[author -> np.ndarray of #num_instances ints] + - seg_metainfos: dict[author -> list of Annotation objects] + """ + + seg_annotations = [ann for ann in annotations if ann.annotation_type == 'segmentation'] + uniq_authors = set(ann.created_by or ann.created_by_model or "unknown" for ann in seg_annotations) + segmentations: dict[str, list[np.ndarray]] = {a: [] + for a in uniq_authors} # tensors of shape (D, H, W) + seg_labels: dict[str, list[int]] = {a: [] for a in uniq_authors} # list of size=#num_instances + seg_metainfos: dict[str, list] = {a: [] for a in uniq_authors} + + # group segmentations by author, identifier and filtered by scope + fsegs, fseg_labels, fseg_anns = self.load_frame_segmentations(seg_annotations) + for author in fsegs: + segmentations[author].extend(fsegs[author]) + seg_labels[author].extend(fseg_labels[author]) + seg_metainfos[author].extend(fseg_anns[author]) + + isegs, iseg_labels, iseg_anns = self.load_image_segmentations(seg_annotations) + for author in isegs: + segmentations[author].extend(isegs[author]) + seg_labels[author].extend(iseg_labels[author]) + seg_metainfos[author].extend(iseg_anns[author]) + + final_segmentations, final_seg_labels = self._stack_segmentations(segmentations, seg_labels) + + assert len(final_segmentations) == len(final_seg_labels) + for author in final_segmentations: + assert final_segmentations[author].shape[0] == final_seg_labels[author].shape[0] + + return final_segmentations, final_seg_labels, seg_metainfos + + def load_segmentation_data(self, ann: 'Annotation', + auto_convert_gray: bool = True) -> np.ndarray: + """Load segmentation data from an annotation. + + Args: + ann: The annotation to load data from. + auto_convert_gray: If True, convert multi-channel grayscale to single channel. + + Returns: + np.ndarray: Binary segmentation array with shape (N, H, W). + For image-level: N=#frames or #slices or depth + For frame-level: N=1 + """ + if ann.type != 'segmentation': + raise ValueError(f"Annotation {ann.id} is not a segmentation") + + ann_data_bytes = ann.fetch_file_data(use_cache=True, auto_convert=False) + ann_data_array = read_array_normalized(ann_data_bytes) + + # Validate shape based on scope + if len(ann_data_array.shape) != 4: + raise ValueError( + f"Segmentation annotation {ann.id} has invalid shape " + f"{ann_data_array.shape}, expected 4D (N, C, H, W)" + ) + + if auto_convert_gray and np.allclose(ann_data_array[:, 0:1, :, :], ann_data_array[:, 1:3, :, :]): + if ann_data_array.shape[1] == 4: + _LOGGER.debug('RGBA detected. Ignoring alpha channel for annotation') + ann_data_array = ann_data_array[:, 0:1, :, :] # (N, 1, H, W) + + _LOGGER.debug( + f"Loaded segmentation for annotation {ann.id} " + f"with shape {ann_data_array.shape}" + ) + + # Validate and extract single channel + if ann_data_array.shape[1] != 1: + raise ValueError(f"Segmentation must have 1 channel, got shape {ann_data_array.shape}") + ann_data_array = ann_data_array[:, 0, :, :] # (N, H, W) + return ann_data_array != 0 # binary mask + + def _merge_union(self, segmentations: dict[str, Tensor]) -> Tensor: + """Union merge: pixel is labeled if ANY annotator labeled it.""" + new_segmentations = torch.zeros_like(list(segmentations.values())[0]) + for seg in segmentations.values(): + new_segmentations += seg + return new_segmentations.bool() + + def _merge_intersection(self, segmentations: dict[str, Tensor]) -> Tensor: + """Intersection merge: pixel is labeled if ALL annotators labeled it.""" + new_segmentations = torch.ones_like(list(segmentations.values())[0]) + for seg in segmentations.values(): + new_segmentations *= seg + return new_segmentations.bool() + + def _merge_mode(self, segmentations: dict[str, Tensor]) -> Tensor: + """Mode merge: pixel is labeled if majority of annotators labeled it.""" + new_segmentations = torch.zeros_like(list(segmentations.values())[0]) + for seg in segmentations.values(): + new_segmentations += seg + new_segmentations = new_segmentations >= len(segmentations) / 2 + return new_segmentations + + def convert_image_labels( + self, + annotations: Sequence['Annotation'], + ) -> dict[str, torch.Tensor]: + """Convert image-level label annotations to one-hot tensors. + + Args: + annotations: List of label annotations (image-scoped). + + Returns: + Dict of annotator_id -> one-hot tensor of shape (num_labels,). + """ + labels_ret_size = (len(self.image_labels_set),) + label2code = self.image_lcodes.get('multilabel', {}) + + labels_by_user: dict[str, torch.Tensor] = {} + + for ann in annotations: + if ann.annotation_type != 'label': + continue + + user_id = ann.created_by or "unknown" + if user_id not in labels_by_user: + labels_by_user[user_id] = torch.zeros(size=labels_ret_size, dtype=torch.int32) + + code = label2code.get(ann.identifier) + if code is not None: + labels_by_user[user_id][code] = 1 + + return labels_by_user + + @overload + def apply_merge_strategy( + self, + segmentations: dict[str, Tensor], + strategy: MergeStrategy, + output_shape: tuple[int, ...] | None = None, + ) -> Tensor: ... + + @overload + def apply_merge_strategy( + self, + segmentations: dict[str, np.ndarray], + strategy: MergeStrategy, + output_shape: tuple[int, ...] | None = None, + ) -> np.ndarray: ... + + def apply_merge_strategy( + self, + segmentations: dict[str, Tensor] | dict[str, np.ndarray], + strategy: MergeStrategy, + output_shape: tuple[int, ...] | None = None, + ) -> Tensor | np.ndarray: + """Merge semantic segmentations from multiple annotators. + + Args: + segmentations: Dict of author -> semantic segmentation tensor. + output_shape: Shape for empty result if no segmentations are present. + strategy: Merge strategy ('union', 'intersection', 'mode'). + + Returns: + Merged tensor if strategy is specified, otherwise original dict. + """ + if len(segmentations) == 0: + if output_shape is None: + raise ValueError("output_shape must be provided when no segmentations are present") + empty_segs = torch.zeros(output_shape, dtype=torch.get_default_dtype()) + empty_segs[0] = 1 # background + return empty_segs + + if isinstance(next(iter(segmentations.values())), np.ndarray): + with torch.no_grad(): + segmentations = {author: torch.from_numpy(seg) for author, seg in segmentations.items()} + return self.apply_merge_strategy(segmentations, strategy, output_shape).numpy() + + _LOGGER.debug( + f"Applying merge strategy '{strategy}' to {len(segmentations)} segmentations of type {type(next(iter(segmentations.values())))}") + if strategy == 'union': + merged = self._merge_union(segmentations) + elif strategy == 'intersection': + merged = self._merge_intersection(segmentations) + elif strategy == 'mode': + merged = self._merge_mode(segmentations) + else: + raise ValueError(f"Unknown merge strategy: {strategy}") + + return merged.to(torch.get_default_dtype()) + + @overload + def instance_to_semantic_segmentation( + self, + segmentations: None, + seg_labels: Tensor | np.ndarray, + num_labels: int + ) -> None: ... + + @overload + def instance_to_semantic_segmentation( + self, + segmentations: Tensor, + seg_labels: Tensor, + num_labels: int + ) -> Tensor: ... + + @overload + def instance_to_semantic_segmentation( + self, + segmentations: np.ndarray, + seg_labels: np.ndarray, + num_labels: int + ) -> np.ndarray: ... + + def instance_to_semantic_segmentation( + self, + segmentations: Tensor | np.ndarray | None, + seg_labels: Tensor | np.ndarray, + num_labels: int + ) -> Tensor | np.ndarray | None: + """Convert instance segmentation to semantic segmentation for a sequence. + + Args: + segmentations: Tensor/array of shape (num_instances, depth, H, W). + seg_labels: Tensor/array of shape (num_instances,). + + Returns: + If segmentations is a Sequence: Tensor/array of shape (num_labels+1, depth, H, W); + If segmentations is None: None; + If segmentations is a Tensor/array: Tensor/array of shape (num_labels+1, depth, H, W). + """ + if segmentations is None: + return None + + if isinstance(segmentations, np.ndarray): + with torch.no_grad(): + segmentations = torch.from_numpy(segmentations) + seg_labels = torch.from_numpy(seg_labels) + return self.instance_to_semantic_segmentation(segmentations, seg_labels, num_labels).numpy() + + if len(segmentations) != len(seg_labels): + raise ValueError("segmentations and seg_labels must have the same length") + + if len(segmentations) == 0: + return torch.zeros((num_labels + 1, 0, 0, 0), dtype=torch.float32) + + depth, h, w = segmentations[0].shape + + semantic_seg = torch.zeros((num_labels + 1, depth, h, w), dtype=torch.uint8) + + for instance_idx in range(len(segmentations)): + instance_seg = segmentations[instance_idx] + instance_label = seg_labels[instance_idx].item() + if instance_label == 0: + raise ValueError(f"Instance {instance_idx} has label code 0 (background)") + + # Union + semantic_seg[instance_label] = torch.logical_or( + semantic_seg[instance_label], + instance_seg + ) + + # Background: pixels not in any segmentation + semantic_seg[0] = semantic_seg.sum(dim=0) == 0 + return semantic_seg.float() + + @staticmethod + def filter_annotations( + annotations: Sequence['Annotation'], + type: Literal['label', 'category', 'segmentation', 'all'] = 'all', + scope: Literal['frame', 'image', 'all'] = 'all', + ) -> list['Annotation']: + """Filter annotations by type and scope. + + Args: + annotations: List of annotations. + type: Filter by annotation type. + scope: Filter by scope (frame/image). + + Returns: + Filtered list of annotations. + """ + if type not in ['label', 'category', 'segmentation', 'all']: + raise ValueError(f"Invalid type: {type}") + if scope not in ['frame', 'image', 'all']: + raise ValueError(f"Invalid scope: {scope}") + + filtered = [] + for ann in annotations: + ann_scope = 'image' if ann.frame_index is None else 'frame' + type_matches = type == 'all' or ann.annotation_type == type + scope_matches = scope == 'all' or scope == ann_scope + + if type_matches and scope_matches: + filtered.append(ann) + + return filtered diff --git a/datamint/dataset/base.py b/datamint/dataset/base.py new file mode 100644 index 00000000..d77743c9 --- /dev/null +++ b/datamint/dataset/base.py @@ -0,0 +1,606 @@ +""" +DatamintBaseDataset - Abstract base class for all Datamint datasets. + +Provides the PyTorch Dataset interface with transform support and annotation +filtering, while delegating data management to DatamintProjectManager. +""" +import logging +from abc import ABC, abstractmethod +from typing import Any, TYPE_CHECKING +from collections.abc import Sequence, Callable, Iterator + +import torch +from torch import Tensor +from torch.utils.data import DataLoader, ConcatDataset +import numpy as np +from datamint.exceptions import DatamintException +from datamint.entities import Annotation +from datamint import Api +from .annotation_processor import AnnotationProcessor, MergeStrategy + +if TYPE_CHECKING: + from datamint.entities import Resource, Project + +_LOGGER = logging.getLogger(__name__) + + +class DatamintDatasetException(DatamintException): + """Exception raised for dataset errors.""" + pass + + +class DatamintBaseDataset(ABC): + """Abstract base class for Datamint datasets. + + This class provides the PyTorch Dataset interface with: + - Transform hooks (albumentations) + - Annotation filtering + - Data loading utilities + + Subclasses must implement `_get_raw_item()` to define how data is loaded. + + Args: + project: Project name, Project object, or None. Mutually exclusive with resources. + resources: List of Resource objects/IDs, or None. Mutually exclusive with project. + auto_update: If True, sync with server on init. + api_key: API key for authentication. + server_url: Datamint server URL. + all_annotations: If True, include unpublished annotations. + return_metainfo: If True, include metadata in output. + return_segmentations: If True, process and return segmentations. + return_as_semantic_segmentation: If True, convert to semantic format. + semantic_seg_merge_strategy: Strategy for merging multi-annotator segs. + alb_transform: Albumentations transform. + include_unannotated: If True, include resources without annotations. + include_annotators: Whitelist of annotators. + exclude_annotators: Blacklist of annotators. + include_segmentation_names: Whitelist of segmentation labels. + exclude_segmentation_names: Blacklist of segmentation labels. + include_image_label_names: Whitelist of image labels. + exclude_image_label_names: Blacklist of image labels. + include_frame_label_names: Whitelist of frame labels. + exclude_frame_label_names: Blacklist of frame labels. + """ + + resources: Sequence['Resource'] + resource_annotations: Sequence[Sequence[Annotation]] + project: 'Project | None' + + def __init__( + self, + project: 'str | Project | None' = None, + resources: 'Sequence[Resource] | Sequence[str] | None' = None, + auto_update: bool = True, + api_key: str | None = None, + server_url: str | None = None, + # all_annotations: bool = False, + return_metainfo: bool = True, + return_segmentations: bool = True, + return_as_semantic_segmentation: bool = False, + semantic_seg_merge_strategy: MergeStrategy | None = None, + alb_transform: Callable | None = None, + include_unannotated: bool = True, + include_annotators: list[str] | None = None, + exclude_annotators: list[str] | None = None, + include_segmentation_names: list[str] | None = None, + exclude_segmentation_names: list[str] | None = None, + include_image_label_names: list[str] | None = None, + exclude_image_label_names: list[str] | None = None, + include_frame_label_names: list[str] | None = None, + exclude_frame_label_names: list[str] | None = None, + ): + # Validate mutually exclusive parameters + if project is not None and resources is not None: + raise DatamintDatasetException( + "Cannot specify both 'project' and 'resources'. Choose one." + ) + + if project is None and resources is None: + raise DatamintDatasetException( + "Must provide either 'project' or 'resources'." + ) + + # Validate filtering parameters + self._validate_filter_params( + include_annotators, exclude_annotators, + include_segmentation_names, exclude_segmentation_names, + include_image_label_names, exclude_image_label_names, + include_frame_label_names, exclude_frame_label_names + ) + + # Validate segmentation parameters + if not return_segmentations and return_as_semantic_segmentation: + raise ValueError("Cannot return semantic segmentation without returning segmentations.") + if semantic_seg_merge_strategy and not return_as_semantic_segmentation: + raise ValueError("semantic_seg_merge_strategy requires return_as_semantic_segmentation=True") + + # Initialize API + self._api = Api( + server_url=server_url, + api_key=api_key, + check_connection=auto_update + ) + + # Initialize from project or resources + if resources is not None: + self.resources = self._initialize_from_resources(resources, self._api) + self.project = None + else: + self.project, self.resources = self._initialize_from_project(project, self._api) # type: ignore + + # Fetch annotations + self.resource_annotations = list(self._api.annotations.get_list( + resource=self.resources, + group_by_resource=True, + )) + + # Store configuration + self.return_metainfo = return_metainfo + self.return_segmentations = return_segmentations + self.return_as_semantic_segmentation = return_as_semantic_segmentation + self.semantic_seg_merge_strategy: MergeStrategy | None = semantic_seg_merge_strategy + self.include_unannotated = include_unannotated + + # Transforms + self.alb_transform = alb_transform + + # Filtering + self.include_annotators = include_annotators + self.exclude_annotators = exclude_annotators + self.include_segmentation_names = include_segmentation_names + self.exclude_segmentation_names = exclude_segmentation_names + self.include_image_label_names = include_image_label_names + self.exclude_image_label_names = exclude_image_label_names + self.include_frame_label_names = include_frame_label_names + self.exclude_frame_label_names = exclude_frame_label_names + + # Internal state + self._logged_uint16_conversion = False + + # Setup + self._setup_dataset() + + def _extract_image_labels( + self, + annotations: Sequence[Annotation], + ) -> dict[str, torch.Tensor]: + """Extract image-level label annotations. + + Args: + annotations: All annotations for the item. + + Returns: + Dict of annotator_id -> label tensor. + """ + label_annotations = AnnotationProcessor.filter_annotations( + annotations, type='label', scope='image' + ) + return self.annotation_processor.convert_image_labels(label_annotations) + + def _validate_filter_params( + self, + include_annotators, exclude_annotators, + include_segmentation_names, exclude_segmentation_names, + include_image_label_names, exclude_image_label_names, + include_frame_label_names, exclude_frame_label_names + ) -> None: + """Validate mutually exclusive filter parameters.""" + pairs = [ + (include_annotators, exclude_annotators, "annotators"), + (include_segmentation_names, exclude_segmentation_names, "segmentation_names"), + (include_image_label_names, exclude_image_label_names, "image_label_names"), + (include_frame_label_names, exclude_frame_label_names, "frame_label_names"), + ] + for include_param, exclude_param, name in pairs: + if include_param is not None and exclude_param is not None: + raise DatamintDatasetException(f"Cannot specify both include_{name} and exclude_{name}.") + + def _initialize_from_project( + self, + project: 'str | Project', + api: Api + ) -> tuple['Project', list['Resource']]: + """Initialize dataset from a project (name or object).""" + + # Handle Project object vs string + if isinstance(project, str): + project = api.projects.get_by_name(project) + if project is None: + raise DatamintDatasetException(f"Project '{project}' not found.") + else: + # Attach API to project if not already set + if not hasattr(project, '_api') or project._api is None: + project._api = api.projects + + # Fetch resources + resources = list(project.fetch_resources()) + return project, resources + + def _initialize_from_resources( + self, + resources: 'Sequence[Resource] | Sequence[str]', + api: Api + ) -> list['Resource']: + """Initialize dataset from a list of resources.""" + # Normalize resources (handle IDs vs objects) + resource_list: list[Resource] = [] + if resources: + first_item = resources[0] if len(resources) > 0 else None + if isinstance(first_item, str): + # Fetch Resource objects from IDs + resource_list = [api.resources.get_by_id(rid) for rid in resources] # type: ignore + else: + resource_list = list(resources) # type: ignore + + # Attach API to resources if needed + for res in resource_list: + if not hasattr(res, '_api') or res._api is None: + res._api = api.resources + + return resource_list + + def _setup_dataset(self) -> None: + """Setup dataset after initialization.""" + if not self.resources: + _LOGGER.warning("No resources found in the dataset.") + + # Setup labels + self._setup_labels() + + # Setup annotation processor + self._setup_annotation_processor() + + # Apply annotation filters + self._apply_annotation_filters() + + # Filter unannotated if needed + if not self.include_unannotated: + self._filter_unannotated() + + def _setup_labels(self) -> None: + """Setup label sets and mappings.""" + # Frame and image labels + self.frame_lsets, self.frame_lcodes = self._get_labels_set(framed=True) + self.image_lsets, self.image_lcodes = self._get_labels_set(framed=False) + + # Segmentation labels + self.seglabel_list, self.seglabel2code = self._get_segmentation_labels() + + def _setup_annotation_processor(self) -> None: + """Initialize the annotation processor. + + Calls _create_annotation_processor() which subclasses can override + to return the appropriate processor type. + """ + self.annotation_processor = AnnotationProcessor( + seglabel2code=self.seglabel2code, + image_labels_set=self.image_labels_set, + image_lcodes=self.image_lcodes, + ) + + def _apply_annotation_filters(self) -> None: + """Apply annotation filters to all resources.""" + for i in range(len(self.resources)): + anns = self.resource_annotations[i] + filtered = self._filter_annotations(anns) + self.resource_annotations[i] = filtered + + @abstractmethod + def _get_raw_item(self, index: int) -> dict[str, Any]: + """Load raw data for the given index. + + Must return dict with at least: + - 'image': Tensor + - 'metainfo': dict + - 'annotations': list[Annotation] + """ + pass + + def _filter_unannotated(self) -> None: + """Filter out indices without annotations.""" + filtered_resources = [] + filtered_annotations = [] + + for resource, annotations in zip(self.resources, self.resource_annotations): + if annotations: + filtered_resources.append(resource) + filtered_annotations.append(annotations) + + self.resources = filtered_resources + self.resource_annotations = filtered_annotations + + def _filter_annotations(self, annotations: Sequence[Annotation]) -> list[Annotation]: + """Filter annotations based on include/exclude settings.""" + return [ann for ann in annotations if self._should_include_annotation(ann)] + + def _should_include_annotation(self, ann: Annotation) -> bool: + """Check if annotation should be included.""" + # Check annotator + annotator = ann.created_by + if annotator is not None and not self._should_include_annotator(annotator): + return False + + # Check by annotation type + if ann.annotation_type == 'segmentation': + return self._should_include_segmentation(ann.identifier) + elif ann.annotation_type == 'label': + if ann.frame_index is None: # image-level + return self._should_include_image_label(ann.identifier) + else: # frame-level + return self._should_include_frame_label(ann.identifier) + + return True + + def _should_include_annotator(self, annotator_id: str) -> bool: + if self.include_annotators is not None: + return annotator_id in self.include_annotators + if self.exclude_annotators is not None: + return annotator_id not in self.exclude_annotators + return True + + def _should_include_segmentation(self, name: str) -> bool: + if self.include_segmentation_names is not None: + return name in self.include_segmentation_names + if self.exclude_segmentation_names is not None: + return name not in self.exclude_segmentation_names + return True + + def _should_include_image_label(self, name: str) -> bool: + if self.include_image_label_names is not None: + return name in self.include_image_label_names + if self.exclude_image_label_names is not None: + return name not in self.exclude_image_label_names + return True + + def _should_include_frame_label(self, name: str) -> bool: + if self.include_frame_label_names is not None: + return name in self.include_frame_label_names + if self.exclude_frame_label_names is not None: + return name not in self.exclude_frame_label_names + return True + + def _get_labels_set(self, framed: bool) -> tuple[dict, dict[str, dict[str, int]]]: + """Get label sets and codes.""" + scope = 'frame' if framed else 'image' + multilabel_set: set[str] = set() + multiclass_set: set[tuple[str, Any]] = set() + + for annotations in self.resource_annotations: + for ann in annotations: + ann_scope = 'image' if ann.frame_index is None else 'frame' + if ann_scope != scope: + continue + + if ann.annotation_type == 'label': + multilabel_set.add(ann.identifier) + elif ann.annotation_type == 'category': + multiclass_set.add((ann.identifier, ann.value)) + + multilabel_list = sorted(multilabel_set) + multiclass_list = sorted(multiclass_set) + + sets = { + 'multilabel': multilabel_list, + 'multiclass': multiclass_list + } + codes = { + 'multilabel': {label: idx for idx, label in enumerate(multilabel_list)}, + 'multiclass': {label: idx for idx, label in enumerate(multiclass_list)} + } + + return sets, codes + + @property + def frame_labels_set(self) -> list[str]: + """Frame-level label names.""" + return self.frame_lsets['multilabel'] + + @property + def image_labels_set(self) -> list[str]: + """Image-level label names.""" + return self.image_lsets['multilabel'] + + @property + def segmentation_labels_set(self) -> list[str]: + """Segmentation label names.""" + return self.seglabel_list + + def _get_segmentation_labels(self) -> tuple[list[str], dict[str, int]]: + """Get segmentation labels from the server.""" + try: + worklist_id = getattr(self.project, 'worklist_id', None) + groups: dict[str, dict] = self._api.annotationsets.get_segmentation_group(worklist_id)['groups'] + + if not groups: + return [], {} + + max_index = max([g['index'] for g in groups.values()]) + seglabel_list: list[str] = ['UNKNOWN'] * max_index + + for segname, g in groups.items(): + seglabel_list[g['index'] - 1] = segname + + seglabel2code = {label: idx + 1 for idx, label in enumerate(seglabel_list)} + return seglabel_list, seglabel2code + except Exception as e: + _LOGGER.warning(f"Failed to fetch segmentation labels: {e}") + return [], {} + + def _preprocess_image_array(self, img: np.ndarray) -> np.ndarray: + """Preprocess image array to have a consistent dtype. + + Args: + img: Input image array. + + Returns: + Preprocessed image array as uint8 or original dtype. + """ + if img.dtype == np.uint16: + if not self._logged_uint16_conversion: + _LOGGER.warning("Converting uint16 to float with normalization to [0, 1]." + " If this is not desired, please process the images accordingly by" + " either converting to uint8 or float32 beforehand or overriding `_preprocess_image_array`.") + self._logged_uint16_conversion = True + img = img.astype(np.float32) + min_val = img.min() + img = (img - min_val) / (img.max() - min_val) * 255 + img = img.astype(np.uint8) + + # if not img.flags.writeable: + # img = img.copy() + # img_tensor = torch.from_numpy(img).contiguous() + + # if isinstance(img_tensor, torch.ByteTensor): + # img_tensor = img_tensor.to(dtype=torch.get_default_dtype()).div(255) + + return img + + def _process_segmentations(self, + segmentations: dict, + seg_labels: dict) -> tuple[Tensor | np.ndarray | dict, dict | None]: + # segmentations['author'] shape: (#instances, depth, H, W) + if self.return_as_semantic_segmentation: + sem_segs = {} + for author in segmentations: + sem_segs[author] = self.annotation_processor.instance_to_semantic_segmentation( + segmentations[author], seg_labels[author], + num_labels=len(self.segmentation_labels_set) + ) + segmentations = sem_segs + _LOGGER.debug( + f'Converted to semantic segmentation. Shapes: {[segmentations[a].shape for a in segmentations]}') + if self.semantic_seg_merge_strategy: + if segmentations: + segmentations = self.annotation_processor.apply_merge_strategy( + segmentations, + strategy=self.semantic_seg_merge_strategy + ) + _LOGGER.debug(f"Merged segmentation shape: {segmentations.shape}") + + seg_labels = None + _LOGGER.debug(f'merged segmentations. Final shape: {segmentations.shape}') + + return segmentations, seg_labels + + def __getitem__(self, index: int) -> dict[str, Any]: + """Get item with full processing.""" + if index >= len(self): + raise IndexError(f"Index {index} out of bounds") + + result = self._get_raw_item(index) + + img = result['image'] + if isinstance(img, np.ndarray): + img = self._preprocess_image_array(img) + annotations = result['annotations'] + resource = result['resource'] + _LOGGER.debug(f"Loaded image {resource.filename} with shape {img.shape}") + _LOGGER.debug(f"Annotations: {len(annotations)} found") + + # Process segmentations + if self.return_segmentations: + seg_anns = AnnotationProcessor.filter_annotations(annotations, + type='segmentation', + scope='all') + segmentations, seg_labels, _ = self.annotation_processor.load_segmentations(seg_anns) + # Apply albumentations if present + if self.alb_transform: + aug_result = self.apply_alb_transform(img, segmentations) + img = aug_result['image'] + result['image'] = img + segmentations = aug_result['segmentations'] + _LOGGER.debug( + f"Applied albumentations transform. Image shape: {img.shape} and segs shape: {[segmentations[a].shape for a in segmentations]}") + + segmentations, seg_labels = self._process_segmentations(segmentations, seg_labels) + + result['segmentations'] = segmentations + if seg_labels: + result['seg_labels'] = seg_labels + + # Process image-level labels + result['image_labels'] = self._extract_image_labels(annotations) + + return result + + @abstractmethod + def apply_alb_transform( + self, + img: np.ndarray, + segmentations: dict[str, np.ndarray] + ) -> dict[str, Any]: + pass + + def __len__(self) -> int: + """Dataset length.""" + return len(self.resources) + + def __iter__(self) -> Iterator[dict[str, Any]]: + """Iterate over dataset.""" + for i in range(len(self)): + yield self[i] + + def __add__(self, other: 'DatamintBaseDataset') -> ConcatDataset: + """Concatenate datasets.""" + return ConcatDataset([self, other]) # type: ignore[list-item] + + def subset(self, indices: list[int]) -> 'DatamintBaseDataset': + pass + + def get_dataloader(self, *args, **kwargs) -> DataLoader: + """Get DataLoader with proper collate function.""" + return DataLoader(self, *args, collate_fn=self.get_collate_fn(), **kwargs) # type: ignore[arg-type] + + def get_collate_fn(self) -> Callable[[list[dict]], dict]: + """Get collate function for DataLoader.""" + def collate_fn(batch: list[dict]) -> dict: + if not batch: + return {} + + keys = batch[0].keys() + collated = {} + + for key in keys: + values = [item[key] for item in batch] + + if isinstance(values[0], torch.Tensor): + shapes = [t.shape for t in values] + if all(s == shapes[0] for s in shapes): + collated[key] = torch.stack(values) + else: + _LOGGER.warning(f"Different shapes for {key}: {shapes}") + collated[key] = values + elif isinstance(values[0], np.ndarray): + collated[key] = np.stack(values) + else: + collated[key] = values + + return collated + + return collate_fn + + def __repr__(self) -> str: + name = self.project.name if self.project else "" + head = f"Dataset {name}" + body = [f"Number of datapoints: {len(self)}"] + + # if self.manager.root is not None: + # body.append(f"Location: {self.manager.dataset_dir}") + + filters = [ + (self.include_annotators, "Including annotators"), + (self.exclude_annotators, "Excluding annotators"), + (self.include_segmentation_names, "Including segmentations"), + (self.exclude_segmentation_names, "Excluding segmentations"), + (self.include_image_label_names, "Including image labels"), + (self.exclude_image_label_names, "Excluding image labels"), + (self.include_frame_label_names, "Including frame labels"), + (self.exclude_frame_label_names, "Excluding frame labels"), + ] + + for value, desc in filters: + if value is not None: + body.append(f"{desc}: {value}") + + lines = [head] + [" " + line for line in body] + return "\n".join(lines) diff --git a/datamint/dataset/base_dataset.py b/datamint/dataset/base_dataset.py index 6d3e82b2..7c2eb60d 100644 --- a/datamint/dataset/base_dataset.py +++ b/datamint/dataset/base_dataset.py @@ -81,6 +81,10 @@ def __init__( include_frame_label_names: list[str] | None = None, exclude_frame_label_names: list[str] | None = None, ): + _LOGGER.warning( + "DatamintBaseDataset is a legacy class and may be removed in future versions. " + "Please use 'from datamint.dataset import ImageDataset, VolumeDataset' instead." + ) self._validate_inputs(project_name, include_annotators, exclude_annotators, include_segmentation_names, exclude_segmentation_names, include_image_label_names, exclude_image_label_names, diff --git a/datamint/dataset/dataset.py b/datamint/dataset/dataset.py index 99aec30a..3570fc21 100644 --- a/datamint/dataset/dataset.py +++ b/datamint/dataset/dataset.py @@ -1,5 +1,5 @@ from .base_dataset import DatamintBaseDataset -from typing import Optional, Callable, Any, Literal, Sequence +from typing import Optional, Callable, Any, Literal, Sequence, TYPE_CHECKING import torch from torch import Tensor import os @@ -11,7 +11,11 @@ from medimgkit.readers import read_array_normalized _LOGGER = logging.getLogger(__name__) - +if not TYPE_CHECKING: + _LOGGER.warning( + "DatamintDataset is a legacy class and may be removed in future versions. " + "Please use `from datamint.dataset import ImageDataset, VolumeDataset` instead." + ) class DatamintDataset(DatamintBaseDataset): """ diff --git a/datamint/dataset/image_dataset.py b/datamint/dataset/image_dataset.py new file mode 100644 index 00000000..41724149 --- /dev/null +++ b/datamint/dataset/image_dataset.py @@ -0,0 +1,216 @@ +""" +ImageDataset - Dataset for 2D images. + +Handles standard 2D medical images like X-rays, pathology patches, +single-frame DICOM, PNG, JPEG, etc. +""" +import logging +from typing import Any +from typing_extensions import override +import torch +from torch import Tensor +import numpy as np +import albumentations + +from medimgkit.readers import read_array_normalized +from .base import DatamintBaseDataset, DatamintDatasetException + +_LOGGER = logging.getLogger(__name__) + + +class ImageDataset(DatamintBaseDataset): + """Dataset for 2D images. + + This dataset provides simple 1:1 mapping between index and resource. + Suitable for X-rays, pathology patches, single-frame DICOM, etc. + + Each `__getitem__` returns a single 2D image with shape (C, H, W). + + Args: + project_name: Name of the project. + auto_update: If True, sync with server on init. + api_key: API key for authentication. + server_url: Datamint server URL. + all_annotations: If True, include unpublished annotations. + return_metainfo: If True, include metadata in output. + return_annotations: If True, include raw annotations in output. + return_segmentations: If True, process and return segmentations. + return_as_semantic_segmentation: If True, convert to semantic format. + semantic_seg_merge_strategy: Strategy for merging multi-annotator segs. + alb_transform: Albumentations transform (applied to image+mask together). + include_unannotated: If True, include resources without annotations. + include_annotators: Whitelist of annotators. + exclude_annotators: Blacklist of annotators. + include_segmentation_names: Whitelist of segmentation labels. + exclude_segmentation_names: Blacklist of segmentation labels. + include_image_label_names: Whitelist of image labels. + exclude_image_label_names: Blacklist of image labels. + + Example: + >>> dataset = ImageDataset("chest-xray-project") + >>> item = dataset[0] + >>> item['image'].shape # (C, H, W) + torch.Size([1, 512, 512]) + >>> item['segmentations'] # dict[author -> Tensor] + """ + + @override + def _get_raw_item(self, index: int) -> dict[str, Any]: + """Load raw image and metadata.""" + resource = self.resources[index] + res_bytesdata = resource.fetch_file_data(auto_convert=False, use_cache=True) + + img, metainfo = read_array_normalized(res_bytesdata, return_metainfo=True) # shape: (N, C, H, W) + img = img.transpose(1, 0, 2, 3) # (N, C, H, W) -> (C, N, H, W) + _LOGGER.debug(f"Raw image shape from resource {resource.filename}: {img.shape}") + + if img.ndim == 4: + if img.shape[1] > 1: + raise DatamintDatasetException(f"2D ImageDataset with 3d image at {resource.filename} detected!") + elif img.ndim == 3: + # (C, H, W) - add depth dim + img = img[:, None, ...] + else: + raise DatamintDatasetException(f"Unexpected image shape {img.shape} for 2D image at {resource.filename}") + + anns = self.resource_annotations[index] + + return { + 'image': img, # shape (C, N, H, W) + 'metainfo': metainfo, + 'annotations': anns, + 'resource': resource, + } + + @override + def __getitem__(self, index: int) -> dict[str, Any]: + result = super().__getitem__(index) + img = result['image'] + img = img.squeeze(1) # (C, 1, H, W) -> (C, H, W) + result['image'] = img + + if self.return_segmentations: + segmentations = result['segmentations'] + _LOGGER.debug(f"final segmentations type: {type(segmentations)} | shape: {segmentations.shape}") + # convert segmentations shape to expected format: + # if semantic and no merge: dict[author -> (num_labels+1, H, W)] + # if instance and no merge: dict[author -> (num_instances, H, W)] + # if merged (semantic only): (num_labels+1, H, W) + if isinstance(segmentations, (Tensor, np.ndarray)): + _LOGGER.debug(f"squeezing merged segmentations of shape {segmentations.shape}") + segmentations = segmentations.squeeze(1) + else: + for author in segmentations: + segmentations[author] = segmentations[author].squeeze(1) + + result['segmentations'] = segmentations + _LOGGER.debug( + f"final segmentations after squeeze type: {type(segmentations)} | shape: {segmentations.shape}") + + return result + + @override + def apply_alb_transform( + self, + img: np.ndarray, + segmentations: dict[str, np.ndarray], + ) -> dict[str, Any]: + """Apply albumentations transform to image and masks. + + Args: + img: Image array of shape (C, depth, H, W). + segmentations: Dict of author -> list of mask arrays of shape (#instances, depth, H, W). + Returns: + Dict with transformed 'image' and 'segmentations'. + + """ + if self.alb_transform is None: + raise ValueError("alb_transform is not set") + if img.ndim != 4: + raise ValueError(f"Expected 4D image array (C, depth, H, W), got shape {img.shape}") + + img_kw = 'image' + mask_kw = 'masks' + apply_per_depth_slice = True + + # transpose to (depth, H, W, C) + img = np.transpose(img, (1, 2, 3, 0)) + + replay_alb_transf = albumentations.ReplayCompose([self.alb_transform]) + _LOGGER.debug( + f'before alb transform image shape: {img.shape} | segmentations shape: {[segmentations[a].shape for a in segmentations]}') + + # Handle 4D data by iterating over depth slices + depth = img.shape[0] + aug_img_slices = [] + aug_seg_slices = {author: [] for author in segmentations} + replay_data = None + + for d in range(depth): + img_slice = img[d] # (H, W, C) + + # Apply transform to first slice or replay to subsequent slices + if apply_per_depth_slice or d == 0: + _LOGGER.debug( + f"Applying albumentations transform to depth slice {d}. img_slice shape: {img_slice.shape}") + aug_data = replay_alb_transf(**{img_kw: img_slice}) + aug_img_slices.append(aug_data[img_kw]) + replay_data = aug_data['replay'] + else: + # Replay the same transform from first slice + aug_result = replay_alb_transf.replay(replay_data, **{img_kw: img_slice}) + aug_img_slices.append(aug_result[img_kw]) + + # Apply same transform to segmentation masks for this slice + for author, segs in segmentations.items(): + # segs shape: (#instances, depth, H, W) + segs_slice = segs[:, d, :, :] # (#instances, H, W) + aug_segs_slice = replay_alb_transf.replay(replay_data, **{mask_kw: segs_slice})[mask_kw] + aug_seg_slices[author].append(aug_segs_slice) + + # Stack slices back together + if isinstance(aug_img_slices[0], np.ndarray): + aug_img = np.stack(aug_img_slices, axis=0) # (depth, H, W, C) + else: + aug_img = torch.stack(aug_img_slices, dim=0) # (depth, H, W, C) + _LOGGER.debug(f"augmented image shape after stacking: {aug_img.shape}") + aug_segmentations = {} + for author in segmentations: + # Stack to get (#instances, depth, H, W) + if isinstance(aug_seg_slices[author][0], np.ndarray): + aug_segmentations[author] = np.stack(aug_seg_slices[author], axis=1) + else: + aug_segmentations[author] = torch.stack(aug_seg_slices[author], dim=1) + + # else: + # # Apply transform to 3D image at once + # aug_data = replay_alb_transf(**{img_kw: img}) + # aug_img = aug_data[img_kw] + # replay_data = aug_data['replay'] + + # aug_segmentations = {} + # for author, segs in segmentations.items(): + # segs = replay_alb_transf.replay(replay_data, **{mask_kw: segs})[mask_kw] + # aug_segmentations[author] = segs + + # transpose back to (C, H, W) or (C, depth, H, W) + if isinstance(aug_img, np.ndarray): + aug_img = np.transpose(aug_img, (3, 0, 1, 2)) + elif isinstance(aug_img, torch.Tensor): + # shape is (depth, C, H, W), assuming albumentation transformation changed it + _LOGGER.debug(f"augmented image tensor shape before permute: {aug_img.shape}") + if aug_img.shape[1] == img.shape[-1]: + aug_img = aug_img.permute(1, 0, 2, 3) + else: + aug_img = aug_img.permute(3, 0, 1, 2) + _LOGGER.debug(f"augmented image tensor shape after permute: {aug_img.shape}") + + return { + 'image': aug_img, + 'segmentations': aug_segmentations, + } + + @override + def __repr__(self) -> str: + base = super().__repr__() + return f"ImageDataset\n{base}" diff --git a/datamint/dataset/volume_dataset.py b/datamint/dataset/volume_dataset.py new file mode 100644 index 00000000..1de1e560 --- /dev/null +++ b/datamint/dataset/volume_dataset.py @@ -0,0 +1,111 @@ +""" +VolumeDataset - Dataset for 3D medical volumes. + +Handles NIfTI volumes, DICOM series, and other 3D medical imaging data +with support for different slice orientations and affine preservation. +""" +import logging +from typing import Any +from typing_extensions import override + +import torch +import numpy as np +import albumentations + +from medimgkit.readers import read_array_normalized +from .base import DatamintBaseDataset + +_LOGGER = logging.getLogger(__name__) + + +# Axis mapping for anatomical orientations +SLICE_AXIS_MAP = { + 'axial': 0, # slicing along depth (superior-inferior) + 'coronal': 1, # slicing along height (anterior-posterior) + 'sagittal': 2, # slicing along width (left-right) +} + + +class VolumeDataset(DatamintBaseDataset): + """Dataset for 3D medical volumes. + + Handles NIfTI (3D/4D), DICOM series, and other volumetric data. + """ + + @override + def _get_raw_item(self, index: int) -> dict[str, Any]: + """Load raw image and metadata.""" + resource = self.resources[index] + res_bytesdata = resource.fetch_file_data(auto_convert=False, use_cache=True) + + img, metainfo = read_array_normalized(res_bytesdata, return_metainfo=True) # shape: (N, C, H, W) + img = img.transpose(1, 0, 2, 3) # (N, C, H, W) -> (C, N, H, W) + _LOGGER.debug(f"Raw image shape from resource {resource.filename}: {img.shape}") + + anns = self.resource_annotations[index] + + return { + 'image': img, # shape (C, N, H, W) + 'metainfo': metainfo, + 'annotations': anns, + 'resource': resource, + } + + @override + def apply_alb_transform( + self, + img: np.ndarray, + segmentations: dict[str, np.ndarray], + ) -> dict[str, Any]: + """Apply albumentations transform to image and masks. + + Args: + img: Image array of shape (C, depth, H, W). + segmentations: Dict of author -> list of mask arrays of shape (#instances, depth, H, W). + Returns: + Dict with transformed 'image' and 'segmentations'. + + """ + if self.alb_transform is None: + raise ValueError("alb_transform is not set") + if img.ndim != 4: + raise ValueError(f"Expected 4D image array (C, depth, H, W), got shape {img.shape}") + + # transpose to (depth, H, W, C) + img = np.transpose(img, (1, 2, 3, 0)) + + replay_alb_transf = albumentations.ReplayCompose([self.alb_transform]) + _LOGGER.debug( + f'before alb transform image shape: {img.shape} | segmentations shape: {[segmentations[a].shape for a in segmentations]}') + + aug_data = replay_alb_transf(volume=img) # First call + replay_data = aug_data['replay'] + aug_img = aug_data['volume'] + + aug_segmentations = {} + for author, segs in segmentations.items(): + aug_segmentations_author = segs.copy() if isinstance(segs, np.ndarray) else segs.clone() + for i, seg_inst in enumerate(segs): # for each instance mask + aug_segmentations_author[i] = replay_alb_transf.replay(replay_data, mask3d=seg_inst)['mask3d'] + aug_segmentations[author] = aug_segmentations_author + + # transpose back to (C, H, W) or (C, depth, H, W) + if isinstance(aug_img, np.ndarray): + aug_img = np.transpose(aug_img, (3, 0, 1, 2)) + elif isinstance(aug_img, torch.Tensor): + # shape is (depth, C, H, W), assuming albumentation transformation changed it + _LOGGER.debug(f"augmented image tensor shape before permute: {aug_img.shape}") + if aug_img.shape[1] == img.shape[-1]: # if C is in dim 1 + aug_img = aug_img.permute(1, 0, 2, 3) + else: + aug_img = aug_img.permute(3, 0, 1, 2) + _LOGGER.debug(f"augmented image tensor shape after permute: {aug_img.shape}") + + return { + 'image': aug_img, + 'segmentations': aug_segmentations, + } + + def __repr__(self) -> str: + base = super().__repr__() + return f"VolumeDataset\n{base}" diff --git a/datamint/entities/project.py b/datamint/entities/project.py index 7c95b852..032c7959 100644 --- a/datamint/entities/project.py +++ b/datamint/entities/project.py @@ -139,7 +139,7 @@ def as_torch_dataset(self, auto_update: bool = True, return_as_semantic_segmentation: bool = False): from datamint.dataset import Dataset - return Dataset(project_name=self.name, + return Dataset(project=self, root=root_dir, auto_update=auto_update, return_as_semantic_segmentation=return_as_semantic_segmentation, From 40532bd7fddd836eace7fba089edf13b05c136e6 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 4 Feb 2026 10:46:02 -0300 Subject: [PATCH 03/47] Refactor AnnotationSetsApi and ProjectsApi to support Project instances; add AnnotationSpec model and enhance Dataset classes for improved annotation handling --- datamint/api/endpoints/annotationsets_api.py | 48 ++++- datamint/api/endpoints/projects_api.py | 26 ++- datamint/dataset/base.py | 136 +++++++++---- datamint/dataset/image_dataset.py | 179 ++++-------------- datamint/entities/__init__.py | 2 + .../entities/annotations/annotation_spec.py | 41 ++++ datamint/entities/base_entity.py | 1 - datamint/entities/project.py | 12 +- datamint/entities/resource.py | 4 - 9 files changed, 253 insertions(+), 196 deletions(-) create mode 100644 datamint/entities/annotations/annotation_spec.py diff --git a/datamint/api/endpoints/annotationsets_api.py b/datamint/api/endpoints/annotationsets_api.py index 5be1b6f0..cd468f9b 100644 --- a/datamint/api/endpoints/annotationsets_api.py +++ b/datamint/api/endpoints/annotationsets_api.py @@ -1,11 +1,49 @@ from datamint.api.base_api import BaseApi -import logging +from typing import TYPE_CHECKING, Any +from datamint.entities import AnnotationSpec +from collections.abc import Sequence -_LOGGER = logging.getLogger(__name__) +if TYPE_CHECKING: + from datamint.entities import Project class AnnotationSetsApi(BaseApi): - def get_segmentation_group(self, annotation_set_id: str) -> dict: - """Get the segmentation group for a given annotation set ID.""" - endpoint = f"/annotationsets/{annotation_set_id}/segmentation-group" + ENDPOINT_BASE = "/annotationsets" + + def get_segmentation_group(self, annotation_set: 'str | Project') -> dict: + """Get the segmentation group for a given annotation set ID or Project.""" + + if isinstance(annotation_set, str): + annotation_set_id = annotation_set + else: + annotation_set_id = annotation_set.worklist_id + + endpoint = f"/{self.ENDPOINT_BASE}/{annotation_set_id}/segmentation-group" return self._make_request("GET", endpoint).json() + + def get_annotations_specs(self, annotation_set: 'str | Project') -> Sequence[AnnotationSpec]: + """Get the annotations specs for a given annotation set ID or Project.""" + + if isinstance(annotation_set, str): + annotation_set_id = annotation_set + else: + annotation_set_id = annotation_set.worklist_id + + result = self._get_by_id(annotation_set_id) + + return [AnnotationSpec(**annspec) for annspec in result['annotations']] + + def _get_by_id(self, annotation_set_id: str) -> dict[str, Any]: + """Get an annotation set by its ID. + + Args: + annotation_set_id: The ID of the annotation set to retrieve. + + Returns: + A dictionary representing the annotation set. + """ + endpoint = f"/{self.ENDPOINT_BASE}/{annotation_set_id}" + result = self._make_request("GET", endpoint).json() + + result['annotations'] = [AnnotationSpec(**annspec) for annspec in result['annotations']] + return result diff --git a/datamint/api/endpoints/projects_api.py b/datamint/api/endpoints/projects_api.py index f1e2a7e5..b2b010a4 100644 --- a/datamint/api/endpoints/projects_api.py +++ b/datamint/api/endpoints/projects_api.py @@ -1,10 +1,13 @@ -from typing import Sequence, Literal, TYPE_CHECKING, overload +from typing import Literal, TYPE_CHECKING, overload +from collections.abc import Sequence + from ..entity_base_api import ApiConfig, CRUDEntityApi from datamint.entities.project import Project import httpx from datamint.entities.resource import Resource if TYPE_CHECKING: - from .resources_api import ResourcesApi + from . import AnnotationSetsApi, ResourcesApi + from datamint.entities.annotations.annotation_spec import AnnotationSpec class ProjectsApi(CRUDEntityApi[Project]): @@ -20,9 +23,11 @@ def __init__(self, config: API configuration containing base URL, API key, etc. client: Optional HTTP client instance. If None, a new one will be created. """ - from .resources_api import ResourcesApi + from . import AnnotationSetsApi, ResourcesApi + super().__init__(config, Project, 'projects', client) self.resources_api = resources_api or ResourcesApi(config, client, projects_api=self) + self.annotationsets_api = AnnotationSetsApi(config, client) def get_project_resources(self, project: Project | str) -> list[Resource]: """Get resources associated with a specific project. @@ -38,7 +43,6 @@ def get_project_resources(self, project: Project | str) -> list[Resource]: resources = [self.resources_api._init_entity_obj(**item) for item in resources_data] return resources - @overload def create(self, name: str, @@ -234,3 +238,17 @@ def set_work_status(self, entity_id=proj_id, add_path=f'resources/{resource_id}/status', json=jsondata) + + def get_annotations_specs(self, project: str | Project) -> Sequence['AnnotationSpec']: + """Get the annotations specs for a given project. + + Args: + project: The project id or Project instance. + + Returns: + A sequence of AnnotationSpec instances. + """ + + if isinstance(project, str): + project = self.get_by_id(project) + return self.annotationsets_api.get_annotations_specs(project) diff --git a/datamint/dataset/base.py b/datamint/dataset/base.py index d77743c9..c0aa90e3 100644 --- a/datamint/dataset/base.py +++ b/datamint/dataset/base.py @@ -13,10 +13,11 @@ from torch import Tensor from torch.utils.data import DataLoader, ConcatDataset import numpy as np +from datamint.apihandler.dto.annotation_dto import AnnotationType from datamint.exceptions import DatamintException from datamint.entities import Annotation -from datamint import Api from .annotation_processor import AnnotationProcessor, MergeStrategy +from datamint.entities.annotations.annotation_spec import AnnotationSpec, CategoryAnnotationSpec if TYPE_CHECKING: from datamint.entities import Resource, Project @@ -63,13 +64,13 @@ class DatamintBaseDataset(ABC): """ resources: Sequence['Resource'] - resource_annotations: Sequence[Sequence[Annotation]] + resource_annotations: list[Sequence[Annotation]] project: 'Project | None' def __init__( self, project: 'str | Project | None' = None, - resources: 'Sequence[Resource] | Sequence[str] | None' = None, + resources: 'Sequence[Resource] | None' = None, auto_update: bool = True, api_key: str | None = None, server_url: str | None = None, @@ -89,6 +90,7 @@ def __init__( include_frame_label_names: list[str] | None = None, exclude_frame_label_names: list[str] | None = None, ): + from datamint import Api # Validate mutually exclusive parameters if project is not None and resources is not None: raise DatamintDatasetException( @@ -123,10 +125,10 @@ def __init__( # Initialize from project or resources if resources is not None: - self.resources = self._initialize_from_resources(resources, self._api) + self.resources = self._initialize_from_resources(resources) self.project = None else: - self.project, self.resources = self._initialize_from_project(project, self._api) # type: ignore + self.project, self.resources = self._initialize_from_project(project) # type: ignore # Fetch annotations self.resource_annotations = list(self._api.annotations.get_list( @@ -198,19 +200,18 @@ def _validate_filter_params( def _initialize_from_project( self, project: 'str | Project', - api: Api ) -> tuple['Project', list['Resource']]: """Initialize dataset from a project (name or object).""" # Handle Project object vs string if isinstance(project, str): - project = api.projects.get_by_name(project) + project = self._api.projects.get_by_name(project) if project is None: raise DatamintDatasetException(f"Project '{project}' not found.") else: # Attach API to project if not already set if not hasattr(project, '_api') or project._api is None: - project._api = api.projects + project._api = self._api.projects # Fetch resources resources = list(project.fetch_resources()) @@ -218,26 +219,14 @@ def _initialize_from_project( def _initialize_from_resources( self, - resources: 'Sequence[Resource] | Sequence[str]', - api: Api - ) -> list['Resource']: + resources: Sequence['Resource'], + ) -> Sequence['Resource']: """Initialize dataset from a list of resources.""" - # Normalize resources (handle IDs vs objects) - resource_list: list[Resource] = [] - if resources: - first_item = resources[0] if len(resources) > 0 else None - if isinstance(first_item, str): - # Fetch Resource objects from IDs - resource_list = [api.resources.get_by_id(rid) for rid in resources] # type: ignore - else: - resource_list = list(resources) # type: ignore - - # Attach API to resources if needed - for res in resource_list: + for res in resources: if not hasattr(res, '_api') or res._api is None: - res._api = api.resources + res._api = self._api.resources - return resource_list + return resources def _setup_dataset(self) -> None: """Setup dataset after initialization.""" @@ -259,12 +248,26 @@ def _setup_dataset(self) -> None: def _setup_labels(self) -> None: """Setup label sets and mappings.""" - # Frame and image labels - self.frame_lsets, self.frame_lcodes = self._get_labels_set(framed=True) - self.image_lsets, self.image_lcodes = self._get_labels_set(framed=False) - # Segmentation labels - self.seglabel_list, self.seglabel2code = self._get_segmentation_labels() + if self.project is not None: + worklist_schema = self._api.annotationsets._get_by_id(self.project.worklist_id) + annotations_specs: Sequence['AnnotationSpec'] = worklist_schema['annotations'] + frame_annotations_specs = [annspec for annspec in annotations_specs + if annspec.scope == 'frame'] + image_annotations_specs = [annspec for annspec in annotations_specs + if annspec.scope == 'image'] + self.frame_lsets, self.frame_lcodes = self._process_annotation_specs(frame_annotations_specs) + self.image_lsets, self.image_lcodes = self._process_annotation_specs(image_annotations_specs) + + # Segmentation labels + self.seglabel_list, self.seglabel2code = self._process_segmentation_group( + worklist_schema['segmentation_group'] + ) + else: + _LOGGER.info("No project provided; inferring labels from annotations.") + self.frame_lsets, self.frame_lcodes = self._infer_labels_set(framed=True) + self.image_lsets, self.image_lcodes = self._infer_labels_set(framed=False) + self.seglabel_list, self.seglabel2code = self._infer_segmentation_group() def _setup_annotation_processor(self) -> None: """Initialize the annotation processor. @@ -359,11 +362,40 @@ def _should_include_frame_label(self, name: str) -> bool: return name not in self.exclude_frame_label_names return True - def _get_labels_set(self, framed: bool) -> tuple[dict, dict[str, dict[str, int]]]: + def _process_annotation_specs(self, + annotations_specs: Sequence['AnnotationSpec'] + ) -> tuple[dict[str, list], dict[str, dict[str, int]]]: + multilabel_list: list[str] = [] + multiclass_list: list[tuple[str, str]] = [] + + for annspec in annotations_specs: + if annspec.type == AnnotationType.LABEL: + multilabel_list.append(annspec.identifier) + elif isinstance(annspec, CategoryAnnotationSpec): + for val in annspec.values: + multiclass_list.append((annspec.identifier, val)) + + sets = { + 'multilabel': multilabel_list, + 'multiclass': multiclass_list + } + codes = { + 'multilabel': self.__build_label_codemap(multilabel_list), + 'multiclass': self.__build_label_codemap(multiclass_list) + } + + return sets, codes + + @staticmethod + def __build_label_codemap(labels: Sequence) -> dict[str, int]: + """Build label to code mapping.""" + return {label: idx for idx, label in enumerate(labels)} + + def _infer_labels_set(self, framed: bool) -> tuple[dict[str, list], dict[str, dict[str, int]]]: """Get label sets and codes.""" scope = 'frame' if framed else 'image' multilabel_set: set[str] = set() - multiclass_set: set[tuple[str, Any]] = set() + multiclass_set: set[tuple[str, str]] = set() for annotations in self.resource_annotations: for ann in annotations: @@ -384,8 +416,8 @@ def _get_labels_set(self, framed: bool) -> tuple[dict, dict[str, dict[str, int]] 'multiclass': multiclass_list } codes = { - 'multilabel': {label: idx for idx, label in enumerate(multilabel_list)}, - 'multiclass': {label: idx for idx, label in enumerate(multiclass_list)} + 'multilabel': self.__build_label_codemap(multilabel_list), + 'multiclass': self.__build_label_codemap(multiclass_list) } return sets, codes @@ -405,11 +437,27 @@ def segmentation_labels_set(self) -> list[str]: """Segmentation label names.""" return self.seglabel_list - def _get_segmentation_labels(self) -> tuple[list[str], dict[str, int]]: + def _infer_segmentation_group(self) -> tuple[list[str], dict[str, int]]: + """Infer segmentation labels from annotations when no project is provided.""" + seglabel_set: set[str] = set() + + for annotations in self.resource_annotations: + for ann in annotations: + if ann.annotation_type == 'segmentation': + # Extract label from the segmentation annotation + # Assuming segmentation annotations have an identifier field + if hasattr(ann, 'identifier') and ann.identifier: + seglabel_set.add(ann.identifier) + + seglabel_list = sorted(seglabel_set) + seglabel2code = {label: idx + 1 for idx, label in enumerate(seglabel_list)} + + return seglabel_list, seglabel2code + + def _process_segmentation_group(self, groups: dict) -> tuple[list[str], dict[str, int]]: """Get segmentation labels from the server.""" try: - worklist_id = getattr(self.project, 'worklist_id', None) - groups: dict[str, dict] = self._api.annotationsets.get_segmentation_group(worklist_id)['groups'] + # groups = self._api.annotationsets.get_segmentation_group(self.project.worklist_id)['groups'] if not groups: return [], {} @@ -500,8 +548,8 @@ def __getitem__(self, index: int) -> dict[str, Any]: # Process segmentations if self.return_segmentations: seg_anns = AnnotationProcessor.filter_annotations(annotations, - type='segmentation', - scope='all') + type='segmentation', + scope='all') segmentations, seg_labels, _ = self.annotation_processor.load_segmentations(seg_anns) # Apply albumentations if present if self.alb_transform: @@ -529,6 +577,16 @@ def apply_alb_transform( img: np.ndarray, segmentations: dict[str, np.ndarray] ) -> dict[str, Any]: + """Apply albumentations transform to image and masks. + + Returns: + Dict with transformed 'image' and 'segmentations' (dict). + It is recommended that 'image' has shape (C, depth, H, W) + and each segmentation of 'segmentations' has shape (num_instances, depth, H, W), so that + common downstream processing can be applied. + If not, please override :py:meth:`_process_segmentations` accordingly. + + """ pass def __len__(self) -> int: diff --git a/datamint/dataset/image_dataset.py b/datamint/dataset/image_dataset.py index 41724149..90627474 100644 --- a/datamint/dataset/image_dataset.py +++ b/datamint/dataset/image_dataset.py @@ -12,80 +12,21 @@ import numpy as np import albumentations -from medimgkit.readers import read_array_normalized -from .base import DatamintBaseDataset, DatamintDatasetException +from .base import DatamintDatasetException +from .volume_dataset import VolumeDataset _LOGGER = logging.getLogger(__name__) -class ImageDataset(DatamintBaseDataset): - """Dataset for 2D images. - - This dataset provides simple 1:1 mapping between index and resource. - Suitable for X-rays, pathology patches, single-frame DICOM, etc. - - Each `__getitem__` returns a single 2D image with shape (C, H, W). - - Args: - project_name: Name of the project. - auto_update: If True, sync with server on init. - api_key: API key for authentication. - server_url: Datamint server URL. - all_annotations: If True, include unpublished annotations. - return_metainfo: If True, include metadata in output. - return_annotations: If True, include raw annotations in output. - return_segmentations: If True, process and return segmentations. - return_as_semantic_segmentation: If True, convert to semantic format. - semantic_seg_merge_strategy: Strategy for merging multi-annotator segs. - alb_transform: Albumentations transform (applied to image+mask together). - include_unannotated: If True, include resources without annotations. - include_annotators: Whitelist of annotators. - exclude_annotators: Blacklist of annotators. - include_segmentation_names: Whitelist of segmentation labels. - exclude_segmentation_names: Blacklist of segmentation labels. - include_image_label_names: Whitelist of image labels. - exclude_image_label_names: Blacklist of image labels. - - Example: - >>> dataset = ImageDataset("chest-xray-project") - >>> item = dataset[0] - >>> item['image'].shape # (C, H, W) - torch.Size([1, 512, 512]) - >>> item['segmentations'] # dict[author -> Tensor] - """ - - @override - def _get_raw_item(self, index: int) -> dict[str, Any]: - """Load raw image and metadata.""" - resource = self.resources[index] - res_bytesdata = resource.fetch_file_data(auto_convert=False, use_cache=True) - - img, metainfo = read_array_normalized(res_bytesdata, return_metainfo=True) # shape: (N, C, H, W) - img = img.transpose(1, 0, 2, 3) # (N, C, H, W) -> (C, N, H, W) - _LOGGER.debug(f"Raw image shape from resource {resource.filename}: {img.shape}") - - if img.ndim == 4: - if img.shape[1] > 1: - raise DatamintDatasetException(f"2D ImageDataset with 3d image at {resource.filename} detected!") - elif img.ndim == 3: - # (C, H, W) - add depth dim - img = img[:, None, ...] - else: - raise DatamintDatasetException(f"Unexpected image shape {img.shape} for 2D image at {resource.filename}") - - anns = self.resource_annotations[index] - - return { - 'image': img, # shape (C, N, H, W) - 'metainfo': metainfo, - 'annotations': anns, - 'resource': resource, - } - +class ImageDataset(VolumeDataset): + """Dataset for 2D medical images.""" @override def __getitem__(self, index: int) -> dict[str, Any]: result = super().__getitem__(index) img = result['image'] + if img.shape[1] != 1: + raise DatamintDatasetException("Expected 2D image with shape (C, 1, H, W)" + f", got {img.shape}") img = img.squeeze(1) # (C, 1, H, W) -> (C, H, W) result['image'] = img @@ -115,95 +56,49 @@ def apply_alb_transform( img: np.ndarray, segmentations: dict[str, np.ndarray], ) -> dict[str, Any]: - """Apply albumentations transform to image and masks. - - Args: - img: Image array of shape (C, depth, H, W). - segmentations: Dict of author -> list of mask arrays of shape (#instances, depth, H, W). - Returns: - Dict with transformed 'image' and 'segmentations'. - - """ if self.alb_transform is None: raise ValueError("alb_transform is not set") - if img.ndim != 4: - raise ValueError(f"Expected 4D image array (C, depth, H, W), got shape {img.shape}") - - img_kw = 'image' - mask_kw = 'masks' - apply_per_depth_slice = True + if img.ndim == 4: + if img.shape[1] != 1: + raise ValueError(f"Expected 2D image with shape (C, 1, H, W), got {img.shape}") + img = img.squeeze(1) # (C, 1, H, W) -> (C, H, W) + elif img.ndim != 3: + raise ValueError(f"Expected 3D image array (C, H, W) or (C, 1, H, W), got shape {img.shape}") - # transpose to (depth, H, W, C) - img = np.transpose(img, (1, 2, 3, 0)) + # transpose to (H, W, C) + img = np.transpose(img, (1, 2, 0)) replay_alb_transf = albumentations.ReplayCompose([self.alb_transform]) _LOGGER.debug( f'before alb transform image shape: {img.shape} | segmentations shape: {[segmentations[a].shape for a in segmentations]}') - # Handle 4D data by iterating over depth slices - depth = img.shape[0] - aug_img_slices = [] - aug_seg_slices = {author: [] for author in segmentations} - replay_data = None - - for d in range(depth): - img_slice = img[d] # (H, W, C) - - # Apply transform to first slice or replay to subsequent slices - if apply_per_depth_slice or d == 0: - _LOGGER.debug( - f"Applying albumentations transform to depth slice {d}. img_slice shape: {img_slice.shape}") - aug_data = replay_alb_transf(**{img_kw: img_slice}) - aug_img_slices.append(aug_data[img_kw]) - replay_data = aug_data['replay'] - else: - # Replay the same transform from first slice - aug_result = replay_alb_transf.replay(replay_data, **{img_kw: img_slice}) - aug_img_slices.append(aug_result[img_kw]) - - # Apply same transform to segmentation masks for this slice - for author, segs in segmentations.items(): - # segs shape: (#instances, depth, H, W) - segs_slice = segs[:, d, :, :] # (#instances, H, W) - aug_segs_slice = replay_alb_transf.replay(replay_data, **{mask_kw: segs_slice})[mask_kw] - aug_seg_slices[author].append(aug_segs_slice) - - # Stack slices back together - if isinstance(aug_img_slices[0], np.ndarray): - aug_img = np.stack(aug_img_slices, axis=0) # (depth, H, W, C) - else: - aug_img = torch.stack(aug_img_slices, dim=0) # (depth, H, W, C) - _LOGGER.debug(f"augmented image shape after stacking: {aug_img.shape}") - aug_segmentations = {} - for author in segmentations: - # Stack to get (#instances, depth, H, W) - if isinstance(aug_seg_slices[author][0], np.ndarray): - aug_segmentations[author] = np.stack(aug_seg_slices[author], axis=1) - else: - aug_segmentations[author] = torch.stack(aug_seg_slices[author], dim=1) - - # else: - # # Apply transform to 3D image at once - # aug_data = replay_alb_transf(**{img_kw: img}) - # aug_img = aug_data[img_kw] - # replay_data = aug_data['replay'] + aug_data = replay_alb_transf(image=img) # First call + replay_data = aug_data['replay'] + aug_img = aug_data['image'] - # aug_segmentations = {} - # for author, segs in segmentations.items(): - # segs = replay_alb_transf.replay(replay_data, **{mask_kw: segs})[mask_kw] - # aug_segmentations[author] = segs - - # transpose back to (C, H, W) or (C, depth, H, W) + aug_segmentations = {} + for author, segs in segmentations.items(): + if segs.ndim == 4 and segs.shape[1] == 1: + segs = segs.squeeze(1) # (num_instances, 1, H, W) -> (num_instances, H, W) + aug_segs = replay_alb_transf.replay(replay_data, masks=segs)['masks'] + # store back with original shape + if segs.ndim == 3: + aug_segs = aug_segs[:, np.newaxis, :, :] # (num_instances, H, W) -> (num_instances, 1, H, W) + aug_segmentations[author] = aug_segs + + # transpose back to (C, H, W) if isinstance(aug_img, np.ndarray): - aug_img = np.transpose(aug_img, (3, 0, 1, 2)) + aug_img = np.transpose(aug_img, (2, 0, 1)) elif isinstance(aug_img, torch.Tensor): - # shape is (depth, C, H, W), assuming albumentation transformation changed it + # shape is (C, H, W), assuming albumentation transformation changed it _LOGGER.debug(f"augmented image tensor shape before permute: {aug_img.shape}") - if aug_img.shape[1] == img.shape[-1]: - aug_img = aug_img.permute(1, 0, 2, 3) + if aug_img.shape[0] == img.shape[-1]: # if C is in dim 0 + aug_img = aug_img.permute(0, 1, 2) else: - aug_img = aug_img.permute(3, 0, 1, 2) + aug_img = aug_img.permute(2, 0, 1) _LOGGER.debug(f"augmented image tensor shape after permute: {aug_img.shape}") + # back to (C, 1, H, W) + aug_img = aug_img[:, np.newaxis, :, :] return { 'image': aug_img, @@ -212,5 +107,5 @@ def apply_alb_transform( @override def __repr__(self) -> str: - base = super().__repr__() + base = super(VolumeDataset, self).__repr__() return f"ImageDataset\n{base}" diff --git a/datamint/entities/__init__.py b/datamint/entities/__init__.py index 9b1af1ee..9322ee05 100644 --- a/datamint/entities/__init__.py +++ b/datamint/entities/__init__.py @@ -8,6 +8,7 @@ from .user import User # new export from .datasetinfo import DatasetInfo from .cache_manager import CacheManager +from .annotations.annotation_spec import AnnotationSpec __all__ = [ 'Annotation', @@ -19,4 +20,5 @@ 'Project', 'Resource', 'User', + 'AnnotationSpec' ] diff --git a/datamint/entities/annotations/annotation_spec.py b/datamint/entities/annotations/annotation_spec.py new file mode 100644 index 00000000..10c28a7f --- /dev/null +++ b/datamint/entities/annotations/annotation_spec.py @@ -0,0 +1,41 @@ +from pydantic import ConfigDict, BaseModel +from datamint.api.dto import AnnotationType + + +class AnnotationSpec(BaseModel): + model_config = ConfigDict(extra='allow', + ser_json_bytes='base64', + val_json_bytes='base64') + + type: AnnotationType + scope: str + required: bool + identifier: str + + def __new__(cls, *args, **kwargs): + if cls is AnnotationSpec and kwargs.get('type') == AnnotationType.CATEGORY: + return super().__new__(CategoryAnnotationSpec) # type: ignore + return super().__new__(cls) + + @classmethod + def create(cls, **kwargs) -> 'AnnotationSpec': + """Factory method to create the appropriate AnnotationSpec subclass based on type.""" + annotation_type = kwargs.get('type') + + if annotation_type == AnnotationType.CATEGORY: + return CategoryAnnotationSpec(**kwargs) + + return cls(**kwargs) + + def asdict(self): + """Convert the entity to a dictionary, including unknown fields.""" + return self.model_dump(warnings='none', exclude_none=True) + + def asjson(self) -> str: + """Convert the entity to a JSON string, including unknown fields.""" + return self.model_dump_json(warnings='none', exclude_none=True) + + +class CategoryAnnotationSpec(AnnotationSpec): + type: AnnotationType = AnnotationType.CATEGORY + values: list[str] diff --git a/datamint/entities/base_entity.py b/datamint/entities/base_entity.py index cd2d9596..b49daab3 100644 --- a/datamint/entities/base_entity.py +++ b/datamint/entities/base_entity.py @@ -4,7 +4,6 @@ from pydantic import ConfigDict, BaseModel, PrivateAttr if TYPE_CHECKING: - from datamint.api.client import Api from datamint.api.entity_base_api import EntityBaseApi if sys.version_info >= (3, 11): diff --git a/datamint/entities/project.py b/datamint/entities/project.py index 032c7959..cafb4385 100644 --- a/datamint/entities/project.py +++ b/datamint/entities/project.py @@ -1,7 +1,8 @@ """Project entity module for DataMint API.""" from datetime import datetime import logging -from typing import Sequence, Literal, TYPE_CHECKING +from typing import Literal, TYPE_CHECKING +from collections.abc import Sequence from .base_entity import BaseEntity, MISSING_FIELD from typing import Any import webbrowser @@ -10,6 +11,7 @@ if TYPE_CHECKING: from datamint.api.endpoints.projects_api import ProjectsApi from .resource import Resource + from datamint.entities.annotations.annotation_spec import AnnotationSpec logger = logging.getLogger(__name__) @@ -144,3 +146,11 @@ def as_torch_dataset(self, auto_update=auto_update, return_as_semantic_segmentation=return_as_semantic_segmentation, all_annotations=True) + + def get_annotations_specs(self) -> Sequence['AnnotationSpec']: + """Get the annotations specs for this project. + + Returns: + Sequence of AnnotationSpec instances for the project. + """ + return self._api.get_annotations_specs(self) \ No newline at end of file diff --git a/datamint/entities/resource.py b/datamint/entities/resource.py index a6809adb..35062c9f 100644 --- a/datamint/entities/resource.py +++ b/datamint/entities/resource.py @@ -113,10 +113,6 @@ def __new__(cls, *args, **kwargs): return super().__new__(LocalResource) return super().__new__(cls) - def __init__(self, **data): - """Initialize the resource entity.""" - super().__init__(**data) - @property def _cache(self) -> CacheManager[bytes]: if not hasattr(self, '__cache'): From aeef9891bf0c1df8a23ce6cfb3b9ba8b6db63e6e Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Fri, 13 Feb 2026 18:29:21 -0300 Subject: [PATCH 04/47] Implemented sliced dataset --- datamint/dataset/__init__.py | 3 + datamint/dataset/sliced_dataset.py | 450 +++++++++++++++++++++++++++++ datamint/dataset/volume_dataset.py | 38 ++- datamint/entities/cache_manager.py | 86 +++++- datamint/entities/resource.py | 13 +- pyproject.toml | 3 +- 6 files changed, 584 insertions(+), 9 deletions(-) create mode 100644 datamint/dataset/sliced_dataset.py diff --git a/datamint/dataset/__init__.py b/datamint/dataset/__init__.py index 14a7db11..336d85f0 100644 --- a/datamint/dataset/__init__.py +++ b/datamint/dataset/__init__.py @@ -13,6 +13,7 @@ from .base import DatamintBaseDataset, DatamintDatasetException from .image_dataset import ImageDataset from .volume_dataset import VolumeDataset +from .sliced_dataset import SlicedVolumeDataset, SlicedVolumeResource __all__ = [ # Core @@ -21,4 +22,6 @@ # Specialized datasets 'ImageDataset', 'VolumeDataset', + 'SlicedVolumeDataset', + 'SlicedVolumeResource', ] \ No newline at end of file diff --git a/datamint/dataset/sliced_dataset.py b/datamint/dataset/sliced_dataset.py new file mode 100644 index 00000000..94545048 --- /dev/null +++ b/datamint/dataset/sliced_dataset.py @@ -0,0 +1,450 @@ +""" +SlicedVolumeDataset - 2D dataset created by slicing a VolumeDataset along an axis. + +Provides a way to iterate over individual 2D slices from 3D volume data, +enabling training of 2D models on volumetric medical imaging data. +""" +import gzip +import logging +from typing import Any +from typing_extensions import override +from collections.abc import Sequence + +import numpy as np +import torch +from torch import Tensor +import albumentations + +from medimgkit.readers import read_array_normalized + +from .base import DatamintBaseDataset +from .annotation_processor import AnnotationProcessor, MergeStrategy + +from datamint.entities import Annotation, Resource +from datamint.entities.cache_manager import CacheManager + +_LOGGER = logging.getLogger(__name__) + +# Cache key for parsed slice numpy arrays +_SLICE_ARRAY_CACHEKEY = "slice_array" + + +class SlicedVolumeResource: + """Proxy that presents a single 2D slice of a 3D volume Resource. + + This class wraps a :class:`Resource` and represents a specific 2D slice + along a given axis. It uses gzip-compressed ``.npy.gz`` files on disk + for efficient storage, with an in-memory LRU cache managed by + :class:`CacheManager`. + + The CacheManager memory cache is disabled by default globally, but the + sliced-volume cache manager enables it by default. + + This shared cache avoids repeated gzip decompression for already-cached + slices. Full-volume caching is intentionally not handled here. + + Args: + parent: The original 3D volume Resource. + slice_index: The index of the slice along the given axis. + slice_axis: The spatial axis to slice along (0=axial/depth, 1=coronal/height, 2=sagittal/width). + sliced_vols_cache: Shared :class:`CacheManager` for disk-based volume caching. + """ + + def __init__( + self, + parent: Resource, + slice_index: int, + slice_axis: int, + sliced_vols_cache: CacheManager, + ): + self._parent = parent + self.slice_index = slice_index + self.slice_axis = slice_axis + self._volume_cache = sliced_vols_cache + + def __getattr__(self, name: str) -> Any: + """Delegate all unresolved attributes to the parent Resource.""" + return getattr(self._parent, name) + + def get_depth(self) -> int: + """A single slice has depth 1.""" + return 1 + + def _get_version_info(self) -> dict: + """Get version info from the parent resource for cache validation.""" + return { + 'created_at': self._parent.created_at, + 'deleted_at': self._parent.deleted_at, + 'size': self._parent.size, + } + + def _slice_cache_entity_id(self) -> str: + return f"{self._parent.id}:axis{self.slice_axis}:slice{self.slice_index}" + + def fetch_slice_data(self) -> np.ndarray: + """Fetch the 2D slice as a (C, H, W) array. + + Returns: + Slice array with shape (C, H, W). + """ + version_info = self._get_version_info() + cache_entity_id = self._slice_cache_entity_id() + + cached_slice = self._volume_cache.get( + cache_entity_id, + _SLICE_ARRAY_CACHEKEY, + version_info, + ) + if cached_slice is not None: + return np.ascontiguousarray(cached_slice) + + raw = self._parent.fetch_file_data(auto_convert=False, use_cache=True) + vol, _meta = read_array_normalized(raw, return_metainfo=True) + vol = vol.transpose(1, 0, 2, 3) + + sliced = np.take(vol, self.slice_index, axis=self.slice_axis + 1) + sliced = np.ascontiguousarray(sliced) + + gz_path = self._volume_cache.get_expected_path(cache_entity_id, _SLICE_ARRAY_CACHEKEY) + gz_path = gz_path.with_suffix('.npy.gz') + gz_path.parent.mkdir(parents=True, exist_ok=True) + with gzip.open(str(gz_path), 'wb', compresslevel=4) as f: + np.save(f, sliced) + + self._volume_cache.register_file_location( + cache_entity_id, + _SLICE_ARRAY_CACHEKEY, + file_path=gz_path, + version_info=version_info, + mimetype='application/gzip', + data=sliced, + ) + + return sliced + + @property + def parent_resource(self) -> Resource: + """The original volume Resource being proxied.""" + return self._parent + + def __repr__(self) -> str: + axis_names = {0: 'axial', 1: 'coronal', 2: 'sagittal'} + axis_name = axis_names.get(self.slice_axis, str(self.slice_axis)) + return ( + f"SlicedVolumeResource(filename='{self._parent.filename}', " + f"axis='{axis_name}', slice={self.slice_index})" + ) + + +# Axis mapping for anatomical orientations +SLICE_AXIS_MAP = { + 'axial': 0, # slicing along depth (superior-inferior) + 'coronal': 1, # slicing along height (anterior-posterior) + 'sagittal': 2, # slicing along width (left-right) +} + +_AXIS_INT_TO_NAME = {v: k for k, v in SLICE_AXIS_MAP.items()} + + +class SlicedVolumeDataset(DatamintBaseDataset): + """2D dataset created by slicing a VolumeDataset along an axis. + + Each item corresponds to a single 2D slice from a 3D volume. + The ``__getitem__`` returns arrays with shape ``(C, H, W)`` for images + and ``(num_instances, H, W)`` or ``(num_labels+1, H, W)`` for segmentations. + + Typically created via :meth:`VolumeDataset.slice`, but can also be + instantiated directly. + + Args: + parent_dataset: The source :class:`DatamintBaseDataset` (e.g. VolumeDataset) + providing resources, annotations, and configuration. + slice_axis: Slice orientation. One of ``'axial'`` (depth), ``'coronal'`` + (height), ``'sagittal'`` (width), or an integer axis index (0--2). + """ + + def __init__( + self, + parent_dataset: 'DatamintBaseDataset', + slice_axis: str | int = 'axial', + ): + # We intentionally do NOT call super().__init__() because that + # requires project/API interaction. Instead, copy needed state + # from the parent dataset. + + # --- Resolve axis --- + if isinstance(slice_axis, str): + if slice_axis not in SLICE_AXIS_MAP: + raise ValueError( + f"Unknown axis '{slice_axis}'. " + f"Must be one of {list(SLICE_AXIS_MAP.keys())} or an int 0-2." + ) + self._slice_axis_int = SLICE_AXIS_MAP[slice_axis] + self._slice_axis = slice_axis + else: + if not (0 <= slice_axis <= 2): + raise ValueError(f"axis must be 0, 1, or 2, got {slice_axis}") + self._slice_axis_int = slice_axis + self._slice_axis = _AXIS_INT_TO_NAME.get(slice_axis, str(slice_axis)) + + self.project = parent_dataset.project + + # Copy configuration from parent + self.return_metainfo = parent_dataset.return_metainfo + self.return_segmentations = parent_dataset.return_segmentations + self.return_as_semantic_segmentation = parent_dataset.return_as_semantic_segmentation + self.semantic_seg_merge_strategy: MergeStrategy | None = parent_dataset.semantic_seg_merge_strategy + self.include_unannotated = parent_dataset.include_unannotated + + # Transforms + self.alb_transform = parent_dataset.alb_transform + + # Filtering (already applied on parent's annotations) + self.include_annotators = parent_dataset.include_annotators + self.exclude_annotators = parent_dataset.exclude_annotators + self.include_segmentation_names = parent_dataset.include_segmentation_names + self.exclude_segmentation_names = parent_dataset.exclude_segmentation_names + self.include_image_label_names = parent_dataset.include_image_label_names + self.exclude_image_label_names = parent_dataset.exclude_image_label_names + self.include_frame_label_names = parent_dataset.include_frame_label_names + self.exclude_frame_label_names = parent_dataset.exclude_frame_label_names + + # Copy label sets and processor from parent + self.annotation_processor = parent_dataset.annotation_processor + self.frame_lsets = parent_dataset.frame_lsets + self.frame_lcodes = parent_dataset.frame_lcodes + self.image_lsets = parent_dataset.image_lsets + self.image_lcodes = parent_dataset.image_lcodes + self.seglabel_list = parent_dataset.seglabel_list + self.seglabel2code = parent_dataset.seglabel2code + + # Internal state + self._logged_uint16_conversion = False + + # --- Build sliced resources --- + volume_cache = CacheManager( + 'sliced_volumes', + enable_memory_cache=True, + memory_cache_maxsize=2, + ) + expanded_resources, expanded_annotations = self._expand_resources( + parent_dataset.resources, + parent_dataset.resource_annotations, + volume_cache, + ) + self.resources = expanded_resources # type: ignore[assignment] + self.resource_annotations = expanded_annotations + + _LOGGER.info( + f"Created SlicedVolumeDataset with {len(self.resources)} slices " + f"from {len(parent_dataset.resources)} volumes (axis={self._slice_axis})" + ) + + def _expand_resources( + self, + resources: Sequence[Resource], + resource_annotations: Sequence[Sequence[Annotation]], + volume_cache: CacheManager, + ) -> tuple[list[SlicedVolumeResource], list[Sequence[Annotation]]]: + """Expand volume resources into per-slice proxy resources. + + Args: + resources: Original volume resources. + resource_annotations: Parallel annotation sequences. + volume_cache: Shared LRU cache for parsed volumes. + + Returns: + Tuple of (sliced_resources, sliced_annotations). + """ + axis_int = self._slice_axis_int + sliced_resources: list[SlicedVolumeResource] = [] + sliced_annotations: list[Sequence[Annotation]] = [] + + for i, resource in enumerate(resources): + # Determine number of slices along the requested axis + if axis_int == 0: + # Axial: use metadata (no full load needed) + num_slices = resource.get_depth() + else: + # Coronal/Sagittal: parse volume once to infer spatial dims + raw = resource.fetch_file_data(auto_convert=False, use_cache=True) + vol, _meta = read_array_normalized(raw, return_metainfo=True) + vol = vol.transpose(1, 0, 2, 3) + num_slices = vol.shape[axis_int + 1] + + anns = resource_annotations[i] + + for s in range(num_slices): + sliced_resources.append( + SlicedVolumeResource(resource, s, axis_int, volume_cache) + ) + sliced_annotations.append(anns) + + return sliced_resources, sliced_annotations + + @override + def _get_raw_item(self, index: int) -> dict[str, Any]: + """Load a single 2D slice and its annotations. + + Returns dict with: + - 'image': np.ndarray of shape (C, 1, H, W) — depth=1 to match pipeline expectations. + - 'metainfo': dict with volume metadata. + - 'annotations': Sequence of Annotation objects. + - 'resource': The SlicedVolumeResource proxy. + """ + resource: SlicedVolumeResource = self.resources[index] # type: ignore[assignment] + img = resource.fetch_slice_data() # shape: (C, H, W) + + # Add depth dim to match the pipeline expectation: (C, 1, H, W) + img = np.expand_dims(img, axis=1) + + anns = self.resource_annotations[index] + + return { + 'image': img, # shape: (C, 1, H, W) + 'annotations': anns, + 'resource': resource, + } + + @override + def __getitem__(self, index: int) -> dict[str, Any]: + """Get a 2D slice item with full processing. + + Returns dict with: + - 'image': np.ndarray or Tensor of shape (C, H, W). + - 'segmentations' (if enabled): segmentation masks with depth dimension removed. + - 'image_labels': dict of annotator -> label tensor. + """ + if index >= len(self): + raise IndexError(f"Index {index} out of bounds for dataset of size {len(self)}") + + result = self._get_raw_item(index) + + img = result['image'] + if isinstance(img, np.ndarray): + img = self._preprocess_image_array(img) + annotations = result['annotations'] + resource: SlicedVolumeResource = result['resource'] + _LOGGER.debug(f"Loaded slice {resource.slice_index} from {resource.filename} with shape {img.shape}") + + # Process segmentations + if self.return_segmentations: + seg_anns = AnnotationProcessor.filter_annotations( + annotations, type='segmentation', scope='all' + ) + segmentations, seg_labels, _ = self.annotation_processor.load_segmentations(seg_anns) + + # Slice segmentations along the same axis as the image + # segmentations[author] shape: (#instances, D, H, W) + slice_idx = resource.slice_index + sliced_segs: dict[str, np.ndarray] = {} + for author, seg_array in segmentations.items(): + # seg_array shape: (#instances, D, H, W) + # Select the slice: (#instances, H, W) + sliced_segs[author] = np.take(seg_array, slice_idx, axis=self._slice_axis_int + 1) + # Add depth=1 dim back for consistency with pipeline: (#instances, 1, H, W) + sliced_segs[author] = np.expand_dims(sliced_segs[author], axis=1) + + # Apply albumentations if present + if self.alb_transform: + aug_result = self.apply_alb_transform(img, sliced_segs) + img = aug_result['image'] + result['image'] = img + sliced_segs = aug_result['segmentations'] + + segmentations_processed, seg_labels_out = self._process_segmentations(sliced_segs, seg_labels) + + # Squeeze depth=1 dimension from segmentations + if isinstance(segmentations_processed, (Tensor, np.ndarray)): + segmentations_processed = segmentations_processed.squeeze(1) + elif isinstance(segmentations_processed, dict): + for author in segmentations_processed: + if isinstance(segmentations_processed[author], (Tensor, np.ndarray)): + segmentations_processed[author] = segmentations_processed[author].squeeze(1) + + result['segmentations'] = segmentations_processed + if seg_labels_out: + result['seg_labels'] = seg_labels_out + + # Process image-level labels + result['image_labels'] = self._extract_image_labels(annotations) + + # Squeeze depth=1 from image: (C, 1, H, W) -> (C, H, W) + img = result['image'] + if isinstance(img, (np.ndarray, Tensor)) and img.ndim == 4 and img.shape[1] == 1: + if isinstance(img, np.ndarray): + img = img.squeeze(axis=1) + else: + img = img.squeeze(1) + result['image'] = img + + return result + + @override + def apply_alb_transform( + self, + img: np.ndarray, + segmentations: dict[str, np.ndarray], + ) -> dict[str, Any]: + """Apply 2D albumentations transform to a single-slice image and masks. + + Uses the same approach as ImageDataset: treats the data as 2D. + + Args: + img: Image array of shape (C, 1, H, W) or (C, H, W). + segmentations: Dict of author -> mask arrays of shape (#instances, 1, H, W) or (#instances, H, W). + + Returns: + Dict with transformed 'image' and 'segmentations'. + """ + if self.alb_transform is None: + raise ValueError("alb_transform is not set") + + # Squeeze depth=1 if present + if img.ndim == 4: + if img.shape[1] != 1: + raise ValueError(f"Expected depth=1, got shape {img.shape}") + img = img.squeeze(1) # (C, 1, H, W) -> (C, H, W) + elif img.ndim != 3: + raise ValueError(f"Expected 3D or 4D image array, got shape {img.shape}") + + # Transpose to (H, W, C) for albumentations + img = np.transpose(img, (1, 2, 0)) + + replay_alb_transf = albumentations.ReplayCompose([self.alb_transform]) + + aug_data = replay_alb_transf(image=img) + replay_data = aug_data['replay'] + aug_img = aug_data['image'] + + aug_segmentations: dict[str, np.ndarray] = {} + for author, segs in segmentations.items(): + had_depth = False + if segs.ndim == 4 and segs.shape[1] == 1: + had_depth = True + segs = segs.squeeze(1) # (#instances, 1, H, W) -> (#instances, H, W) + aug_segs = replay_alb_transf.replay(replay_data, masks=segs)['masks'] + if had_depth: + aug_segs = aug_segs[:, np.newaxis, :, :] + aug_segmentations[author] = aug_segs + + # Transpose back to (C, H, W) + if isinstance(aug_img, np.ndarray): + aug_img = np.transpose(aug_img, (2, 0, 1)) + elif isinstance(aug_img, torch.Tensor): + if aug_img.shape[0] == img.shape[-1]: + pass # already (C, H, W) + else: + aug_img = aug_img.permute(2, 0, 1) + + # Add depth=1 back: (C, 1, H, W) + aug_img = aug_img[:, np.newaxis, :, :] + + return { + 'image': aug_img, + 'segmentations': aug_segmentations, + } + + def __repr__(self) -> str: + base = super().__repr__() + return f"SlicedVolumeDataset (axis={self._slice_axis})\n{base}" diff --git a/datamint/dataset/volume_dataset.py b/datamint/dataset/volume_dataset.py index 1de1e560..a2f4e6ed 100644 --- a/datamint/dataset/volume_dataset.py +++ b/datamint/dataset/volume_dataset.py @@ -5,7 +5,7 @@ with support for different slice orientations and affine preservation. """ import logging -from typing import Any +from typing import Any, TYPE_CHECKING from typing_extensions import override import torch @@ -15,6 +15,9 @@ from medimgkit.readers import read_array_normalized from .base import DatamintBaseDataset +if TYPE_CHECKING: + from .sliced_dataset import SlicedVolumeDataset + _LOGGER = logging.getLogger(__name__) @@ -109,3 +112,36 @@ def apply_alb_transform( def __repr__(self) -> str: base = super().__repr__() return f"VolumeDataset\n{base}" + + def slice(self, axis: str | int = 'axial') -> 'SlicedVolumeDataset': + """Create a 2D dataset by slicing this volume along an axis. + + Each 3D volume is expanded into multiple 2D slices, one per depth index + along the given axis. The returned dataset yields 2D items with shape + ``(C, H, W)`` instead of ``(C, D, H, W)``. + + Parsed volumes are cached to disk as gzip-compressed ``.npy.gz`` files. + A shared in-memory LRU cache also keeps recently used full volumes to + avoid repeated decompression when iterating neighboring slices. + + Args: + axis: Slice orientation. One of ``'axial'`` (depth), ``'coronal'`` + (height), ``'sagittal'`` (width), or an integer axis index (0--2). + + Returns: + A :class:`SlicedVolumeDataset` that iterates over individual 2D slices. + + Example:: + + vol_ds = VolumeDataset(project='my_ct_project') + sliced = vol_ds.slice(axis='axial') + print(len(sliced)) # total number of axial slices across all volumes + item = sliced[0] + print(item['image'].shape) # (C, H, W) + """ + from .sliced_dataset import SlicedVolumeDataset + + return SlicedVolumeDataset( + parent_dataset=self, + slice_axis=axis, + ) diff --git a/datamint/entities/cache_manager.py b/datamint/entities/cache_manager.py index a78574e3..c06adfcf 100644 --- a/datamint/entities/cache_manager.py +++ b/datamint/entities/cache_manager.py @@ -8,10 +8,13 @@ import json import logging import pickle +import gzip +import numpy as np from datetime import datetime from pathlib import Path from typing import Any, TypeVar, Generic from pydantic import BaseModel +from cachetools import LRUCache # import appdirs import datamint.configs @@ -51,12 +54,21 @@ class ItemMetadata(BaseModel): version_info: dict | None = None entity_id: str | None = None - def __init__(self, entity_type: str, cache_root: Path | str | None = None): + def __init__( + self, + entity_type: str, + cache_root: Path | str | None = None, + enable_memory_cache: bool = False, + memory_cache_maxsize: int = 2, + ): """Initialize the cache manager. Args: entity_type: Type of entity (e.g., 'resources', 'annotations') cache_root: Root directory for cache. If None, uses system cache directory. + enable_memory_cache: Whether to enable an in-process LRU memory cache. + Disabled by default. + memory_cache_maxsize: Maximum number of entries in the in-memory LRU cache. """ self.entity_type = entity_type @@ -69,6 +81,56 @@ def __init__(self, entity_type: str, cache_root: Path | str | None = None): cache_root = Path(cache_root) self.cache_root = cache_root / entity_type + self._memory_cache: LRUCache[tuple[str, str, str | None], T] | None = None + if enable_memory_cache: + self._memory_cache = LRUCache(maxsize=memory_cache_maxsize) + + def _get_memory_key( + self, + entity_id: str, + data_key: str, + version_info: dict[str, Any] | None = None, + ) -> tuple[str, str, str | None]: + version_hash = None + if version_info is not None: + version_hash = self._compute_version_hash(version_info) + return (entity_id, data_key, version_hash) + + def get_memory( + self, + entity_id: str, + data_key: str, + version_info: dict[str, Any] | None = None, + ) -> T | None: + if self._memory_cache is None: + return None + key = self._get_memory_key(entity_id, data_key, version_info) + return self._memory_cache.get(key) + + def set_memory( + self, + entity_id: str, + data_key: str, + data: T, + version_info: dict[str, Any] | None = None, + ) -> None: + if self._memory_cache is None: + return + key = self._get_memory_key(entity_id, data_key, version_info) + self._memory_cache[key] = data + + def invalidate_memory(self, entity_id: str, data_key: str | None = None) -> None: + if self._memory_cache is None: + return + keys_to_remove = [] + for eid, dkey, vhash in self._memory_cache.keys(): + if eid != entity_id: + continue + if data_key is not None and dkey != data_key: + continue + keys_to_remove.append((eid, dkey, vhash)) + for key in keys_to_remove: + self._memory_cache.pop(key, None) def _get_entity_cache_dir(self, entity_id: str) -> Path: """Get the cache directory for a specific entity. @@ -191,6 +253,11 @@ def get( Returns: Cached data if valid, None if cache miss or invalid """ + mem_data = self.get_memory(entity_id, data_key, version_info) + if mem_data is not None: + _LOGGER.debug(f"Memory cache hit for {entity_id}/{data_key}") + return mem_data + cached_metadata, data_path = self._get_validated_metadata(entity_id, data_key, version_info) if cached_metadata is None: @@ -198,6 +265,7 @@ def get( try: data = self._load_data(cached_metadata) + self.set_memory(entity_id, data_key, data, version_info) _LOGGER.debug(f"Cache hit for {entity_id}/{data_key}") return data except Exception as e: @@ -243,7 +311,8 @@ def register_file_location( data_key: str, file_path: str | Path, version_info: dict[str, Any] | None = None, - mimetype: str = 'application/octet-stream' + mimetype: str = 'application/octet-stream', + data: T | None = None, ) -> None: """Register an external file location in cache metadata without copying data. @@ -256,6 +325,7 @@ def register_file_location( file_path: Path to the external file to register version_info: Optional version information from server mimetype: MIME type of the file data + data: Optional data object to populate the in-memory cache immediately. """ metadata_path = self._get_metadata_path(entity_id) file_path = Path(file_path).resolve().absolute() @@ -279,6 +349,9 @@ def register_file_location( with open(metadata_path, 'w') as f: f.write(metadata.model_dump_json(indent=2)) + if data is not None: + self.set_memory(entity_id, data_key, data, version_info) + _LOGGER.debug(f"Registered external file for {entity_id}/{data_key}: {file_path}") except Exception as e: @@ -324,6 +397,7 @@ def set( with open(metadata_path, 'w') as f: f.write(metadata.model_dump_json(indent=2)) + self.set_memory(entity_id, data_key, data, version_info) _LOGGER.debug(f"Cached data for {entity_id}/{data_key}") except Exception as e: @@ -335,6 +409,11 @@ def _load_data(self, if metadata.mimetype == 'application/octet-stream': with open(path, 'rb') as f: return f.read() + elif metadata.mimetype == 'application/gzip': + with gzip.open(path, 'rb') as f: + return np.load(f) + elif metadata.mimetype == 'application/x-numpy': + return np.load(path) else: with open(path, 'rb') as f: return pickle.load(f) @@ -360,6 +439,7 @@ def invalidate(self, entity_id: str, data_key: str | None = None) -> None: entity_id: Unique identifier for the entity data_key: Optional key for specific data. If None, invalidates all data for entity. """ + self.invalidate_memory(entity_id, data_key) if data_key is None: # Invalidate entire entity cache entity_dir = self._get_entity_cache_dir(entity_id) @@ -388,6 +468,8 @@ def invalidate(self, entity_id: str, data_key: str | None = None) -> None: def clear_all(self) -> None: """Clear all cached data for this entity type.""" + if self._memory_cache is not None: + self._memory_cache.clear() if self.cache_root.exists(): import shutil shutil.rmtree(self.cache_root) diff --git a/datamint/entities/resource.py b/datamint/entities/resource.py index bfccbdd2..53aedd97 100644 --- a/datamint/entities/resource.py +++ b/datamint/entities/resource.py @@ -7,7 +7,7 @@ import urllib.request import webbrowser from pathlib import Path -from typing import TYPE_CHECKING, Literal, overload +from typing import TYPE_CHECKING, ClassVar, Literal, overload from collections.abc import Sequence from pydantic import PrivateAttr @@ -107,6 +107,7 @@ class Resource(BaseEntity): user_info: dict[str, str | None] = MISSING_FIELD _api: 'ResourcesApi' = PrivateAttr() + _shared_cache: ClassVar[CacheManager[bytes] | None] = None def __new__(cls, *args, **kwargs): if cls is Resource and ('local_filepath' in kwargs or 'raw_data' in kwargs): @@ -115,10 +116,12 @@ def __new__(cls, *args, **kwargs): @property def _cache(self) -> CacheManager[bytes]: - if not hasattr(self, '__cache'): - self.__cache = CacheManager[bytes]('resources') - return self.__cache - + if Resource._shared_cache is None: + Resource._shared_cache = CacheManager[bytes]('resources', + enable_memory_cache=True, + memory_cache_maxsize=2) + return Resource._shared_cache + @overload def fetch_file_data( self, diff --git a/pyproject.toml b/pyproject.toml index 175c689b..48b64142 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ platformdirs = "^4.0.0" pandas = ">=2.0.0" matplotlib = "*" lightning = { extras = ['extra'], version = ">=2.0.0, !=2.5.1, !=2.5.1.post0" } -mlflow = ">=3.8.1" # version 2 has security issues +mlflow-skinny = "==3.8.1" albumentations = ">=2.0.0" lazy-loader = ">=0.3.0" medimgkit = ">=0.11.2" @@ -46,6 +46,7 @@ typing_extensions = ">=4.0.0" pydantic = ">=2.6.4" certifi = ">=2025.0.0" httpx = "*" +cachetools = ">=6.0.0" backports-strenum = { version = "*", python = "<3.11" } # For compatibility with the datamintapi package # datamintapi = "0.0.*" From 050dfeb59bfe20dbc4e400daa463fee1bc64d841 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 23 Feb 2026 12:19:21 -0300 Subject: [PATCH 05/47] Improve axis handling; update dependencies in pyproject.toml --- datamint/dataset/sliced_dataset.py | 104 ++++++++++++++++++----------- pyproject.toml | 6 +- 2 files changed, 69 insertions(+), 41 deletions(-) diff --git a/datamint/dataset/sliced_dataset.py b/datamint/dataset/sliced_dataset.py index 94545048..3fd5954e 100644 --- a/datamint/dataset/sliced_dataset.py +++ b/datamint/dataset/sliced_dataset.py @@ -6,7 +6,7 @@ """ import gzip import logging -from typing import Any +from typing import Any, Literal, TYPE_CHECKING from typing_extensions import override from collections.abc import Sequence @@ -16,12 +16,15 @@ import albumentations from medimgkit.readers import read_array_normalized +from medimgkit import dicom_utils +from medimgkit import nifti_utils from .base import DatamintBaseDataset from .annotation_processor import AnnotationProcessor, MergeStrategy -from datamint.entities import Annotation, Resource from datamint.entities.cache_manager import CacheManager +if TYPE_CHECKING: + from datamint.entities import Annotation, Resource _LOGGER = logging.getLogger(__name__) @@ -52,7 +55,7 @@ class SlicedVolumeResource: def __init__( self, - parent: Resource, + parent: 'Resource', slice_index: int, slice_axis: int, sliced_vols_cache: CacheManager, @@ -123,7 +126,7 @@ def fetch_slice_data(self) -> np.ndarray: return sliced @property - def parent_resource(self) -> Resource: + def parent_resource(self) -> 'Resource': """The original volume Resource being proxied.""" return self._parent @@ -136,14 +139,14 @@ def __repr__(self) -> str: ) -# Axis mapping for anatomical orientations -SLICE_AXIS_MAP = { - 'axial': 0, # slicing along depth (superior-inferior) - 'coronal': 1, # slicing along height (anterior-posterior) - 'sagittal': 2, # slicing along width (left-right) -} +# # Axis mapping for anatomical orientations +# SLICE_AXIS_MAP = { +# 'axial': 0, # slicing along depth (superior-inferior) +# 'coronal': 1, # slicing along height (anterior-posterior) +# 'sagittal': 2, # slicing along width (left-right) +# } -_AXIS_INT_TO_NAME = {v: k for k, v in SLICE_AXIS_MAP.items()} +# _AXIS_INT_TO_NAME = {v: k for k, v in SLICE_AXIS_MAP.items()} class SlicedVolumeDataset(DatamintBaseDataset): @@ -166,7 +169,7 @@ class SlicedVolumeDataset(DatamintBaseDataset): def __init__( self, parent_dataset: 'DatamintBaseDataset', - slice_axis: str | int = 'axial', + slice_axis: Literal['axial', 'coronal', 'sagittal'] | int = 'axial', ): # We intentionally do NOT call super().__init__() because that # requires project/API interaction. Instead, copy needed state @@ -174,18 +177,17 @@ def __init__( # --- Resolve axis --- if isinstance(slice_axis, str): - if slice_axis not in SLICE_AXIS_MAP: + valid_slice_axis = ['axial', 'coronal', 'sagittal'] + if slice_axis not in valid_slice_axis: raise ValueError( f"Unknown axis '{slice_axis}'. " - f"Must be one of {list(SLICE_AXIS_MAP.keys())} or an int 0-2." + f"Must be one of {valid_slice_axis} or an int 0-2." ) - self._slice_axis_int = SLICE_AXIS_MAP[slice_axis] self._slice_axis = slice_axis else: if not (0 <= slice_axis <= 2): raise ValueError(f"axis must be 0, 1, or 2, got {slice_axis}") self._slice_axis_int = slice_axis - self._slice_axis = _AXIS_INT_TO_NAME.get(slice_axis, str(slice_axis)) self.project = parent_dataset.project @@ -235,17 +237,30 @@ def __init__( self.resources = expanded_resources # type: ignore[assignment] self.resource_annotations = expanded_annotations - _LOGGER.info( - f"Created SlicedVolumeDataset with {len(self.resources)} slices " - f"from {len(parent_dataset.resources)} volumes (axis={self._slice_axis})" - ) + @staticmethod + def _get_slice_axis_int(r: 'Resource', + slice_axis: Literal['axial', 'coronal', 'sagittal']) -> int: + if r.is_dicom(): + dicom_data = r.fetch_file_data(auto_convert=True, use_cache=True) + ret = dicom_utils.get_plane_axis(dicom_data, plane=slice_axis) + if ret is None: + raise ValueError(f"Could not determine slice axis for DICOM resource {r.id} with plane '{slice_axis}'") + return ret + elif r.is_nifti(): + nifti_data = r.fetch_file_data(auto_convert=True, use_cache=True) + ret = nifti_utils.get_plane_axis(nifti_data, plane=slice_axis) + if ret is None: + raise ValueError(f"Could not determine slice axis for NIfTI resource {r.id} with plane '{slice_axis}'") + return ret + else: + raise ValueError(f"Unsupported resource type for slice axis inference: {r.filename} | {r.mimetype}") def _expand_resources( self, - resources: Sequence[Resource], - resource_annotations: Sequence[Sequence[Annotation]], + resources: Sequence['Resource'], + resource_annotations: Sequence[Sequence['Annotation']], volume_cache: CacheManager, - ) -> tuple[list[SlicedVolumeResource], list[Sequence[Annotation]]]: + ) -> tuple[list[SlicedVolumeResource], list[Sequence['Annotation']]]: """Expand volume resources into per-slice proxy resources. Args: @@ -256,27 +271,36 @@ def _expand_resources( Returns: Tuple of (sliced_resources, sliced_annotations). """ - axis_int = self._slice_axis_int sliced_resources: list[SlicedVolumeResource] = [] - sliced_annotations: list[Sequence[Annotation]] = [] - - for i, resource in enumerate(resources): - # Determine number of slices along the requested axis - if axis_int == 0: - # Axial: use metadata (no full load needed) - num_slices = resource.get_depth() + sliced_annotations: list[Sequence['Annotation']] = [] + + for i, r in enumerate(resources): + if not hasattr(self, '_slice_axis_int'): + res_data = r.fetch_file_data(auto_convert=True, use_cache=True) + if r.is_dicom(): + axis_int = dicom_utils.get_plane_axis(res_data, plane=self._slice_axis) + + if axis_int is None: + raise ValueError( + f"Could not determine slice axis for DICOM resource {r.id} with plane '{self._slice_axis}'") + axis_size = dicom_utils.get_dim_size(res_data, axis_int) + elif r.is_nifti(): + axis_int = nifti_utils.get_plane_axis(res_data, plane=self._slice_axis) + if axis_int is None: + raise ValueError( + f"Could not determine slice axis for NIfTI resource {r.id} with plane '{self._slice_axis}'") + axis_size = nifti_utils.get_dim_size(res_data, axis_int) + else: + raise ValueError(f"Unsupported resource type for slice axis inference: {r.filename} | {r.mimetype}") else: - # Coronal/Sagittal: parse volume once to infer spatial dims - raw = resource.fetch_file_data(auto_convert=False, use_cache=True) - vol, _meta = read_array_normalized(raw, return_metainfo=True) - vol = vol.transpose(1, 0, 2, 3) - num_slices = vol.shape[axis_int + 1] + # TODO + raise NotImplementedError anns = resource_annotations[i] - for s in range(num_slices): + for s in range(axis_size): sliced_resources.append( - SlicedVolumeResource(resource, s, axis_int, volume_cache) + SlicedVolumeResource(r, s, axis_int, volume_cache) ) sliced_annotations.append(anns) @@ -328,6 +352,7 @@ def __getitem__(self, index: int) -> dict[str, Any]: _LOGGER.debug(f"Loaded slice {resource.slice_index} from {resource.filename} with shape {img.shape}") # Process segmentations + # FIXME: This currently re-loads the slice data for each segmentation annotation, which is inefficient. We should ideally load the slice once and reuse it for all segmentations. This may require refactoring how annotations are processed to avoid redundant data loading. if self.return_segmentations: seg_anns = AnnotationProcessor.filter_annotations( annotations, type='segmentation', scope='all' @@ -341,7 +366,8 @@ def __getitem__(self, index: int) -> dict[str, Any]: for author, seg_array in segmentations.items(): # seg_array shape: (#instances, D, H, W) # Select the slice: (#instances, H, W) - sliced_segs[author] = np.take(seg_array, slice_idx, axis=self._slice_axis_int + 1) + axis_index = resource.slice_axis + 1 # account for instance dimension + sliced_segs[author] = np.take(seg_array, slice_idx, axis=axis_index) # Add depth=1 dim back for consistency with pipeline: (#instances, 1, H, W) sliced_segs[author] = np.expand_dims(sliced_segs[author], axis=1) diff --git a/pyproject.toml b/pyproject.toml index c876c892..d6167bf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,10 +38,12 @@ platformdirs = "^4.0.0" pandas = ">=2.0.0" matplotlib = "*" lightning = { extras = ['extra'], version = ">=2.0.0, !=2.5.1, !=2.5.1.post0" } -mlflow-skinny = "==3.8.1" +mlflow-skinny = "==3.8.*" +Flask = { version = "<4" } +Flask-Cors = { version = "<7" } albumentations = ">=2.0.0" lazy-loader = ">=0.3.0" -medimgkit = ">=0.11.4" +medimgkit = ">=0.13.0" typing_extensions = ">=4.0.0" pydantic = ">=2.6.4" certifi = ">=2025.0.0" From ca7c601258a0e3d85f8f2eea444ad87964813189 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Fri, 27 Feb 2026 08:20:35 -0300 Subject: [PATCH 06/47] improved slicing logic in SlicedVolumeResource --- datamint/dataset/annotation_processor.py | 7 - datamint/dataset/base.py | 2 +- datamint/dataset/sliced_dataset.py | 183 ++++++++++++++--------- 3 files changed, 112 insertions(+), 80 deletions(-) diff --git a/datamint/dataset/annotation_processor.py b/datamint/dataset/annotation_processor.py index a5cb1d8b..0dbe50f4 100644 --- a/datamint/dataset/annotation_processor.py +++ b/datamint/dataset/annotation_processor.py @@ -68,7 +68,6 @@ def collate_frame_segmentations(self, try: seg = self.load_segmentation_data(ann) seg_code_i = self.seglabel2code.get(ann.identifier, 0) - _LOGGER.debug(f'Processing frame annotation {ann.id} at index {ann.frame_index} with shape {seg.shape}') if seg_code != -1 and seg_code != seg_code_i: raise ValueError(f"Conflicting segmentation codes for frame annotations: " f"{seg_code} vs {seg_code_i}") @@ -189,9 +188,6 @@ def load_frame_segmentations( seg_labels = defaultdict(list) seg_anns = defaultdict(list) - _LOGGER.debug( - f"Found {len(seg_frame_anns_map)} unique (author, identifier) groups for frame-level segmentations") - for (author, identifier), fr_anns in seg_frame_anns_map.items(): stacked_seg, seg_code = self.collate_frame_segmentations(fr_anns) if stacked_seg is None: @@ -210,7 +206,6 @@ def _stack_segmentations(self, final_segmentations: dict[str, np.ndarray] = {} final_seg_labels: dict[str, np.ndarray] = {} for author in segmentations: - _LOGGER.debug(f"Author {author} has {len(segmentations[author])} segmentations to stack") final_segmentations[author] = np.stack(segmentations[author], axis=0) # (#num_instances, Z, H, W) final_seg_labels[author] = np.array(seg_labels[author], dtype=np.int32) @@ -399,8 +394,6 @@ def apply_merge_strategy( segmentations = {author: torch.from_numpy(seg) for author, seg in segmentations.items()} return self.apply_merge_strategy(segmentations, strategy, output_shape).numpy() - _LOGGER.debug( - f"Applying merge strategy '{strategy}' to {len(segmentations)} segmentations of type {type(next(iter(segmentations.values())))}") if strategy == 'union': merged = self._merge_union(segmentations) elif strategy == 'intersection': diff --git a/datamint/dataset/base.py b/datamint/dataset/base.py index c0aa90e3..4122d7ad 100644 --- a/datamint/dataset/base.py +++ b/datamint/dataset/base.py @@ -525,8 +525,8 @@ def _process_segmentations(self, ) _LOGGER.debug(f"Merged segmentation shape: {segmentations.shape}") + # 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. seg_labels = None - _LOGGER.debug(f'merged segmentations. Final shape: {segmentations.shape}') return segmentations, seg_labels diff --git a/datamint/dataset/sliced_dataset.py b/datamint/dataset/sliced_dataset.py index 3fd5954e..59114f2c 100644 --- a/datamint/dataset/sliced_dataset.py +++ b/datamint/dataset/sliced_dataset.py @@ -6,6 +6,7 @@ """ import gzip import logging +from functools import cached_property from typing import Any, Literal, TYPE_CHECKING from typing_extensions import override from collections.abc import Sequence @@ -49,22 +50,48 @@ class SlicedVolumeResource: Args: parent: The original 3D volume Resource. slice_index: The index of the slice along the given axis. - slice_axis: The spatial axis to slice along (0=axial/depth, 1=coronal/height, 2=sagittal/width). + slice_axis: The spatial axis to slice along ('axial', 'coronal', 'sagittal'). sliced_vols_cache: Shared :class:`CacheManager` for disk-based volume caching. """ + _CACHE_MANAGER_NAMESPACE = "sliced_volumes" + def __init__( self, parent: 'Resource', slice_index: int, - slice_axis: int, - sliced_vols_cache: CacheManager, + slice_axis: str, + sliced_vols_cache: CacheManager | None = None, ): self._parent = parent self.slice_index = slice_index self.slice_axis = slice_axis + if sliced_vols_cache is None: + sliced_vols_cache = CacheManager(SlicedVolumeResource._CACHE_MANAGER_NAMESPACE) self._volume_cache = sliced_vols_cache + @staticmethod + def slice_over(resource: 'Resource', + slice_axis: Literal['axial', 'coronal', 'sagittal'], + volume_cache: CacheManager | None = None) -> list['SlicedVolumeResource']: + sliced_resources = [] + res_data = resource.fetch_file_data(auto_convert=True, use_cache=True) + if resource.is_dicom(): + axis_size = dicom_utils.get_dim_size(res_data, slice_axis) + elif resource.is_nifti(): + axis_size = nifti_utils.get_dim_size(res_data, slice_axis) + else: + raise ValueError(f"Unsupported resource type for slicing axis: {resource.filename}|{resource.mimetype}") + + # anns = resource_annotations[i] + + for s in range(axis_size): + sliced_resources.append( + SlicedVolumeResource(resource, s, slice_axis, volume_cache) + ) + + return sliced_resources + def __getattr__(self, name: str) -> Any: """Delegate all unresolved attributes to the parent Resource.""" return getattr(self._parent, name) @@ -102,10 +129,19 @@ def fetch_slice_data(self) -> np.ndarray: return np.ascontiguousarray(cached_slice) raw = self._parent.fetch_file_data(auto_convert=False, use_cache=True) - vol, _meta = read_array_normalized(raw, return_metainfo=True) - vol = vol.transpose(1, 0, 2, 3) - - sliced = np.take(vol, self.slice_index, axis=self.slice_axis + 1) + vol, self.data_metainfo = read_array_normalized(raw, return_metainfo=True) + # vol.shape is (D,C,H,W) + + _LOGGER.debug( + f"Slicing {self._parent.filename} along axis {self.slice_axis=} ({self.slice_axis_idx_std=}) at index {self.slice_index=} with shape {vol.shape=}") + sliced = np.take(vol, self.slice_index, axis=self.slice_axis_idx_std) + # vol is (D, C, H, W); after np.take the sliced axis is removed. + # C was at index 1. If we sliced axis 0 (D), C shifts to index 0 — already correct. + # If we sliced axis 2 (H) or 3 (W), C stays at index 1 → move it to 0. + channel_axis_in_result = 1 if self.slice_axis_idx_std > 1 else 0 + if channel_axis_in_result != 0: + sliced = np.moveaxis(sliced, channel_axis_in_result, 0) + # sliced is now (C, DIM1, DIM2) sliced = np.ascontiguousarray(sliced) gz_path = self._volume_cache.get_expected_path(cache_entity_id, _SLICE_ARRAY_CACHEKEY) @@ -123,8 +159,40 @@ def fetch_slice_data(self) -> np.ndarray: data=sliced, ) + _LOGGER.debug(f'Sliced shape: {sliced.shape}') + return sliced + @cached_property + def data_metainfo(self) -> dict: + """Volume metadata. Loaded once and cached for the lifetime of this resource.""" + raw = self._parent.fetch_file_data(auto_convert=False, use_cache=True) + _, metainfo = read_array_normalized(raw, return_metainfo=True) + return metainfo + + @cached_property + def slice_axis_idx(self) -> int: + """Raw axis index for the slice axis. Computed once and cached.""" + if self._parent.is_dicom(): + return dicom_utils.get_plane_axis(self.data_metainfo, self.slice_axis) + elif self._parent.is_nifti(): + return nifti_utils.get_plane_axis(self.data_metainfo, self.slice_axis) + else: + raise ValueError( + "Unsupported resource type for slicing axis:" + f" {self._parent.filename}|{self._parent.mimetype}|{self.slice_axis}") + + @cached_property + def slice_axis_idx_std(self) -> int: + """Standardised axis index for the slice axis. Computed once and cached.""" + if self._parent.is_dicom(): + return dicom_utils.rawplaneaxis2stdplaneaxis_idx(self.slice_axis_idx) + elif self._parent.is_nifti(): + return nifti_utils.rawplaneaxis2stdplaneaxis_idx(self.slice_axis_idx) + else: + raise ValueError( + f"Unsupported resource type for slicing axis: {self._parent.filename}|{self._parent.mimetype}") + @property def parent_resource(self) -> 'Resource': """The original volume Resource being proxied.""" @@ -138,6 +206,13 @@ def __repr__(self) -> str: f"axis='{axis_name}', slice={self.slice_index})" ) + def __getattribute__(self, name: str) -> Any: + try: + return super().__getattribute__(name) + except AttributeError: + parent = super().__getattribute__('_parent') + return getattr(parent, name) + # # Axis mapping for anatomical orientations # SLICE_AXIS_MAP = { @@ -237,24 +312,6 @@ def __init__( self.resources = expanded_resources # type: ignore[assignment] self.resource_annotations = expanded_annotations - @staticmethod - def _get_slice_axis_int(r: 'Resource', - slice_axis: Literal['axial', 'coronal', 'sagittal']) -> int: - if r.is_dicom(): - dicom_data = r.fetch_file_data(auto_convert=True, use_cache=True) - ret = dicom_utils.get_plane_axis(dicom_data, plane=slice_axis) - if ret is None: - raise ValueError(f"Could not determine slice axis for DICOM resource {r.id} with plane '{slice_axis}'") - return ret - elif r.is_nifti(): - nifti_data = r.fetch_file_data(auto_convert=True, use_cache=True) - ret = nifti_utils.get_plane_axis(nifti_data, plane=slice_axis) - if ret is None: - raise ValueError(f"Could not determine slice axis for NIfTI resource {r.id} with plane '{slice_axis}'") - return ret - else: - raise ValueError(f"Unsupported resource type for slice axis inference: {r.filename} | {r.mimetype}") - def _expand_resources( self, resources: Sequence['Resource'], @@ -275,34 +332,10 @@ def _expand_resources( sliced_annotations: list[Sequence['Annotation']] = [] for i, r in enumerate(resources): - if not hasattr(self, '_slice_axis_int'): - res_data = r.fetch_file_data(auto_convert=True, use_cache=True) - if r.is_dicom(): - axis_int = dicom_utils.get_plane_axis(res_data, plane=self._slice_axis) - - if axis_int is None: - raise ValueError( - f"Could not determine slice axis for DICOM resource {r.id} with plane '{self._slice_axis}'") - axis_size = dicom_utils.get_dim_size(res_data, axis_int) - elif r.is_nifti(): - axis_int = nifti_utils.get_plane_axis(res_data, plane=self._slice_axis) - if axis_int is None: - raise ValueError( - f"Could not determine slice axis for NIfTI resource {r.id} with plane '{self._slice_axis}'") - axis_size = nifti_utils.get_dim_size(res_data, axis_int) - else: - raise ValueError(f"Unsupported resource type for slice axis inference: {r.filename} | {r.mimetype}") - else: - # TODO - raise NotImplementedError - anns = resource_annotations[i] - - for s in range(axis_size): - sliced_resources.append( - SlicedVolumeResource(r, s, axis_int, volume_cache) - ) - sliced_annotations.append(anns) + per_slice = SlicedVolumeResource.slice_over(r, self._slice_axis, volume_cache) + sliced_resources.extend(per_slice) + sliced_annotations.extend(anns for _ in per_slice) return sliced_resources, sliced_annotations @@ -311,21 +344,21 @@ def _get_raw_item(self, index: int) -> dict[str, Any]: """Load a single 2D slice and its annotations. Returns dict with: - - 'image': np.ndarray of shape (C, 1, H, W) — depth=1 to match pipeline expectations. + - 'image': np.ndarray of shape (C, 1, DIM1, DIM2) — depth=1 to match pipeline expectations. - 'metainfo': dict with volume metadata. - 'annotations': Sequence of Annotation objects. - 'resource': The SlicedVolumeResource proxy. """ resource: SlicedVolumeResource = self.resources[index] # type: ignore[assignment] - img = resource.fetch_slice_data() # shape: (C, H, W) + img = resource.fetch_slice_data() # ndims=3. - # Add depth dim to match the pipeline expectation: (C, 1, H, W) - img = np.expand_dims(img, axis=1) + # # Add depth dim to match the pipeline expectation: (C, 1, DIM1, DIM2) + # img = np.expand_dims(img, axis=1) anns = self.resource_annotations[index] return { - 'image': img, # shape: (C, 1, H, W) + 'image': img, # shape: (C, 1, DIM1, DIM2) 'annotations': anns, 'resource': resource, } @@ -349,7 +382,6 @@ def __getitem__(self, index: int) -> dict[str, Any]: img = self._preprocess_image_array(img) annotations = result['annotations'] resource: SlicedVolumeResource = result['resource'] - _LOGGER.debug(f"Loaded slice {resource.slice_index} from {resource.filename} with shape {img.shape}") # Process segmentations # FIXME: This currently re-loads the slice data for each segmentation annotation, which is inefficient. We should ideally load the slice once and reuse it for all segmentations. This may require refactoring how annotations are processed to avoid redundant data loading. @@ -362,13 +394,20 @@ def __getitem__(self, index: int) -> dict[str, Any]: # Slice segmentations along the same axis as the image # segmentations[author] shape: (#instances, D, H, W) slice_idx = resource.slice_index + slice_axis_idx_std = resource.slice_axis_idx_std + # resource normalized is always (D, C, H, W) + map_slice_axis_idx_std = { + 0: 1, + 2: 2, + 3: 3, + } + seg_slice_axis_idx = map_slice_axis_idx_std[slice_axis_idx_std] sliced_segs: dict[str, np.ndarray] = {} for author, seg_array in segmentations.items(): # seg_array shape: (#instances, D, H, W) - # Select the slice: (#instances, H, W) - axis_index = resource.slice_axis + 1 # account for instance dimension - sliced_segs[author] = np.take(seg_array, slice_idx, axis=axis_index) - # Add depth=1 dim back for consistency with pipeline: (#instances, 1, H, W) + # Select the slice: (#instances, DIM1, DIM2) where DIM1 and DIM2 depend on the slice axis + sliced_segs[author] = np.take(seg_array, slice_idx, axis=seg_slice_axis_idx) + # Add a dummy dimension for consistency with pipeline: (#instances, 1, DIM1, DIM2) sliced_segs[author] = np.expand_dims(sliced_segs[author], axis=1) # Apply albumentations if present @@ -379,8 +418,7 @@ def __getitem__(self, index: int) -> dict[str, Any]: sliced_segs = aug_result['segmentations'] segmentations_processed, seg_labels_out = self._process_segmentations(sliced_segs, seg_labels) - - # Squeeze depth=1 dimension from segmentations + # remove temporary dummy dimension: (#instances, 1, DIM1, DIM2) -> (#instances, DIM1, DIM2) if isinstance(segmentations_processed, (Tensor, np.ndarray)): segmentations_processed = segmentations_processed.squeeze(1) elif isinstance(segmentations_processed, dict): @@ -395,14 +433,15 @@ def __getitem__(self, index: int) -> dict[str, Any]: # Process image-level labels result['image_labels'] = self._extract_image_labels(annotations) - # Squeeze depth=1 from image: (C, 1, H, W) -> (C, H, W) - img = result['image'] - if isinstance(img, (np.ndarray, Tensor)) and img.ndim == 4 and img.shape[1] == 1: - if isinstance(img, np.ndarray): - img = img.squeeze(axis=1) - else: - img = img.squeeze(1) - result['image'] = img + # # Squeeze depth=1 from image: (C, 1, H, W) -> (C, H, W) + # img = result['image'] + # _LOGGER.debug(f"Final image shape before squeezing depth: {img.shape}") + # if isinstance(img, (np.ndarray, Tensor)) and img.ndim == 4 and img.shape[1] == 1: + # if isinstance(img, np.ndarray): + # img = img.squeeze(axis=1) + # else: + # img = img.squeeze(1) + # result['image'] = img return result From 5a459ce13620180cf4d981130298d58ead76ff5c Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 4 Mar 2026 17:16:09 -0300 Subject: [PATCH 07/47] Misc organization --- datamint/api/base_api.py | 7 ++----- datamint/api/endpoints/inference_api.py | 2 +- datamint/entities/annotations/volume_segmentation.py | 7 +++++-- datamint/entities/resource.py | 2 +- pyproject.toml | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index 493df617..51af0ac8 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -733,7 +733,7 @@ def _determine_mimetype(content: bytes, Args: content: Raw file content bytes - declared_mimetype: Optional MIME type declared by the source + declared_mimetype: Optional MIME type declared by the source, used as a fallback if content-based detection fails Returns: Tuple of (inferred_mimetype, file_extension) @@ -744,10 +744,7 @@ def _determine_mimetype(content: bytes, # get mimetype from resource info if not detected if declared_mimetype is not None: - if mimetype is None: - mimetype = declared_mimetype - ext = guess_extension(mimetype) - elif mimetype == DEFAULT_MIME_TYPE: + if mimetype is None or mimetype == DEFAULT_MIME_TYPE: mimetype = declared_mimetype ext = guess_extension(mimetype) diff --git a/datamint/api/endpoints/inference_api.py b/datamint/api/endpoints/inference_api.py index 5aa84e15..9382f7ed 100644 --- a/datamint/api/endpoints/inference_api.py +++ b/datamint/api/endpoints/inference_api.py @@ -160,7 +160,7 @@ def wait( *, on_status: Callable[[InferenceJob], None] | None = None, poll_interval: float = 2.0, - timeout: float | None = None, + timeout: float | None = 1800, ) -> InferenceJob: """Block until an inference job reaches a terminal state. diff --git a/datamint/entities/annotations/volume_segmentation.py b/datamint/entities/annotations/volume_segmentation.py index 1637ce1d..3c58c026 100644 --- a/datamint/entities/annotations/volume_segmentation.py +++ b/datamint/entities/annotations/volume_segmentation.py @@ -53,6 +53,9 @@ def __init__(self, """ kwargs['scope'] = 'image' kwargs['annotation_type'] = AnnotationType.SEGMENTATION + if isinstance(kwargs.get('class_map'), str): + raise ValueError("class_map must be dict[int, str], not str." + " Use from_semantic_segmentation factory method for string class_map.") kwargs.setdefault('identifier', '') super().__init__(**kwargs) @@ -166,7 +169,7 @@ def _standardize_class_map( Convert class_map to standard dict[int, str] format. Args: - class_map: Either a dict or a single class name for binary seg + class_map: Either a dict or a single class name for binary segmentation segmentation: The segmentation array to infer labels from Returns: @@ -176,7 +179,7 @@ def _standardize_class_map( ValueError: If class_map format is invalid """ if isinstance(class_map, str): - # Binary segmentation: assume label 1 = class_map, 0 = background + # Binary segmentation: class_map is a single class name unique_labels = np.unique(segmentation) unique_labels = unique_labels[unique_labels > 0] # Exclude 0 diff --git a/datamint/entities/resource.py b/datamint/entities/resource.py index 53aedd97..319c1bb7 100644 --- a/datamint/entities/resource.py +++ b/datamint/entities/resource.py @@ -182,7 +182,7 @@ def download_callback(path: str | None) -> bytes: if auto_convert: try: - mimetype, ext = BaseApi._determine_mimetype(img_data, self.mimetype) + mimetype, _ = BaseApi._determine_mimetype(img_data, self.mimetype) img_data = BaseApi.convert_format(img_data, mimetype=mimetype, file_path=save_path) diff --git a/pyproject.toml b/pyproject.toml index bfd4c340..a86cd0ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,12 +38,12 @@ platformdirs = "^4.0.0" pandas = ">=2.0.0" matplotlib = "*" lightning = { extras = ['extra'], version = ">=2.0.0, !=2.5.1, !=2.5.1.post0" } -mlflow-skinny = "==3.8.*" +mlflow-skinny = "==3.8.1" Flask = { version = "<4" } Flask-Cors = { version = "<7" } albumentations = ">=2.0.0" lazy-loader = ">=0.3.0" -medimgkit = ">=0.13.0" +medimgkit = ">=0.14.1" typing_extensions = ">=4.0.0" pydantic = ">=2.6.4" certifi = ">=2025.0.0" From 5a917b5a5c5267d13b510286f13c6073300d62f3 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 5 Mar 2026 22:36:47 -0300 Subject: [PATCH 08/47] Little improvement of imports --- datamint/api/base_api.py | 5 +++-- datamint/entities/project.py | 25 ++++++++++++------------- datamint/mlflow/__init__.py | 23 +++++++++++------------ 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index 51af0ac8..f2739821 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -8,12 +8,10 @@ import json from PIL import Image import cv2 -import nibabel as nib from io import BytesIO import gzip import contextlib import asyncio -from medimgkit.format_detection import GZIP_MIME_TYPES, DEFAULT_MIME_TYPE, guess_typez, guess_extension from datamint.utils.env import ensure_asyncio_loop import os @@ -688,6 +686,8 @@ def convert_format(bytes_array: bytes, """ import pydicom + import nibabel as nib + from medimgkit.format_detection import GZIP_MIME_TYPES if mimetype is None: mimetype, ext = BaseApi._determine_mimetype(bytes_array) @@ -738,6 +738,7 @@ def _determine_mimetype(content: bytes, Returns: Tuple of (inferred_mimetype, file_extension) """ + from medimgkit.format_detection import DEFAULT_MIME_TYPE, guess_typez, guess_extension # Determine mimetype from file content mimetype_list, ext = guess_typez(content, use_magic=True) mimetype = mimetype_list[-1] diff --git a/datamint/entities/project.py b/datamint/entities/project.py index cafb4385..02df28df 100644 --- a/datamint/entities/project.py +++ b/datamint/entities/project.py @@ -1,12 +1,11 @@ """Project entity module for DataMint API.""" -from datetime import datetime import logging from typing import Literal, TYPE_CHECKING from collections.abc import Sequence from .base_entity import BaseEntity, MISSING_FIELD from typing import Any import webbrowser -from pydantic import PrivateAttr +from pydantic import PrivateAttr, Field if TYPE_CHECKING: from datamint.api.endpoints.projects_api import ProjectsApi @@ -54,17 +53,17 @@ class Project(BaseEntity): description: str | None viewable_ai_segs: list | None editable_ai_segs: list | None - registered_model: Any | None = MISSING_FIELD - ai_model_id: str | None = MISSING_FIELD - closed_resources_count: int = MISSING_FIELD - resources_to_annotate_count: int = MISSING_FIELD - most_recent_experiment: str | None = MISSING_FIELD - annotators: list[dict] = MISSING_FIELD - archived_on: str | None = MISSING_FIELD - archived_by: str | None = MISSING_FIELD - is_active_learning: bool = MISSING_FIELD - two_up_display: bool = MISSING_FIELD - require_review: bool = MISSING_FIELD + registered_model: Any | None = Field(default=MISSING_FIELD) + ai_model_id: str | None = Field(default=MISSING_FIELD) + closed_resources_count: int = Field(default=MISSING_FIELD) + resources_to_annotate_count: int = Field(default=MISSING_FIELD) + most_recent_experiment: str | None = Field(default=MISSING_FIELD) + annotators: list[dict] = Field(default=MISSING_FIELD) + archived_on: str | None = Field(default=MISSING_FIELD) + archived_by: str | None = Field(default=MISSING_FIELD) + is_active_learning: bool = Field(default=MISSING_FIELD) + two_up_display: bool = Field(default=MISSING_FIELD) + require_review: bool = Field(default=MISSING_FIELD) _api: 'ProjectsApi' = PrivateAttr() diff --git a/datamint/mlflow/__init__.py b/datamint/mlflow/__init__.py index 9b9b33a6..44de0273 100644 --- a/datamint/mlflow/__init__.py +++ b/datamint/mlflow/__init__.py @@ -1,5 +1,4 @@ # Monkey patch mlflow.tracking._tracking_service.utils.get_tracking_uri -from .tracking.fluent import set_project import mlflow.tracking._tracking_service.utils as mlflow_utils from functools import wraps import logging @@ -12,9 +11,6 @@ _original_get_tracking_uri = mlflow_utils.get_tracking_uri _SETUP_CALLED_SUCCESSFULLY = False -if mlflow_utils.is_tracking_uri_set(): - _LOGGER.warning("MLflow tracking URI is already set before patching get_tracking_uri.") - @wraps(_original_get_tracking_uri) def _patched_get_tracking_uri(*args, **kwargs): @@ -44,10 +40,6 @@ def _patched_get_tracking_uri(*args, **kwargs): return ret -setup_mlflow_environment(set_mlflow=False) -# Replace the original function with our patched version -mlflow_utils.get_tracking_uri = _patched_get_tracking_uri - _ALREADY_CONFIGURED_LOGGING = False @@ -95,14 +87,20 @@ def _configure_mlflow_loggers(): ) _LOGGER.info("Configured MLflow loggers with RichHandler and level %s", mlflow_log_level) -try: - _configure_mlflow_loggers() -except Exception as e: - _LOGGER.error("Failed to configure MLflow loggers: %s", e) if TYPE_CHECKING: from .flavors.model import DatamintModel + from .tracking.fluent import set_project else: + if mlflow_utils.is_tracking_uri_set(): + _LOGGER.warning("MLflow tracking URI is already set before patching get_tracking_uri.") + setup_mlflow_environment(set_mlflow=False) + # Replace the original function with our patched version + mlflow_utils.get_tracking_uri = _patched_get_tracking_uri + try: + _configure_mlflow_loggers() + except Exception as e: + _LOGGER.error("Failed to configure MLflow loggers: %s", e) import lazy_loader as lazy __getattr__, __dir__, __all__ = lazy.attach( @@ -111,6 +109,7 @@ def _configure_mlflow_loggers(): submod_attrs={ "flavors.model": ["DatamintModel"], "flavors.datamint_flavor": ["log_model", "load_model"], + "tracking.fluent": ["set_project"], }, ) From cb3f8a17e12e1b280e41e8d532c07cb89bd18bc5 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 5 Mar 2026 22:58:28 -0300 Subject: [PATCH 09/47] Finishing implementation of slicing a VolumeDataset --- datamint/dataset/annotation_processor.py | 48 ++++++++---- datamint/dataset/base.py | 95 +++++++++++++++++++++--- datamint/dataset/sliced_dataset.py | 3 +- 3 files changed, 123 insertions(+), 23 deletions(-) diff --git a/datamint/dataset/annotation_processor.py b/datamint/dataset/annotation_processor.py index 0dbe50f4..79ca114b 100644 --- a/datamint/dataset/annotation_processor.py +++ b/datamint/dataset/annotation_processor.py @@ -53,10 +53,12 @@ def __init__( seglabel2code: dict[str, int], image_labels_set: list[str], image_lcodes: dict[str, dict[str, int]], + allow_external_annotations: bool = False, ): self.seglabel2code = seglabel2code self.image_labels_set = image_labels_set self.image_lcodes = image_lcodes + self.allow_external_annotations = allow_external_annotations def collate_frame_segmentations(self, fr_anns: Sequence['Annotation'], @@ -67,7 +69,15 @@ def collate_frame_segmentations(self, for ann in fr_anns: try: seg = self.load_segmentation_data(ann) - seg_code_i = self.seglabel2code.get(ann.identifier, 0) + seg_code_i = self.seglabel2code.get(ann.identifier) + if seg_code_i is None: + if self.allow_external_annotations and ann.identifier: + seg_code_i = max(self.seglabel2code.values(), default=0) + 1 + self.seglabel2code[ann.identifier] = seg_code_i + _LOGGER.info(f"Dynamically added segmentation label '{ann.identifier}' with code {seg_code_i}") + else: + raise ValueError(f"Unknown segmentation label '{ann.identifier}' and external annotations are not allowed") + if seg_code != -1 and seg_code != seg_code_i: raise ValueError(f"Conflicting segmentation codes for frame annotations: " f"{seg_code} vs {seg_code_i}") @@ -145,8 +155,17 @@ def load_image_segmentations(self, author = ann.created_by or ann.created_by_model or "unknown" try: + seg_code = self.seglabel2code.get(ann.identifier) + if seg_code is None: + if self.allow_external_annotations: + seg_code = max(self.seglabel2code.values(), default=0) + 1 + self.seglabel2code[ann.identifier] = seg_code + _LOGGER.info(f"Dynamically added segmentation label '{ann.identifier}' with code {seg_code}") + else: + raise ValueError(f"Segmentation annotation {ann.id} has unknown identifier " + f"{ann.identifier} with no corresponding code in {self.seglabel2code=}") seg = self.load_segmentation_data(ann) - seg_code = self.seglabel2code.get(ann.identifier, 0) + # seg shape: (#slices, H, W) except Exception as e: _LOGGER.error(f"Failed to load segmentation for annotation {ann.id}: {e}") @@ -331,9 +350,20 @@ def convert_image_labels( Returns: Dict of annotator_id -> one-hot tensor of shape (num_labels,). """ - labels_ret_size = (len(self.image_labels_set),) label2code = self.image_lcodes.get('multilabel', {}) + # If allow_external_annotations, first pass to discover unknown labels + if self.allow_external_annotations: + for ann in annotations: + if ann.annotation_type != 'label': + continue + if ann.identifier not in label2code: + new_code = len(self.image_labels_set) + self.image_labels_set.append(ann.identifier) + label2code[ann.identifier] = new_code + _LOGGER.info(f"Dynamically added image label '{ann.identifier}' with code {new_code}") + + labels_ret_size = (len(self.image_labels_set),) labels_by_user: dict[str, torch.Tensor] = {} for ann in annotations: @@ -355,7 +385,6 @@ def apply_merge_strategy( self, segmentations: dict[str, Tensor], strategy: MergeStrategy, - output_shape: tuple[int, ...] | None = None, ) -> Tensor: ... @overload @@ -363,36 +392,29 @@ def apply_merge_strategy( self, segmentations: dict[str, np.ndarray], strategy: MergeStrategy, - output_shape: tuple[int, ...] | None = None, ) -> np.ndarray: ... def apply_merge_strategy( self, segmentations: dict[str, Tensor] | dict[str, np.ndarray], strategy: MergeStrategy, - output_shape: tuple[int, ...] | None = None, ) -> Tensor | np.ndarray: """Merge semantic segmentations from multiple annotators. Args: segmentations: Dict of author -> semantic segmentation tensor. - output_shape: Shape for empty result if no segmentations are present. strategy: Merge strategy ('union', 'intersection', 'mode'). Returns: Merged tensor if strategy is specified, otherwise original dict. """ if len(segmentations) == 0: - if output_shape is None: - raise ValueError("output_shape must be provided when no segmentations are present") - empty_segs = torch.zeros(output_shape, dtype=torch.get_default_dtype()) - empty_segs[0] = 1 # background - return empty_segs + raise ValueError("No segmentations to merge") if isinstance(next(iter(segmentations.values())), np.ndarray): with torch.no_grad(): segmentations = {author: torch.from_numpy(seg) for author, seg in segmentations.items()} - return self.apply_merge_strategy(segmentations, strategy, output_shape).numpy() + return self.apply_merge_strategy(segmentations, strategy).numpy() if strategy == 'union': merged = self._merge_union(segmentations) diff --git a/datamint/dataset/base.py b/datamint/dataset/base.py index 4122d7ad..c3874b01 100644 --- a/datamint/dataset/base.py +++ b/datamint/dataset/base.py @@ -15,12 +15,11 @@ import numpy as np from datamint.apihandler.dto.annotation_dto import AnnotationType from datamint.exceptions import DatamintException -from datamint.entities import Annotation from .annotation_processor import AnnotationProcessor, MergeStrategy from datamint.entities.annotations.annotation_spec import AnnotationSpec, CategoryAnnotationSpec if TYPE_CHECKING: - from datamint.entities import Resource, Project + from datamint.entities import Resource, Project, Annotation _LOGGER = logging.getLogger(__name__) @@ -61,10 +60,14 @@ class DatamintBaseDataset(ABC): exclude_image_label_names: Blacklist of image labels. include_frame_label_names: Whitelist of frame labels. exclude_frame_label_names: Blacklist of frame labels. + allow_external_annotations: If True, allow and automatically include annotation + labels that are not part of the project's official schema (e.g., labels + from other projects or legacy annotations). If False, these annotations + will be filtered out. """ resources: Sequence['Resource'] - resource_annotations: list[Sequence[Annotation]] + resource_annotations: list[Sequence['Annotation']] project: 'Project | None' def __init__( @@ -89,6 +92,7 @@ def __init__( exclude_image_label_names: list[str] | None = None, include_frame_label_names: list[str] | None = None, exclude_frame_label_names: list[str] | None = None, + allow_external_annotations: bool = False, ): from datamint import Api # Validate mutually exclusive parameters @@ -155,6 +159,7 @@ def __init__( self.exclude_image_label_names = exclude_image_label_names self.include_frame_label_names = include_frame_label_names self.exclude_frame_label_names = exclude_frame_label_names + self.allow_external_annotations = allow_external_annotations # Internal state self._logged_uint16_conversion = False @@ -164,7 +169,7 @@ def __init__( def _extract_image_labels( self, - annotations: Sequence[Annotation], + annotations: Sequence['Annotation'], ) -> dict[str, torch.Tensor]: """Extract image-level label annotations. @@ -263,12 +268,51 @@ def _setup_labels(self) -> None: self.seglabel_list, self.seglabel2code = self._process_segmentation_group( worklist_schema['segmentation_group'] ) + + if self.allow_external_annotations: + self._augment_labels_from_annotations() else: _LOGGER.info("No project provided; inferring labels from annotations.") self.frame_lsets, self.frame_lcodes = self._infer_labels_set(framed=True) self.image_lsets, self.image_lcodes = self._infer_labels_set(framed=False) self.seglabel_list, self.seglabel2code = self._infer_segmentation_group() + def _augment_labels_from_annotations(self) -> None: + """Augment project-defined label sets with identifiers found in actual annotations. + + Scans resource annotations for identifiers not present in the project's + annotations_specs and adds them to the corresponding label/segmentation mappings. + """ + inferred_frame_lsets, inferred_frame_lcodes = self._infer_labels_set(framed=True) + inferred_image_lsets, inferred_image_lcodes = self._infer_labels_set(framed=False) + inferred_seglabel_list, _ = self._infer_segmentation_group() + + # Augment frame labels + for kind in ('multilabel', 'multiclass'): + existing = set(self.frame_lsets[kind]) + new_labels = sorted([label for label in inferred_frame_lsets[kind] if label not in existing]) + for label in new_labels: + _LOGGER.info(f"Allowing external frame label '{label}' not in project specs.") + self.frame_lsets[kind].append(label) + self.frame_lcodes[kind] = self.__build_label_codemap(self.frame_lsets[kind]) + + # Augment image labels + for kind in ('multilabel', 'multiclass'): + existing = set(self.image_lsets[kind]) + new_labels = sorted([label for label in inferred_image_lsets[kind] if label not in existing]) + for label in new_labels: + _LOGGER.info(f"Allowing external image label '{label}' not in project specs.") + self.image_lsets[kind].append(label) + self.image_lcodes[kind] = self.__build_label_codemap(self.image_lsets[kind]) + + # Augment segmentation labels + existing_segs = set(self.seglabel_list) + new_segs = sorted([label for label in inferred_seglabel_list if label not in existing_segs]) + for label in new_segs: + _LOGGER.info(f"Allowing external segmentation label '{label}' not in project specs.") + self.seglabel_list.append(label) + self.seglabel2code[label] = len(self.seglabel_list) # 1-based code + def _setup_annotation_processor(self) -> None: """Initialize the annotation processor. @@ -279,6 +323,7 @@ def _setup_annotation_processor(self) -> None: seglabel2code=self.seglabel2code, image_labels_set=self.image_labels_set, image_lcodes=self.image_lcodes, + allow_external_annotations=self.allow_external_annotations, ) def _apply_annotation_filters(self) -> None: @@ -312,11 +357,11 @@ def _filter_unannotated(self) -> None: self.resources = filtered_resources self.resource_annotations = filtered_annotations - def _filter_annotations(self, annotations: Sequence[Annotation]) -> list[Annotation]: + def _filter_annotations(self, annotations: Sequence['Annotation']) -> list['Annotation']: """Filter annotations based on include/exclude settings.""" return [ann for ann in annotations if self._should_include_annotation(ann)] - def _should_include_annotation(self, ann: Annotation) -> bool: + def _should_include_annotation(self, ann: 'Annotation') -> bool: """Check if annotation should be included.""" # Check annotator annotator = ann.created_by @@ -331,6 +376,12 @@ def _should_include_annotation(self, ann: Annotation) -> bool: return self._should_include_image_label(ann.identifier) else: # frame-level return self._should_include_frame_label(ann.identifier) + elif ann.annotation_type == 'category': + if not self.allow_external_annotations: + lsets = self.image_lsets if ann.frame_index is None else self.frame_lsets + valid_identifiers = {ident for ident, _ in lsets.get('multiclass', [])} + if ann.identifier not in valid_identifiers: + return False return True @@ -342,6 +393,8 @@ def _should_include_annotator(self, annotator_id: str) -> bool: return True def _should_include_segmentation(self, name: str) -> bool: + if not self.allow_external_annotations and name not in self.segmentation_labels_set: + return False if self.include_segmentation_names is not None: return name in self.include_segmentation_names if self.exclude_segmentation_names is not None: @@ -349,6 +402,8 @@ def _should_include_segmentation(self, name: str) -> bool: return True def _should_include_image_label(self, name: str) -> bool: + if not self.allow_external_annotations and name not in self.image_labels_set: + return False if self.include_image_label_names is not None: return name in self.include_image_label_names if self.exclude_image_label_names is not None: @@ -356,6 +411,8 @@ def _should_include_image_label(self, name: str) -> bool: return True def _should_include_frame_label(self, name: str) -> bool: + if not self.allow_external_annotations and name not in self.frame_labels_set: + return False if self.include_frame_label_names is not None: return name in self.include_frame_label_names if self.exclude_frame_label_names is not None: @@ -505,7 +562,16 @@ def _preprocess_image_array(self, img: np.ndarray) -> np.ndarray: def _process_segmentations(self, segmentations: dict, - seg_labels: dict) -> tuple[Tensor | np.ndarray | dict, dict | None]: + seg_labels: dict, + output_shape: tuple | None = None) -> tuple[Tensor | np.ndarray | dict, dict | None]: + """ + Process segmentations by optionally converting to semantic format and applying merge strategy. + + Args: + segmentations: Dict of annotator_id -> segmentation array (num_instances, depth, H, W). + seg_labels: Dict of annotator_id -> list of label names corresponding to each instance in the segmentation array. + 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) + """ # segmentations['author'] shape: (#instances, depth, H, W) if self.return_as_semantic_segmentation: sem_segs = {} @@ -518,12 +584,22 @@ def _process_segmentations(self, _LOGGER.debug( f'Converted to semantic segmentation. Shapes: {[segmentations[a].shape for a in segmentations]}') if self.semantic_seg_merge_strategy: - if segmentations: + if len(segmentations) > 0: segmentations = self.annotation_processor.apply_merge_strategy( segmentations, strategy=self.semantic_seg_merge_strategy ) _LOGGER.debug(f"Merged segmentation shape: {segmentations.shape}") + else: + if output_shape is None: + raise ValueError("output_shape must be provided when no segmentations are present" + " to infer shape from.") + # Create empty semantic segmentation with just background class + segmentations = torch.zeros((len(self.segmentation_labels_set), *output_shape), + dtype=torch.get_default_dtype()) + segmentations[0] = 1 # background + _LOGGER.debug("No segmentations found. " + f"Created empty semantic segmentation with shape: {segmentations.shape}") # 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. seg_labels = None @@ -560,7 +636,8 @@ def __getitem__(self, index: int) -> dict[str, Any]: _LOGGER.debug( f"Applied albumentations transform. Image shape: {img.shape} and segs shape: {[segmentations[a].shape for a in segmentations]}") - segmentations, seg_labels = self._process_segmentations(segmentations, seg_labels) + segmentations, seg_labels = self._process_segmentations(segmentations, seg_labels, + output_shape=img.shape[1:]) result['segmentations'] = segmentations if seg_labels: diff --git a/datamint/dataset/sliced_dataset.py b/datamint/dataset/sliced_dataset.py index 59114f2c..8a747ca1 100644 --- a/datamint/dataset/sliced_dataset.py +++ b/datamint/dataset/sliced_dataset.py @@ -417,7 +417,8 @@ def __getitem__(self, index: int) -> dict[str, Any]: result['image'] = img sliced_segs = aug_result['segmentations'] - segmentations_processed, seg_labels_out = self._process_segmentations(sliced_segs, seg_labels) + segmentations_processed, seg_labels_out = self._process_segmentations(sliced_segs, seg_labels, + output_shape=img.shape[1:]) # remove temporary dummy dimension: (#instances, 1, DIM1, DIM2) -> (#instances, DIM1, DIM2) if isinstance(segmentations_processed, (Tensor, np.ndarray)): segmentations_processed = segmentations_processed.squeeze(1) From 8f3775016853930d6265b41b9f50aa59b315f076 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Fri, 6 Mar 2026 16:39:38 -0300 Subject: [PATCH 10/47] Add sliced segmentation caching to SlicedVolumeDataset; refactor AnnotationProcessor methods --- datamint/dataset/annotation_processor.py | 61 ++++-- datamint/dataset/sliced_dataset.py | 240 +++++++++++++++++++++-- pyproject.toml | 2 +- 3 files changed, 260 insertions(+), 43 deletions(-) diff --git a/datamint/dataset/annotation_processor.py b/datamint/dataset/annotation_processor.py index 79ca114b..02904319 100644 --- a/datamint/dataset/annotation_processor.py +++ b/datamint/dataset/annotation_processor.py @@ -60,6 +60,39 @@ def __init__( self.image_lcodes = image_lcodes self.allow_external_annotations = allow_external_annotations + def resolve_seg_code(self, identifier: str) -> int: + """Resolve a segmentation label name to its integer code. + + If the label is unknown and ``allow_external_annotations`` is True, a new + code is assigned and stored in :attr:`seglabel2code`. Otherwise raises + :exc:`ValueError`. + + Args: + identifier: Segmentation label name. + + Returns: + Integer code for the label. + """ + code = self.seglabel2code.get(identifier) + if code is not None: + return code + if self.allow_external_annotations and identifier: + code = max(self.seglabel2code.values(), default=0) + 1 + self.seglabel2code[identifier] = code + _LOGGER.info(f"Dynamically added segmentation label '{identifier}' with code {code}") + return code + raise ValueError( + f"Unknown segmentation label '{identifier}' and external annotations are not allowed" + ) + + @staticmethod + def get_author(ann: 'Annotation') -> str: + """Return a consistent author key for an annotation. + + Prefers ``created_by``, falls back to ``created_by_model``, then ``"unknown"``. + """ + return ann.created_by or getattr(ann, 'created_by_model', None) or "unknown" + def collate_frame_segmentations(self, fr_anns: Sequence['Annotation'], depth: int | None = None) -> tuple[np.ndarray | None, int]: @@ -69,18 +102,12 @@ def collate_frame_segmentations(self, for ann in fr_anns: try: seg = self.load_segmentation_data(ann) - seg_code_i = self.seglabel2code.get(ann.identifier) - if seg_code_i is None: - if self.allow_external_annotations and ann.identifier: - seg_code_i = max(self.seglabel2code.values(), default=0) + 1 - self.seglabel2code[ann.identifier] = seg_code_i - _LOGGER.info(f"Dynamically added segmentation label '{ann.identifier}' with code {seg_code_i}") - else: - raise ValueError(f"Unknown segmentation label '{ann.identifier}' and external annotations are not allowed") + seg_code_i = self.resolve_seg_code(ann.identifier) if seg_code != -1 and seg_code != seg_code_i: - raise ValueError(f"Conflicting segmentation codes for frame annotations: " - f"{seg_code} vs {seg_code_i}") + raise ValueError( + f"Conflicting segmentation codes for frame annotations: {seg_code} vs {seg_code_i}" + ) seg_code = seg_code_i # seg shape: (1, H, W) seg = seg[0] # -> (H, W) @@ -119,7 +146,7 @@ def group_annotations(self, for ann in annotations: key_parts = [] if by_author: - author = ann.created_by or "unknown" + author = self.get_author(ann) key_parts.append(author) if by_identifier: identifier = ann.identifier @@ -152,18 +179,10 @@ def load_image_segmentations(self, seg_image_annotations = [ann for ann in annotations if ann.scope == 'image' and ann.annotation_type == 'segmentation'] for ann in seg_image_annotations: - author = ann.created_by or ann.created_by_model or "unknown" + author = self.get_author(ann) try: - seg_code = self.seglabel2code.get(ann.identifier) - if seg_code is None: - if self.allow_external_annotations: - seg_code = max(self.seglabel2code.values(), default=0) + 1 - self.seglabel2code[ann.identifier] = seg_code - _LOGGER.info(f"Dynamically added segmentation label '{ann.identifier}' with code {seg_code}") - else: - raise ValueError(f"Segmentation annotation {ann.id} has unknown identifier " - f"{ann.identifier} with no corresponding code in {self.seglabel2code=}") + seg_code = self.resolve_seg_code(ann.identifier) seg = self.load_segmentation_data(ann) # seg shape: (#slices, H, W) diff --git a/datamint/dataset/sliced_dataset.py b/datamint/dataset/sliced_dataset.py index 8a747ca1..b48a4299 100644 --- a/datamint/dataset/sliced_dataset.py +++ b/datamint/dataset/sliced_dataset.py @@ -5,6 +5,7 @@ enabling training of 2D models on volumetric medical imaging data. """ import gzip +import hashlib import logging from functools import cached_property from typing import Any, Literal, TYPE_CHECKING @@ -32,6 +33,9 @@ # Cache key for parsed slice numpy arrays _SLICE_ARRAY_CACHEKEY = "slice_array" +# Cache key for sliced segmentation numpy arrays +_SEG_SLICE_CACHEKEY = "seg_slice_array" + class SlicedVolumeResource: """Proxy that presents a single 2D slice of a 3D volume Resource. @@ -298,6 +302,13 @@ def __init__( # Internal state self._logged_uint16_conversion = False + # --- Segmentation slice cache --- + self._seg_slice_cache = CacheManager( + 'sliced_segmentations', + enable_memory_cache=True, + memory_cache_maxsize=8, + ) + # --- Build sliced resources --- volume_cache = CacheManager( 'sliced_volumes', @@ -339,6 +350,213 @@ def _expand_resources( return sliced_resources, sliced_annotations + # --- Axis mapping for segmentation slicing --- + # Volume is normalized to (D, C, H, W). load_segmentation_data returns (D, H, W). + # Axis 1 is the channel axis and is never sliced — the mapping is simply: + # std_axis == 0 -> seg axis 0 (depth) + # std_axis == 2 -> seg axis 1 (height) + # std_axis == 3 -> seg axis 2 (width) + @staticmethod + def _std_axis_to_seg_axis(slice_axis_idx_std: int) -> int: + """Convert a standardised 4D volume axis index to the matching 3D seg array axis.""" + if slice_axis_idx_std == 0: + return 0 + if slice_axis_idx_std in (2, 3): + return slice_axis_idx_std - 1 + raise ValueError( + f"Cannot slice along channel axis (std axis 1). Got slice_axis_idx_std={slice_axis_idx_std}" + ) + + def _load_sliced_segmentations( + self, + annotations: Sequence['Annotation'], + resource: SlicedVolumeResource, + ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray], dict[str, list]]: + """Load segmentations already sliced for a specific 2D slice, with caching. + + Instead of loading entire 3D segmentation volumes and then slicing, + this method caches sliced 2D segmentations per annotation to avoid + redundant volume parsing on repeated access. + + Args: + annotations: Segmentation annotations for this resource. + resource: The SlicedVolumeResource identifying the slice. + + Returns: + Tuple of (sliced_segs, seg_labels, seg_metainfos): + - sliced_segs: dict[author -> np.ndarray (#instances, 1, DIM1, DIM2)] + - seg_labels: dict[author -> np.ndarray of int codes] + - seg_metainfos: dict[author -> list] + """ + seg_anns = [ann for ann in annotations if ann.annotation_type == 'segmentation'] + if not seg_anns: + return {}, {}, {} + + seg_slice_axis = self._std_axis_to_seg_axis(resource.slice_axis_idx_std) + + image_seg_anns = [a for a in seg_anns if a.scope == 'image'] + frame_seg_anns = [a for a in seg_anns if a.scope == 'frame'] + + uniq_authors = set( + self.annotation_processor.get_author(a) for a in seg_anns + ) + segmentations: dict[str, list[np.ndarray]] = {a: [] for a in uniq_authors} + seg_labels: dict[str, list[int]] = {a: [] for a in uniq_authors} + seg_metainfos: dict[str, list] = {a: [] for a in uniq_authors} + + # --- Image-scoped segmentations --- + for ann in image_seg_anns: + author = self.annotation_processor.get_author(ann) + seg_code = self.annotation_processor.resolve_seg_code(ann.identifier) + + sliced_seg = self._fetch_sliced_seg_annotation(ann, resource, seg_slice_axis) + # sliced_seg shape: (DIM1, DIM2) + segmentations[author].append(sliced_seg) + seg_labels[author].append(seg_code) + seg_metainfos[author].append(ann) + + # --- Frame-scoped segmentations --- + if frame_seg_anns: + frame_groups = self.annotation_processor.group_annotations( + frame_seg_anns, by_author=True, by_identifier=True + ) + for (author, identifier), fr_anns in frame_groups.items(): + seg_code = self.annotation_processor.resolve_seg_code(identifier) + + sliced_seg = self._fetch_sliced_frame_seg_group( + fr_anns, resource, seg_slice_axis + ) + if sliced_seg is None: + continue + segmentations[author].append(sliced_seg) + seg_labels[author].append(seg_code) + seg_metainfos[author].append(fr_anns) + + # Stack per-author and add dummy depth dim + final_segmentations: dict[str, np.ndarray] = {} + final_seg_labels: dict[str, np.ndarray] = {} + for author in segmentations: + if segmentations[author]: + stacked = np.stack(segmentations[author], axis=0) # (#instances, DIM1, DIM2) + stacked = np.expand_dims(stacked, axis=1) # (#instances, 1, DIM1, DIM2) + final_segmentations[author] = stacked + final_seg_labels[author] = np.array(seg_labels[author], dtype=np.int32) + + return final_segmentations, final_seg_labels, seg_metainfos + + def _fetch_sliced_seg_annotation( + self, + ann: 'Annotation', + resource: SlicedVolumeResource, + seg_slice_axis: int, + ) -> np.ndarray: + """Load a single image-scoped segmentation, slice it, and cache the 2D result. + + On the first call the full 3D segmentation is loaded via + ``load_segmentation_data`` (raw bytes are already cached by the + annotation entity). The requested 2D slice is extracted, cached in + ``_seg_slice_cache``, and returned. Subsequent calls for the same + annotation + slice return the cached array directly, avoiding the + expensive volume parsing. + + Args: + ann: Image-scoped segmentation annotation. + resource: Slice proxy identifying axis and index. + seg_slice_axis: Axis index in the (D, H, W) segmentation array. + + Returns: + 2D boolean segmentation array of shape (DIM1, DIM2). + """ + cache_entity_id = f"{ann.id}:axis{resource.slice_axis}:slice{resource.slice_index}" + version_info = { + 'created_at': ann.created_at, + 'deleted_at': ann.deleted_at, + 'associated_file': ann.associated_file, + } + + cached = self._seg_slice_cache.get(cache_entity_id, _SEG_SLICE_CACHEKEY, version_info) + if cached is not None: + return cached + + # Load the full 3D segmentation volume (raw bytes are cached by ann.fetch_file_data) + full_seg = self.annotation_processor.load_segmentation_data(ann) + # full_seg shape: (D, H, W) + sliced = np.take(full_seg, resource.slice_index, axis=seg_slice_axis) + sliced = np.ascontiguousarray(sliced) + + self._seg_slice_cache.set(cache_entity_id, _SEG_SLICE_CACHEKEY, sliced, version_info) + return sliced + + def _fetch_sliced_frame_seg_group( + self, + fr_anns: list['Annotation'], + resource: SlicedVolumeResource, + seg_slice_axis: int, + ) -> np.ndarray | None: + """Collate frame-level segmentation annotations, slice, and cache the 2D result. + + Frame-level annotations each cover a single frame (depth index). They + are first assembled into a full (D, H, W) volume via + ``collate_frame_segmentations``, then sliced and cached. Subsequent + calls for the same group + slice return the cached array. + + Args: + fr_anns: Frame-scoped annotations sharing the same author+identifier. + resource: Slice proxy identifying axis and index. + seg_slice_axis: Axis index in the (D, H, W) segmentation array. + + Returns: + 2D boolean array of shape (DIM1, DIM2), or None if collation yields nothing. + """ + # Build a stable cache key from a hash of sorted annotation IDs + ann_ids_str = ','.join(sorted(a.id or '' for a in fr_anns)) + group_hash = hashlib.sha256(ann_ids_str.encode()).hexdigest()[:16] + cache_entity_id = f"frame_seg:{group_hash}:axis{resource.slice_axis}:slice{resource.slice_index}" + version_info = { + 'ann_ids_with_versions': [ + (a.id or '', a.associated_file, a.deleted_at) + for a in sorted(fr_anns, key=lambda a: a.id or '') + ], + } + + cached = self._seg_slice_cache.get(cache_entity_id, _SEG_SLICE_CACHEKEY, version_info) + if cached is not None: + return cached + + # Fast path: when slicing along the depth axis, only the annotation + # whose frame_index matches the requested slice contributes non-zero + # data — load just that one instead of assembling the full volume. + if seg_slice_axis == 0: + matching = [a for a in fr_anns if a.frame_index == resource.slice_index] + if not matching: + # None of the frame annotations cover this depth slice → all zeros + # We need the spatial shape; load just the first ann to obtain it. + sample_seg = self.annotation_processor.load_segmentation_data(fr_anns[0]) + sliced = np.zeros(sample_seg.shape[1:], dtype=bool) # (H, W) + self._seg_slice_cache.set(cache_entity_id, _SEG_SLICE_CACHEKEY, sliced, version_info) + return sliced + + # Union of all matching annotations at this depth index + sliced: np.ndarray | None = None + for ann in matching: + seg = self.annotation_processor.load_segmentation_data(ann) # (1, H, W) + frame_mask = seg[0] # (H, W) + sliced = frame_mask if sliced is None else (sliced | frame_mask) + sliced = np.ascontiguousarray(sliced) + self._seg_slice_cache.set(cache_entity_id, _SEG_SLICE_CACHEKEY, sliced, version_info) + return sliced + + # Non-depth axis: must assemble the full volume then slice along the chosen axis. + stacked_seg, _ = self.annotation_processor.collate_frame_segmentations(fr_anns) + if stacked_seg is None: + return None + # stacked_seg shape: (D, H, W) + sliced = np.take(stacked_seg, resource.slice_index, axis=seg_slice_axis) + sliced = np.ascontiguousarray(sliced) + + self._seg_slice_cache.set(cache_entity_id, _SEG_SLICE_CACHEKEY, sliced, version_info) + return sliced + @override def _get_raw_item(self, index: int) -> dict[str, Any]: """Load a single 2D slice and its annotations. @@ -384,31 +602,11 @@ def __getitem__(self, index: int) -> dict[str, Any]: resource: SlicedVolumeResource = result['resource'] # Process segmentations - # FIXME: This currently re-loads the slice data for each segmentation annotation, which is inefficient. We should ideally load the slice once and reuse it for all segmentations. This may require refactoring how annotations are processed to avoid redundant data loading. if self.return_segmentations: seg_anns = AnnotationProcessor.filter_annotations( annotations, type='segmentation', scope='all' ) - segmentations, seg_labels, _ = self.annotation_processor.load_segmentations(seg_anns) - - # Slice segmentations along the same axis as the image - # segmentations[author] shape: (#instances, D, H, W) - slice_idx = resource.slice_index - slice_axis_idx_std = resource.slice_axis_idx_std - # resource normalized is always (D, C, H, W) - map_slice_axis_idx_std = { - 0: 1, - 2: 2, - 3: 3, - } - seg_slice_axis_idx = map_slice_axis_idx_std[slice_axis_idx_std] - sliced_segs: dict[str, np.ndarray] = {} - for author, seg_array in segmentations.items(): - # seg_array shape: (#instances, D, H, W) - # Select the slice: (#instances, DIM1, DIM2) where DIM1 and DIM2 depend on the slice axis - sliced_segs[author] = np.take(seg_array, slice_idx, axis=seg_slice_axis_idx) - # Add a dummy dimension for consistency with pipeline: (#instances, 1, DIM1, DIM2) - sliced_segs[author] = np.expand_dims(sliced_segs[author], axis=1) + sliced_segs, seg_labels, _ = self._load_sliced_segmentations(seg_anns, resource) # Apply albumentations if present if self.alb_transform: diff --git a/pyproject.toml b/pyproject.toml index a86cd0ab..714d65f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ Flask = { version = "<4" } Flask-Cors = { version = "<7" } albumentations = ">=2.0.0" lazy-loader = ">=0.3.0" -medimgkit = ">=0.14.1" +medimgkit = ">=0.14.3" typing_extensions = ">=4.0.0" pydantic = ">=2.6.4" certifi = ">=2025.0.0" From c48f13151feb8b3bb8700a52754b14a69c8b5800 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Fri, 6 Mar 2026 17:03:35 -0300 Subject: [PATCH 11/47] Fixed image dimension handling in SlicedVolumeDataset transformation --- datamint/dataset/sliced_dataset.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/datamint/dataset/sliced_dataset.py b/datamint/dataset/sliced_dataset.py index b48a4299..d70f7ae5 100644 --- a/datamint/dataset/sliced_dataset.py +++ b/datamint/dataset/sliced_dataset.py @@ -665,11 +665,12 @@ def apply_alb_transform( raise ValueError("alb_transform is not set") # Squeeze depth=1 if present - if img.ndim == 4: + orig_dim = img.ndim + if orig_dim == 4: if img.shape[1] != 1: raise ValueError(f"Expected depth=1, got shape {img.shape}") img = img.squeeze(1) # (C, 1, H, W) -> (C, H, W) - elif img.ndim != 3: + elif orig_dim != 3: raise ValueError(f"Expected 3D or 4D image array, got shape {img.shape}") # Transpose to (H, W, C) for albumentations @@ -701,8 +702,9 @@ def apply_alb_transform( else: aug_img = aug_img.permute(2, 0, 1) - # Add depth=1 back: (C, 1, H, W) - aug_img = aug_img[:, np.newaxis, :, :] + # Add depth=1 back: (C, 1, H, W) if original had it, else keep (C, H, W) + if orig_dim == 4: + aug_img = aug_img[:, np.newaxis, :, :] return { 'image': aug_img, From cda8ce069625402af183635c2d62b2d40af1f12d Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 9 Mar 2026 10:08:57 -0300 Subject: [PATCH 12/47] Small refactor for bettter organization --- datamint/dataset/__init__.py | 5 +- datamint/dataset/sliced_dataset.py | 197 +-------------------------- datamint/entities/resource.py | 29 +++- datamint/entities/sliced_resource.py | 197 +++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 197 deletions(-) create mode 100644 datamint/entities/sliced_resource.py diff --git a/datamint/dataset/__init__.py b/datamint/dataset/__init__.py index 336d85f0..ab69e4b9 100644 --- a/datamint/dataset/__init__.py +++ b/datamint/dataset/__init__.py @@ -13,7 +13,7 @@ from .base import DatamintBaseDataset, DatamintDatasetException from .image_dataset import ImageDataset from .volume_dataset import VolumeDataset -from .sliced_dataset import SlicedVolumeDataset, SlicedVolumeResource +from .sliced_dataset import SlicedVolumeDataset __all__ = [ # Core @@ -22,6 +22,5 @@ # Specialized datasets 'ImageDataset', 'VolumeDataset', - 'SlicedVolumeDataset', - 'SlicedVolumeResource', + 'SlicedVolumeDataset' ] \ No newline at end of file diff --git a/datamint/dataset/sliced_dataset.py b/datamint/dataset/sliced_dataset.py index d70f7ae5..24be0a0a 100644 --- a/datamint/dataset/sliced_dataset.py +++ b/datamint/dataset/sliced_dataset.py @@ -4,218 +4,29 @@ Provides a way to iterate over individual 2D slices from 3D volume data, enabling training of 2D models on volumetric medical imaging data. """ -import gzip +from __future__ import annotations import hashlib -import logging -from functools import cached_property from typing import Any, Literal, TYPE_CHECKING from typing_extensions import override from collections.abc import Sequence - import numpy as np import torch from torch import Tensor import albumentations -from medimgkit.readers import read_array_normalized -from medimgkit import dicom_utils -from medimgkit import nifti_utils - from .base import DatamintBaseDataset from .annotation_processor import AnnotationProcessor, MergeStrategy from datamint.entities.cache_manager import CacheManager if TYPE_CHECKING: from datamint.entities import Annotation, Resource - -_LOGGER = logging.getLogger(__name__) - -# Cache key for parsed slice numpy arrays -_SLICE_ARRAY_CACHEKEY = "slice_array" + from datamint.entities.sliced_resource import SlicedVolumeResource # Cache key for sliced segmentation numpy arrays _SEG_SLICE_CACHEKEY = "seg_slice_array" -class SlicedVolumeResource: - """Proxy that presents a single 2D slice of a 3D volume Resource. - - This class wraps a :class:`Resource` and represents a specific 2D slice - along a given axis. It uses gzip-compressed ``.npy.gz`` files on disk - for efficient storage, with an in-memory LRU cache managed by - :class:`CacheManager`. - - The CacheManager memory cache is disabled by default globally, but the - sliced-volume cache manager enables it by default. - - This shared cache avoids repeated gzip decompression for already-cached - slices. Full-volume caching is intentionally not handled here. - - Args: - parent: The original 3D volume Resource. - slice_index: The index of the slice along the given axis. - slice_axis: The spatial axis to slice along ('axial', 'coronal', 'sagittal'). - sliced_vols_cache: Shared :class:`CacheManager` for disk-based volume caching. - """ - - _CACHE_MANAGER_NAMESPACE = "sliced_volumes" - - def __init__( - self, - parent: 'Resource', - slice_index: int, - slice_axis: str, - sliced_vols_cache: CacheManager | None = None, - ): - self._parent = parent - self.slice_index = slice_index - self.slice_axis = slice_axis - if sliced_vols_cache is None: - sliced_vols_cache = CacheManager(SlicedVolumeResource._CACHE_MANAGER_NAMESPACE) - self._volume_cache = sliced_vols_cache - - @staticmethod - def slice_over(resource: 'Resource', - slice_axis: Literal['axial', 'coronal', 'sagittal'], - volume_cache: CacheManager | None = None) -> list['SlicedVolumeResource']: - sliced_resources = [] - res_data = resource.fetch_file_data(auto_convert=True, use_cache=True) - if resource.is_dicom(): - axis_size = dicom_utils.get_dim_size(res_data, slice_axis) - elif resource.is_nifti(): - axis_size = nifti_utils.get_dim_size(res_data, slice_axis) - else: - raise ValueError(f"Unsupported resource type for slicing axis: {resource.filename}|{resource.mimetype}") - - # anns = resource_annotations[i] - - for s in range(axis_size): - sliced_resources.append( - SlicedVolumeResource(resource, s, slice_axis, volume_cache) - ) - - return sliced_resources - - def __getattr__(self, name: str) -> Any: - """Delegate all unresolved attributes to the parent Resource.""" - return getattr(self._parent, name) - - def get_depth(self) -> int: - """A single slice has depth 1.""" - return 1 - - def _get_version_info(self) -> dict: - """Get version info from the parent resource for cache validation.""" - return { - 'created_at': self._parent.created_at, - 'deleted_at': self._parent.deleted_at, - 'size': self._parent.size, - } - - def _slice_cache_entity_id(self) -> str: - return f"{self._parent.id}:axis{self.slice_axis}:slice{self.slice_index}" - - def fetch_slice_data(self) -> np.ndarray: - """Fetch the 2D slice as a (C, H, W) array. - - Returns: - Slice array with shape (C, H, W). - """ - version_info = self._get_version_info() - cache_entity_id = self._slice_cache_entity_id() - - cached_slice = self._volume_cache.get( - cache_entity_id, - _SLICE_ARRAY_CACHEKEY, - version_info, - ) - if cached_slice is not None: - return np.ascontiguousarray(cached_slice) - - raw = self._parent.fetch_file_data(auto_convert=False, use_cache=True) - vol, self.data_metainfo = read_array_normalized(raw, return_metainfo=True) - # vol.shape is (D,C,H,W) - - _LOGGER.debug( - f"Slicing {self._parent.filename} along axis {self.slice_axis=} ({self.slice_axis_idx_std=}) at index {self.slice_index=} with shape {vol.shape=}") - sliced = np.take(vol, self.slice_index, axis=self.slice_axis_idx_std) - # vol is (D, C, H, W); after np.take the sliced axis is removed. - # C was at index 1. If we sliced axis 0 (D), C shifts to index 0 — already correct. - # If we sliced axis 2 (H) or 3 (W), C stays at index 1 → move it to 0. - channel_axis_in_result = 1 if self.slice_axis_idx_std > 1 else 0 - if channel_axis_in_result != 0: - sliced = np.moveaxis(sliced, channel_axis_in_result, 0) - # sliced is now (C, DIM1, DIM2) - sliced = np.ascontiguousarray(sliced) - - gz_path = self._volume_cache.get_expected_path(cache_entity_id, _SLICE_ARRAY_CACHEKEY) - gz_path = gz_path.with_suffix('.npy.gz') - gz_path.parent.mkdir(parents=True, exist_ok=True) - with gzip.open(str(gz_path), 'wb', compresslevel=4) as f: - np.save(f, sliced) - - self._volume_cache.register_file_location( - cache_entity_id, - _SLICE_ARRAY_CACHEKEY, - file_path=gz_path, - version_info=version_info, - mimetype='application/gzip', - data=sliced, - ) - - _LOGGER.debug(f'Sliced shape: {sliced.shape}') - - return sliced - - @cached_property - def data_metainfo(self) -> dict: - """Volume metadata. Loaded once and cached for the lifetime of this resource.""" - raw = self._parent.fetch_file_data(auto_convert=False, use_cache=True) - _, metainfo = read_array_normalized(raw, return_metainfo=True) - return metainfo - - @cached_property - def slice_axis_idx(self) -> int: - """Raw axis index for the slice axis. Computed once and cached.""" - if self._parent.is_dicom(): - return dicom_utils.get_plane_axis(self.data_metainfo, self.slice_axis) - elif self._parent.is_nifti(): - return nifti_utils.get_plane_axis(self.data_metainfo, self.slice_axis) - else: - raise ValueError( - "Unsupported resource type for slicing axis:" - f" {self._parent.filename}|{self._parent.mimetype}|{self.slice_axis}") - - @cached_property - def slice_axis_idx_std(self) -> int: - """Standardised axis index for the slice axis. Computed once and cached.""" - if self._parent.is_dicom(): - return dicom_utils.rawplaneaxis2stdplaneaxis_idx(self.slice_axis_idx) - elif self._parent.is_nifti(): - return nifti_utils.rawplaneaxis2stdplaneaxis_idx(self.slice_axis_idx) - else: - raise ValueError( - f"Unsupported resource type for slicing axis: {self._parent.filename}|{self._parent.mimetype}") - - @property - def parent_resource(self) -> 'Resource': - """The original volume Resource being proxied.""" - return self._parent - - def __repr__(self) -> str: - axis_names = {0: 'axial', 1: 'coronal', 2: 'sagittal'} - axis_name = axis_names.get(self.slice_axis, str(self.slice_axis)) - return ( - f"SlicedVolumeResource(filename='{self._parent.filename}', " - f"axis='{axis_name}', slice={self.slice_index})" - ) - def __getattribute__(self, name: str) -> Any: - try: - return super().__getattribute__(name) - except AttributeError: - parent = super().__getattribute__('_parent') - return getattr(parent, name) # # Axis mapping for anatomical orientations @@ -247,7 +58,7 @@ class SlicedVolumeDataset(DatamintBaseDataset): def __init__( self, - parent_dataset: 'DatamintBaseDataset', + parent_dataset: DatamintBaseDataset, slice_axis: Literal['axial', 'coronal', 'sagittal'] | int = 'axial', ): # We intentionally do NOT call super().__init__() because that @@ -339,6 +150,8 @@ def _expand_resources( Returns: Tuple of (sliced_resources, sliced_annotations). """ + from datamint.entities.sliced_resource import SlicedVolumeResource + sliced_resources: list[SlicedVolumeResource] = [] sliced_annotations: list[Sequence['Annotation']] = [] diff --git a/datamint/entities/resource.py b/datamint/entities/resource.py index 319c1bb7..f3c94ad3 100644 --- a/datamint/entities/resource.py +++ b/datamint/entities/resource.py @@ -2,7 +2,6 @@ from datetime import datetime import logging -import shutil import urllib.parse import urllib.request import webbrowser @@ -18,7 +17,7 @@ if TYPE_CHECKING: from datamint.api.endpoints.resources_api import ResourcesApi - from .project import Project + from medimgkit import ViewPlane from .annotations.annotation import Annotation from datamint.types import ImagingData from datamint.api.dto import AnnotationType @@ -339,6 +338,32 @@ def from_local_file(file_path: str | Path): """ return LocalResource(local_filepath=file_path) + @property + def _slice_cache_manager(self) -> CacheManager: + """Cache manager for sliced volumes derived from this resource.""" + if not hasattr(self, '__slice_cache_manager'): + self.__slice_cache_manager = CacheManager( + 'sliced_volumes', + enable_memory_cache=True, + memory_cache_maxsize=1, + ) + return self.__slice_cache_manager + + def get_slice(self, axis: 'ViewPlane', index: int): + """Get a specific slice of the volume as a SlicedVolumeResource. + + Args: + axis: The anatomical plane to slice along (e.g., 'axial', 'coronal', 'sagittal') + index: The index of the slice along the specified axis + Returns: + A numpy array representing the specified slice + """ + from .sliced_resource import SlicedVolumeResource + sr = SlicedVolumeResource(self, index, + slice_axis=axis, + sliced_vols_cache=self._slice_cache_manager) + return sr.fetch_slice_data() + class LocalResource(Resource): """Represents a local resource that hasn't been uploaded to DataMint API yet.""" diff --git a/datamint/entities/sliced_resource.py b/datamint/entities/sliced_resource.py new file mode 100644 index 00000000..f92e548e --- /dev/null +++ b/datamint/entities/sliced_resource.py @@ -0,0 +1,197 @@ +from __future__ import annotations +import gzip +import logging +from typing import Any, Literal, TYPE_CHECKING +from functools import cached_property +from medimgkit.readers import read_array_normalized +from medimgkit import dicom_utils, nifti_utils, ViewPlane +from datamint.entities.cache_manager import CacheManager +import numpy as np + +if TYPE_CHECKING: + from datamint.entities import Resource + +_LOGGER = logging.getLogger(__name__) + +# Cache key for parsed slice numpy arrays +_SLICE_ARRAY_CACHEKEY = "slice_array" + +class SlicedVolumeResource: + """Proxy that presents a single 2D slice of a 3D volume Resource. + + This class wraps a :class:`Resource` and represents a specific 2D slice + along a given axis. It uses gzip-compressed ``.npy.gz`` files on disk + for efficient storage, with an in-memory LRU cache managed by + :class:`CacheManager`. + + The CacheManager memory cache is disabled by default globally, but the + sliced-volume cache manager enables it by default. + + This shared cache avoids repeated gzip decompression for already-cached + slices. Full-volume caching is intentionally not handled here. + + Args: + parent: The original 3D volume Resource. + slice_index: The index of the slice along the given axis. + slice_axis: The spatial axis to slice along ('axial', 'coronal', 'sagittal'). + sliced_vols_cache: Shared :class:`CacheManager` for disk-based volume caching. + """ + + _CACHE_MANAGER_NAMESPACE = "sliced_volumes" + + def __init__( + self, + parent: Resource, + slice_index: int, + slice_axis: ViewPlane, + sliced_vols_cache: CacheManager | None = None, + ): + self._parent = parent + self.slice_index = slice_index + self.slice_axis = slice_axis + if sliced_vols_cache is None: + sliced_vols_cache = CacheManager(SlicedVolumeResource._CACHE_MANAGER_NAMESPACE) + self._volume_cache = sliced_vols_cache + + @staticmethod + def slice_over(resource: Resource, + slice_axis: ViewPlane, + volume_cache: CacheManager | None = None) -> list[SlicedVolumeResource]: + sliced_resources = [] + res_data = resource.fetch_file_data(auto_convert=True, use_cache=True) + if resource.is_dicom(): + axis_size = dicom_utils.get_dim_size(res_data, slice_axis) + elif resource.is_nifti(): + axis_size = nifti_utils.get_dim_size(res_data, slice_axis) + else: + raise ValueError(f"Unsupported resource type for slicing axis: {resource.filename}|{resource.mimetype}") + + # anns = resource_annotations[i] + + for s in range(axis_size): + sliced_resources.append( + SlicedVolumeResource(resource, s, slice_axis, volume_cache) + ) + + return sliced_resources + + def __getattr__(self, name: str) -> Any: + """Delegate all unresolved attributes to the parent Resource.""" + return getattr(self._parent, name) + + def get_depth(self) -> int: + """A single slice has depth 1.""" + return 1 + + def _get_version_info(self) -> dict: + """Get version info from the parent resource for cache validation.""" + return { + 'created_at': self._parent.created_at, + 'deleted_at': self._parent.deleted_at, + 'size': self._parent.size, + } + + def _slice_cache_entity_id(self) -> str: + return f"{self._parent.id}:axis{self.slice_axis}:slice{self.slice_index}" + + def fetch_slice_data(self) -> np.ndarray: + """Fetch the 2D slice as a (C, H, W) array. + + Returns: + Slice array with shape (C, H, W). + """ + version_info = self._get_version_info() + cache_entity_id = self._slice_cache_entity_id() + + cached_slice = self._volume_cache.get( + cache_entity_id, + _SLICE_ARRAY_CACHEKEY, + version_info, + ) + if cached_slice is not None: + return np.ascontiguousarray(cached_slice) + + raw = self._parent.fetch_file_data(auto_convert=False, use_cache=True) + vol, self.data_metainfo = read_array_normalized(raw, return_metainfo=True) + # vol.shape is (D,C,H,W) + + _LOGGER.debug( + f"Slicing {self._parent.filename} along axis {self.slice_axis=} ({self.slice_axis_idx_std=}) at index {self.slice_index=} with shape {vol.shape=}") + sliced = np.take(vol, self.slice_index, axis=self.slice_axis_idx_std) + # vol is (D, C, H, W); after np.take the sliced axis is removed. + # C was at index 1. If we sliced axis 0 (D), C shifts to index 0 — already correct. + # If we sliced axis 2 (H) or 3 (W), C stays at index 1 → move it to 0. + channel_axis_in_result = 1 if self.slice_axis_idx_std > 1 else 0 + if channel_axis_in_result != 0: + sliced = np.moveaxis(sliced, channel_axis_in_result, 0) + # sliced is now (C, DIM1, DIM2) + sliced = np.ascontiguousarray(sliced) + + gz_path = self._volume_cache.get_expected_path(cache_entity_id, _SLICE_ARRAY_CACHEKEY) + gz_path = gz_path.with_suffix('.npy.gz') + gz_path.parent.mkdir(parents=True, exist_ok=True) + with gzip.open(str(gz_path), 'wb', compresslevel=4) as f: + np.save(f, sliced) + + self._volume_cache.register_file_location( + cache_entity_id, + _SLICE_ARRAY_CACHEKEY, + file_path=gz_path, + version_info=version_info, + mimetype='application/gzip', + data=sliced, + ) + + _LOGGER.debug(f'Sliced shape: {sliced.shape}') + + return sliced + + @cached_property + def data_metainfo(self) -> dict: + """Volume metadata. Loaded once and cached for the lifetime of this resource.""" + raw = self._parent.fetch_file_data(auto_convert=False, use_cache=True) + _, metainfo = read_array_normalized(raw, return_metainfo=True) + return metainfo + + @cached_property + def slice_axis_idx(self) -> int: + """Raw axis index for the slice axis. Computed once and cached.""" + if self._parent.is_dicom(): + return dicom_utils.get_plane_axis(self.data_metainfo, self.slice_axis) + elif self._parent.is_nifti(): + return nifti_utils.get_plane_axis(self.data_metainfo, self.slice_axis) + else: + raise ValueError( + "Unsupported resource type for slicing axis:" + f" {self._parent.filename}|{self._parent.mimetype}|{self.slice_axis}") + + @cached_property + def slice_axis_idx_std(self) -> int: + """Standardised axis index for the slice axis. Computed once and cached.""" + if self._parent.is_dicom(): + return dicom_utils.rawplaneaxis2stdplaneaxis_idx(self.slice_axis_idx) + elif self._parent.is_nifti(): + return nifti_utils.rawplaneaxis2stdplaneaxis_idx(self.slice_axis_idx) + else: + raise ValueError( + f"Unsupported resource type for slicing axis: {self._parent.filename}|{self._parent.mimetype}") + + @property + def parent_resource(self) -> Resource: + """The original volume Resource being proxied.""" + return self._parent + + def __repr__(self) -> str: + axis_names = {0: 'axial', 1: 'coronal', 2: 'sagittal'} + axis_name = axis_names.get(self.slice_axis, str(self.slice_axis)) + return ( + f"SlicedVolumeResource(filename='{self._parent.filename}', " + f"axis='{axis_name}', slice={self.slice_index})" + ) + + def __getattribute__(self, name: str) -> Any: + try: + return super().__getattribute__(name) + except AttributeError: + parent = super().__getattribute__('_parent') + return getattr(parent, name) \ No newline at end of file From 3077b3c177f03939d64f3b4fa90a55704e034f74 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 9 Mar 2026 14:01:09 -0300 Subject: [PATCH 13/47] minor refactor --- datamint/dataset/sliced_dataset.py | 145 +++++++++++++++++++++-------- datamint/dataset/volume_dataset.py | 2 +- 2 files changed, 107 insertions(+), 40 deletions(-) diff --git a/datamint/dataset/sliced_dataset.py b/datamint/dataset/sliced_dataset.py index 24be0a0a..d0cf846c 100644 --- a/datamint/dataset/sliced_dataset.py +++ b/datamint/dataset/sliced_dataset.py @@ -6,7 +6,7 @@ """ from __future__ import annotations import hashlib -from typing import Any, Literal, TYPE_CHECKING +from typing import Any, TYPE_CHECKING from typing_extensions import override from collections.abc import Sequence import numpy as np @@ -15,12 +15,13 @@ import albumentations from .base import DatamintBaseDataset -from .annotation_processor import AnnotationProcessor, MergeStrategy +from .annotation_processor import AnnotationProcessor from datamint.entities.cache_manager import CacheManager if TYPE_CHECKING: from datamint.entities import Annotation, Resource from datamint.entities.sliced_resource import SlicedVolumeResource + from medimgkit import ViewPlane # Cache key for sliced segmentation numpy arrays _SEG_SLICE_CACHEKEY = "seg_slice_array" @@ -46,25 +47,25 @@ class SlicedVolumeDataset(DatamintBaseDataset): The ``__getitem__`` returns arrays with shape ``(C, H, W)`` for images and ``(num_instances, H, W)`` or ``(num_labels+1, H, W)`` for segmentations. - Typically created via :meth:`VolumeDataset.slice`, but can also be - instantiated directly. + Can be instantiated directly with all the same parameters as + :class:`DatamintBaseDataset` plus ``slice_axis``, or created from an + already-loaded dataset via the :meth:`from_dataset` factory classmethod + (which avoids additional server calls). Args: - parent_dataset: The source :class:`DatamintBaseDataset` (e.g. VolumeDataset) - providing resources, annotations, and configuration. + project: Project name, Project object, or None. Mutually exclusive with resources. + resources: List of Resource objects, or None. Mutually exclusive with project. slice_axis: Slice orientation. One of ``'axial'`` (depth), ``'coronal'`` (height), ``'sagittal'`` (width), or an integer axis index (0--2). + See :class:`DatamintBaseDataset` for all remaining parameters. """ def __init__( self, - parent_dataset: DatamintBaseDataset, - slice_axis: Literal['axial', 'coronal', 'sagittal'] | int = 'axial', + *args, + slice_axis: ViewPlane | int = 'axial', + **kwargs, ): - # We intentionally do NOT call super().__init__() because that - # requires project/API interaction. Instead, copy needed state - # from the parent dataset. - # --- Resolve axis --- if isinstance(slice_axis, str): valid_slice_axis = ['axial', 'coronal', 'sagittal'] @@ -79,42 +80,106 @@ def __init__( raise ValueError(f"axis must be 0, 1, or 2, got {slice_axis}") self._slice_axis_int = slice_axis - self.project = parent_dataset.project + super().__init__( + *args, + **kwargs, + ) + + # --- Segmentation slice cache --- + self._seg_slice_cache = CacheManager( + 'sliced_segmentations', + enable_memory_cache=True, + memory_cache_maxsize=8, + ) + + # --- Build sliced resources --- + volume_cache = CacheManager( + 'sliced_volumes', + enable_memory_cache=True, + memory_cache_maxsize=2, + ) + expanded_resources, expanded_annotations = self._expand_resources( + self.resources, + self.resource_annotations, + volume_cache, + ) + self.resources = expanded_resources # type: ignore[assignment] + self.resource_annotations = expanded_annotations + + @classmethod + def from_dataset( + cls, + parent_dataset: DatamintBaseDataset, + slice_axis: 'ViewPlane | int' = 'axial', + ) -> 'SlicedVolumeDataset': + """Create a SlicedVolumeDataset from an existing dataset without additional server calls. + + Copies all configuration, label mappings, and already-loaded resources + from ``parent_dataset``, then expands them into per-slice proxy resources. + Use this factory when you already have a loaded dataset and want to obtain + 2D slices without triggering new API requests. + + Args: + parent_dataset: The source :class:`DatamintBaseDataset` (e.g. VolumeDataset) + providing resources, annotations, and configuration. + slice_axis: Slice orientation. One of ``'axial'`` (depth), ``'coronal'`` + (height), ``'sagittal'`` (width), or an integer axis index (0--2). + + Returns: + A new :class:`SlicedVolumeDataset` instance. + """ + instance: SlicedVolumeDataset = cls.__new__(cls) + + # --- Resolve axis --- + if isinstance(slice_axis, str): + valid_slice_axis = ['axial', 'coronal', 'sagittal'] + if slice_axis not in valid_slice_axis: + raise ValueError( + f"Unknown axis '{slice_axis}'. " + f"Must be one of {valid_slice_axis} or an int 0-2." + ) + instance._slice_axis = slice_axis + else: + if not (0 <= slice_axis <= 2): + raise ValueError(f"axis must be 0, 1, or 2, got {slice_axis}") + instance._slice_axis_int = slice_axis + + instance.project = parent_dataset.project # Copy configuration from parent - self.return_metainfo = parent_dataset.return_metainfo - self.return_segmentations = parent_dataset.return_segmentations - self.return_as_semantic_segmentation = parent_dataset.return_as_semantic_segmentation - self.semantic_seg_merge_strategy: MergeStrategy | None = parent_dataset.semantic_seg_merge_strategy - self.include_unannotated = parent_dataset.include_unannotated + instance.return_metainfo = parent_dataset.return_metainfo + instance.return_segmentations = parent_dataset.return_segmentations + instance.return_as_semantic_segmentation = parent_dataset.return_as_semantic_segmentation + instance.semantic_seg_merge_strategy = parent_dataset.semantic_seg_merge_strategy + instance.include_unannotated = parent_dataset.include_unannotated # Transforms - self.alb_transform = parent_dataset.alb_transform + instance.alb_transform = parent_dataset.alb_transform # Filtering (already applied on parent's annotations) - self.include_annotators = parent_dataset.include_annotators - self.exclude_annotators = parent_dataset.exclude_annotators - self.include_segmentation_names = parent_dataset.include_segmentation_names - self.exclude_segmentation_names = parent_dataset.exclude_segmentation_names - self.include_image_label_names = parent_dataset.include_image_label_names - self.exclude_image_label_names = parent_dataset.exclude_image_label_names - self.include_frame_label_names = parent_dataset.include_frame_label_names - self.exclude_frame_label_names = parent_dataset.exclude_frame_label_names + instance.include_annotators = parent_dataset.include_annotators + instance.exclude_annotators = parent_dataset.exclude_annotators + instance.include_segmentation_names = parent_dataset.include_segmentation_names + instance.exclude_segmentation_names = parent_dataset.exclude_segmentation_names + instance.include_image_label_names = parent_dataset.include_image_label_names + instance.exclude_image_label_names = parent_dataset.exclude_image_label_names + instance.include_frame_label_names = parent_dataset.include_frame_label_names + instance.exclude_frame_label_names = parent_dataset.exclude_frame_label_names # Copy label sets and processor from parent - self.annotation_processor = parent_dataset.annotation_processor - self.frame_lsets = parent_dataset.frame_lsets - self.frame_lcodes = parent_dataset.frame_lcodes - self.image_lsets = parent_dataset.image_lsets - self.image_lcodes = parent_dataset.image_lcodes - self.seglabel_list = parent_dataset.seglabel_list - self.seglabel2code = parent_dataset.seglabel2code + instance.annotation_processor = parent_dataset.annotation_processor + instance.frame_lsets = parent_dataset.frame_lsets + instance.frame_lcodes = parent_dataset.frame_lcodes + instance.image_lsets = parent_dataset.image_lsets + instance.image_lcodes = parent_dataset.image_lcodes + instance.seglabel_list = parent_dataset.seglabel_list + instance.seglabel2code = parent_dataset.seglabel2code # Internal state - self._logged_uint16_conversion = False + instance._logged_uint16_conversion = False # --- Segmentation slice cache --- - self._seg_slice_cache = CacheManager( + instance._seg_slice_cache = CacheManager( 'sliced_segmentations', enable_memory_cache=True, memory_cache_maxsize=8, @@ -126,13 +191,15 @@ def __init__( enable_memory_cache=True, memory_cache_maxsize=2, ) - expanded_resources, expanded_annotations = self._expand_resources( + expanded_resources, expanded_annotations = instance._expand_resources( parent_dataset.resources, parent_dataset.resource_annotations, volume_cache, ) - self.resources = expanded_resources # type: ignore[assignment] - self.resource_annotations = expanded_annotations + instance.resources = expanded_resources # type: ignore[assignment] + instance.resource_annotations = expanded_annotations + + return instance def _expand_resources( self, diff --git a/datamint/dataset/volume_dataset.py b/datamint/dataset/volume_dataset.py index a2f4e6ed..419af758 100644 --- a/datamint/dataset/volume_dataset.py +++ b/datamint/dataset/volume_dataset.py @@ -141,7 +141,7 @@ def slice(self, axis: str | int = 'axial') -> 'SlicedVolumeDataset': """ from .sliced_dataset import SlicedVolumeDataset - return SlicedVolumeDataset( + return SlicedVolumeDataset.from_dataset( parent_dataset=self, slice_axis=axis, ) From a2c429874c4ae5373793d37dd57c77fd274705bb Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 9 Mar 2026 16:50:10 -0300 Subject: [PATCH 14/47] VideoDataset class --- datamint/dataset/__init__.py | 8 +- datamint/dataset/base.py | 2 +- datamint/dataset/multiframe_dataset.py | 114 +++++ datamint/dataset/sliced_dataset.py | 7 +- datamint/dataset/sliced_video_dataset.py | 460 +++++++++++++++++++++ datamint/dataset/video_dataset.py | 65 +++ datamint/dataset/volume_dataset.py | 87 +--- datamint/entities/resource.py | 12 +- datamint/entities/sliced_video_resource.py | 153 +++++++ 9 files changed, 816 insertions(+), 92 deletions(-) create mode 100644 datamint/dataset/multiframe_dataset.py create mode 100644 datamint/dataset/sliced_video_dataset.py create mode 100644 datamint/dataset/video_dataset.py create mode 100644 datamint/entities/sliced_video_resource.py diff --git a/datamint/dataset/__init__.py b/datamint/dataset/__init__.py index ab69e4b9..a484a904 100644 --- a/datamint/dataset/__init__.py +++ b/datamint/dataset/__init__.py @@ -11,16 +11,22 @@ # New modular architecture from .base import DatamintBaseDataset, DatamintDatasetException +from .multiframe_dataset import MultiFrameDataset from .image_dataset import ImageDataset from .volume_dataset import VolumeDataset +from .video_dataset import VideoDataset from .sliced_dataset import SlicedVolumeDataset +from .sliced_video_dataset import SlicedVideoDataset __all__ = [ # Core 'DatamintBaseDataset', 'DatamintDatasetException', + 'MultiFrameDataset', # Specialized datasets 'ImageDataset', 'VolumeDataset', - 'SlicedVolumeDataset' + 'VideoDataset', + 'SlicedVolumeDataset', + 'SlicedVideoDataset', ] \ No newline at end of file diff --git a/datamint/dataset/base.py b/datamint/dataset/base.py index c3874b01..fc355d26 100644 --- a/datamint/dataset/base.py +++ b/datamint/dataset/base.py @@ -595,7 +595,7 @@ def _process_segmentations(self, raise ValueError("output_shape must be provided when no segmentations are present" " to infer shape from.") # Create empty semantic segmentation with just background class - segmentations = torch.zeros((len(self.segmentation_labels_set), *output_shape), + segmentations = torch.zeros((len(self.segmentation_labels_set)+1, *output_shape), dtype=torch.get_default_dtype()) segmentations[0] = 1 # background _LOGGER.debug("No segmentations found. " diff --git a/datamint/dataset/multiframe_dataset.py b/datamint/dataset/multiframe_dataset.py new file mode 100644 index 00000000..8d461039 --- /dev/null +++ b/datamint/dataset/multiframe_dataset.py @@ -0,0 +1,114 @@ +""" +MultiFrameDataset - Abstract base for datasets with multiple frames per resource. + +Shared logic for VolumeDataset (3D medical volumes) and VideoDataset +(temporal video sequences). Both handle data with shape (C, N, H, W) +where N is the number of frames/slices. +""" +import logging +from typing import Any +from typing_extensions import override + +import torch +import numpy as np +import albumentations + +from medimgkit.readers import read_array_normalized +from .base import DatamintBaseDataset + +_LOGGER = logging.getLogger(__name__) + + +class MultiFrameDataset(DatamintBaseDataset): + """Abstract base for multi-frame datasets. + + Handles loading and augmenting data with shape ``(C, N, H, W)`` where + ``N`` is the number of frames (temporal for video) or slices (spatial + for volumes). + + Subclasses add modality-specific features: + - :class:`VolumeDataset`: anatomical slicing via ``.slice()`` + - :class:`VideoDataset`: frame-by-frame iteration via ``.frame_by_frame()`` + """ + + @override + def _get_raw_item(self, index: int) -> dict[str, Any]: + """Load raw image and metadata. + + Returns: + Dict with: + - ``'image'``: np.ndarray of shape ``(C, N, H, W)`` + - ``'metainfo'``: dict with file metadata + - ``'annotations'``: sequence of Annotation objects + - ``'resource'``: the Resource object + """ + resource = self.resources[index] + res_bytesdata = resource.fetch_file_data(auto_convert=False, use_cache=True) + + img, metainfo = read_array_normalized(res_bytesdata, return_metainfo=True) # shape: (N, C, H, W) + img = img.transpose(1, 0, 2, 3) # (N, C, H, W) -> (C, N, H, W) + _LOGGER.debug(f"Raw image shape from resource {resource.filename}: {img.shape}") + + anns = self.resource_annotations[index] + + return { + 'image': img, # shape (C, N, H, W) + 'metainfo': metainfo, + 'annotations': anns, + 'resource': resource, + } + + @override + def apply_alb_transform( + self, + img: np.ndarray, + segmentations: dict[str, np.ndarray], + ) -> dict[str, Any]: + """Apply albumentations transform to 4D image and masks. + + Args: + img: Image array of shape ``(C, depth, H, W)``. + segmentations: Dict of author -> mask arrays of shape + ``(#instances, depth, H, W)``. + + Returns: + Dict with transformed ``'image'`` and ``'segmentations'``. + """ + if self.alb_transform is None: + raise ValueError("alb_transform is not set") + if img.ndim != 4: + raise ValueError(f"Expected 4D image array (C, depth, H, W), got shape {img.shape}") + + # transpose to (depth, H, W, C) + img = np.transpose(img, (1, 2, 3, 0)) + + replay_alb_transf = albumentations.ReplayCompose([self.alb_transform]) + _LOGGER.debug( + f'before alb transform image shape: {img.shape} | segmentations shape: {[segmentations[a].shape for a in segmentations]}') + + aug_data = replay_alb_transf(volume=img) # First call + replay_data = aug_data['replay'] + aug_img = aug_data['volume'] + + aug_segmentations = {} + for author, segs in segmentations.items(): + aug_segmentations_author = segs.copy() if isinstance(segs, np.ndarray) else segs.clone() + for i, seg_inst in enumerate(segs): # for each instance mask + aug_segmentations_author[i] = replay_alb_transf.replay(replay_data, mask3d=seg_inst)['mask3d'] + aug_segmentations[author] = aug_segmentations_author + + # transpose back to (C, depth, H, W) + if isinstance(aug_img, np.ndarray): + aug_img = np.transpose(aug_img, (3, 0, 1, 2)) + elif isinstance(aug_img, torch.Tensor): + _LOGGER.debug(f"augmented image tensor shape before permute: {aug_img.shape}") + if aug_img.shape[1] == img.shape[-1]: # if C is in dim 1 + aug_img = aug_img.permute(1, 0, 2, 3) + else: + aug_img = aug_img.permute(3, 0, 1, 2) + _LOGGER.debug(f"augmented image tensor shape after permute: {aug_img.shape}") + + return { + 'image': aug_img, + 'segmentations': aug_segmentations, + } diff --git a/datamint/dataset/sliced_dataset.py b/datamint/dataset/sliced_dataset.py index d0cf846c..6905bd06 100644 --- a/datamint/dataset/sliced_dataset.py +++ b/datamint/dataset/sliced_dataset.py @@ -27,9 +27,6 @@ _SEG_SLICE_CACHEKEY = "seg_slice_array" - - - # # Axis mapping for anatomical orientations # SLICE_AXIS_MAP = { # 'axial': 0, # slicing along depth (superior-inferior) @@ -218,7 +215,7 @@ def _expand_resources( Tuple of (sliced_resources, sliced_annotations). """ from datamint.entities.sliced_resource import SlicedVolumeResource - + sliced_resources: list[SlicedVolumeResource] = [] sliced_annotations: list[Sequence['Annotation']] = [] @@ -326,7 +323,7 @@ def _load_sliced_segmentations( def _fetch_sliced_seg_annotation( self, - ann: 'Annotation', + ann: Annotation, resource: SlicedVolumeResource, seg_slice_axis: int, ) -> np.ndarray: diff --git a/datamint/dataset/sliced_video_dataset.py b/datamint/dataset/sliced_video_dataset.py new file mode 100644 index 00000000..e4a61f7a --- /dev/null +++ b/datamint/dataset/sliced_video_dataset.py @@ -0,0 +1,460 @@ +""" +SlicedVideoDataset - 2D dataset created by iterating over frames of a VideoDataset. + +Provides a way to iterate over individual 2D frames from video data, +enabling training of 2D models on temporal medical imaging data. +""" +from __future__ import annotations +import hashlib +import logging +from typing import Any, TYPE_CHECKING +from typing_extensions import override +from collections.abc import Sequence + +import numpy as np +import torch +from torch import Tensor +import albumentations + +from .base import DatamintBaseDataset +from .annotation_processor import AnnotationProcessor +from datamint.entities.cache_manager import CacheManager + +if TYPE_CHECKING: + from datamint.entities import Annotation + from datamint.entities.sliced_video_resource import SlicedVideoResource + +_LOGGER = logging.getLogger(__name__) + +# Cache key for sliced segmentation numpy arrays +_SEG_FRAME_CACHEKEY = "seg_frame_array" + + +class SlicedVideoDataset(DatamintBaseDataset): + """2D dataset created by iterating over frames of a video. + + Each item corresponds to a single frame from a video. + The ``__getitem__`` returns arrays with shape ``(C, H, W)`` for images + and ``(num_instances, H, W)`` or ``(num_labels+1, H, W)`` for segmentations. + + Can be instantiated directly with all the same parameters as + :class:`DatamintBaseDataset`, or created from an already-loaded dataset + via the :meth:`from_dataset` factory classmethod (which avoids additional + server calls). + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # --- Segmentation frame cache --- + self._seg_frame_cache = CacheManager( + 'sliced_video_segmentations', + enable_memory_cache=True, + memory_cache_maxsize=8, + ) + + # --- Build per-frame resources --- + frame_cache = CacheManager( + 'sliced_video_frames', + enable_memory_cache=True, + memory_cache_maxsize=2, + ) + expanded_resources, expanded_annotations = self._expand_resources( + self.resources, + self.resource_annotations, + frame_cache, + ) + self.resources = expanded_resources # type: ignore[assignment] + self.resource_annotations = expanded_annotations + + @classmethod + def from_dataset( + cls, + parent_dataset: DatamintBaseDataset, + ) -> SlicedVideoDataset: + """Create a SlicedVideoDataset from an existing dataset without additional server calls. + + Copies all configuration, label mappings, and already-loaded resources + from ``parent_dataset``, then expands them into per-frame proxy resources. + + Args: + parent_dataset: The source dataset (e.g. VideoDataset). + + Returns: + A new :class:`SlicedVideoDataset` instance. + """ + instance: SlicedVideoDataset = cls.__new__(cls) + + instance.project = parent_dataset.project + + # Copy configuration from parent + instance.return_metainfo = parent_dataset.return_metainfo + instance.return_segmentations = parent_dataset.return_segmentations + instance.return_as_semantic_segmentation = parent_dataset.return_as_semantic_segmentation + instance.semantic_seg_merge_strategy = parent_dataset.semantic_seg_merge_strategy + instance.include_unannotated = parent_dataset.include_unannotated + + # Transforms + instance.alb_transform = parent_dataset.alb_transform + + # Filtering (already applied on parent's annotations) + instance.include_annotators = parent_dataset.include_annotators + instance.exclude_annotators = parent_dataset.exclude_annotators + instance.include_segmentation_names = parent_dataset.include_segmentation_names + instance.exclude_segmentation_names = parent_dataset.exclude_segmentation_names + instance.include_image_label_names = parent_dataset.include_image_label_names + instance.exclude_image_label_names = parent_dataset.exclude_image_label_names + instance.include_frame_label_names = parent_dataset.include_frame_label_names + instance.exclude_frame_label_names = parent_dataset.exclude_frame_label_names + + # Copy label sets and processor from parent + instance.annotation_processor = parent_dataset.annotation_processor + instance.frame_lsets = parent_dataset.frame_lsets + instance.frame_lcodes = parent_dataset.frame_lcodes + instance.image_lsets = parent_dataset.image_lsets + instance.image_lcodes = parent_dataset.image_lcodes + instance.seglabel_list = parent_dataset.seglabel_list + instance.seglabel2code = parent_dataset.seglabel2code + + # Internal state + instance._logged_uint16_conversion = False + + # --- Segmentation frame cache --- + instance._seg_frame_cache = CacheManager( + 'sliced_video_segmentations', + enable_memory_cache=True, + memory_cache_maxsize=8, + ) + + # --- Build per-frame resources --- + frame_cache = CacheManager( + 'sliced_video_frames', + enable_memory_cache=True, + memory_cache_maxsize=2, + ) + expanded_resources, expanded_annotations = instance._expand_resources( + parent_dataset.resources, + parent_dataset.resource_annotations, + frame_cache, + ) + instance.resources = expanded_resources # type: ignore[assignment] + instance.resource_annotations = expanded_annotations + + return instance + + def _expand_resources( + self, + resources: Sequence, + resource_annotations: Sequence[Sequence['Annotation']], + frame_cache: CacheManager, + ) -> tuple[list['SlicedVideoResource'], list[Sequence['Annotation']]]: + """Expand video resources into per-frame proxy resources. + + Args: + resources: Original video resources. + resource_annotations: Parallel annotation sequences. + frame_cache: Shared cache for decoded frames. + + Returns: + Tuple of (frame_resources, frame_annotations). + """ + from datamint.entities.sliced_video_resource import SlicedVideoResource + + frame_resources: list[SlicedVideoResource] = [] + frame_annotations: list[Sequence[Annotation]] = [] + + for i, r in enumerate(resources): + anns = resource_annotations[i] + per_frame = SlicedVideoResource.slice_over(r, frame_cache) + frame_resources.extend(per_frame) + frame_annotations.extend(anns for _ in per_frame) + + return frame_resources, frame_annotations + + def _load_frame_segmentations( + self, + annotations: Sequence['Annotation'], + resource: 'SlicedVideoResource', + ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray], dict[str, list]]: + """Load segmentations for a specific video frame, with caching. + + Args: + annotations: Segmentation annotations for this resource. + resource: The SlicedVideoResource identifying the frame. + + Returns: + Tuple of (frame_segs, seg_labels, seg_metainfos): + - frame_segs: dict[author -> np.ndarray (#instances, 1, H, W)] + - seg_labels: dict[author -> np.ndarray of int codes] + - seg_metainfos: dict[author -> list] + """ + seg_anns = [ann for ann in annotations if ann.annotation_type == 'segmentation'] + if not seg_anns: + return {}, {}, {} + + image_seg_anns = [a for a in seg_anns if a.scope == 'image'] + frame_seg_anns = [a for a in seg_anns if a.scope == 'frame'] + + uniq_authors = set( + self.annotation_processor.get_author(a) for a in seg_anns + ) + segmentations: dict[str, list[np.ndarray]] = {a: [] for a in uniq_authors} + seg_labels: dict[str, list[int]] = {a: [] for a in uniq_authors} + seg_metainfos: dict[str, list] = {a: [] for a in uniq_authors} + + # --- Image-scoped segmentations (full video masks) --- + for ann in image_seg_anns: + author = self.annotation_processor.get_author(ann) + seg_code = self.annotation_processor.resolve_seg_code(ann.identifier) + + frame_seg = self._fetch_frame_seg_annotation(ann, resource) + segmentations[author].append(frame_seg) + seg_labels[author].append(seg_code) + seg_metainfos[author].append(ann) + + # --- Frame-scoped segmentations --- + if frame_seg_anns: + frame_groups = self.annotation_processor.group_annotations( + frame_seg_anns, by_author=True, by_identifier=True + ) + for (author, identifier), fr_anns in frame_groups.items(): + seg_code = self.annotation_processor.resolve_seg_code(identifier) + + frame_seg = self._fetch_frame_seg_group(fr_anns, resource) + if frame_seg is None: + continue + segmentations[author].append(frame_seg) + seg_labels[author].append(seg_code) + seg_metainfos[author].append(fr_anns) + + # Stack per-author and add dummy depth dim + final_segmentations: dict[str, np.ndarray] = {} + final_seg_labels: dict[str, np.ndarray] = {} + for author in segmentations: + if segmentations[author]: + stacked = np.stack(segmentations[author], axis=0) # (#instances, H, W) + stacked = np.expand_dims(stacked, axis=1) # (#instances, 1, H, W) + final_segmentations[author] = stacked + final_seg_labels[author] = np.array(seg_labels[author], dtype=np.int32) + + return final_segmentations, final_seg_labels, seg_metainfos + + def _fetch_frame_seg_annotation( + self, + ann: 'Annotation', + resource: 'SlicedVideoResource', + ) -> np.ndarray: + """Load an image-scoped segmentation and extract the frame, with caching. + + Args: + ann: Image-scoped segmentation annotation. + resource: Frame proxy identifying the frame index. + + Returns: + 2D boolean segmentation array of shape ``(H, W)``. + """ + cache_entity_id = f"{ann.id}:frame{resource.frame_index}" + version_info = { + 'created_at': ann.created_at, + 'deleted_at': ann.deleted_at, + 'associated_file': ann.associated_file, + } + + cached = self._seg_frame_cache.get(cache_entity_id, _SEG_FRAME_CACHEKEY, version_info) + if cached is not None: + return cached + + # Load the full segmentation (N, H, W) and extract the frame + full_seg = self.annotation_processor.load_segmentation_data(ann) + # full_seg shape: (N, H, W) where N = number of frames + frame_seg = full_seg[resource.frame_index] # (H, W) + frame_seg = np.ascontiguousarray(frame_seg) + + self._seg_frame_cache.set(cache_entity_id, _SEG_FRAME_CACHEKEY, frame_seg, version_info) + return frame_seg + + def _fetch_frame_seg_group( + self, + fr_anns: list['Annotation'], + resource: 'SlicedVideoResource', + ) -> np.ndarray | None: + """Collate frame-level segmentation annotations and extract the frame. + + Frame-level annotations each cover a single frame. Only the annotation + matching the requested frame index contributes; others are ignored. + + Args: + fr_anns: Frame-scoped annotations sharing the same author+identifier. + resource: Frame proxy identifying the frame index. + + Returns: + 2D boolean array of shape ``(H, W)``, or None if no annotation matches. + """ + ann_ids_str = ','.join(sorted(a.id or '' for a in fr_anns)) + group_hash = hashlib.sha256(ann_ids_str.encode()).hexdigest()[:16] + cache_entity_id = f"frame_seg:{group_hash}:frame{resource.frame_index}" + version_info = { + 'ann_ids_with_versions': [ + (a.id or '', a.associated_file, a.deleted_at) + for a in sorted(fr_anns, key=lambda a: a.id or '') + ], + } + + cached = self._seg_frame_cache.get(cache_entity_id, _SEG_FRAME_CACHEKEY, version_info) + if cached is not None: + return cached + + # Find annotations matching the requested frame index + matching = [a for a in fr_anns if a.frame_index == resource.frame_index] + if not matching: + # No annotation covers this frame → all zeros + sample_seg = self.annotation_processor.load_segmentation_data(fr_anns[0]) + frame_seg = np.zeros(sample_seg.shape[1:], dtype=bool) # (H, W) + self._seg_frame_cache.set(cache_entity_id, _SEG_FRAME_CACHEKEY, frame_seg, version_info) + return frame_seg + + # Union of all matching annotations at this frame index + frame_seg: np.ndarray | None = None + for ann in matching: + seg = self.annotation_processor.load_segmentation_data(ann) # (1, H, W) + mask = seg[0] # (H, W) + frame_seg = mask if frame_seg is None else (frame_seg | mask) + frame_seg = np.ascontiguousarray(frame_seg) + + self._seg_frame_cache.set(cache_entity_id, _SEG_FRAME_CACHEKEY, frame_seg, version_info) + return frame_seg + + @override + def _get_raw_item(self, index: int) -> dict[str, Any]: + """Load a single video frame and its annotations. + + Returns dict with: + - ``'image'``: np.ndarray of shape ``(C, H, W)``. + - ``'annotations'``: Sequence of Annotation objects. + - ``'resource'``: The SlicedVideoResource proxy. + """ + resource: SlicedVideoResource = self.resources[index] # type: ignore[assignment] + img = resource.fetch_frame_data() # (C, H, W) + + anns = self.resource_annotations[index] + + return { + 'image': img, + 'annotations': anns, + 'resource': resource, + } + + @override + def __getitem__(self, index: int) -> dict[str, Any]: + """Get a single frame item with full processing. + + Returns dict with: + - ``'image'``: np.ndarray or Tensor of shape ``(C, H, W)``. + - ``'segmentations'`` (if enabled): segmentation masks of shape ``(num_instances, H, W)`` or ``(num_labels+1, H, W)``. + - ``'image_labels'``: dict of annotator -> label tensor. + """ + if index >= len(self): + raise IndexError(f"Index {index} out of bounds for dataset of size {len(self)}") + + result = self._get_raw_item(index) + + img = result['image'] + _LOGGER.debug(f'>>>>1 {img.shape=}') + if isinstance(img, np.ndarray): + img = self._preprocess_image_array(img) + annotations = result['annotations'] + resource: SlicedVideoResource = result['resource'] + + # Process segmentations + if self.return_segmentations: + seg_anns = AnnotationProcessor.filter_annotations( + annotations, type='segmentation', scope='all' + ) + frame_segs, seg_labels, _ = self._load_frame_segmentations(seg_anns, resource) + + # Apply albumentations if present + if self.alb_transform: + aug_result = self.apply_alb_transform(img, frame_segs) + img = aug_result['image'] + result['image'] = img + frame_segs = aug_result['segmentations'] + + segmentations_processed, seg_labels_out = self._process_segmentations( + frame_segs, seg_labels, output_shape=img.shape[1:], + ) + # Remove dummy depth dimension: (#instances, 1, H, W) -> (#instances, H, W) + if isinstance(segmentations_processed, (Tensor, np.ndarray)): + segmentations_processed = segmentations_processed.squeeze(1) + elif isinstance(segmentations_processed, dict): + for author in segmentations_processed: + if isinstance(segmentations_processed[author], (Tensor, np.ndarray)): + segmentations_processed[author] = segmentations_processed[author].squeeze(1) + + result['segmentations'] = segmentations_processed + if seg_labels_out: + result['seg_labels'] = seg_labels_out + + # Process image-level labels + result['image_labels'] = self._extract_image_labels(annotations) + + return result + + @override + def apply_alb_transform( + self, + img: np.ndarray, + segmentations: dict[str, np.ndarray], + ) -> dict[str, Any]: + """Apply 2D albumentations transform to a single frame and masks. + + Args: + img: Image array of shape ``(C, H, W)``. + segmentations: Dict of author -> mask arrays of shape + ``(#instances, 1, H, W)`` or ``(#instances, H, W)``. + + Returns: + Dict with transformed ``'image'`` and ``'segmentations'``. + """ + if self.alb_transform is None: + raise ValueError("alb_transform is not set") + + if img.ndim != 3: + raise ValueError(f"Expected 3D image array (C, H, W), got shape {img.shape}") + + # Transpose to (H, W, C) for albumentations + img = np.transpose(img, (1, 2, 0)) + + replay_alb_transf = albumentations.ReplayCompose([self.alb_transform]) + + aug_data = replay_alb_transf(image=img) + replay_data = aug_data['replay'] + aug_img = aug_data['image'] + + aug_segmentations: dict[str, np.ndarray] = {} + for author, segs in segmentations.items(): + had_depth = False + if segs.ndim == 4 and segs.shape[1] == 1: + had_depth = True + segs = segs.squeeze(1) # (#instances, 1, H, W) -> (#instances, H, W) + aug_segs = replay_alb_transf.replay(replay_data, masks=segs)['masks'] + if had_depth: + aug_segs = aug_segs[:, np.newaxis, :, :] + aug_segmentations[author] = aug_segs + + # Transpose back to (C, H, W) + if isinstance(aug_img, np.ndarray): + aug_img = np.transpose(aug_img, (2, 0, 1)) + elif isinstance(aug_img, torch.Tensor): + if aug_img.shape[0] == img.shape[-1]: + pass # already (C, H, W) + else: + aug_img = aug_img.permute(2, 0, 1) + + return { + 'image': aug_img, + 'segmentations': aug_segmentations, + } + + def __repr__(self) -> str: + base = super().__repr__() + return f"SlicedVideoDataset\n{base}" diff --git a/datamint/dataset/video_dataset.py b/datamint/dataset/video_dataset.py new file mode 100644 index 00000000..48ba7e7b --- /dev/null +++ b/datamint/dataset/video_dataset.py @@ -0,0 +1,65 @@ +""" +VideoDataset - Dataset for video medical data. + +Handles video files (MP4, AVI, etc.) and multi-frame DICOM data from +modalities like ultrasound (US), angiography (XA), and fluoroscopy (RF). +""" +import logging +from typing import TYPE_CHECKING + +from .multiframe_dataset import MultiFrameDataset + +if TYPE_CHECKING: + from .sliced_video_dataset import SlicedVideoDataset + +_LOGGER = logging.getLogger(__name__) + + +class VideoDataset(MultiFrameDataset): + """Dataset for video medical data. + + Each item is a full video with shape ``(C, N, H, W)`` where ``N`` is the + number of frames. Inherits multi-frame loading and augmentation from + :class:`MultiFrameDataset`. + + Supports video files (MP4, AVI, MOV) and multi-frame DICOM from temporal + modalities (ultrasound, angiography, fluoroscopy). + + Example:: + + ds = VideoDataset(project='my_ultrasound_project') + item = ds[0] + print(item['image'].shape) # (C, N, H, W) + + # Iterate frame-by-frame + frame_ds = ds.frame_by_frame() + print(frame_ds[0]['image'].shape) # (C, H, W) + """ + + def __repr__(self) -> str: + base = super().__repr__() + return f"VideoDataset\n{base}" + + def frame_by_frame(self) -> 'SlicedVideoDataset': + """Create a 2D dataset iterating over individual video frames. + + Each video is expanded into ``N`` individual frames. The returned + dataset yields 2D items with shape ``(C, H, W)`` instead of + ``(C, N, H, W)``. + + Parsed frames are cached to disk as gzip-compressed ``.npy.gz`` files. + + Returns: + A :class:`SlicedVideoDataset` that iterates over individual frames. + + Example:: + + vid_ds = VideoDataset(project='my_ultrasound_project') + frame_ds = vid_ds.frame_by_frame() + print(len(frame_ds)) # total number of frames across all videos + item = frame_ds[0] + print(item['image'].shape) # (C, H, W) + """ + from .sliced_video_dataset import SlicedVideoDataset + + return SlicedVideoDataset.from_dataset(parent_dataset=self) \ No newline at end of file diff --git a/datamint/dataset/volume_dataset.py b/datamint/dataset/volume_dataset.py index 419af758..6f8dacd0 100644 --- a/datamint/dataset/volume_dataset.py +++ b/datamint/dataset/volume_dataset.py @@ -5,15 +5,9 @@ with support for different slice orientations and affine preservation. """ import logging -from typing import Any, TYPE_CHECKING -from typing_extensions import override +from typing import TYPE_CHECKING -import torch -import numpy as np -import albumentations - -from medimgkit.readers import read_array_normalized -from .base import DatamintBaseDataset +from .multiframe_dataset import MultiFrameDataset if TYPE_CHECKING: from .sliced_dataset import SlicedVolumeDataset @@ -29,86 +23,13 @@ } -class VolumeDataset(DatamintBaseDataset): +class VolumeDataset(MultiFrameDataset): """Dataset for 3D medical volumes. Handles NIfTI (3D/4D), DICOM series, and other volumetric data. + Inherits multi-frame loading and augmentation from :class:`MultiFrameDataset`. """ - @override - def _get_raw_item(self, index: int) -> dict[str, Any]: - """Load raw image and metadata.""" - resource = self.resources[index] - res_bytesdata = resource.fetch_file_data(auto_convert=False, use_cache=True) - - img, metainfo = read_array_normalized(res_bytesdata, return_metainfo=True) # shape: (N, C, H, W) - img = img.transpose(1, 0, 2, 3) # (N, C, H, W) -> (C, N, H, W) - _LOGGER.debug(f"Raw image shape from resource {resource.filename}: {img.shape}") - - anns = self.resource_annotations[index] - - return { - 'image': img, # shape (C, N, H, W) - 'metainfo': metainfo, - 'annotations': anns, - 'resource': resource, - } - - @override - def apply_alb_transform( - self, - img: np.ndarray, - segmentations: dict[str, np.ndarray], - ) -> dict[str, Any]: - """Apply albumentations transform to image and masks. - - Args: - img: Image array of shape (C, depth, H, W). - segmentations: Dict of author -> list of mask arrays of shape (#instances, depth, H, W). - Returns: - Dict with transformed 'image' and 'segmentations'. - - """ - if self.alb_transform is None: - raise ValueError("alb_transform is not set") - if img.ndim != 4: - raise ValueError(f"Expected 4D image array (C, depth, H, W), got shape {img.shape}") - - # transpose to (depth, H, W, C) - img = np.transpose(img, (1, 2, 3, 0)) - - replay_alb_transf = albumentations.ReplayCompose([self.alb_transform]) - _LOGGER.debug( - f'before alb transform image shape: {img.shape} | segmentations shape: {[segmentations[a].shape for a in segmentations]}') - - aug_data = replay_alb_transf(volume=img) # First call - replay_data = aug_data['replay'] - aug_img = aug_data['volume'] - - aug_segmentations = {} - for author, segs in segmentations.items(): - aug_segmentations_author = segs.copy() if isinstance(segs, np.ndarray) else segs.clone() - for i, seg_inst in enumerate(segs): # for each instance mask - aug_segmentations_author[i] = replay_alb_transf.replay(replay_data, mask3d=seg_inst)['mask3d'] - aug_segmentations[author] = aug_segmentations_author - - # transpose back to (C, H, W) or (C, depth, H, W) - if isinstance(aug_img, np.ndarray): - aug_img = np.transpose(aug_img, (3, 0, 1, 2)) - elif isinstance(aug_img, torch.Tensor): - # shape is (depth, C, H, W), assuming albumentation transformation changed it - _LOGGER.debug(f"augmented image tensor shape before permute: {aug_img.shape}") - if aug_img.shape[1] == img.shape[-1]: # if C is in dim 1 - aug_img = aug_img.permute(1, 0, 2, 3) - else: - aug_img = aug_img.permute(3, 0, 1, 2) - _LOGGER.debug(f"augmented image tensor shape after permute: {aug_img.shape}") - - return { - 'image': aug_img, - 'segmentations': aug_segmentations, - } - def __repr__(self) -> str: base = super().__repr__() return f"VolumeDataset\n{base}" diff --git a/datamint/entities/resource.py b/datamint/entities/resource.py index f3c94ad3..c3358a1e 100644 --- a/datamint/entities/resource.py +++ b/datamint/entities/resource.py @@ -277,14 +277,22 @@ def is_nifti(self) -> bool: """ if self.mimetype == 'application/nifti': return True - return self.mimetype in 'application/gzip' and self.filename.lower().endswith('.nii.gz') + return self.mimetype == 'application/gzip' and self.filename.lower().endswith('.nii.gz') + + def is_video(self) -> bool: + """Check if the resource is a video file. + + Returns: + True if the resource is a video file, False otherwise + """ + return self.mimetype.startswith('video/') or self.storage == 'VideoResourceHandler' def get_depth(self) -> int: if self.is_dicom() or self.is_nifti(): return self.metadata['frame_count'] if self.mimetype.startswith('image/'): return 1 - if self.mimetype.startswith('video/'): + if self.is_video(): for st in self.metadata['streams']: if st['codec_type'] == 'video': return st['nb_frames'] diff --git a/datamint/entities/sliced_video_resource.py b/datamint/entities/sliced_video_resource.py new file mode 100644 index 00000000..26381a08 --- /dev/null +++ b/datamint/entities/sliced_video_resource.py @@ -0,0 +1,153 @@ +""" +SlicedVideoResource - Proxy for a single frame of a video Resource. + +Analogous to :class:`SlicedVolumeResource` but simplified for temporal +data — videos always slice along the frame (temporal) axis. +""" +from __future__ import annotations +import gzip +import logging +from typing import Any, TYPE_CHECKING +from functools import cached_property + +from medimgkit.readers import read_array_normalized +from datamint.entities.cache_manager import CacheManager +import numpy as np + +if TYPE_CHECKING: + from datamint.entities import Resource + +_LOGGER = logging.getLogger(__name__) + +# Cache key for parsed frame numpy arrays +_FRAME_ARRAY_CACHEKEY = "frame_array" + + +class SlicedVideoResource: + """Proxy that presents a single frame of a video Resource. + + Wraps a :class:`Resource` and represents a specific frame by index. + Uses gzip-compressed ``.npy.gz`` files on disk for caching, with an + in-memory LRU cache managed by :class:`CacheManager`. + + Args: + parent: The original video Resource. + frame_index: The index of the frame in the video. + frame_cache: Shared :class:`CacheManager` for disk-based frame caching. + """ + + _CACHE_MANAGER_NAMESPACE = "sliced_video_frames" + + def __init__( + self, + parent: Resource, + frame_index: int, + frame_cache: CacheManager | None = None, + ): + self._parent = parent + self.frame_index = frame_index + if frame_cache is None: + frame_cache = CacheManager(SlicedVideoResource._CACHE_MANAGER_NAMESPACE) + self._frame_cache = frame_cache + + @staticmethod + def slice_over( + resource: Resource, + frame_cache: CacheManager | None = None, + ) -> list[SlicedVideoResource]: + """Expand a video resource into per-frame proxy resources. + + Args: + resource: The video Resource to expand. + frame_cache: Shared cache for decoded frames. + + Returns: + List of :class:`SlicedVideoResource`, one per frame. + """ + num_frames = resource.get_depth() + return [ + SlicedVideoResource(resource, i, frame_cache) + for i in range(num_frames) + ] + + def get_depth(self) -> int: + """A single frame has depth 1.""" + return 1 + + def _get_version_info(self) -> dict: + """Get version info from the parent resource for cache validation.""" + return { + 'created_at': self._parent.created_at, + 'deleted_at': self._parent.deleted_at, + 'size': self._parent.size, + } + + def _frame_cache_entity_id(self) -> str: + return f"{self._parent.id}:frame{self.frame_index}" + + def fetch_frame_data(self) -> np.ndarray: + """Fetch the frame as a ``(C, H, W)`` array. + + Returns: + Frame array with shape ``(C, H, W)``. + """ + version_info = self._get_version_info() + cache_entity_id = self._frame_cache_entity_id() + + cached_frame = self._frame_cache.get( + cache_entity_id, + _FRAME_ARRAY_CACHEKEY, + version_info, + ) + if cached_frame is not None: + return np.ascontiguousarray(cached_frame) + + raw = self._parent.fetch_file_data(auto_convert=False, use_cache=True) + frame, self.data_metainfo = read_array_normalized(raw, return_metainfo=True, + index=self.frame_index) # frame.shape is (C, H, W) + _LOGGER.debug(f'Fetched raw frame data for frame index {self.frame_index}. Frame shape: {frame.shape}') + frame = np.ascontiguousarray(frame) + + gz_path = self._frame_cache.get_expected_path(cache_entity_id, _FRAME_ARRAY_CACHEKEY) + gz_path = gz_path.with_suffix('.npy.gz') + gz_path.parent.mkdir(parents=True, exist_ok=True) + with gzip.open(str(gz_path), 'wb', compresslevel=4) as f: + np.save(f, frame) + + self._frame_cache.register_file_location( + cache_entity_id, + _FRAME_ARRAY_CACHEKEY, + file_path=gz_path, + version_info=version_info, + mimetype='application/gzip', + data=frame, + ) + + _LOGGER.debug(f'Frame shape: {frame.shape}') + + return frame + + @cached_property + def data_metainfo(self) -> dict: + """Video metadata. Loaded once and cached for the lifetime of this resource.""" + raw = self._parent.fetch_file_data(auto_convert=False, use_cache=True) + _, metainfo = read_array_normalized(raw, return_metainfo=True) + return metainfo + + @property + def parent_resource(self) -> Resource: + """The original video Resource being proxied.""" + return self._parent + + def __repr__(self) -> str: + return ( + f"SlicedVideoResource(filename='{self._parent.filename}', " + f"frame={self.frame_index})" + ) + + def __getattribute__(self, name: str) -> Any: + try: + return super().__getattribute__(name) + except AttributeError: + parent = super().__getattribute__('_parent') + return getattr(parent, name) From 6816cdefac27ce5c86b0dd97f6f2bdc26556b500 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 11 Mar 2026 18:21:18 -0300 Subject: [PATCH 15/47] Implement dataset splitting and DataModule for Lightning integration; better dataset initialization and state management --- datamint/api/client.py | 12 + datamint/dataset/annotation_processor.py | 10 +- datamint/dataset/base.py | 356 +++++++++++++++++++++-- datamint/dataset/image_dataset.py | 4 +- datamint/entities/base_entity.py | 18 ++ datamint/lightning/__init__.py | 6 +- datamint/lightning/datamintdatamodule.py | 103 ------- datamint/lightning/datamodule.py | 218 ++++++++++++++ 8 files changed, 584 insertions(+), 143 deletions(-) delete mode 100644 datamint/lightning/datamintdatamodule.py create mode 100644 datamint/lightning/datamodule.py diff --git a/datamint/api/client.py b/datamint/api/client.py index 0cc0922f..a934b9a7 100644 --- a/datamint/api/client.py +++ b/datamint/api/client.py @@ -181,3 +181,15 @@ def deploy(self) -> DeployModelApi: def inference(self) -> InferenceApi: """Access model inference endpoints.""" return self._get_endpoint('inference', is_mlflow=True) + + def __getstate__(self) -> dict: + return { + 'server_url': self.config.server_url, + 'api_key': self.config.api_key, + 'timeout': self.config.timeout, + 'max_retries': self.config.max_retries, + 'verify_ssl': self.config.verify_ssl, + } + + def __setstate__(self, state: dict) -> None: + self.__init__(check_connection=False, **state) diff --git a/datamint/dataset/annotation_processor.py b/datamint/dataset/annotation_processor.py index 02904319..d6f85a4a 100644 --- a/datamint/dataset/annotation_processor.py +++ b/datamint/dataset/annotation_processor.py @@ -114,7 +114,7 @@ def collate_frame_segmentations(self, if stacked_seg is None: if depth is None: depth = ann.resource.get_depth() - stacked_seg = np.zeros((depth, *seg.shape), dtype=bool) + stacked_seg = np.zeros((depth, *seg.shape), dtype=np.uint8) if ann.frame_index is None: raise ValueError(f"Frame-level annotation {ann.id} missing frame_index") stacked_seg[ann.frame_index] = seg @@ -333,21 +333,21 @@ def load_segmentation_data(self, ann: 'Annotation', if ann_data_array.shape[1] != 1: raise ValueError(f"Segmentation must have 1 channel, got shape {ann_data_array.shape}") ann_data_array = ann_data_array[:, 0, :, :] # (N, H, W) - return ann_data_array != 0 # binary mask + return (ann_data_array != 0).astype(np.uint8) # binary mask def _merge_union(self, segmentations: dict[str, Tensor]) -> Tensor: """Union merge: pixel is labeled if ANY annotator labeled it.""" new_segmentations = torch.zeros_like(list(segmentations.values())[0]) for seg in segmentations.values(): new_segmentations += seg - return new_segmentations.bool() + return new_segmentations.bool().to(torch.uint8) def _merge_intersection(self, segmentations: dict[str, Tensor]) -> Tensor: """Intersection merge: pixel is labeled if ALL annotators labeled it.""" new_segmentations = torch.ones_like(list(segmentations.values())[0]) for seg in segmentations.values(): new_segmentations *= seg - return new_segmentations.bool() + return new_segmentations.bool().to(torch.uint8) def _merge_mode(self, segmentations: dict[str, Tensor]) -> Tensor: """Mode merge: pixel is labeled if majority of annotators labeled it.""" @@ -355,7 +355,7 @@ def _merge_mode(self, segmentations: dict[str, Tensor]) -> Tensor: for seg in segmentations.values(): new_segmentations += seg new_segmentations = new_segmentations >= len(segmentations) / 2 - return new_segmentations + return new_segmentations.bool().to(torch.uint8) def convert_image_labels( self, diff --git a/datamint/dataset/base.py b/datamint/dataset/base.py index fc355d26..c4d711d4 100644 --- a/datamint/dataset/base.py +++ b/datamint/dataset/base.py @@ -20,6 +20,7 @@ if TYPE_CHECKING: from datamint.entities import Resource, Project, Annotation + from albumentations import BaseCompose _LOGGER = logging.getLogger(__name__) @@ -82,7 +83,7 @@ def __init__( return_segmentations: bool = True, return_as_semantic_segmentation: bool = False, semantic_seg_merge_strategy: MergeStrategy | None = None, - alb_transform: Callable | None = None, + alb_transform: 'Callable | BaseCompose | None' = None, include_unannotated: bool = True, include_annotators: list[str] | None = None, exclude_annotators: list[str] | None = None, @@ -94,7 +95,6 @@ def __init__( exclude_frame_label_names: list[str] | None = None, allow_external_annotations: bool = False, ): - from datamint import Api # Validate mutually exclusive parameters if project is not None and resources is not None: raise DatamintDatasetException( @@ -120,25 +120,12 @@ def __init__( if semantic_seg_merge_strategy and not return_as_semantic_segmentation: raise ValueError("semantic_seg_merge_strategy requires return_as_semantic_segmentation=True") - # Initialize API - self._api = Api( - server_url=server_url, - api_key=api_key, - check_connection=auto_update - ) - - # Initialize from project or resources - if resources is not None: - self.resources = self._initialize_from_resources(resources) - self.project = None - else: - self.project, self.resources = self._initialize_from_project(project) # type: ignore - - # Fetch annotations - self.resource_annotations = list(self._api.annotations.get_list( - resource=self.resources, - group_by_resource=True, - )) + # Store API configuration for (possibly deferred) initialization + self._server_url = server_url + self._api_key = api_key + self._auto_update = auto_update + self._init_project = project + self._init_resources = resources # Store configuration self.return_metainfo = return_metainfo @@ -148,7 +135,7 @@ def __init__( self.include_unannotated = include_unannotated # Transforms - self.alb_transform = alb_transform + self.set_transform(alb_transform) # Filtering self.include_annotators = include_annotators @@ -163,10 +150,100 @@ def __init__( # Internal state self._logged_uint16_conversion = False + self._is_prepared = False + + def __getattr__(self, name: str) -> Any: + # __getattr__ is only invoked when normal attribute lookup fails — + # i.e. for attributes populated by _prepare() (resources, project, + # label sets, annotation_processor, …). Guard against recursion for + # attributes not yet written during __init__ itself. + if self.__dict__.get('_is_prepared') is not False: + raise AttributeError(name) + self._prepare() + return object.__getattribute__(self, name) + + def _prepare(self) -> None: + """Fetch data from the API and set up the dataset. + + Called automatically on first access of any attribute that requires + server data (e.g. ``resources``, ``project``, label sets). + Idempotent — safe to call multiple times. + """ + if self._is_prepared: + return + + from datamint import Api + + # Initialize API + self._api = Api( + server_url=self._server_url, + api_key=self._api_key, + check_connection=self._auto_update, + ) - # Setup + # Initialize from project or resources + if self._init_resources is not None: + self.resources = self._initialize_from_resources(self._init_resources) + self.project = None + else: + self.project, self.resources = self._initialize_from_project(self._init_project) # type: ignore + + # Fetch annotations + self.resource_annotations = list(self._api.annotations.get_list( + resource=self.resources, + group_by_resource=True, + )) + + # Setup dataset (labels, annotation processor, filters) self._setup_dataset() + self._is_prepared = True + + # Clean up temporary attributes used only for deferred initialisation + del self._server_url, self._api_key, self._auto_update + del self._init_project, self._init_resources + + # def __getstate__(self) -> object: + # # print the size in MB + # print(f'>>>{len(str(self.__dict__))/1024/1024:.2f} MB') + + # return super().__getstate__() + + def __setstate__(self, state: dict) -> None: + vars(self).update(state) + if self._is_prepared: + self._reinit_api() + + def _reinit_api(self) -> None: + """Re-inject sub-API handles into entities after unpickling in a worker. + + ``self._api`` is already a fresh ``Api`` instance (reconstructed by + ``Api.__setstate__``); we only need to wire its per-resource/annotation + sub-APIs back into the individual entity objects. + """ + resources_api = self._api.resources + annotations_api = self._api.annotations + projects_api = self._api.projects + for res in self.resources: + res._api = resources_api + if self.project is not None: + self.project._api = projects_api + for ann_list in self.resource_annotations: + for ann in ann_list: + ann._api = annotations_api + + def set_transform(self, alb_transform: 'BaseCompose | None' = None) -> None: + """Set transforms after initialization.""" + self.alb_transform = alb_transform + + def add_transform(self, alb_transform: 'BaseCompose') -> None: + import albumentations as A + """Add an albumentations transform, composing with existing one if present.""" + if self.alb_transform is not None: + self.alb_transform = A.Compose([self.alb_transform, alb_transform]) + else: + self.alb_transform = alb_transform + def _extract_image_labels( self, annotations: Sequence['Annotation'], @@ -617,9 +694,8 @@ def __getitem__(self, index: int) -> dict[str, Any]: if isinstance(img, np.ndarray): img = self._preprocess_image_array(img) annotations = result['annotations'] - resource = result['resource'] - _LOGGER.debug(f"Loaded image {resource.filename} with shape {img.shape}") - _LOGGER.debug(f"Annotations: {len(annotations)} found") + # resource = result['resource'] + # _LOGGER.debug(f"Loaded image {resource.filename} with shape {img.shape}") # Process segmentations if self.return_segmentations: @@ -633,8 +709,6 @@ def __getitem__(self, index: int) -> dict[str, Any]: img = aug_result['image'] result['image'] = img segmentations = aug_result['segmentations'] - _LOGGER.debug( - f"Applied albumentations transform. Image shape: {img.shape} and segs shape: {[segmentations[a].shape for a in segmentations]}") segmentations, seg_labels = self._process_segmentations(segmentations, seg_labels, output_shape=img.shape[1:]) @@ -679,9 +753,6 @@ def __add__(self, other: 'DatamintBaseDataset') -> ConcatDataset: """Concatenate datasets.""" return ConcatDataset([self, other]) # type: ignore[list-item] - def subset(self, indices: list[int]) -> 'DatamintBaseDataset': - pass - def get_dataloader(self, *args, **kwargs) -> DataLoader: """Get DataLoader with proper collate function.""" return DataLoader(self, *args, collate_fn=self.get_collate_fn(), **kwargs) # type: ignore[arg-type] @@ -706,13 +777,29 @@ def collate_fn(batch: list[dict]) -> dict: _LOGGER.warning(f"Different shapes for {key}: {shapes}") collated[key] = values elif isinstance(values[0], np.ndarray): - collated[key] = np.stack(values) + shapes = [a.shape for a in values] + if all(s == shapes[0] for s in shapes): + collated[key] = np.stack(values) + else: + _LOGGER.warning(f"Different shapes for {key}: {shapes}") + collated[key] = values else: collated[key] = values return collated return collate_fn + + def subset(self, indices: list[int]) -> 'DatamintBaseDataset': + """Create a dataset subset by slicing resources and annotations.""" + import copy + new_ds = copy.copy(self) + try: + new_ds.resources = [self.resources[i] for i in indices] + new_ds.resource_annotations = [self.resource_annotations[i] for i in indices] + except IndexError as e: + raise IndexError(f"Subset indices out of bounds for dataset of length {len(self)}.") from e + return new_ds def __repr__(self) -> str: name = self.project.name if self.project else "" @@ -739,3 +826,208 @@ def __repr__(self) -> str: lines = [head] + [" " + line for line in body] return "\n".join(lines) + + def split( + self, + *, + seed: int | None = None, + use_server_splits: bool | None = None, + **splits: float, + ) -> dict[str, 'DatamintBaseDataset']: + """Split the dataset into multiple named subsets. + + The mode is selected automatically when *use_server_splits* is not + given: + + - If ratio kwargs are provided (e.g. ``train=0.7``), local splitting + is used (equivalent to ``use_server_splits=False``). + - If no ratio kwargs are provided, server-side ``split:*`` tags on + resources are used (equivalent to ``use_server_splits=True``). + + Examples:: + + # Local split — ratios infer use_server_splits=False + parts = dataset.split(train=0.7, val=0.15, test=0.15, seed=42) + train_ds = parts['train'] + + # Server-side split — no ratios, infers use_server_splits=True + parts = dataset.split() + + # Explicit override + parts = dataset.split(use_server_splits=True) + + Args: + seed: Random seed for reproducible local splitting. + use_server_splits: If ``True``, read ``split:*`` tags from each + resource instead of performing a random split. If ``None`` + (default), inferred from whether ratio kwargs are provided. + **splits: Named split ratios (e.g. ``train=0.7, test=0.3``). + Must sum to 1.0 (±0.01 tolerance). Must be empty when + *use_server_splits* is ``True``. + + Returns: + Dictionary mapping split names to new dataset instances. + + Raises: + ValueError: If ratios are invalid or arguments conflict. + """ + if use_server_splits is None: + use_server_splits = not splits # True when no ratios given + + if use_server_splits: + return self._split_by_server_tags(splits) + + return self._split_locally(splits, seed) + + def _split_by_server_tags( + self, + splits: dict[str, float], + ) -> dict[str, 'DatamintBaseDataset']: + """Group resources by ``split:`` tags.""" + if splits: + raise ValueError( + "Ratio kwargs (e.g. train=0.7) must not be provided when " + "use_server_splits=True." + ) + + from collections import defaultdict + split_indices: dict[str, list[int]] = defaultdict(list) + + for idx, resource in enumerate(self.resources): + tags = resource.tags or [] + for tag in tags: + if tag.startswith("split:"): + split_name = tag[len("split:"):] + split_indices[split_name].append(idx) + + if not split_indices: + raise ValueError( + "No resources have 'split:*' tags. Tag resources on the " + "server first or use local splitting (use_server_splits=False)." + ) + + return {name: self.subset(indices) for name, indices in split_indices.items()} + + def _split_locally( + self, + splits: dict[str, float], + seed: int | None, + ) -> dict[str, 'DatamintBaseDataset']: + """Randomly partition resources by ratios.""" + if len(splits) < 2: + raise ValueError("At least 2 splits are required (e.g. train=0.7, test=0.3).") + + for name, ratio in splits.items(): + if ratio <= 0: + raise ValueError(f"Split ratio for '{name}' must be positive, got {ratio}.") + + total = sum(splits.values()) + if abs(total - 1.0) > 0.01: + raise ValueError( + f"Split ratios must sum to 1.0 (got {total:.4f}). " + f"Provided: {splits}" + ) + + import random + n = len(self) + indices = list(range(n)) + rng = random.Random(seed) + rng.shuffle(indices) + + result: dict[str, DatamintBaseDataset] = {} + start = 0 + split_items = list(splits.items()) + + for i, (name, ratio) in enumerate(split_items): + if i == len(split_items) - 1: + # Last split gets all remaining indices to avoid rounding issues + end = n + else: + end = start + round(ratio * n) + result[name] = self.subset(indices[start:end]) + start = end + + return result + + def filter( + self, + *, + tags: list[str] | None = None, + filename_pattern: str | None = None, + has_annotations: bool | None = None, + annotation_names: list[str] | None = None, + custom_fn: 'Callable[[Resource, Sequence[Annotation]], bool] | None' = None, + ) -> 'DatamintBaseDataset': + """Return a new dataset containing only resources that match **all** + specified criteria. + + This method is chainable — the returned dataset supports the same + interface, so you can write:: + + filtered = dataset.filter(tags=['busi']).filter(has_annotations=True) + + or combine with :meth:`split`:: + + parts = dataset.filter(tags=['ultrasound']).split(train=0.8, test=0.2) + + Args: + tags: Keep resources whose tags contain **any** of the given values. + filename_pattern: Keep resources whose filename matches this + pattern (interpreted as :func:`fnmatch.fnmatch` glob). + has_annotations: If ``True``, keep only resources with at least one + annotation. If ``False``, keep only those **without** + annotations. + annotation_names: Keep resources that have at least one annotation + whose ``identifier`` is in this list. + custom_fn: Arbitrary predicate receiving ``(resource, annotations)`` + and returning ``True`` to keep the resource. + + Returns: + A new :class:`DatamintBaseDataset` containing only the matching + resources. + + Raises: + ValueError: If no filter criteria are specified. + """ + if all(v is None for v in (tags, filename_pattern, has_annotations, + annotation_names, custom_fn)): + raise ValueError("At least one filter criterion must be specified.") + + import fnmatch + + passing_indices: list[int] = [] + + for idx, (resource, annotations) in enumerate( + zip(self.resources, self.resource_annotations) + ): + # --- tags (OR within this criterion) --- + if tags is not None: + resource_tags = resource.tags or [] + if not any(t in resource_tags for t in tags): + continue + + # --- filename_pattern --- + if filename_pattern is not None: + if not fnmatch.fnmatch(resource.filename, filename_pattern): + continue + + # --- has_annotations --- + if has_annotations is not None: + has_any = len(annotations) > 0 + if has_any != has_annotations: + continue + + # --- annotation_names --- + if annotation_names is not None: + ann_identifiers = {a.identifier for a in annotations} + if not ann_identifiers.intersection(annotation_names): + continue + + # --- custom_fn --- + if custom_fn is not None: + if not custom_fn(resource, annotations): + continue + + passing_indices.append(idx) + + return self.subset(passing_indices) diff --git a/datamint/dataset/image_dataset.py b/datamint/dataset/image_dataset.py index 90627474..9d73bcee 100644 --- a/datamint/dataset/image_dataset.py +++ b/datamint/dataset/image_dataset.py @@ -69,8 +69,6 @@ def apply_alb_transform( img = np.transpose(img, (1, 2, 0)) replay_alb_transf = albumentations.ReplayCompose([self.alb_transform]) - _LOGGER.debug( - f'before alb transform image shape: {img.shape} | segmentations shape: {[segmentations[a].shape for a in segmentations]}') aug_data = replay_alb_transf(image=img) # First call replay_data = aug_data['replay'] @@ -80,6 +78,8 @@ def apply_alb_transform( for author, segs in segmentations.items(): if segs.ndim == 4 and segs.shape[1] == 1: segs = segs.squeeze(1) # (num_instances, 1, H, W) -> (num_instances, H, W) + # if segs.dtype == bool: + # segs = segs.astype(np.uint8) aug_segs = replay_alb_transf.replay(replay_data, masks=segs)['masks'] # store back with original shape if segs.ndim == 3: diff --git a/datamint/entities/base_entity.py b/datamint/entities/base_entity.py index 35b123ec..7d492794 100644 --- a/datamint/entities/base_entity.py +++ b/datamint/entities/base_entity.py @@ -115,6 +115,24 @@ def has_missing_attrs(self) -> bool: """ return any(self.is_attr_missing(attr_name) for attr_name in self.__pydantic_fields__.keys()) + def __getstate__(self) -> dict: + state = super().__getstate__() + # Strip _api (contains unpicklable connections) + if state.get('__pydantic_private__') is not None: + state = dict(state) + state['__pydantic_private__'] = { + k: v for k, v in state['__pydantic_private__'].items() if k != '_api' + } + return state + + def __setstate__(self, state: dict) -> None: + if state.get('__pydantic_private__') is not None: + state = dict(state) + private = dict(state['__pydantic_private__']) + private['_api'] = None # placeholder; + state['__pydantic_private__'] = private + super().__setstate__(state) + def _fetch_and_cache_file_data( self, cache_manager: 'Any', # CacheManager[bytes] diff --git a/datamint/lightning/__init__.py b/datamint/lightning/__init__.py index 2a3ba90a..3ebfdba2 100644 --- a/datamint/lightning/__init__.py +++ b/datamint/lightning/__init__.py @@ -1 +1,5 @@ -from .datamintdatamodule import DatamintDataModule \ No newline at end of file +"""Datamint Lightning integration.""" + +from .datamodule import DatamintDataModule + +__all__ = ["DatamintDataModule"] diff --git a/datamint/lightning/datamintdatamodule.py b/datamint/lightning/datamintdatamodule.py deleted file mode 100644 index 72f02622..00000000 --- a/datamint/lightning/datamintdatamodule.py +++ /dev/null @@ -1,103 +0,0 @@ -from torch.utils.data import DataLoader -from datamint import Dataset -import lightning as L -from typing import Any -from copy import copy -import numpy as np - - -class DatamintDataModule(L.LightningDataModule): - """ - LightningDataModule for Datamint datasets with train/val split. - TODO: Add support for test and predict dataloaders. - """ - - def __init__( - self, - project_name: str = "./", - batch_size: int = 32, - image_transform=None, - mask_transform=None, - alb_transform=None, - alb_train_transform=None, - alb_val_transform=None, - train_split: float = 0.9, - val_split: float = 0.1, - seed: int = 42, - num_workers: int = 4, - **dataset_kwargs: Any, - ): - super().__init__() - self.project_name = project_name - self.batch_size = batch_size - self.image_transform = image_transform - self.mask_transform = mask_transform - - if alb_transform is not None and (alb_train_transform is not None or alb_val_transform is not None): - raise ValueError("You cannot specify both `alb_transform` and `alb_train_transform`/`alb_val_transform`.") - - # Handle backward compatibility for alb_transform - if alb_transform is not None: - self.alb_train_transform = alb_transform - self.alb_val_transform = alb_transform - else: - self.alb_train_transform = alb_train_transform - self.alb_val_transform = alb_val_transform - - self.train_split = train_split - self.val_split = val_split - self.seed = seed - self.dataset_kwargs = dataset_kwargs - self.num_workers = num_workers - - self.dataset = None - - def prepare_data(self) -> None: - """Download or update data if needed.""" - Dataset( - project_name=self.project_name, - auto_update=True, - ) - - def setup(self, stage: str = None) -> None: - """Set up datasets and perform train/val split.""" - if self.dataset is None: - # Create base dataset for getting indices - self.dataset = Dataset( - return_as_semantic_segmentation=True, - semantic_seg_merge_strategy="union", - return_frame_by_frame=True, - include_unannotated=False, - project_name=self.project_name, - image_transform=self.image_transform, - mask_transform=self.mask_transform, - alb_transform=None, # No transform for base dataset - auto_update=False, - **self.dataset_kwargs, - ) - - indices = list(copy(self.dataset.subset_indices)) - rs = np.random.RandomState(self.seed) - rs.shuffle(indices) - train_end = int(self.train_split * len(indices)) - train_idx = indices[:train_end] - val_idx = indices[train_end:] - - self.train_dataset = copy(self.dataset).subset(train_idx) - self.train_dataset.alb_transform = self.alb_train_transform - self.val_dataset = copy(self.dataset).subset(val_idx) - self.val_dataset.alb_transform = self.alb_val_transform - - def train_dataloader(self) -> DataLoader: - return self.train_dataset.get_dataloader(batch_size=self.batch_size, num_workers=self.num_workers, shuffle=True) - - def val_dataloader(self) -> DataLoader: - return self.val_dataset.get_dataloader(batch_size=self.batch_size, num_workers=self.num_workers, shuffle=False) - - def test_dataloader(self): - # Use the same dataloader as validation for testing, because we have so few samples - return self.val_dataset.get_dataloader(batch_size=self.batch_size, num_workers=self.num_workers, shuffle=False) - - def predict_dataloader(self): - # Use the same dataloader as validation for testing, because we have so few samples - return self.val_dataset.get_dataloader(batch_size=self.batch_size, num_workers=self.num_workers, shuffle=False) diff --git a/datamint/lightning/datamodule.py b/datamint/lightning/datamodule.py new file mode 100644 index 00000000..a343c414 --- /dev/null +++ b/datamint/lightning/datamodule.py @@ -0,0 +1,218 @@ +""" +DatamintDataModule — LightningDataModule wrapper for Datamint datasets. + +Wraps any :class:`~datamint.dataset.base.DatamintBaseDataset` subclass and +provides ``train_dataloader``, ``val_dataloader``, ``test_dataloader``, and +``predict_dataloader`` for use with a Lightning :class:`~lightning.pytorch.trainer.trainer.Trainer`. +""" +from __future__ import annotations + +import logging +from collections.abc import Callable + +import lightning as L +from torch.utils.data import DataLoader + +from datamint.dataset.base import DatamintBaseDataset + +_LOGGER = logging.getLogger(__name__) + + +class DatamintDataModule(L.LightningDataModule): + """A :class:`~lightning.pytorch.core.LightningDataModule` that wraps a + :class:`~datamint.dataset.base.DatamintBaseDataset`. + + The dataset must already be fully constructed (project loaded, filters + applied). Splitting is delegated to :meth:`DatamintBaseDataset.split`. + Stage-specific transforms are applied to each split after splitting. + + Args: + dataset: A fully initialised Datamint dataset (without transforms; + those are applied per-split via *train_transform* / *eval_transform*). + batch_size: Default batch size for every stage. + train_batch_size: Override batch size for training. + val_batch_size: Override batch size for validation. + test_batch_size: Override batch size for testing. + num_workers: Number of DataLoader workers. + pin_memory: Whether to pin memory in DataLoaders. + shuffle_train: Shuffle the training dataloader. + drop_last_train: Drop last incomplete training batch. + split: Split ratios forwarded to :meth:`DatamintBaseDataset.split` + (e.g. ``{'train': 0.7, 'val': 0.15, 'test': 0.15}``). + When *None* the full dataset is used for every stage. + split_seed: Random seed for reproducible local splits. + use_server_splits: If *True*, use server-side ``split:*`` tags + instead of local random splitting. + train_transform: Albumentations transform applied **only** to the + training split (e.g. augmentations). Calls + :meth:`~datamint.dataset.base.DatamintBaseDataset.set_transform` + on the train split after :meth:`setup` resolves the splits. + eval_transform: Albumentations transform applied to the validation + and test splits (typically resize/normalise only, no augmentation). + + Example:: + + import albumentations as A + + train_tfm = A.Compose([A.RandomHorizontalFlip(), A.Normalize()]) + eval_tfm = A.Compose([A.Normalize()]) + + dataset = ImageDataset(project='my_project', ...) + dm = DatamintDataModule( + dataset, + batch_size=8, + split={'train': 0.8, 'val': 0.1, 'test': 0.1}, + split_seed=42, + train_transform=train_tfm, + eval_transform=eval_tfm, + ) + + # prepare_data() / setup() fetch data and make attributes available: + trainer = L.Trainer(...) + trainer.fit(model, datamodule=dm) + trainer.test(datamodule=dm) + """ + + def __init__( + self, + dataset: DatamintBaseDataset, + batch_size: int = 32, + train_batch_size: int | None = None, + val_batch_size: int | None = None, + test_batch_size: int | None = None, + num_workers: int = 0, + pin_memory: bool = True, + shuffle_train: bool = True, + drop_last_train: bool = False, + split: dict[str, float] | bool | None = True, + split_seed: int | None = None, + use_server_splits: bool | None = None, + train_transform: Callable | None = None, + eval_transform: Callable | None = None, + ) -> None: + super().__init__() + self.save_hyperparameters(ignore=["dataset"]) + + self._dataset = dataset + self._batch_size = batch_size + self._train_batch_size = train_batch_size or batch_size + self._val_batch_size = val_batch_size or batch_size + self._test_batch_size = test_batch_size or batch_size + self._num_workers = num_workers + self._pin_memory = pin_memory + self._shuffle_train = shuffle_train + self._drop_last_train = drop_last_train + if isinstance(split, bool): + self._split = split + self._split_cfg = None + else: + self._split = split is not None + self._split_cfg = split + self._split_seed = split_seed + self._use_server_splits = use_server_splits + self._train_transform = train_transform + self._eval_transform = eval_transform + + # Populated by setup() + self._train_dataset: DatamintBaseDataset | None = None + self._val_dataset: DatamintBaseDataset | None = None + self._test_dataset: DatamintBaseDataset | None = None + + # Cache the split result so setup() is idempotent + self._splits_resolved = False + + @property + def dataset(self) -> DatamintBaseDataset: + """The wrapped Datamint dataset.""" + return self._dataset + + # ------------------------------------------------------------------ + # LightningDataModule lifecycle + # ------------------------------------------------------------------ + def prepare_data(self) -> None: + self._dataset._prepare() + + def setup(self, stage: str | None = None) -> None: + if self._splits_resolved: + return + + if self._split or self._split_cfg is not None or self._use_server_splits: + parts = self._dataset.split( + seed=self._split_seed, + use_server_splits=self._use_server_splits, + **(self._split_cfg or {}), + ) + self._train_dataset = parts.get("train") + self._val_dataset = parts.get("val") + self._test_dataset = parts.get("test") + + if stage == "fit" and self._train_dataset is None: + raise ValueError( + "No 'train' split found. Make sure the split config " + "contains a 'train' key." + ) + else: + # No split config: use the full dataset for every stage. + self._train_dataset = self._dataset + self._val_dataset = None + self._test_dataset = self._dataset + + # Apply stage-specific transforms after splits are resolved. + if self._train_transform is not None and self._train_dataset is not None: + self._train_dataset.set_transform(self._train_transform) + if self._eval_transform is not None: + for ds in (self._val_dataset, self._test_dataset): + if ds is not None: + ds.set_transform(self._eval_transform) + + self._splits_resolved = True + + # ------------------------------------------------------------------ + # DataLoaders + # ------------------------------------------------------------------ + + def train_dataloader(self) -> DataLoader: + if self._train_dataset is None: + raise RuntimeError("No training dataset available. Call setup('fit') first.") + return DataLoader( + self._train_dataset, + batch_size=self._train_batch_size, + shuffle=self._shuffle_train, + drop_last=self._drop_last_train, + num_workers=self._num_workers, + pin_memory=self._pin_memory, + collate_fn=self._dataset.get_collate_fn(), + ) + + def val_dataloader(self) -> DataLoader | None: + if self._val_dataset is None: + return None + return DataLoader( + self._val_dataset, + batch_size=self._val_batch_size, + shuffle=False, + num_workers=self._num_workers, + pin_memory=self._pin_memory, + collate_fn=self._dataset.get_collate_fn(), + ) + + def test_dataloader(self) -> DataLoader: + ds = self._test_dataset if self._test_dataset is not None else self._dataset + return DataLoader( + ds, + batch_size=self._test_batch_size, + shuffle=False, + num_workers=self._num_workers, + pin_memory=self._pin_memory, + collate_fn=self._dataset.get_collate_fn(), + ) + + def predict_dataloader(self) -> DataLoader: + return DataLoader( + self._dataset, + batch_size=self._test_batch_size, + shuffle=False, + num_workers=self._num_workers, + pin_memory=self._pin_memory, + collate_fn=self._dataset.get_collate_fn(), + ) From 26157a04549ffcbabb2112ce248223650bc62fb6 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 12 Mar 2026 09:08:43 -0300 Subject: [PATCH 16/47] Updated example notebook --- datamint/utils/visualization.py | 9 +- ...segmentation_2d-unetpp_BUSI_tutorial.ipynb | 536 +++++++----------- 2 files changed, 218 insertions(+), 327 deletions(-) diff --git a/datamint/utils/visualization.py b/datamint/utils/visualization.py index 878c892f..c6d5ed05 100644 --- a/datamint/utils/visualization.py +++ b/datamint/utils/visualization.py @@ -7,6 +7,9 @@ import colorsys from collections.abc import Sequence from matplotlib.axes import Axes +import logging + +_LOGGER = logging.getLogger(__name__) def show(imgs: Sequence[Tensor | np.ndarray] | Tensor | np.ndarray, @@ -94,7 +97,7 @@ def draw_masks( image: Tensor | np.ndarray, masks: Tensor | np.ndarray, alpha: float = 0.5, - colors: list[str | tuple[int, int, int]] | str | tuple[int, int, int] | None = None, + colors: Sequence[str | tuple[int, int, int]] | str | tuple[int, int, int] | None = None, ) -> Tensor: """ Draws segmentation masks on given RGB image. @@ -121,6 +124,10 @@ def draw_masks( if isinstance(masks, np.ndarray): masks = torch.from_numpy(masks) + if masks.ndim == 4: + _LOGGER.warning(f"In draw_masks: Expected masks to have shape (num_masks, H, W) or (H, W), but got {masks.shape}." + " It might produce unexpected results. Please check the shape of the masks.") + if image.ndim == 3 and image.shape[0] == 1: # convert to RGB image = image.expand(3, -1, -1) diff --git a/notebooks/use_cases/segmentation_2d-unetpp_BUSI_tutorial.ipynb b/notebooks/use_cases/segmentation_2d-unetpp_BUSI_tutorial.ipynb index 433a89ae..cee1952c 100644 --- a/notebooks/use_cases/segmentation_2d-unetpp_BUSI_tutorial.ipynb +++ b/notebooks/use_cases/segmentation_2d-unetpp_BUSI_tutorial.ipynb @@ -202,44 +202,12 @@ "print(f\"Found {len(label_paths)} segmentation masks\")" ] }, - { - "cell_type": "markdown", - "id": "d420790c", - "metadata": {}, - "source": [ - "### 2.3 Define Classes\n", - "\n", - "We map class ids to class names for segmentation.\n", - "We need this mapping because training labels are stored as integers (not strings) in the masks.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "26a254a7", - "metadata": {}, - "outputs": [], - "source": [ - "# Class mapping for segmentation. \n", - "# Class 0 is background (no tumor)\n", - "CLASS_NAMES = {\n", - " 0: \"background\",\n", - " 1: \"benign\",\n", - " 2: \"malignant\",\n", - "}\n", - "\n", - "CLASS_NAME_TO_LABEL = {v: k for k, v in CLASS_NAMES.items()}\n", - "NUM_CLASSES = len(CLASS_NAMES)\n", - "\n", - "print(f\"Number of classes: {NUM_CLASSES}\")" - ] - }, { "cell_type": "markdown", "id": "464994f5", "metadata": {}, "source": [ - "### 2.4 Upload Images to Datamint\n", + "### 2.3 Upload Images to Datamint\n", "\n", "We upload each ultrasound image as a resource with appropriate tags." ] @@ -267,7 +235,7 @@ "id": "63ea53fe", "metadata": {}, "source": [ - "### 2.5 Upload Segmentation Masks\n", + "### 2.4 Upload Segmentation Masks\n", "\n", "Now we upload the corresponding segmentation masks." ] @@ -327,7 +295,7 @@ "id": "0d4562af", "metadata": {}, "source": [ - "### 2.6 Create Train/Validation/Test Splits\n", + "### 2.5 Create Train/Validation/Test Splits\n", "\n", "We split the dataset into three subsets using tags for reproducibility.\n", "\n", @@ -397,6 +365,36 @@ "- Uses Albumentations for data augmentation" ] }, + { + "cell_type": "markdown", + "id": "65e63fde", + "metadata": {}, + "source": [ + "Define Classes\n", + "\n", + "We map class ids to class names for segmentation.\n", + "We need this mapping because training labels are stored as integers (not strings) in the masks.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9c08cd34", + "metadata": {}, + "outputs": [], + "source": [ + "# Class mapping for segmentation. \n", + "# Class 0 is background (no tumor)\n", + "CLASS_NAMES = {\n", + " 1: \"benign\",\n", + " 2: \"malignant\"\n", + "}\n", + "\n", + "NUM_CLASSES = len(CLASS_NAMES)\n", + "\n", + "print(f\"Number of classes: {NUM_CLASSES}\")" + ] + }, { "cell_type": "markdown", "id": "65ef441a", @@ -420,6 +418,10 @@ "# Image size for UNet++ (should be divisible by 32 for encoder-decoder architectures)\n", "IMAGE_SIZE = 256\n", "\n", + "# ImageNet normalization stats — required for the pretrained ResNet34 encoder\n", + "IMAGENET_MEAN = (0.485, 0.456, 0.406)\n", + "IMAGENET_STD = (0.229, 0.224, 0.225)\n", + "\n", "# Training transforms with augmentation\n", "train_transforms = A.Compose([\n", " A.Resize(IMAGE_SIZE, IMAGE_SIZE),\n", @@ -428,14 +430,14 @@ " A.ElasticTransform(alpha=50, sigma=5, p=0.3),\n", " A.GridDistortion(num_steps=5, distort_limit=0.2, p=0.3),\n", " A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5),\n", - " A.Normalize(0,1), # Normalize to [0, 1] by dividing by 255\n", + " A.Normalize(IMAGENET_MEAN, IMAGENET_STD), # Normalize to ImageNet stats\n", " ToTensorV2(),\n", "])\n", "\n", "# Validation/Test transforms (no augmentation)\n", "val_transforms = A.Compose([\n", " A.Resize(IMAGE_SIZE, IMAGE_SIZE),\n", - " A.Normalize(0,1), # Normalize to [0, 1] by dividing by 255\n", + " A.Normalize(IMAGENET_MEAN, IMAGENET_STD), # Normalize to ImageNet stats\n", " ToTensorV2(),\n", "])" ] @@ -456,213 +458,85 @@ { "cell_type": "code", "execution_count": null, - "id": "1d15a7a6", + "id": "2a346243", "metadata": {}, "outputs": [], "source": [ - "import torch\n", - "from torch.utils.data import Dataset\n", - "from collections import defaultdict\n", - "import numpy as np\n", + "from datamint.dataset import ImageDataset\n", + "from datamint.lightning.datamodule import DatamintDataModule\n", "\n", - "class MedicalSegmentationDataset(Dataset[dict]): # dataset that returns dicts\n", - " def __init__(\n", - " self,\n", - " split: str | None = None,\n", - " transforms = None,\n", - " ):\n", - " \"\"\"\n", - " Medical Segmentation Dataset for BUSI dataset stored in Datamint.\n", - "\n", - " Args:\n", - " split (str | None): One of 'train', 'val', 'test', or None for all data.\n", - " transforms: Albumentations transforms to apply.\n", - " resources (list[Resource] | None): Optional pre-fetched list of resources. If None, fetches from Datamint based on split.\n", - " inference_mode (bool): If True, dataset is used for inference (no masks).\n", - " \"\"\"\n", - " self.transforms = transforms\n", - " self.num_classes = NUM_CLASSES\n", - " \n", - " self.resources = api.resources.get_list(\n", - " project_name=PROJECT_NAME,\n", - " tags=[f'split:{split}'] if split else None,\n", - " )\n", - " \n", - " all_annotations = api.annotations.get_list(\n", - " resource=self.resources,\n", - " annotation_type='segmentation'\n", - " )\n", - " \n", - " self.resource_annotations = defaultdict(list) # just mapping resource_id -> [annotations]\n", - " for ann in all_annotations:\n", - " self.resource_annotations[ann.resource_id].append(ann)\n", - " \n", - " def __len__(self):\n", - " return len(self.resources)\n", - " \n", - " def __getitem__(self, idx:int) -> dict:\n", - " resource = self.resources[idx]\n", - " \n", - " # Load image\n", - " image = resource.fetch_file_data(auto_convert=True, use_cache=True)\n", - " original_width = image.width\n", - " original_height = image.height\n", - " # image is a PIL.Image object\n", - " image = np.array(image) # image.shape: (H, W, 3) or (H, W)\n", - " \n", - " # Convert grayscale to RGB if needed\n", - " if image.ndim == 2:\n", - " image = np.stack([image] * 3, axis=-1)\n", - " \n", - " # Load mask if not in inference mode\n", - " annotations = self.resource_annotations[resource.id]\n", - " if not annotations or 'normal' in resource.filename:\n", - " mask = np.zeros((original_height, original_width), dtype=np.int64)\n", - " else:\n", - " if len(annotations) > 1:\n", - " print(f\"Warning: Resource {resource.filename} has multiple annotations. Using the first one.\")\n", - " mask = np.array(annotations[0].fetch_file_data(use_cache=True)).astype(np.int64)\n", - " # Convert binary mask {0, 255} to class indices {0, 1 or 2}\n", - " # Determine class from filename\n", - " if 'benign' in resource.filename:\n", - " mask = (mask > 0).astype(np.int64) # 0 -> 0 (background), 255 -> 1 (benign)\n", - " elif 'malignant' in resource.filename:\n", - " mask = (mask > 0).astype(np.int64) * 2 # 0 -> 0 (background), 255 -> 2 (malignant)\n", - " \n", - " if self.transforms:\n", - " if mask is not None:\n", - " transformed = self.transforms(image=image, mask=mask)\n", - " image = transformed['image']\n", - " mask = transformed['mask'].long()\n", - " else:\n", - " transformed = self.transforms(image=image)\n", - " image = transformed['image']\n", - " else:\n", - " # Fallback if no transforms provided\n", - " image = torch.from_numpy(image).float() / 255.0\n", - " if image.ndim == 2:\n", - " image = image.unsqueeze(0)\n", - " if mask is not None:\n", - " mask = torch.from_numpy(mask).long()\n", - " \n", - " res = {\n", - " \"image\": image, \n", - " \"mask\": mask,\n", - " \"filename\": resource.filename,\n", - " 'original_width': original_width,\n", - " 'original_height': original_height\n", - " }\n", + "# Configuration\n", + "BATCH_SIZE = 16 # Adjust based on your GPU memory\n", + "NUM_WORKERS = 4 # Adjust based on your CPU cores\n", "\n", - " return res" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8ef95ef", - "metadata": {}, - "outputs": [], - "source": [ - "# Test Dataset\n", + "D = ImageDataset(\n", + " project=PROJECT_NAME,\n", + " return_as_semantic_segmentation=True,\n", + " semantic_seg_merge_strategy='union',\n", + " allow_external_annotations=True,\n", + " include_unannotated=False,\n", "\n", - "train_dataset = MedicalSegmentationDataset(\n", - " split='train',\n", - " transforms=train_transforms,\n", ")\n", - "\n", - "train_dataset[0].keys() # Fetch first sample to test" + "splitted_dataset = D.split()\n", + "Dtrain = splitted_dataset['train']\n", + "Dval = splitted_dataset['val']\n", + "Dtest = splitted_dataset['test']\n", + "Dtrain.get_dataloader()\n", + "\n", + "ddm = DatamintDataModule(D,\n", + " num_workers=NUM_WORKERS,\n", + " batch_size=BATCH_SIZE,\n", + " train_transform=train_transforms,\n", + " eval_transform=val_transforms)\n" ] }, { "cell_type": "markdown", - "id": "2279a59c", - "metadata": {}, - "source": [ - "### 3.4 Create DataLoaders\n", - "\n", - "Now we instantiate datasets and dataloaders for each split." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "168737d8", + "id": "c5c05052", "metadata": {}, - "outputs": [], "source": [ - "from torch.utils.data import DataLoader\n", - "\n", - "# Configuration\n", - "BATCH_SIZE = 16 # Adjust based on your GPU memory\n", - "NUM_WORKERS = 4 # Adjust based on your CPU cores\n", - "\n", - "\n", - "# Create datasets\n", - "print(\"Building training dataset...\")\n", - "train_dataset = MedicalSegmentationDataset(\n", - " split='train',\n", - " transforms=train_transforms,\n", - ")\n", - "print(f\" Training samples (slices): {len(train_dataset)}\")\n", - "\n", - "print(\"Building validation dataset...\")\n", - "val_dataset = MedicalSegmentationDataset(\n", - " split='val',\n", - " transforms=val_transforms,\n", - ")\n", - "print(f\" Validation samples (slices): {len(val_dataset)}\")\n", - "\n", - "print(\"Building test dataset...\")\n", - "test_dataset = MedicalSegmentationDataset(\n", - " split='test',\n", - " transforms=val_transforms,\n", - ")\n", - "print(f\" Test samples (slices): {len(test_dataset)}\")\n", - "\n", - "# Create DataLoaders\n", - "train_dataloader = DataLoader(\n", - " train_dataset,\n", - " batch_size=BATCH_SIZE,\n", - " shuffle=True,\n", - " num_workers=NUM_WORKERS,\n", - ")\n", - "\n", - "val_dataloader = DataLoader(\n", - " val_dataset,\n", - " batch_size=BATCH_SIZE,\n", - " shuffle=False,\n", - " num_workers=NUM_WORKERS,\n", - ")\n", - "\n", - "test_dataloader = DataLoader(\n", - " test_dataset,\n", - " batch_size=BATCH_SIZE,\n", - " shuffle=False,\n", - " num_workers=NUM_WORKERS,\n", - ")" + "Optionally, instead of `DatamintDataModule`, you can use:\n", + "```python\n", + "splitted_dataset = D.split()\n", + "Dtrain = splitted_dataset['train'] # Pytorch compatible dataset\n", + "Dval = splitted_dataset['val']\n", + "Dtest = splitted_dataset['test']\n", + "\n", + "Dtrain.set_transforms(train_transforms)\n", + "# (...)\n", + "\n", + "train_loader = Dtrain.get_dataloader(batch_size=16, shuffle=True) # Pytorch compatible DataLoader\n", + "# (...)\n", + "\n", + "# or build custom dataloaders:\n", + "train_loader = DataLoader(Dtrain, batch_size=16, shuffle=True, collate_fn=Dtrain.get_collate_fn())\n", + "# (...)\n", + "```" ] }, { "cell_type": "code", "execution_count": null, - "id": "a6352de7", + "id": "5fb31d85", "metadata": {}, "outputs": [], "source": [ "# Visualize a sample batch\n", "from datamint.utils.visualization import show, draw_masks\n", "\n", + "ddm.setup() # Ensure dataloaders are ready\n", "\n", - "sample_batch = next(iter(train_dataloader))\n", + "sample_batch = next(iter(ddm.train_dataloader()))\n", "print(f\"Batch image shape: {sample_batch['image'].shape}\") # (B, C, H, W)\n", - "print(f\"Batch mask shape: {sample_batch['mask'].shape}\") # (B, H, W)\n", + "print(f\"Batch mask shape: {sample_batch['segmentations'].shape}\") # (B, H, W)\n", "\n", "# Plot first 2 samples\n", "for i in range(2):\n", " img = sample_batch['image'][i]\n", - " mask = sample_batch['mask'][i]\n", - " print(f\"Filename: {sample_batch['filename'][i]}\")\n", - " img_with_mask = draw_masks(img, mask)\n", + " mask = sample_batch['segmentations'][i]\n", + " r = sample_batch['resource'][i]\n", + " print(f\"Resource: {r.id=}, {r.filename=}\")\n", + " img_with_mask = draw_masks(img, mask[1:]) # Skip background class\n", " show(img_with_mask)" ] }, @@ -706,7 +580,6 @@ "import torch\n", "import torch.nn as nn\n", "import torch.nn.functional as F\n", - "from torchmetrics.segmentation import DiceScore\n", "\n", "class CombinedLoss(nn.Module):\n", " \"\"\"Combined CrossEntropy and Dice Loss.\n", @@ -721,43 +594,47 @@ " dice_weight: Weight for Dice loss\n", " \"\"\"\n", " \n", - " def __init__(\n", - " self,\n", - " num_classes: int,\n", - " ce_weight: float = 1.0,\n", - " dice_weight: float = 1.0,\n", - " class_weights: torch.Tensor | None = None,\n", - " ):\n", - " super().__init__()\n", - " self.ce_loss = nn.CrossEntropyLoss(weight=class_weights)\n", - " self.dicescore = DiceScore(num_classes=num_classes, \n", - " average='macro',\n", - " input_format=\"mixed\")\n", - " self.ce_weight = ce_weight\n", - " self.dice_weight = dice_weight\n", - " \n", " def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:\n", " \"\"\"\n", " Args:\n", - " pred: Predictions (B, C, H, W) - logits\n", - " target: Ground truth (B, H, W) - class indices\n", - " \n", + " pred: Predicted logits of shape (B, C, H, W).\n", + " target: Multi-hot ground truth mask of shape (B, C, H, W), values in {0, 1}.\n", + " Classes can overlap (non-exclusive).\n", + "\n", " Returns:\n", - " Combined loss (scalar)\n", + " Combined BCE-with-logits + soft Dice loss (scalar).\n", " \"\"\"\n", - " ce = self.ce_loss(pred, target.long())\n", - " dice = 1 - self.dicescore(F.softmax(pred, dim=1), target)\n", - " \n", - " return self.ce_weight * ce + self.dice_weight * dice\n", + " if pred.shape != target.shape:\n", + " raise ValueError(\n", + " f\"For non-exclusive classes, pred and target must have the same shape. \"\n", + " f\"Got pred={tuple(pred.shape)} and target={tuple(target.shape)}.\"\n", + " )\n", + "\n", + " target = target.float()\n", + "\n", + " # BCE term for independent per-class pixel classification\n", + " bce = F.binary_cross_entropy_with_logits(pred, target)\n", + "\n", + " # Soft Dice term for overlap quality in multi-label segmentation\n", + " probs = torch.sigmoid(pred)\n", + " dims = (0, 2, 3) # reduce over batch and spatial dims, keep class dim\n", + " intersection = (probs * target).sum(dim=dims)\n", + " cardinality = probs.sum(dim=dims) + target.sum(dim=dims)\n", + " dice_per_class = (2.0 * intersection + 1e-6) / (cardinality + 1e-6)\n", + " dice_loss = 1.0 - dice_per_class.mean()\n", + "\n", + " return bce + dice_loss\n", "\n", "\n", "# Quick test\n", - "dummy_pred = torch.randn(2, NUM_CLASSES+1, 64, 64) # logits\n", - "dummy_target = torch.randint(0, NUM_CLASSES+1, (2, 64, 64))\n", + "dummy_pred = torch.randn(2, NUM_CLASSES, 64, 64) # logits\n", + "dummy_target = torch.randint(0, NUM_CLASSES, (2, 64, 64))\n", + "# one-hot encode dummy_target\n", + "dummy_target = F.one_hot(dummy_target, num_classes=NUM_CLASSES).permute(0, 3, 1, 2).float()\n", "\n", - "loss_fn = CombinedLoss(num_classes=NUM_CLASSES+1)\n", + "loss_fn = CombinedLoss()\n", "loss = loss_fn(dummy_pred, dummy_target)\n", - "print(f\"Test loss value: {loss.item():.4f}\")" + "print(f\"Test loss value: {loss.item():.4f}\")\n" ] }, { @@ -784,19 +661,19 @@ "\n", "class UNetPPModule(L.LightningModule):\n", " \"\"\"PyTorch Lightning module for UNet++ segmentation.\n", - " \n", + "\n", " This module handles:\n", " - Model architecture (UNet++ with pretrained encoder)\n", " - Combined loss function (CrossEntropy + Dice)\n", " - Metrics tracking (IoU, Dice)\n", " - Optimizer configuration with learning rate scheduling\n", - " \n", + "\n", " Args:\n", - " num_classes: Number of segmentation classes (including background)\n", + " num_classes: Number of segmentation classes (excluding background)\n", " encoder_name: Name of the encoder backbone (e.g., 'resnet34', 'efficientnet-b0')\n", " learning_rate: Initial learning rate\n", " \"\"\"\n", - " \n", + "\n", " def __init__(\n", " self,\n", " num_classes: int,\n", @@ -804,10 +681,10 @@ " learning_rate: float = 1e-4,\n", " ):\n", " super().__init__()\n", - " self.save_hyperparameters() # Save hyperparameters for logging\n", + " self.save_hyperparameters() # Save hyperparameters for logging\n", "\n", " self.learning_rate = learning_rate\n", - " \n", + "\n", " # UNet++ model from segmentation_models_pytorch\n", " self.model = smp.UnetPlusPlus(\n", " encoder_name=encoder_name,\n", @@ -815,84 +692,85 @@ " in_channels=3, # RGB input (we repeat grayscale to 3 channels)\n", " classes=num_classes,\n", " )\n", - " \n", + "\n", " # Loss function\n", " self.criterion = CombinedLoss(\n", - " num_classes=num_classes,\n", " ce_weight=1.0,\n", " dice_weight=1.0,\n", " )\n", "\n", - " # Metrics for each split\n", - " # Note: MeanIoU expects predictions and targets as one-hot encoded by default.\n", - " # Therefore, we need to change expected input_format.\n", + " input_format = 'one-hot'\n", " self.iou_metrics = {\n", - " 'train': MeanIoU(num_classes=num_classes, input_format='index'),\n", - " 'val': MeanIoU(num_classes=num_classes, input_format='index'),\n", - " 'test': MeanIoU(num_classes=num_classes, input_format='index'),\n", + " 'train': MeanIoU(num_classes=num_classes, input_format=input_format),\n", + " 'val': MeanIoU(num_classes=num_classes, input_format=input_format),\n", + " 'test': MeanIoU(num_classes=num_classes, input_format=input_format),\n", " }\n", " # register the iou metrics:\n", " for stage, metric in self.iou_metrics.items():\n", " self.add_module(f\"{stage}_mean_iou\", metric)\n", "\n", " self.dice_metrics = {\n", - " 'train': GeneralizedDiceScore(num_classes=num_classes, input_format='index'),\n", - " 'val': GeneralizedDiceScore(num_classes=num_classes, input_format='index'),\n", - " 'test': GeneralizedDiceScore(num_classes=num_classes, input_format='index'),\n", + " 'train': GeneralizedDiceScore(num_classes=num_classes, input_format=input_format),\n", + " 'val': GeneralizedDiceScore(num_classes=num_classes, input_format=input_format),\n", + " 'test': GeneralizedDiceScore(num_classes=num_classes, input_format=input_format),\n", " }\n", " # register the dice metrics:\n", " for stage, metric in self.dice_metrics.items():\n", " self.add_module(f\"{stage}_dice_score\", metric)\n", - " \n", + "\n", " def forward(self, x: torch.Tensor) -> torch.Tensor:\n", " \"\"\"Forward pass through UNet++.\"\"\"\n", " return self.model(x)\n", - " \n", + "\n", " def _common_step(self, batch: dict, stage: str) -> torch.Tensor:\n", " \"\"\"Common step for train/val/test.\n", - " \n", + "\n", " Args:\n", - " batch: Dictionary with 'image' and 'mask' tensors\n", + " batch: Dictionary with 'image' and 'segmentations' tensors\n", " stage: One of 'train', 'val', 'test'\n", - " \n", + "\n", " Returns:\n", " Loss tensor\n", " \"\"\"\n", " images = batch['image']\n", - " masks = batch['mask'] # input format: index-based. shape: (B, H, W)\n", - " \n", + " masks = batch['segmentations'] # one-hot encoded (B, #classes+1, H, W)\n", + " masks = masks[:, 1:] # exclude background class\n", + "\n", " # Forward pass\n", - " logits = self(images) # (B, C, H, W)\n", - " \n", + " logits = self(images) # (B, #classes, H, W)\n", + "\n", + " # # Convert masks to one-hot for the loss function\n", + " # masks_onehot = F.one_hot(masks, num_classes=self.hparams.num_classes).permute(0, 3, 1, 2).float() # (B, C, H, W)\n", + "\n", " # Compute loss\n", " loss = self.criterion(logits, masks)\n", - " \n", - " # Get predictions (class indices)\n", - " preds = torch.argmax(logits, dim=1) # (B, H, W)\n", - " \n", + "\n", + " preds = (logits > 0).long()\n", + "\n", " # Update metrics\n", " if stage is not None:\n", - " self.iou_metrics[stage].update(preds, masks)\n", - " self.dice_metrics[stage].update(preds, masks)\n", - " self.log(f'{stage}/loss', loss, on_step=(stage == 'train'), on_epoch=True, prog_bar=True, batch_size=len(images))\n", - " \n", + " self.iou_metrics[stage].update(preds, masks.long())\n", + " self.dice_metrics[stage].update(preds, masks.long())\n", + " self.log(f'{stage}/loss', loss, on_step=(stage == 'train'),\n", + " on_epoch=True, prog_bar=True, batch_size=len(images))\n", + "\n", " return loss\n", - " \n", + "\n", " def training_step(self, batch: dict, batch_idx: int) -> torch.Tensor:\n", " return self._common_step(batch, 'train')\n", - " \n", + "\n", " def validation_step(self, batch: dict, batch_idx: int) -> torch.Tensor:\n", " return self._common_step(batch, 'val')\n", - " \n", + "\n", " def test_step(self, batch: dict, batch_idx: int) -> torch.Tensor:\n", " return self._common_step(batch, 'test')\n", - " \n", + "\n", " def predict_step(self, batch: dict, batch_idx: int) -> torch.Tensor:\n", " images = batch['image']\n", " logits = self(images)\n", - " preds = torch.argmax(logits, dim=1)\n", + " preds = (logits > 0).long()\n", " return preds\n", - " \n", + "\n", " def _common_epoch_end(self, stage: str):\n", " iou = self.iou_metrics[stage]\n", " dice = self.dice_metrics[stage]\n", @@ -900,38 +778,24 @@ " self.log(f'{stage}/dice', dice.compute())\n", " iou.reset()\n", " dice.reset()\n", - " \n", + "\n", " def on_train_epoch_end(self):\n", " self._common_epoch_end('train')\n", - " \n", + "\n", " def on_validation_epoch_end(self):\n", " self._common_epoch_end('val')\n", "\n", " def on_test_epoch_end(self):\n", " self._common_epoch_end('test')\n", - " \n", + "\n", " def configure_optimizers(self):\n", " optimizer = torch.optim.AdamW(\n", " self.parameters(),\n", " lr=self.learning_rate,\n", " weight_decay=1e-4,\n", " )\n", - " \n", - " scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(\n", - " optimizer,\n", - " mode='min',\n", - " factor=0.5,\n", - " patience=5,\n", - " )\n", - " \n", - " return {\n", - " 'optimizer': optimizer,\n", - " 'lr_scheduler': {\n", - " 'scheduler': scheduler,\n", - " 'monitor': 'val/loss',\n", - " 'interval': 'epoch',\n", - " }\n", - " }" + "\n", + " return optimizer" ] }, { @@ -955,8 +819,11 @@ " print(f\"Output shape: {sample_output.shape}\")\n", "\n", " # test loss computation\n", - " sample_target = train_dataloader.dataset[-1]['mask'] # (1, H, W)\n", - " sample_loss = model.criterion(sample_output, sample_target.unsqueeze(0))\n", + " sample_target = ddm.train_dataloader().dataset[-1]['segmentations'] # shape: (#classes+1, H, W)\n", + " print(f\"Sample target shape: {sample_target.shape}\")\n", + " sample_loss = model.criterion(sample_output,\n", + " sample_target[1:].unsqueeze(0) # Remove background mask and add batch dimension\n", + " )\n", " print(f\"Sample loss: {sample_loss.item():.4f}\")" ] }, @@ -1037,7 +904,7 @@ "source": [ "# Initialize trainer\n", "trainer = L.Trainer(\n", - " max_epochs=20, # Maximum training epochs\n", + " max_epochs=20, # Maximum training epochs\n", " logger=mlflow_logger, # MLflow logging\n", " callbacks=[checkpoint_callback, early_stop_callback],\n", " accelerator='auto', # Auto-detect GPU/CPU\n", @@ -1048,8 +915,7 @@ "print(\"🚀 Starting training...\")\n", "trainer.fit(\n", " model,\n", - " train_dataloaders=train_dataloader,\n", - " val_dataloaders=val_dataloader,\n", + " datamodule=ddm,\n", ")" ] }, @@ -1098,7 +964,7 @@ "source": [ "# Evaluate on test set and register model\n", "print(\"🔍 Evaluating on test set...\")\n", - "test_results = trainer.test(dataloaders=test_dataloader)\n", + "test_results = trainer.test(dataloaders=ddm.test_dataloader())\n", "\n", "print(f\"Best model checkpoint: {checkpoint_callback.best_model_path}\")\n", "print(f\"Best validation IoU: {checkpoint_callback.best_model_score:.4f}\")" @@ -1134,9 +1000,15 @@ "metadata": {}, "outputs": [], "source": [ + "import numpy as np\n", + "from datamint.utils.visualization import show, draw_masks\n", + "from torchmetrics.functional.segmentation import mean_iou\n", + "from matplotlib import pyplot as plt\n", + "\n", + "\n", "def visualize_predictions(model, dataset):\n", " \"\"\"Visualize model predictions compared to ground truth.\n", - " \n", + "\n", " Args:\n", " model: Trained model\n", " dataset: Dataset to sample from\n", @@ -1144,27 +1016,39 @@ " device: Device for inference\n", " \"\"\"\n", " model.eval()\n", - " \n", - " idx = np.random.choice(len(dataset), 1, replace=False)[0]\n", - " \n", - " with torch.inference_mode():\n", - " sample = dataset[idx]\n", - " image = sample['image'].unsqueeze(0).to(model.device)\n", - " mask_gt = sample['mask'] # shape: (H, W)\n", - " \n", - " logits = model(image) # shape: (1, #classes, H, W)\n", - " mask_pred = torch.argmax(logits, dim=1).squeeze(0).cpu() # shape: (H, W)\n", "\n", - " overlay_mask = draw_masks(image.squeeze(0).cpu(), torch.stack([mask_gt, mask_pred]), alpha=0.5)\n", - " show(overlay_mask)\n", + " idx = np.random.choice(len(dataset), 1)[0]\n", "\n", - " yp = (mask_pred == 1).sum().item() # predicted positives\n", - " yg = (mask_gt == 1).sum().item() # ground truth positives\n", - " tp = ((mask_pred == 1) & (mask_gt == 1)).sum().item() # true positives\n", - " iou = tp / (yp + yg - tp) if (yp + yg - tp) > 0 else 1.0\n", + " with torch.inference_mode():\n", + " sample = dataset[idx]\n", + " image = sample['image'].to(model.device) # image.shape: (C, H, W)\n", + " mask_gt = sample['segmentations'] # shape: (#classes+1, H, W) One-hot encoded as float\n", + " mask_gt = mask_gt[1:] # remove background class\n", + "\n", + " # model expects batch dimension, so add it with unsqueeze(0)\n", + " logits = model(image.unsqueeze(0)) # logits.shape: (1, #classes+1, H, W)\n", + " mask_pred = logits[0] > 0 # shape: (#classes+1, H, W). one Binary mask for each class\n", + "\n", + " _, axes = plt.subplots(1, NUM_CLASSES, figsize=(10, 5))\n", + " for i in range(NUM_CLASSES):\n", + " ax = axes[i]\n", + " overlay_mask = draw_masks(image.cpu(),\n", + " torch.stack([mask_gt[i], mask_pred[i]]),\n", + " alpha=0.5,\n", + " )\n", + " ax.set_title(f\"Class: {CLASS_NAMES[i+1]}\")\n", + " show(overlay_mask, ax=ax)\n", + "\n", + " iou = mean_iou(mask_pred.unsqueeze(0).bool(), # IMPORTANT: torchmetrics expects same dtype for both inputs.\n", + " mask_gt.unsqueeze(0).bool(),\n", + " include_background=True, # adds back the bg, since we removed early\n", + " per_class=True,\n", + " input_format='one-hot')\n", + " iou = iou.max() # take the best IoU across classes since we have exclusive classes\n", " print(f\"IoU: {iou:.1%}\")\n", "\n", - "visualize_predictions(model, test_dataset)" + "\n", + "visualize_predictions(model, ddm.test_dataloader().dataset)" ] }, { @@ -1432,7 +1316,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.12.13" } }, "nbformat": 4, From ac73f9c9ce7727f35cdae8ad95785469ddb23af4 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 12 Mar 2026 09:41:22 -0300 Subject: [PATCH 17/47] Removed deprecated examples --- examples/experiment_traintest_classifier.py | 175 ----- examples/experiment_traintest_segmentation.py | 249 ------- .../experiment_segmentation_lightning.ipynb | 705 ------------------ 3 files changed, 1129 deletions(-) delete mode 100644 examples/experiment_traintest_classifier.py delete mode 100644 examples/experiment_traintest_segmentation.py delete mode 100644 notebooks/experiment_segmentation_lightning.ipynb diff --git a/examples/experiment_traintest_classifier.py b/examples/experiment_traintest_classifier.py deleted file mode 100644 index 55ab2c30..00000000 --- a/examples/experiment_traintest_classifier.py +++ /dev/null @@ -1,175 +0,0 @@ -import torch.utils -from tqdm.auto import tqdm -import torch.optim as optim -import torch.nn.functional as F -import torch.nn as nn -import torch -from datamint import Experiment -import logging -from torchmetrics import Recall, Precision, Specificity, F1Score, Accuracy, MatthewsCorrCoef -import torchmetrics -from torchvision.transforms import v2 as T -from torchvision.models import resnet18, ResNet18_Weights -from typing import Sequence - -LOGGER = logging.getLogger(__name__) - -NUM_EPOCHS = 2 -DEVICE = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu" - -## Set API Key ## -# run `datamint-config` in the terminal to set the API key OR set it here: -# os.environ["DATAMINT_API_KEY"] = "abc123", # uncomment this line if you have not configured the API key - - -# Define the network architecture: - -class MyModel(nn.Module): - def __init__(self, num_labels): - super().__init__() - self.num_labels = num_labels - self.resnet = resnet18(weights=ResNet18_Weights.DEFAULT) - # Freeze all layers except the last one - for param in self.resnet.parameters(): - param.requires_grad = False - self.resnet.fc = nn.Linear(self.resnet.fc.in_features, num_labels) - - def forward(self, x): - return F.sigmoid(self.resnet(x)) - - -def main(): - # Initialize the experiment. Creates a new experiment on the platform. - exp = Experiment(name="Test Experiment1", - project_name='testproject', - allow_existing=True, # If an experiment with the same name exists, allow_existing=True returns the existing experiment - dry_run=True # Set True to avoid uploading the results to the platform - ) - - ### Load dataset ### - dataset_params = dict( - return_frame_by_frame=True, - image_transform=T.Compose([T.RGB(), # Resnet18 expects 3 channels - ResNet18_Weights.DEFAULT.transforms()] - ), - return_segmentations=False, # We just want frame labels for classification - ) - - train_dataset = exp.get_dataset("train", **dataset_params) - test_dataset = exp.get_dataset("test", **dataset_params) - - trainloader = train_dataset.get_dataloader(batch_size=8) - testloader = test_dataset.get_dataloader(batch_size=8) - - #################### - - num_labels = len(train_dataset.frame_labels_set) - if num_labels == 0: - raise ValueError("The dataset does not have any frame labels!") - print(f"Number of labels: {num_labels}") - task = "multilabel" if num_labels > 1 else "binary" - - cls_metrics_params = dict( - task=task, - num_labels=num_labels, - average="macro" - ) - - ### Define the model, loss function, and metrics ### - model = MyModel(num_labels=num_labels) - metrics = [Recall(**cls_metrics_params), - Precision(**cls_metrics_params), - Specificity(**cls_metrics_params), - F1Score(**cls_metrics_params), - Accuracy(**cls_metrics_params), - MatthewsCorrCoef(task=task, num_labels=num_labels) - ] - criterion = nn.BCELoss() - #################### - - training_loop(model, criterion, trainloader, metrics) - - # Evaluate the model on the test data - test_loop(model, criterion, testloader, metrics) - - -def training_loop(model, criterion, trainloader, - metrics: Sequence[torchmetrics.Metric], - lr=0.003): - # To device - model.to(DEVICE) - model.train() - for metric in metrics: - metric.to(DEVICE) - criterion.to(DEVICE) - - optimizer = optim.Adam(model.parameters(), lr=lr) - with tqdm(total=NUM_EPOCHS) as pbar: - for e in range(NUM_EPOCHS): - pbar.set_description(f"Epoch {e}") - running_loss = 0 - for batch in trainloader: - # batch is a dictionary with keys "images", "labels" - images = batch["image"].to(DEVICE) - labels = batch["labels"].to(DEVICE) # labels is a tensor of shape (batch_size, num_labels) - yhat = model(images) - loss = criterion(yhat, labels.float()) - - optimizer.zero_grad() - loss.backward() - optimizer.step() - - for metric in metrics: - metric.update(yhat, labels) - - running_loss += loss.item() - epoch_loss = running_loss/len(trainloader) - LOGGER.info(f"Training loss: {epoch_loss}") - pbar.set_postfix(loss=epoch_loss) - pbar.update(1) - LOGGER.info("Training metrics:") - for metric in metrics: - LOGGER.info(f"\t{metric.__class__.__name__}: {metric.compute()}") - metric.reset() - - LOGGER.info("Finished training") - - -def test_loop(model, criterion, testloader, - metrics: Sequence[torchmetrics.Metric]): - # To device - model.to(DEVICE) - model.eval() - for metric in metrics: - metric.to(DEVICE) - criterion.to(DEVICE) - - eval_loss = 0 - - with torch.no_grad(): - for batch in tqdm(testloader): - # batch is a dictionary with keys "images", "labels" - images = batch["image"].to(DEVICE) - labels = batch["labels"].to(DEVICE) # labels is a tensor of shape (batch_size, num_labels) - - pred = model(images) - loss = criterion(pred, labels.float()) - for metric in metrics: - metric.update(pred, labels) - eval_loss += loss.item() - - eval_loss /= len(testloader) - - LOGGER.info(f"Eval Loss: {eval_loss}") - LOGGER.info("Testing metrics:") - for metric in metrics: - LOGGER.info(f"\t{metric.__class__.__name__}: {metric.compute()}") - metric.reset() - - -if __name__ == "__main__": - import rich.logging - LOGGER.setLevel(logging.INFO) - logging.getLogger('datamint').setLevel(logging.DEBUG) - logging.getLogger().addHandler(rich.logging.RichHandler()) - main() diff --git a/examples/experiment_traintest_segmentation.py b/examples/experiment_traintest_segmentation.py deleted file mode 100644 index ed649b03..00000000 --- a/examples/experiment_traintest_segmentation.py +++ /dev/null @@ -1,249 +0,0 @@ -""" -This example demonstrates how to fine-tune a segmentation model on the platform. - -The example uses the `deeplabv3_mobilenet_v3_large` model from torchvision, which is pre-trained on the COCO dataset. -The model is fine-tuned on a custom dataset using the CrossEntropyLoss loss function -and evaluated using the MeanIoU and GeneralizedDiceScore metrics. - -The example demonstrates the following steps: -1. Initialize the experiment -2. Load the dataset -3. Define the model, loss function, and metrics -4. Train the model -5. Evaluate the model on the test data -""" - -import torch.utils -from tqdm.auto import tqdm -import torch.optim as optim -import torch.nn as nn -import torch -from datamint import Experiment -import logging -import os -from torchmetrics.segmentation import MeanIoU, GeneralizedDiceScore -import torchmetrics -from torchvision.models.segmentation import deeplabv3_mobilenet_v3_large, DeepLabV3_MobileNet_V3_Large_Weights -from torchmetrics import Recall, Precision, Specificity, F1Score, Accuracy, MatthewsCorrCoef -import torchvision -from typing import Sequence -from torchvision.transforms import v2 - -LOGGER = logging.getLogger(__name__) -DEVICE = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu" - -## Set API Key ## -# run `datamint-config` in the terminal to set the API key OR set it here: -# os.environ["DATAMINT_API_KEY"] = "abc123", # uncomment this line if you have not configured the API key - - -class ClsMetricForSegmentation(torchmetrics.Metric): - """ - This class is used to convert the segmentation output to a classification output. - The segmentation output is a tensor of shape (batch_size, num_classes, H, W). - We convert it to a tensor of shape (batch_size, num_classes) by taking the maximum value along the height and width - """ - - def __init__(self, num_labels: int, metrics, **kwargs): - super().__init__(**kwargs) - self.num_labels = num_labels - self.metrics = metrics - - def update(self, yhat, y): - yhat_cls = yhat.amax(dim=(2, 3)).float() # yhat_cls.shape = (batch_size, num_classes) - y_cls = y.amax(dim=(2, 3)).float() # y_cls.shape = (batch_size, num_classes) - for metric in self.metrics: - metric.update(yhat_cls, y_cls) - - def compute(self): - return {metric.__class__.__name__: metric.compute().item() for metric in self.metrics} - - def reset(self): - for metric in self.metrics: - metric.reset() - - def to(self, device): - for metric in self.metrics: - metric.to(device) - - -def initialize_model(num_classes: int, weights): - # Load the pre-trained model. - model = deeplabv3_mobilenet_v3_large(weights=weights) - - model.aux_classifier = None - # Freezing the weights of the model - for param in model.parameters(): - param.requires_grad = False - - # Replace the classifier head with a new one that has the correct number of output classes. - # This is specific to the deeplabv3_mobilenet_v3_large model. - # For other models, you may need to replace a different part of the model. - model.classifier = torchvision.models.segmentation.deeplabv3.DeepLabHead(960, num_classes) - - return model - - -def main(): - # Initialize the experiment. Creates a new experiment on the platform. - exp = Experiment(name='experiment16', - project_name='testproject', - allow_existing=True, # If an experiment with the same name exists, allow_existing=True returns the existing experiment - dry_run=True # Set dry_run=True to avoid uploading the results to the platform - ) - - weights = DeepLabV3_MobileNet_V3_Large_Weights.DEFAULT - - ### Load dataset ### - dataset_params = dict( - return_frame_by_frame=True, - image_transform=v2.Compose([v2.Resize((520, 520)), - v2.RGB(), - weights.transforms() - ]), - mask_transform=v2.Resize((520, 520), antialias=False, interpolation=v2.InterpolationMode.NEAREST), - return_segmentations=True, - # This will return the mask as a semantic segmentation tensor (#classes, H, W) - return_as_semantic_segmentation=True, - semantic_seg_merge_strategy='union', - ) - - # Load the train and test datasets. This returns a subclass of PyTorch dataset object. - train_dataset = exp.get_dataset("train", **dataset_params) - test_dataset = exp.get_dataset("test", **dataset_params) - - # Create dataloaders for the train and test datasets. - # This method has the convinience of automatically dealing with collate_fn. - trainloader = train_dataset.get_dataloader(batch_size=2, drop_last=True) - testloader = test_dataset.get_dataloader(batch_size=2, drop_last=True) - - #################### - - num_segmentation_classes = len(train_dataset.segmentation_labels_set)+1 # +1 for the background class - - ### Define the model, loss function, and metrics ### - model = initialize_model(num_segmentation_classes, weights) - metrics = [MeanIoU(num_classes=num_segmentation_classes), - GeneralizedDiceScore(num_classes=num_segmentation_classes)] - - cls_metrics_params = dict( - task="multilabel" if num_segmentation_classes > 1 else "binary", - num_labels=num_segmentation_classes, - average="macro" - ) - - # These metrics will be used when converting the segmentation output to classification output. - cls_metrics = [Recall(**cls_metrics_params), - Precision(**cls_metrics_params), - Specificity(**cls_metrics_params), - F1Score(**cls_metrics_params), - Accuracy(**cls_metrics_params), - # MatthewsCorrCoef(task="multilabel", num_labels=num_labels) - ] - metrics.append(ClsMetricForSegmentation(num_segmentation_classes, cls_metrics)) - criterion = nn.CrossEntropyLoss() - #################### - - training_loop(model, criterion, trainloader, metrics) - exp.log_model(model) - - # Evaluate the model on the test data - test_loop(model, criterion, testloader, metrics, exp) - - -def training_loop(model, criterion, trainloader, - metrics: Sequence[torchmetrics.Metric], - lr=0.003): - # To device - model.to(DEVICE) - model.train() - for metric in metrics: - metric.to(DEVICE) - criterion.to(DEVICE) - - optimizer = optim.Adam(model.parameters(), lr=lr) - epochs = 2 - with tqdm(total=epochs) as pbar: - for e in range(epochs): - pbar.set_description(f"Epoch {e}") - running_loss = 0 - for batch in trainloader: - # batch is a dictionary with keys "images", "labels" - images = batch["image"].to(DEVICE) - # segmentations is a tensor of shape (batch_size, #classes, H, W) - segmentations = batch["segmentations"].to(DEVICE) - - yhat = model(images)['out'] # yhat.shape = (batch_size, #classes, H, W) - - loss = criterion(yhat, segmentations) - - optimizer.zero_grad() - loss.backward() - optimizer.step() - - for metric in metrics: - metric.update(yhat > 0.0, segmentations.bool()) - - running_loss += loss.item() - epoch_loss = running_loss / len(trainloader) - LOGGER.info(f"Training loss: {epoch_loss}") - pbar.set_postfix(loss=epoch_loss) - pbar.update(1) - LOGGER.info("Training metrics:") - for metric in metrics: - LOGGER.info(f"\t{metric.__class__.__name__}: {metric.compute()}") - metric.reset() - - LOGGER.info("Finished training") - - -def test_loop(model, criterion, - testloader: torch.utils.data.DataLoader, - metrics: Sequence[torchmetrics.Metric], - exp: Experiment): - # To device - model.to(DEVICE) - model.eval() - for metric in metrics: - metric.to(DEVICE) - criterion.to(DEVICE) - - eval_loss = 0 - with torch.no_grad(): - for batch in tqdm(testloader): - # batch is a dictionary with keys "images", "labels" - images = batch["image"].to(DEVICE) - # segmentations is a tensor of shape (batch_size, #classes, H, W) - segmentations = batch["segmentations"].to(DEVICE) - # yhat.shape = (batch_size, #classes, H, W). Not normalized (-inf, +inf) - yhat = model(images)['out'] - # remove background - loss = criterion(yhat, segmentations) - for metric in metrics: - metric.update(yhat > 0.0, segmentations.bool()) - eval_loss += loss.item() - - yhat = yhat[:, 1:] - yhat = torch.sigmoid(yhat) - exp.log_semantic_seg_predictions(yhat.cpu().numpy(), - resource_ids=[b['id'] for b in batch['metainfo']], - label_names=testloader.dataset.segmentation_labels_set, - frame_idxs=[b['frame_index'] for b in batch['metainfo']], - threshold=0.5 - ) - - eval_loss /= len(testloader) - - LOGGER.info(f"Eval Loss: {eval_loss}") - LOGGER.info("Testing metrics:") - for metric in metrics: - LOGGER.info(f"\t{metric.__class__.__name__}: {metric.compute()}") - metric.reset() - - -if __name__ == "__main__": - import rich.logging - LOGGER.setLevel(logging.INFO) - logging.getLogger('datamint').setLevel(logging.INFO) - logging.getLogger().addHandler(rich.logging.RichHandler()) - main() diff --git a/notebooks/experiment_segmentation_lightning.ipynb b/notebooks/experiment_segmentation_lightning.ipynb deleted file mode 100644 index 4fc317d1..00000000 --- a/notebooks/experiment_segmentation_lightning.ipynb +++ /dev/null @@ -1,705 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Bone Segmentation with Datamint and Pytorch Lightning\n", - "\n", - "In this notebook, we will use [Datamint](https://sonanceai.github.io/datamint-python-api/) + [Pytorch Lightning](https://lightning.ai/docs/pytorch/stable/) to run a segmentation task on a 2D dataset of bone images.\n", - "\n", - "## Table of Contents\n", - "\n", - "1. [Introduction](#bone-segmentation-with-datamint-and-pytorch-lightning)\n", - "2. [Creating Experiments and Loading Datasets](#creating-experiments-and-loading-datasets)\n", - "3. [Quick Visualization](#quick-visualization)\n", - "4. [Easy Generation of Dataloaders](#easy-generation-of-dataloaders)\n", - "5. [Defining Our Model](#defining-our-model)\n", - "6. [Training](#training)\n", - "7. [Predict](#predict)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Creating experiments and loading datasets\n", - "\n", - "Datamint organizes ML workflows into experiments within projects. Below we create an experiment in the \"BoneSeg\" project:" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from datamint import Experiment\n", - "\n", - "exp = Experiment(\n", - " name=\"Experiment 1\",\n", - " project_name=\"BoneSeg\",\n", - " allow_existing=True, # allows connecting to an existing experiment with the same name\n", - " auto_log=False, # disables automatic logging.\n", - " # dry_run=True, # uncomment to test without saving to the server\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The `get_dataset()` method provides a standardized way to access data with immediate preprocessing options:\n", - "\n", - "| Parameter | Purpose |\n", - "|-----------|----------|\n", - "| `return_as_semantic_segmentation` | Converts instance segmentation to semantic segmentation |\n", - "| `semantic_seg_merge_strategy` | How to combine multiple masks from different annotators |\n", - "| `return_frame_by_frame` | Process data frame-by-frame or video-by-video | \n", - "\n", - "> **TIP:**\n", - "> Check more handy transformations at [DatamintDataset documentation](https://sonanceai.github.io/datamint-python-api/datamint.dataset.html#datamintapi.dataset.dataset.DatamintDataset)" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "a7769106559d4292bbfdd8eefb82db3d", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "0.00B [00:00, ?B/s]" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Inconsistent updated_at dates detected (2025-03-01T05:12:06.828Z < 2025-03-04T13:32:01.798Z).Fixing it to 2025-03-04T13:32:01.798Z\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "24 3\n" - ] - } - ], - "source": [ - "from torchvision.transforms import v2\n", - "from torchvision.models.segmentation import DeepLabV3_ResNet50_Weights\n", - "\n", - "IMAGE_SIZE = (520, 520)\n", - "\n", - "# Define image transformations - standardization is important for pretrained models\n", - "image_transform = v2.Compose([v2.Resize(IMAGE_SIZE),\n", - " v2.Grayscale(num_output_channels=3),\n", - " v2.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.0, hue=0.0),\n", - " DeepLabV3_ResNet50_Weights.DEFAULT.transforms(),\n", - " ])\n", - "\n", - "# It is important to use NEAREST interpolation for masks.\n", - "mask_transform = v2.Resize(IMAGE_SIZE, antialias=False, interpolation=v2.InterpolationMode.NEAREST)\n", - "\n", - "# Handy dataset parameters\n", - "dataset_params = dict(\n", - " return_as_semantic_segmentation=True, # Transforms instance segmentation into semantic segmentation.\n", - " semantic_seg_merge_strategy=\"union\", # Merges all author's masks into a single one.\n", - " return_frame_by_frame=True, # Iterates over each frame of the video, instead of the video as a whole.\n", - " image_transform=image_transform, # Transforms the image.\n", - " mask_transform=mask_transform, # Transforms the mask.\n", - " discard_without_annotations=True, # Discards images without annotations.\n", - ")\n", - "\n", - "# Get the train dataset associated with the experiment.\n", - "Dtrain = exp.get_dataset(\n", - " **dataset_params,\n", - " split='train'\n", - ")\n", - "\n", - "# Get the test dataset associated with the experiment.\n", - "Dtest = exp.get_dataset(\n", - " **dataset_params,\n", - " split='test'\n", - ")\n", - "print(len(Dtrain), len(Dtest))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Quick visualization\n", - "\n", - "Let's visualize a sample from our dataset to confirm image and mask alignment. The `draw_masks()` function overlays segmentation masks on top of the original image:\n", - " " - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "from datamint.utils.visualization import draw_masks, show\n", - "\n", - "# Fetch a sample item from the training dataset\n", - "item = Dtrain[-1]\n", - "\n", - "segs = item['segmentations'] # Tensor of shape (L, H, W), where L is the number of labels plus background.\n", - "# segs[0] is the background mask\n", - "\n", - "image = item['image'] # Tensor of shape (C, H, W)\n", - "segs_names = Dtrain.segmentation_labels_set # (list of str)\n", - "\n", - "# Display the image with bone segmentation masks overlaid\n", - "image_with_mask = draw_masks(image,\n", - " masks=segs[1:] == 1)\n", - "show(image_with_mask)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Easy generation of dataloaders\n", - "\n", - "DatamintDataset provides a convenient `get_dataloader()` method that wraps PyTorch's DataLoader with appropriate settings, so you don't need to provide a `collate_fn`, for instance." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "torch.Size([8, 3, 520, 520])\n", - "torch.Size([8, 5, 520, 520])\n" - ] - } - ], - "source": [ - "train_dataloader = Dtrain.get_dataloader(batch_size=8, num_workers=4, shuffle=True)\n", - "test_dataloader = Dtest.get_dataloader(batch_size=2, num_workers=4, shuffle=False)\n", - "# Check the dataloader output shapes\n", - "sample_batch = next(iter(train_dataloader))\n", - "print(sample_batch['image'].shape) # (8, 3, 520, 520)\n", - "print(sample_batch['segmentations'].shape) # (8, 5, 520, 520)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Defining our model\n", - "\n", - "We'll create a PyTorch Lightning model with:\n", - "\n", - "- **Architecture**: DeepLabV3 with ResNet50 backbone for semantic segmentation\n", - "- **Metrics**: A combination of segmentation metrics (IoU) and classification metrics adapted for segmentation tasks\n", - "- **Logging**: Integration with both Lightning and Datamint's experiment tracking\n", - "\n", - "> **Key concept**: The `SegmentationToClassificationWrapper` allows us to use classification metrics like F1 and Precision with segmentation data" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [], - "source": [ - "import lightning as L\n", - "import torchvision\n", - "from torch import nn\n", - "from torchvision.models.segmentation import deeplabv3_resnet50\n", - "from torchmetrics.segmentation import MeanIoU\n", - "from torchmetrics.classification import Recall, Precision, F1Score, Specificity, Accuracy\n", - "from datamint.utils.torchmetrics import SegmentationToClassificationWrapper\n", - "from torch import Tensor\n", - "import torch\n", - "\n", - "\n", - "class MyModel(L.LightningModule):\n", - " \"\"\"DeepLabV3 semantic segmentation model with ResNet50 backbone.\"\"\"\n", - " \n", - " def __init__(self,\n", - " num_classes: int,\n", - " experiment: Experiment = None):\n", - " \"\"\"\n", - " Initialize model with metrics for training, validation and testing.\n", - " \n", - " Args:\n", - " num_classes: Number of segmentation classes (excluding background)\n", - " experiment: Optional experiment object for logging to Datamint\n", - " \"\"\"\n", - " super().__init__()\n", - " # self.model = MyModel.initialize_model_mobilenet(num_classes+1,\n", - " # weights=DeepLabV3_MobileNet_V3_Large_Weights.DEFAULT)\n", - " self.experiment = experiment\n", - " self.model = MyModel.initialize_model(num_classes+1)\n", - " self.criterion = nn.CrossEntropyLoss()\n", - " self.num_classes = num_classes\n", - "\n", - " # train metrics #\n", - " self.train_cls_metrics = self.create_cls_metrics()\n", - " self.train_seg_metrics = self.create_seg_metrics()\n", - "\n", - " # val metrics #\n", - " self.val_cls_metrics = self.create_cls_metrics()\n", - " self.val_seg_metrics = self.create_seg_metrics()\n", - "\n", - " # test metrics #\n", - " self.test_cls_metrics = self.create_cls_metrics()\n", - " self.test_seg_metrics = self.create_seg_metrics()\n", - "\n", - " def create_seg_metrics(self) -> nn.ModuleDict:\n", - " \"\"\"\n", - " Create segmentation metrics (IoU).\n", - " \n", - " Returns:\n", - " ModuleDict containing segmentation metrics\n", - " \"\"\"\n", - " metrics = nn.ModuleDict()\n", - " metrics[\"iou\"] = MeanIoU(num_classes=self.num_classes+1, include_background=True)\n", - " return metrics\n", - "\n", - " def create_cls_metrics(self) -> nn.ModuleDict:\n", - " \"\"\"\n", - " Create classification metrics wrapped for segmentation evaluation.\n", - " \n", - " Returns:\n", - " ModuleDict with classification metrics adapted for segmentation\n", - " \"\"\"\n", - " metrics = nn.ModuleDict()\n", - " for clsmetric_cls in [Recall, Precision, F1Score, Specificity, Accuracy]:\n", - " clsmetric_obj = clsmetric_cls(task=\"multilabel\", num_labels=self.num_classes+1, average=\"macro\")\n", - " metric = SegmentationToClassificationWrapper(clsmetric_obj, iou_threshold=0.5)\n", - " metrics[clsmetric_cls.__name__] = metric\n", - "\n", - " return metrics\n", - "\n", - " @staticmethod\n", - " def initialize_model(num_classes: int, weights='DEFAULT'):\n", - " \"\"\"\n", - " Initialize DeepLabV3 model with ResNet50 backbone.\n", - " \n", - " Args:\n", - " num_classes: Number of output classes (including background)\n", - " weights: Pretrained weights specification\n", - " \n", - " Returns:\n", - " Configured DeepLabV3 model\n", - " \"\"\"\n", - " model = deeplabv3_resnet50(weights=weights)\n", - " model.classifier = torchvision.models.segmentation.deeplabv3.DeepLabHead(2048, num_classes)\n", - " return model\n", - "\n", - " def forward(self, x) -> Tensor:\n", - " \"\"\"Run forward pass through the model.\"\"\"\n", - " return self.model(x)['out']\n", - "\n", - " def predict_step(self, batch: dict, batch_idx, dataloader_idx=0):\n", - " \"\"\"Generate binary predictions from model output.\"\"\"\n", - " x = batch[\"image\"]\n", - " y_hat = self(x)\n", - " return y_hat > 0\n", - "\n", - " def _get_metrics(self, phase: str) -> tuple:\n", - " \"\"\"\n", - " Get appropriate metrics for the current phase.\n", - " \n", - " Args:\n", - " phase: One of 'train', 'val', or 'test'\n", - " \n", - " Returns:\n", - " Tuple of (segmentation_metrics, classification_metrics)\n", - " \"\"\"\n", - " if phase == 'train':\n", - " seg_metrics = self.train_seg_metrics\n", - " cls_metrics = self.train_cls_metrics\n", - " elif phase == 'val':\n", - " seg_metrics = self.val_seg_metrics\n", - " cls_metrics = self.val_cls_metrics\n", - " elif phase == 'test':\n", - " seg_metrics = self.test_seg_metrics\n", - " cls_metrics = self.test_cls_metrics\n", - " else:\n", - " raise ValueError(f\"Invalid phase: {phase}\")\n", - " return seg_metrics, cls_metrics\n", - "\n", - " def _run_step(self, batch: dict, batch_idx, phase: str) -> Tensor:\n", - " \"\"\"\n", - " Run a single training/validation/test step.\n", - " \n", - " Args:\n", - " batch: Input data containing 'image' and 'segmentations'\n", - " batch_idx: Index of current batch\n", - " phase: Current phase ('train', 'val', 'test', or None)\n", - " \n", - " Returns:\n", - " Loss tensor\n", - " \"\"\"\n", - " x = batch[\"image\"]\n", - " y = batch[\"segmentations\"]\n", - " y_hat = self(x)\n", - " loss = self.criterion(y_hat, y)\n", - " y_hat = y_hat > 0\n", - " if phase is not None:\n", - " y = y.to(torch.bool)\n", - " seg_metrics, cls_metrics = self._get_metrics(phase)\n", - "\n", - " for metric in seg_metrics.values():\n", - " metric.update(y_hat, y)\n", - " for metric in cls_metrics.values():\n", - " metric.update(y_hat, y)\n", - "\n", - " self.log(f\"{phase}/loss\", loss, prog_bar=True, on_epoch=True, on_step=False, batch_size=len(x))\n", - "\n", - " return loss\n", - "\n", - " def training_step(self,\n", - " batch: dict,\n", - " batch_idx):\n", - " \"\"\"Execute training step with loss calculation.\"\"\"\n", - " loss = self._run_step(batch, batch_idx, phase='train')\n", - " return loss\n", - "\n", - " def log(self, name, value, *args, log_to_datamint=False, **kwargs):\n", - " \"\"\"\n", - " Log metrics to both Lightning and optionally Datamint.\n", - " \n", - " Args:\n", - " name: Metric name\n", - " value: Metric value\n", - " log_to_datamint: Whether to also log to Datamint experiment\n", - " \"\"\"\n", - " super().log(name, value, *args, **kwargs)\n", - " if self.experiment is not None and log_to_datamint:\n", - " try:\n", - " if isinstance(value, torch.Tensor):\n", - " value = value.item()\n", - " self.experiment.log_metric(name, value)\n", - " except Exception as e:\n", - " print(f\"ERROR: Failed to log metric {name}: {e}\")\n", - "\n", - " def validation_step(self, batch: dict, batch_idx):\n", - " \"\"\"Execute validation step and update metrics.\"\"\"\n", - " self._run_step(batch, batch_idx, phase='val')\n", - "\n", - " def test_step(self, batch: dict, batch_idx):\n", - " \"\"\"Execute test step and update metrics.\"\"\"\n", - " self._run_step(batch, batch_idx, phase='test')\n", - "\n", - " def configure_optimizers(self):\n", - " \"\"\"Configure Adam optimizer with weight decay.\"\"\"\n", - " return torch.optim.AdamW(self.parameters(), lr=2e-4, weight_decay=1e-3)\n", - "\n", - " def _compute_metrics_end(self, phase: str):\n", - " \"\"\"\n", - " Compute and log all metrics at the end of an epoch.\n", - " \n", - " Args:\n", - " phase: Current phase ('train', 'val', or 'test')\n", - " \"\"\"\n", - " seg_metrics, cls_metrics = self._get_metrics(phase)\n", - " for name, metric in seg_metrics.items():\n", - " self.log(f\"{phase}/{name}\", metric.compute(), on_epoch=True, on_step=False, log_to_datamint=True)\n", - " metric.reset()\n", - " for name, metric in cls_metrics.items():\n", - " self.log(f\"{phase}/{name}\", metric.compute(), on_epoch=True, on_step=False, log_to_datamint=True)\n", - " metric.reset()\n", - "\n", - " def on_train_epoch_end(self):\n", - " \"\"\"Compute and log training metrics at epoch end.\"\"\"\n", - " self._compute_metrics_end('train')\n", - "\n", - " def on_validation_epoch_end(self):\n", - " \"\"\"Compute and log validation metrics at epoch end.\"\"\"\n", - " self._compute_metrics_end('val')\n", - "\n", - " def on_test_epoch_end(self):\n", - " \"\"\"Compute and log test metrics at epoch end.\"\"\"\n", - " self._compute_metrics_end('test')\n" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "# Initialize our model with the number of classes detected from the dataset\n", - "model = MyModel(num_classes=len(Dtrain.segmentation_labels_set),\n", - " experiment=exp)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Training\n", - "\n", - "We'll set up the training process with:\n", - "- Model checkpointing to save the best performing model\n", - "- GPU acceleration for faster training\n", - "- Validation after each epoch to monitor performance" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from lightning.pytorch.callbacks import ModelCheckpoint\n", - "\n", - "# Define checkpoint callback to save best models based on validation loss\n", - "checkpoint_callback = ModelCheckpoint(\n", - " monitor='val/loss', # Monitor validation loss\n", - " filename='boneseg-{epoch:02d}-{val/loss:.4f}',\n", - " save_top_k=1, # Save only the best model\n", - " mode='min', # Lower val loss is better\n", - " save_last=False, # Also save the last model\n", - " verbose=False,\n", - " auto_insert_metric_name=False,\n", - ")\n", - "\n", - "save_last_epoch = ModelCheckpoint()\n", - "\n", - "# Set up Lightning trainer with appropriate hardware acceleration\n", - "trainer = L.Trainer(\n", - " accelerator='gpu',\n", - " max_epochs=60,\n", - " callbacks=[checkpoint_callback, save_last_epoch],\n", - ")\n", - "\n", - "# Start model training\n", - "trainer.fit(model,\n", - " train_dataloaders=train_dataloader,\n", - " # Ideally, we should use be a separated validation set, but for simplicity we use the test set:\n", - " val_dataloaders=test_dataloader\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "LOCAL_RANK: 0 - CUDA_VISIBLE_DEVICES: [0]\n" - ] - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "8e3860822a324bea92a97ba9aeae7fcc", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "Testing: | | 0/? [00:00┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n", - "┃ Test metric DataLoader 0 ┃\n", - "┡━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━┩\n", - "│ test/Accuracy 0.7333333492279053 │\n", - "│ test/F1Score 0.6599999666213989 │\n", - "│ test/Precision 0.800000011920929 │\n", - "│ test/Recall 0.6000000238418579 │\n", - "│ test/Specificity 0.4000000059604645 │\n", - "│ test/iou 0.48983272910118103 │\n", - "│ test/loss 0.19067145884037018 │\n", - "└───────────────────────────┴───────────────────────────┘\n", - "\n" - ], - "text/plain": [ - "┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n", - "┃\u001b[1m \u001b[0m\u001b[1m Test metric \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1m DataLoader 0 \u001b[0m\u001b[1m \u001b[0m┃\n", - "┡━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━┩\n", - "│\u001b[36m \u001b[0m\u001b[36m test/Accuracy \u001b[0m\u001b[36m \u001b[0m│\u001b[35m \u001b[0m\u001b[35m 0.7333333492279053 \u001b[0m\u001b[35m \u001b[0m│\n", - "│\u001b[36m \u001b[0m\u001b[36m test/F1Score \u001b[0m\u001b[36m \u001b[0m│\u001b[35m \u001b[0m\u001b[35m 0.6599999666213989 \u001b[0m\u001b[35m \u001b[0m│\n", - "│\u001b[36m \u001b[0m\u001b[36m test/Precision \u001b[0m\u001b[36m \u001b[0m│\u001b[35m \u001b[0m\u001b[35m 0.800000011920929 \u001b[0m\u001b[35m \u001b[0m│\n", - "│\u001b[36m \u001b[0m\u001b[36m test/Recall \u001b[0m\u001b[36m \u001b[0m│\u001b[35m \u001b[0m\u001b[35m 0.6000000238418579 \u001b[0m\u001b[35m \u001b[0m│\n", - "│\u001b[36m \u001b[0m\u001b[36m test/Specificity \u001b[0m\u001b[36m \u001b[0m│\u001b[35m \u001b[0m\u001b[35m 0.4000000059604645 \u001b[0m\u001b[35m \u001b[0m│\n", - "│\u001b[36m \u001b[0m\u001b[36m test/iou \u001b[0m\u001b[36m \u001b[0m│\u001b[35m \u001b[0m\u001b[35m 0.48983272910118103 \u001b[0m\u001b[35m \u001b[0m│\n", - "│\u001b[36m \u001b[0m\u001b[36m test/loss \u001b[0m\u001b[36m \u001b[0m│\u001b[35m \u001b[0m\u001b[35m 0.19067145884037018 \u001b[0m\u001b[35m \u001b[0m│\n", - "└───────────────────────────┴───────────────────────────┘\n" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "[{'test/loss': 0.19067145884037018,\n", - " 'test/iou': 0.48983272910118103,\n", - " 'test/Recall': 0.6000000238418579,\n", - " 'test/Precision': 0.800000011920929,\n", - " 'test/F1Score': 0.6599999666213989,\n", - " 'test/Specificity': 0.4000000059604645,\n", - " 'test/Accuracy': 0.7333333492279053}]" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Run final evaluation on test dataset to get performance metrics\n", - "trainer.test(model, dataloaders=test_dataloader) " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'test/Sensitivity': 0.6000000238418579,\n", - " 'test/Positive Predictive Value': 0.800000011920929,\n", - " 'test/F1Score': 0.6599999666213989,\n", - " 'test/Accuracy': 0.7333333492279053}" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Display summary of all logged metrics from the experiment\n", - "exp.summary_log['metrics']" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Finalize the experiment to complete logging\n", - "exp.finish()" - ] - }, - { - "attachments": { - "image.png": { - "image/png": "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" - } - }, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "After finishing, we can check the experiment in the Datamint web app. The dashboard provides visual tracking of metrics.\n", - "\n", - "![image.png](attachment:image.png)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Predict\n", - "\n", - "Now that we have a trained model, let's generate predictions on the test dataset and visualize the results." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Generate predictions on the test dataset\n", - "predictions_batches: list[Tensor] = trainer.predict(model, test_dataloader)\n", - "# list of tensors. each tensor has shape (batch_size, #classes, H, W)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Visualize model predictions on test images\n", - "\n", - "k = 0\n", - "for prediction_batch in predictions_batches:\n", - " # prediction_batch is a tensor of shape (batch_size, #classes, H, W)\n", - " for prediction in prediction_batch:\n", - " item = Dtest[k]\n", - " image = item['image']\n", - "\n", - " segs = item['segmentations']\n", - "\n", - " # Show predicted segmentation masks overlaid on the image\n", - " prediction_mask = prediction[1:] == 1\n", - " image_with_prediction = draw_masks(image,\n", - " masks=prediction_mask)\n", - "\n", - " show(image_with_prediction)\n", - " k += 1" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.6" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} From 4eaf414cd950ea9238a3dfe33cfe2896ede06782 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 17 Mar 2026 08:38:19 -0300 Subject: [PATCH 18/47] Add image categories processing and validation strategies to dataset class --- datamint/api/endpoints/annotations_api.py | 2 +- datamint/dataset/annotation_processor.py | 117 ++++++++++++++++++++++ datamint/dataset/base.py | 72 ++++++++++++- 3 files changed, 186 insertions(+), 5 deletions(-) diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py index c7c969bf..17af4237 100644 --- a/datamint/api/endpoints/annotations_api.py +++ b/datamint/api/endpoints/annotations_api.py @@ -596,7 +596,7 @@ def upload_segmentations(self, # Handle NIfTI files specially - upload as single volume if isinstance(file_path, str) and (file_path.endswith('.nii') or file_path.endswith('.nii.gz')): - _LOGGER.info(f"Uploading NIfTI segmentation file: {file_path}") + _LOGGER.info("Uploading NIfTI segmentation file: %s", file_path) if frame_index is not None: raise ValueError("Do not provide frame_index for NIfTI segmentations.") diff --git a/datamint/dataset/annotation_processor.py b/datamint/dataset/annotation_processor.py index d6f85a4a..bc93a7f7 100644 --- a/datamint/dataset/annotation_processor.py +++ b/datamint/dataset/annotation_processor.py @@ -399,6 +399,123 @@ def convert_image_labels( return labels_by_user + def convert_image_categories( + self, + annotations: Sequence['Annotation'], + ) -> dict[str, torch.Tensor]: + """Convert image-level category annotations to class index tensors. + + For multiclass classification, we expect exclusively one valid category + per image (per user), representing the target class index in CrossEntropyLoss. + If multiple categories exist, the first one encountered is used. + + Args: + annotations: List of category annotations (image-scoped). + + Returns: + Dict of annotator_id -> 0-D long tensor containing the class index. + """ + category2code = self.image_lcodes.get('multiclass', {}) + + # If allow_external_annotations, discover unknown labels + if self.allow_external_annotations: + for ann in annotations: + if ann.annotation_type != 'category': + continue + key = (ann.identifier, ann.value) + if key not in category2code: + new_code = len(category2code) + category2code[key] = new_code + _LOGGER.info(f"Dynamically added category {key} with code {new_code}") + # Notice we don't strictly need to update self.images_labels_set as category2code tracks it + + categories_by_user: dict[str, torch.Tensor] = {} + + for ann in annotations: + if ann.annotation_type != 'category': + continue + + user_id = ann.created_by or "unknown" + if user_id in categories_by_user: + continue # already populated one category for this user + + key = (ann.identifier, ann.value) + code = category2code.get(key) + if code is not None: + categories_by_user[user_id] = torch.tensor(code, dtype=torch.long) + + return categories_by_user + + def merge_image_labels( + self, + labels_by_user: dict[str, Tensor], + strategy: MergeStrategy, + ) -> Tensor: + """Merge per-annotator label tensors into a single binary tensor. + + Args: + labels_by_user: Dict of annotator_id -> binary label tensor of shape (num_labels,). + strategy: One of 'union', 'intersection', or 'mode'. + + Returns: + Merged label tensor of shape (num_labels,), dtype int32. + """ + if not labels_by_user: + return torch.zeros(len(self.image_labels_set), dtype=torch.int32) + stacked = torch.stack(list(labels_by_user.values()), dim=0).float() # (num_users, num_labels) + if strategy == 'union': + return (stacked.max(dim=0).values > 0).int() + if strategy == 'intersection': + return (stacked.min(dim=0).values > 0).int() + if strategy == 'mode': + return (stacked.mean(dim=0) >= 0.5).int() + raise ValueError(f"Unknown merge strategy: {strategy!r}") + + @staticmethod + def merge_image_categories( + categories_by_user: dict[str, Tensor], + strategy: MergeStrategy, + num_categories: int, + ) -> Tensor: + """Merge per-annotator category tensors into a single tensor. + + For ``'mode'``, returns a scalar long tensor with the majority class index (-1 if empty). + For ``'union'`` and ``'intersection'``, returns a multi-hot int tensor of shape + ``(num_categories,)``. + + Args: + categories_by_user: Dict of annotator_id -> scalar long tensor (class index). + strategy: One of 'union', 'intersection', or 'mode'. + num_categories: Total number of (identifier, value) category classes. + + Returns: + Scalar long tensor for 'mode'; multi-hot int tensor for 'union'/'intersection'. + """ + if not categories_by_user: + if strategy == 'mode': + return torch.tensor(-1, dtype=torch.long) + return torch.zeros(num_categories, dtype=torch.int32) + + if strategy == 'mode': + valid = torch.stack([t for t in categories_by_user.values() if t.item() >= 0]) + if valid.numel() == 0: + return torch.tensor(-1, dtype=torch.long) + counts = torch.bincount(valid, minlength=max(num_categories, 1)) + return counts.argmax().long() + + user_hots = [] + for t in categories_by_user.values(): + h = torch.zeros(num_categories, dtype=torch.float32) + if t.item() >= 0: + h[t] = 1.0 + user_hots.append(h) + stacked = torch.stack(user_hots, dim=0) # (num_users, num_categories) + if strategy == 'union': + return (stacked.max(dim=0).values > 0).int() + if strategy == 'intersection': + return (stacked.min(dim=0).values > 0).int() + raise ValueError(f"Unknown merge strategy: {strategy!r}") + @overload def apply_merge_strategy( self, diff --git a/datamint/dataset/base.py b/datamint/dataset/base.py index c4d711d4..5c657472 100644 --- a/datamint/dataset/base.py +++ b/datamint/dataset/base.py @@ -94,7 +94,22 @@ def __init__( include_frame_label_names: list[str] | None = None, exclude_frame_label_names: list[str] | None = None, allow_external_annotations: bool = False, + image_labels_merge_strategy: MergeStrategy | None = None, + image_categories_merge_strategy: MergeStrategy | None = None, ): + # Validate merge strategy values + _valid_strategies = ('union', 'intersection', 'mode', None) + if image_labels_merge_strategy not in _valid_strategies: + raise ValueError( + f"image_labels_merge_strategy must be one of {_valid_strategies[:-1]!r}, " + f"got {image_labels_merge_strategy!r}" + ) + if image_categories_merge_strategy not in _valid_strategies: + raise ValueError( + f"image_categories_merge_strategy must be one of {_valid_strategies[:-1]!r}, " + f"got {image_categories_merge_strategy!r}" + ) + # Validate mutually exclusive parameters if project is not None and resources is not None: raise DatamintDatasetException( @@ -147,6 +162,8 @@ def __init__( self.include_frame_label_names = include_frame_label_names self.exclude_frame_label_names = exclude_frame_label_names self.allow_external_annotations = allow_external_annotations + self.image_labels_merge_strategy: MergeStrategy | None = image_labels_merge_strategy + self.image_categories_merge_strategy: MergeStrategy | None = image_categories_merge_strategy # Internal state self._logged_uint16_conversion = False @@ -247,19 +264,60 @@ def add_transform(self, alb_transform: 'BaseCompose') -> None: def _extract_image_labels( self, annotations: Sequence['Annotation'], - ) -> dict[str, torch.Tensor]: + ) -> dict[str, torch.Tensor] | torch.Tensor: """Extract image-level label annotations. + When ``image_labels_merge_strategy`` is ``None`` (default), returns a dict + mapping each annotator id to its binary label tensor of shape ``(num_labels,)``. + When a strategy is set, returns a single merged tensor of the same shape. + Args: annotations: All annotations for the item. Returns: - Dict of annotator_id -> label tensor. + Dict[annotator_id, Tensor] or merged Tensor depending on + :attr:`image_labels_merge_strategy`. """ label_annotations = AnnotationProcessor.filter_annotations( annotations, type='label', scope='image' ) - return self.annotation_processor.convert_image_labels(label_annotations) + labels_dict = self.annotation_processor.convert_image_labels(label_annotations) + if self.image_labels_merge_strategy is None: + return labels_dict + return self.annotation_processor.merge_image_labels( + labels_dict, self.image_labels_merge_strategy + ) + + def _extract_image_categories( + self, + annotations: Sequence['Annotation'], + ) -> dict[str, torch.Tensor] | torch.Tensor: + """Extract image-level category annotations. + + When ``image_categories_merge_strategy`` is ``None`` (default), returns a dict + mapping each annotator id to a scalar long tensor with the class index. + When a strategy is set: + - ``'mode'``: scalar long tensor (majority class index, -1 if no annotations). + - ``'union'``/``'intersection'``: multi-hot int tensor of shape ``(num_categories,)``. + + Args: + annotations: All annotations for the item. + + Returns: + Dict[annotator_id, Tensor] or merged Tensor depending on + :attr:`image_categories_merge_strategy`. + """ + category_annotations = AnnotationProcessor.filter_annotations( + annotations, type='category', scope='image' + ) + categories_dict = self.annotation_processor.convert_image_categories(category_annotations) + if self.image_categories_merge_strategy is None: + return categories_dict + return AnnotationProcessor.merge_image_categories( + categories_dict, + self.image_categories_merge_strategy, + num_categories=len(self.image_categories_set), + ) def _validate_filter_params( self, @@ -564,7 +622,12 @@ def frame_labels_set(self) -> list[str]: @property def image_labels_set(self) -> list[str]: """Image-level label names.""" - return self.image_lsets['multilabel'] + return self.image_lsets.get('multilabel', []) + + @property + def image_categories_set(self) -> list[tuple[str, str]]: + """Image-level classification category names/values.""" + return self.image_lsets.get('multiclass', []) @property def segmentation_labels_set(self) -> list[str]: @@ -719,6 +782,7 @@ def __getitem__(self, index: int) -> dict[str, Any]: # Process image-level labels result['image_labels'] = self._extract_image_labels(annotations) + result['image_categories'] = self._extract_image_categories(annotations) return result From 4a500a29cf8a777032c4653323322a700f899404 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 17 Mar 2026 08:38:35 -0300 Subject: [PATCH 19/47] Add CombinedLoss class to sum multiple loss functions --- datamint/utils/torchmetrics.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/datamint/utils/torchmetrics.py b/datamint/utils/torchmetrics.py index b8280126..a32833ba 100644 --- a/datamint/utils/torchmetrics.py +++ b/datamint/utils/torchmetrics.py @@ -1,3 +1,4 @@ +import torch from torchmetrics.classification import Recall, Precision, F1Score, Specificity from torchmetrics.wrappers.abstract import WrapperMetric import torchmetrics @@ -62,9 +63,26 @@ def transform_mask_to_binary(pred: Tensor, target: Tensor, iou_threshold: float return cls_pred, cls_target -# # test -# cls_metric = Recall(task="multilabel", average="macro", num_labels=3) -# metric = SegmentationToClassificationWrapper(cls_metric, iou_threshold=0.33) -# metric(preds=torch.randint(0, 2, size=(4, 3, 32, 32)), -# target=torch.randint(0, 2, size=(4, 3, 32, 32)) -# ) \ No newline at end of file +class CombinedLoss(torch.nn.Module): + """ + Combines multiple loss functions into a single loss by summing them up. + + Each loss can be specified as: + - A loss module: ``loss_fn`` + - A tuple of (loss module, weight): ``(loss_fn, weight)`` + + Args: + *losses: Loss functions to combine. Each can be a ``torch.nn.Module`` or a + ``(torch.nn.Module, float)`` tuple. + """ + + def __init__(self, *losses: torch.nn.Module | tuple[torch.nn.Module, float]): + super().__init__() + parsed = [(l[0], l[1]) if isinstance(l, tuple) else (l, 1.0) for l in losses] + self.losses, self.weights = zip(*parsed) + + def forward(self, preds: Tensor, target: Tensor) -> Tensor: + total_loss = 0.0 + for loss, weight in zip(self.losses, self.weights): + total_loss += weight * loss(preds, target) + return total_loss \ No newline at end of file From 8389beb6d5afa7b4ec6580f578606f5c6eea34fa Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 17 Mar 2026 08:39:39 -0300 Subject: [PATCH 20/47] Refactor DatamintModel: Extract model lifecycle management to LinkedModelLoader and implement prediction routing with PredictionRouter - Moved model lifecycle management logic from DatamintModel to LinkedModelLoader for better separation of concerns. - Introduced PredictionRouter to handle prediction mode dispatching, replacing the previous hardcoded method. - Updated DatamintModel to utilize the new LinkedModelLoader and PredictionRouter for improved maintainability and clarity. --- datamint/mlflow/flavors/model.py | 864 +++---------------- datamint/mlflow/flavors/model_loader.py | 156 ++++ datamint/mlflow/flavors/prediction_router.py | 198 +++++ 3 files changed, 462 insertions(+), 756 deletions(-) create mode 100644 datamint/mlflow/flavors/model_loader.py create mode 100644 datamint/mlflow/flavors/prediction_router.py diff --git a/datamint/mlflow/flavors/model.py b/datamint/mlflow/flavors/model.py index cc9f33a5..af760621 100644 --- a/datamint/mlflow/flavors/model.py +++ b/datamint/mlflow/flavors/model.py @@ -6,18 +6,15 @@ """ from typing import Any, TypeAlias -from collections.abc import Callable -from abc import ABC, abstractmethod +from abc import ABC from enum import Enum from dataclasses import dataclass -from mlflow.environment_variables import MLFLOW_DEFAULT_PREDICTION_DEVICE -from mlflow.pyfunc import load_model as pyfunc_load_model -from mlflow.pytorch import load_model as pytorch_load_model from mlflow.pyfunc import PyFuncModel, PythonModel, PythonModelContext from datamint.entities.annotations import Annotation from datamint.entities.resource import Resource +from datamint.mlflow.flavors.model_loader import LinkedModelLoader +from datamint.mlflow.flavors.prediction_router import PredictionRouter import logging -import os logger = logging.getLogger(__name__) @@ -85,794 +82,149 @@ class PredictionMode(str, Enum): class DatamintModel(ABC, PythonModel): - """ - Abstract adapter class for wrapping models to produce Datamint annotations. - - This class provides a flexible framework for integrating ML models with DataMint. - The main `predict()` method routes requests to specific handlers based on the - prediction mode, allowing users to implement only the modes they need. - - Quick Start: - ----------- - ```python - class MyModel(DatamintModel): - def __init__(self): - super().__init__( - mlflow_models_uri={'model': 'models:/MyModel/latest'}, - config=ModelSettings(need_gpu=True) - ) - - def predict_default(self, model_input, **kwargs): - # Access the device for your computation - device = self.inference_device # Reads from MLFLOW_DEFAULT_PREDICTION_DEVICE or defaults to 'cpu' - model = self.mlflow_models['model'].get_raw_model().to(device) - # ... process and return annotations - return predictions - ``` - - Prediction Modes: - ---------------- - Users can request different prediction modes via params['mode']: - - **Default**: Default processing - ```python - model.predict(resources) # or params={'mode': 'default'} - ``` - - **Video Frame**: Extract specific frame - ```python - model.predict(videos, params={'mode': 'frame', 'frame_index': 42}) - ``` - - **3D Slice**: Extract specific slice - ```python - model.predict(volumes, params={'mode': 'slice', 'slice_index': 50, 'axis': 'axial'}) - ``` - - **Interactive**: With prompts - ```python - model.predict(images, params={'mode': 'interactive', 'prompt': {'points': [[x, y]], 'labels': [1]}}) - ``` - - Common Parameters: - ----------------- - - `confidence_threshold` (float): Filter predictions by confidence score - - `batch_size` (int): Batch size for processing - - `render_annotation` (bool): Return annotated images instead of annotations - - Device Configuration: - -------------------- - The device for computation is automatically configured from the - `MLFLOW_DEFAULT_PREDICTION_DEVICE` environment variable. Access it via `self.inference_device`. - Defaults to 'cpu' if not set. - - Implementation Guide: - -------------------- - 1. Implement `predict_default()` - this is required and serves as fallback - 2. Optionally implement specific modes your model supports - 3. Override `_render_annotations()` if you want to support visualization - 4. Use `self.mlflow_models` to access loaded MLflow models - 5. Configure deployment settings via `ModelSettings` - - See individual method docstrings for detailed parameter specifications. + """Abstract adapter for wrapping ML models to produce Datamint annotations. + + Delegates model lifecycle to :class:`LinkedModelLoader` and prediction + dispatch to :class:`PredictionRouter`. Subclasses only need to override + ``predict_default`` (and optionally other ``predict_*`` hooks). + + Quick Start:: + + class MyModel(DatamintModel): + def __init__(self): + super().__init__( + mlflow_models_uri={'model': 'models:/MyModel/latest'}, + settings=ModelSettings(need_gpu=True), + ) + + def predict_default(self, model_input, **kwargs): + device = self.inference_device + model = self.get_mlflow_models()['model'].get_raw_model().to(device) + return predictions """ + # Keep for backward compat with subclass references LINKED_MODELS_DIR = "linked_models" - _CACHED_ATTRS = ['_mlflow_models', '_mlflow_torch_models', '_inference_device'] - def __init__(self, - settings: ModelSettings | dict[str, Any] | None = None, - mlflow_torch_models_uri: dict[str, str] | None = None, - mlflow_models_uri: dict[str, str] | None = None, - ) -> None: - """ - Initialize the DatamintModel adapter. - - Args: - config: ModelSettings instance or dict with deployment settings. - Example: {'need_gpu': True} - mlflow_torch_models_uri: Dictionary mapping model names to PyTorch model URIs. - Example: {'backbone': 'models:/MyClassifier/2'} - These models will be lazy-loaded and accessible via ``self.mlflow_torch_models_uri['backbone']`` - mlflow_models_uri: Dictionary mapping model names to MLflow URIs. - Example: {'detector': 'models:/MyDetector/1', - 'classifier': 'models:/MyClassifier/latest'} - These models will be lazy-loaded and accessible via ``self.mlflow_models['detector']`` - - """ + def __init__( + self, + settings: ModelSettings | dict[str, Any] | None = None, + mlflow_torch_models_uri: dict[str, str] | None = None, + mlflow_models_uri: dict[str, str] | None = None, + ) -> None: super().__init__() - self.mlflow_models_uri = (mlflow_models_uri or {}).copy() - self.mlflow_torch_models_uri = (mlflow_torch_models_uri or {}).copy() - - # Handle settings - convert dict to ModelSettings if needed + self._loader = LinkedModelLoader( + mlflow_models_uri=mlflow_models_uri, + mlflow_torch_models_uri=mlflow_torch_models_uri, + ) if isinstance(settings, dict): self.settings = ModelSettings.from_dict(settings) elif isinstance(settings, ModelSettings): self.settings = settings else: self.settings = ModelSettings() + self._router: PredictionRouter | None = None - self._supported_modes_cache = None - - def load_context(self, context: PythonModelContext): - """ - Called by MLflow when loading the model. - - Override this if you need custom loading logic. - """ - self._inference_device = self._load_inference_device(context=context) - self._mlflow_models = self._load_mlflow_models() - self._mlflow_torch_models = self._load_mlflow_torch_models() - # model_config = context.model_config - - def _get_linked_models_uri(self) -> dict[str, Any]: - """Get all linked models (MLflow and PyTorch)""" - linked = {} - linked.update(self.mlflow_models_uri) - linked.update(self.mlflow_torch_models_uri) - return linked - - def _clear_linked_models_cache(self): - """Clear loaded linked models to free memory""" - - for attr in self._CACHED_ATTRS: - if hasattr(self, attr): - delattr(self, attr) - - - def __getstate__(self): - state = self.__dict__.copy() - - for attr in self._CACHED_ATTRS: - if attr in state: - del state[attr] + # ------------------------------------------------------------------ + # Lifecycle (delegates to loader) + # ------------------------------------------------------------------ - return state - - def __setstate__(self, state): - self.__dict__.update(state) - # avoid possible invalid states after unpickling - self._clear_linked_models_cache() - - def _load_inference_device(self, context: PythonModelContext | None = None) -> str: - """ - Load inference device from model config or environment variable. - """ - import torch - - device = None - if context and context.model_config: - device = context.model_config.get("device", None) - logger.info(f"Model config device: {device}") - if device is None: - env_device = MLFLOW_DEFAULT_PREDICTION_DEVICE.get() - if env_device: - device = env_device - elif torch.cuda.is_available(): - device = 'cuda' - else: - device = 'cpu' - - logger.info(f"Set inference device: {device}") - return device + def load_context(self, context: PythonModelContext) -> None: + """Called by MLflow when loading the model.""" + self._loader.load_all(context) @property def inference_device(self) -> str: - if hasattr(self, '_inference_device') and self._inference_device is not None: - return self._inference_device - env_device = MLFLOW_DEFAULT_PREDICTION_DEVICE.get() - if env_device: - logger.info(f"Inference device not set; getting from environment variable ({env_device})") - return env_device - logger.warning("Inference device not set; defaulting to 'cpu'") - return 'cpu' - - def _load_models_generic(self, uris: dict[str, str], - loader_func: Callable, - **loader_kwargs) -> dict[str, Any]: - """Generic helper to load models from URIs.""" - loaded_models = {} - for name, uri in uris.items(): - model_uri = uri - if os.path.exists(uri): - logger.info(f"Model '{name}' found locally at '{uri}'") - model_uri = os.path.abspath(uri) - elif uri.startswith("models:/"): - local_path = uri.replace("models:/", DatamintModel.LINKED_MODELS_DIR + "/", 1) - if os.path.exists(local_path): - logger.info(f"Model '{name}' found locally at '{local_path}'") - model_uri = os.path.abspath(local_path) - - try: - loaded_models[name] = loader_func(model_uri, **loader_kwargs) - logger.info(f"Loaded model '{name}' from {model_uri}") - except Exception as e: - logger.error(f"Failed to load model '{name}' from {model_uri}: {e}") - raise - return loaded_models - - def _load_mlflow_models(self) -> dict[str, PyFuncModel]: - """Load all MLflow models specified in mlflow_models_uri.""" - return self._load_models_generic( - self.mlflow_models_uri, - pyfunc_load_model, - model_config={'device': self.inference_device} - ) - - def _load_mlflow_torch_models(self) -> dict[str, Any]: - """Load all MLflow PyTorch models specified in mlflow_torch_models_uri.""" - models = self._load_models_generic( - self.mlflow_torch_models_uri, - pytorch_load_model, - device=self.inference_device, - map_location=self.inference_device, - ) - for m in models.values(): - if hasattr(m, 'eval'): - m.eval() - return models + return self._loader.inference_device def get_mlflow_models(self) -> dict[str, PyFuncModel]: - """ - Access loaded MLflow models. - - Returns: - Dictionary mapping model names to PyFuncModel instances. - Use .get_raw_model() to access the underlying model (e.g., torch.nn.Module) - """ - if not hasattr(self, '_mlflow_models'): - logger.warning("Loading MLflow models on first access") - self._mlflow_models = self._load_mlflow_models() - return self._mlflow_models + """Access loaded MLflow models.""" + return self._loader.mlflow_models def get_mlflow_torch_models(self) -> dict[str, Any]: - """ - Access loaded MLflow PyTorch models. - - Returns: - Dictionary mapping model names to PyTorch model instances. - """ - if not hasattr(self, '_mlflow_torch_models'): - logger.warning("Loading MLflow PyTorch models on first access") - self._mlflow_torch_models = self._load_mlflow_torch_models() - return self._mlflow_torch_models - - # def _preprocess_input(self, - # model_input: list[InferenceResource | Resource | dict[str, Any]], - # params: dict[str, Any]) -> list[Resource]: - # """ - # Preprocess input to convert to list of Resource objects. - - # Args: - # model_input: List of InferenceResource, Resource, or dict - # params: Additional parameters (unused here) - # Returns: - # List of Resource objects - # """ - # resources = [] - # for item in model_input: - # if isinstance(item, Resource): - # resources.append(item) - # elif isinstance(item, InferenceResource): - # resources.append(item.fabricate_resource()) - # elif isinstance(item, dict): - # if 'local_filepath' in item or item.get('id', None) == '': - # logger.debug(f'Creating LocalResource from dict: {item}') - # resources.append(LocalResource(local_filepath=item['local_filepath'])) - # elif 'upload_channel' in item or 'location' in item or 'storage' in item: - # resources.append(Resource(**item)) - # else: - # resources.append(InferenceResource(**item).fabricate_resource()) - # else: - # raise ValueError(f"Unsupported input type: {type(item)}") - # return resources - - def predict(self, - model_input: list[Resource], - params: dict[str, Any] | None = None) -> PredictionResult: - """ - Main prediction entry point. - - Routes to appropriate prediction method based on params['mode']. - DO NOT override this method - implement specific predict_* methods instead. - - Args: - model_input: List of Resource objects to process - params: Optional configuration dictionary with keys: - - mode (str): Prediction mode (default: 'default') - - confidence_threshold (float): Filter by confidence - - batch_size (int): Batch size for processing - - render_annotation (bool): Return rendered images - - device (str): Computation device - + mode-specific parameters (see individual method docs) - - Returns: - List of annotation lists (one per resource), or rendered outputs - if render_annotation=True - - Raises: - ValueError: If mode is invalid or required parameters are missing - NotImplementedError: If requested mode is not implemented - """ - params = params or {} - # model_input = self._preprocess_input(model_input, params) - - # Parse and validate mode - mode = self._parse_mode(model_input=model_input, params=params) - logger.info(f"Received prediction request with {len(model_input)} resources and params {params} with mode '{mode.value}'") - - # Route to appropriate prediction method - try: - if not self._is_mode_implemented(mode): - if self._is_mode_implemented(PredictionMode.DEFAULT): - logger.info(f"Mode '{mode.value}' not implemented, falling back to default") - mode = PredictionMode.DEFAULT - else: - raise NotImplementedError - logger.info(f"Routing to '{mode.value}' mode for {len(model_input)} resources") - result = self._route_prediction(model_input, mode, params) - - # Apply common post-processing - result = self._post_process(result, model_input, params) - - return result - - except NotImplementedError: - available = self.get_supported_modes() - raise NotImplementedError( - f"Prediction mode '{mode.value}' is not supported by this model.\n" - f"Supported modes: {', '.join(available)}\n" - f"Implement predict_{mode.value}() to add support for this mode." - ) - - def _parse_mode(self, - params: dict[str, Any], - model_input: list[Resource] | None = None) -> PredictionMode: - """Parse and validate prediction mode from params.""" - mode_str = params.get('mode', PredictionMode.DEFAULT.value) - try: - is_all_image = all(res.mimetype.startswith('image/') for res in model_input) if model_input else False - except Exception: - is_all_image = False - - logger.debug(f"Parsing prediction mode: '{mode_str}' | {is_all_image=}") - - if mode_str == PredictionMode.DEFAULT.value and is_all_image: - mode_str = PredictionMode.IMAGE.value - - try: - return PredictionMode(mode_str) - except ValueError: - valid_modes = [m.value for m in PredictionMode] - raise ValueError( - f"Invalid prediction mode: '{mode_str}'\n" - f"Valid modes: {', '.join(valid_modes)}" - ) - - def _route_prediction(self, - model_input: list[Resource], - mode: PredictionMode, - params: dict[str, Any]) -> PredictionResult: - """Route to the appropriate prediction method based on mode.""" - - # Extract mode-specific parameters and remove from kwargs - mode_params, common_params = self._extract_mode_params(mode, params) - - # Dispatch to appropriate method - method = self._get_method_for_mode(mode) - - # if method is None or not self._is_mode_implemented(mode, method): - # raise NotImplementedError - - # Call with explicit parameters - return method(model_input, **mode_params, **common_params) - - def _extract_mode_params(self, mode: PredictionMode, params: dict[str, Any]) -> tuple[dict, dict]: - """ - Extract mode-specific and common parameters. + """Access loaded MLflow PyTorch models.""" + return self._loader.torch_models - Returns: - Tuple of (mode_specific_params, common_params) - """ - # Define mode-specific parameter mappings - mode_param_keys = { - PredictionMode.FRAME: ['frame_index'], - PredictionMode.FRAME_RANGE: ['start_frame', 'end_frame', 'step'], - PredictionMode.SLICE: ['slice_index', 'axis'], - PredictionMode.SLICE_RANGE: ['start_index', 'end_index', 'axis', 'step'], - PredictionMode.PRIMARY_SLICE: ['axis'], - PredictionMode.INTERACTIVE: ['prompt'], - PredictionMode.FEW_SHOT: ['context_resources', 'k'], - PredictionMode.TEMPORAL_SEQUENCE: ['center_frame', 'window_size'], - PredictionMode.IMAGE: [], - } - - reserved_keys = {'mode', 'confidence_threshold'} - - # Extract parameters - mode_specific = {} - common = {} - - mode_keys = set(mode_param_keys.get(mode, ())) - - for key, value in params.items(): - if key in reserved_keys: - continue # Skip mode itself and post-processing-only params - if key in mode_keys: - mode_specific[key] = value - else: - common[key] = value - - return mode_specific, common - - def _get_method_for_mode(self, mode: PredictionMode): - """Get the method corresponding to the given prediction mode.""" - method_name = f"predict_{mode.value}" - method = getattr(self, method_name, None) - return method + # ------------------------------------------------------------------ + # Backward-compat aliases + # ------------------------------------------------------------------ - def get_supported_modes(self) -> list[str]: - """ - Get list of prediction modes supported by this model. - - Returns: - List of mode names (strings) - """ - if self._supported_modes_cache is not None: - return self._supported_modes_cache - - supported = [] - for mode in PredictionMode: - if self._is_mode_implemented(mode): - supported.append(mode.value) - - self._supported_modes_cache = supported - return supported - - def _is_mode_implemented(self, mode: PredictionMode) -> bool: - """Determine whether the given mode has a concrete implementation.""" - method = self._get_method_for_mode(mode) - if method is None: - return False - - # Check if method is from DatamintModel base class (not overridden) - if hasattr(DatamintModel, method.__name__): - self._get_method_for_mode - base_method = getattr(DatamintModel, method.__name__) - # Method is implemented if it's not the same as base class method - return method.__func__ is not base_method - - return True - - def _post_process(self, - predictions: PredictionResult, - resources: list[Resource], - params: dict[str, Any]) -> PredictionResult: - """Apply common post-processing based on params.""" - - # Apply confidence threshold filtering - conf_threshold = params.get('confidence_threshold') - if conf_threshold is not None: - predictions = [ - [ann for ann in pred_list - if getattr(ann, 'confiability', 1.0) >= conf_threshold] - for pred_list in predictions - ] - logger.debug(f"Applied confidence threshold: {conf_threshold}") - - return predictions - - def predict_default(self, - model_input: list[Resource], - **kwargs) -> PredictionResult: - """ - **OPTIONAL**: Default prediction on entire resources. - - This is the default mode and serves as fallback for unimplemented modes. - Override this method to implement default prediction behavior. - If called without being overridden, raises NotImplementedError. + @property + def mlflow_models_uri(self) -> dict[str, str]: + return self._loader.mlflow_models_uri - Args: - model_input: Resources to process - **kwargs: Additional user-defined parameters + @mlflow_models_uri.setter + def mlflow_models_uri(self, value: dict[str, str]) -> None: + self._loader.mlflow_models_uri = value - Returns: - List of annotation lists, one per resource + @property + def mlflow_torch_models_uri(self) -> dict[str, str]: + return self._loader.mlflow_torch_models_uri - Example: - ```python - def predict_default(self, model_input, **kwargs): - dataset = MyDataset(model_input) - dataloader = DataLoader(dataset) - model = self.mlflow_models['model'].get_raw_model() + @mlflow_torch_models_uri.setter + def mlflow_torch_models_uri(self, value: dict[str, str]) -> None: + self._loader.mlflow_torch_models_uri = value - predictions = [] - for batch in dataloader: - outputs = model(batch) - predictions.extend(self._outputs_to_annotations(outputs)) + def _get_linked_models_uri(self) -> dict[str, Any]: + return self._loader.get_all_uris() - return predictions - ``` - """ - raise NotImplementedError( - "predict_default() must be implemented in your DatamintModel subclass. " - "This is the default fallback mode for prediction." - ) + def _clear_linked_models_cache(self) -> None: + self._loader.clear_cache() - # ======================================================================== - # VIDEO/TEMPORAL MODES - # ======================================================================== + # ------------------------------------------------------------------ + # Serialization + # ------------------------------------------------------------------ - def predict_frame(self, - model_input: list[Resource], - frame_index: int, - **kwargs) -> PredictionResult: - """ - Process specific frame from video resources. - - Args: - model_input: Video resources - frame_index: Index of frame to extract and process (0-based) - - Returns: - Annotations for the specified frame (one list per resource) - - Example: - ```python - # Extract frame 42 from multiple videos - predictions = model.predict( - videos, - params={'mode': 'frame', 'frame_index': 42} - ) - ``` - """ - logger.warning(f"predict_frame not implemented, falling back to predict_default") - return self.predict_default(model_input, **kwargs) - - def predict_frame_range(self, - model_input: list[Resource], - start_frame: int, - end_frame: int, - step: int = 1, - **kwargs) -> PredictionResult: - """ - Process range of frames from video resources. - - Args: - model_input: Video resources - start_frame: Start frame index (inclusive) - end_frame: End frame index (inclusive) - step: Step size between frames (default: 1) - - Returns: - Annotations for frames in range (may be frame-scoped annotations) - - Example: - ```python - # Process frames 0-100, every 10th frame - predictions = model.predict( - videos, - params={'mode': 'frame_range', - 'start_frame': 0, - 'end_frame': 100, - 'step': 10} - ) - ``` - """ - logger.warning(f"predict_frame_range not implemented, falling back to predict_default") - return self.predict_default(model_input, **kwargs) - - def predict_frame_interval(self, - model_input: list[Resource], - interval: int, - start_frame: int = 0, - end_frame: int | None = None, - **kwargs) -> PredictionResult: - """ - Process every nth frame from video resources. - - Args: - model_input: Video resources - interval: Process every nth frame - start_frame: Starting frame index - end_frame: Ending frame index (None = last frame) - - Returns: - Annotations for sampled frames - - Example: - ```python - # Process every 30th frame (1 fps for 30fps video) - predictions = model.predict( - videos, - params={'mode': 'frame_interval', 'interval': 30} - ) - ``` - """ - logger.warning(f"predict_frame_interval not implemented, falling back to predict_default") - return self.predict_default(model_input, **kwargs) + def __getstate__(self) -> dict: + state = self.__dict__.copy() + state.pop("_router", None) + return state - def predict_all_frames(self, - model_input: list[Resource], - **kwargs) -> PredictionResult: - """ - Process all frames independently. - - Args: - model_input: Video resources - - Returns: - Annotations for all frames (likely frame-scoped) - - Example: - ```python - # Analyze every frame - predictions = model.predict( - videos, - params={'mode': 'all_frames'} - ) - ``` - """ - logger.warning(f"predict_all_frames not implemented, falling back to predict_default") - return self.predict_default(model_input, **kwargs) - - # ======================================================================== - # 3D VOLUME MODES - # ======================================================================== - - def predict_slice(self, - model_input: list[Resource], - slice_index: int, - axis: str = 'axial', - **kwargs) -> PredictionResult: - """ - Process specific slice from 3D volume. - - Args: - model_input: 3D volume resources (DICOM series, NIfTI, etc.) - slice_index: Index of slice to extract - axis: Anatomical axis ('axial', 'sagittal', 'coronal') - - Returns: - Annotations for the specified slice - - Example: - ```python - # Extract and analyze axial slice 50 - predictions = model.predict( - ct_scans, - params={'mode': 'slice', - 'slice_index': 50, - 'axis': 'axial'} - ) - ``` - """ - logger.warning(f"predict_slice not implemented, falling back to predict_default") - return self.predict_default(model_input, **kwargs) - - def predict_slice_range(self, - model_input: list[Resource], - start_index: int, - end_index: int, - axis: str = 'axial', - step: int = 1, - **kwargs) -> PredictionResult: - """ - Process range of slices from 3D volume. + def __setstate__(self, state: dict) -> None: + self.__dict__.update(state) + self._router = None + self._loader.clear_cache() - Args: - model_input: 3D volume resources - start_index: Start slice index (inclusive) - end_index: End slice index (inclusive) - axis: Anatomical axis - step: Step size between slices + # ------------------------------------------------------------------ + # Prediction (delegates to router) + # ------------------------------------------------------------------ - Returns: - Annotations for slices in range - """ - logger.warning(f"predict_slice_range not implemented, falling back to predict_default") - return self.predict_default(model_input, **kwargs) + def predict( + self, + model_input: list[Resource], + params: dict[str, Any] | None = None, + ) -> PredictionResult: + """Main prediction entry point. - def predict_volume(self, - model_input: list[Resource], - **kwargs) -> PredictionResult: + Routes to the appropriate handler based on ``params['mode']``. + **Do not override** — implement ``predict_default`` (or other + ``predict_*`` hooks) instead. """ - Process entire 3D volume. - - For true 3D models (not slice-by-slice). - - Args: - model_input: 3D volume resources + if self._router is None: + self._router = PredictionRouter(self, DatamintModel) + return self._router.dispatch(model_input, params or {}) - Returns: - 3D annotations for entire volume - """ - logger.warning(f"predict_volume not implemented, falling back to predict_default") - return self.predict_default(model_input, **kwargs) + def get_supported_modes(self) -> list[str]: + """Get list of prediction modes supported by this model.""" + if self._router is None: + self._router = PredictionRouter(self, DatamintModel) + return self._router.supported_modes() - # ======================================================================== - # ADVANCED MODES - # ======================================================================== + # ------------------------------------------------------------------ + # The only overridable prediction hook in the base + # ------------------------------------------------------------------ - def predict_interactive(self, - model_input: list[Resource], - prompt: dict[str, Any], - **kwargs) -> PredictionResult: - """ - Interactive prediction with user prompts. - - For models like Segment Anything (SAM) that accept user guidance. - - Args: - model_input: Resources to process - prompt: Prompt dictionary with keys: - - 'points': list of [x, y] coordinates - - 'labels': list of labels (1=foreground, 0=background) - - 'boxes': list of [x1, y1, x2, y2] bounding boxes - - 'masks': list of binary mask arrays - - Returns: - Annotations based on prompts - - Example: - ```python - # Segment based on positive and negative points - predictions = model.predict( - images, - params={'mode': 'interactive', - 'prompt': { - 'points': [[100, 150], [200, 250]], - 'labels': [1, 0] # foreground, background - }} - ) - ``` - """ - logger.warning(f"predict_interactive not implemented, falling back to predict_default") - return self.predict_default(model_input, **kwargs) - - def predict_few_shot(self, - model_input: list[Resource], - context_resources: list[Resource], - k: int = 5, - **kwargs) -> PredictionResult: - """ - Few-shot prediction with context examples. - - For models that can adapt based on a few labeled examples. - - Args: - model_input: Resources to annotate - context_resources: Resources with existing annotations to use as examples - k: Number of examples to use (if more are provided) - - Returns: - Annotations informed by context examples - - Example: - ```python - # Predict using similar annotated examples - predictions = model.predict( - new_images, - params={'mode': 'few_shot', - 'context_resources': annotated_examples, - 'k': 3} - ) - ``` - """ - logger.warning(f"predict_few_shot not implemented, falling back to predict_default") - return self.predict_default(model_input, **kwargs) + def predict_default( + self, + model_input: list[Resource], + **kwargs: Any, + ) -> PredictionResult: + """Default prediction on entire resources. - def predict_image(self, - model_input: list[Resource], - **kwargs) -> PredictionResult: + Override this in your subclass. """ - Process single 2D image resources. + raise NotImplementedError( + "predict_default() must be implemented in your DatamintModel subclass." + ) - Args: - model_input: 2D image resources - Returns: - Annotations for each image - """ - logger.warning(f"predict_image not implemented, falling back to predict_default") - return self.predict_default(model_input, **kwargs) diff --git a/datamint/mlflow/flavors/model_loader.py b/datamint/mlflow/flavors/model_loader.py new file mode 100644 index 00000000..b2c0eb63 --- /dev/null +++ b/datamint/mlflow/flavors/model_loader.py @@ -0,0 +1,156 @@ +""" +Extracted model lifecycle management for DatamintModel. + +Owns URI resolution, lazy model loading, device detection, cache lifecycle, +and serialization — all previously interleaved in the monolithic DatamintModel. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Callable +from typing import Any + +from mlflow.environment_variables import MLFLOW_DEFAULT_PREDICTION_DEVICE +from mlflow.pyfunc import PyFuncModel +from mlflow.pyfunc import load_model as pyfunc_load_model +from mlflow.pyfunc.model import PythonModelContext +from mlflow.pytorch import load_model as pytorch_load_model + +_LOGGER = logging.getLogger(__name__) + +LINKED_MODELS_DIR = "linked_models" +_CACHED_ATTRS = frozenset({"_mlflow_models", "_mlflow_torch_models", "_inference_device"}) + + +class LinkedModelLoader: + """Owns URI resolution, lazy model loading, device management, and cache lifecycle. + + Extracted from DatamintModel so prediction routing and model lifecycle are independent. + """ + + def __init__( + self, + mlflow_models_uri: dict[str, str] | None = None, + mlflow_torch_models_uri: dict[str, str] | None = None, + ) -> None: + self.mlflow_models_uri: dict[str, str] = (mlflow_models_uri or {}).copy() + self.mlflow_torch_models_uri: dict[str, str] = (mlflow_torch_models_uri or {}).copy() + + # --- Device ----------------------------------------------------------- + + @property + def inference_device(self) -> str: + if hasattr(self, "_inference_device") and self._inference_device is not None: + return self._inference_device + env_device = MLFLOW_DEFAULT_PREDICTION_DEVICE.get() + if env_device: + _LOGGER.info("Inference device not set; getting from environment variable (%s)", env_device) + return env_device + _LOGGER.warning("Inference device not set; defaulting to 'cpu'") + return "cpu" + + def detect_device(self, context: PythonModelContext | None = None) -> str: + import torch + + device = None + if context and context.model_config: + device = context.model_config.get("device", None) + _LOGGER.info("Model config device: %s", device) + if device is None: + env_device = MLFLOW_DEFAULT_PREDICTION_DEVICE.get() + if env_device: + device = env_device + elif torch.cuda.is_available(): + device = "cuda" + else: + device = "cpu" + + _LOGGER.info("Set inference device: %s", device) + self._inference_device = device + return device + + # --- Loading ---------------------------------------------------------- + + def load_all(self, context: PythonModelContext | None = None) -> None: + self.detect_device(context) + self._mlflow_models = self._load_pyfunc_models() + self._mlflow_torch_models = self._load_torch_models() + + def _resolve_uri(self, uri: str) -> str: + if os.path.exists(uri): + return os.path.abspath(uri) + if uri.startswith("models:/"): + local = uri.replace("models:/", f"{LINKED_MODELS_DIR}/", 1) + if os.path.exists(local): + _LOGGER.info("Model found locally at '%s'", local) + return os.path.abspath(local) + return uri + + def _load_generic( + self, uris: dict[str, str], loader: Callable, **kwargs: Any + ) -> dict[str, Any]: + loaded: dict[str, Any] = {} + for name, uri in uris.items(): + resolved = self._resolve_uri(uri) + loaded[name] = loader(resolved, **kwargs) + _LOGGER.info("Loaded model '%s' from %s", name, resolved) + return loaded + + def _load_pyfunc_models(self) -> dict[str, PyFuncModel]: + return self._load_generic( + self.mlflow_models_uri, + pyfunc_load_model, + model_config={"device": self.inference_device}, + ) + + def _load_torch_models(self) -> dict[str, Any]: + models = self._load_generic( + self.mlflow_torch_models_uri, + pytorch_load_model, + device=self.inference_device, + map_location=self.inference_device, + ) + for m in models.values(): + if hasattr(m, "eval"): + m.eval() + return models + + # --- Access (lazy) ---------------------------------------------------- + + @property + def mlflow_models(self) -> dict[str, PyFuncModel]: + if not hasattr(self, "_mlflow_models"): + _LOGGER.warning("Loading MLflow models on first access") + self._mlflow_models = self._load_pyfunc_models() + return self._mlflow_models + + @property + def torch_models(self) -> dict[str, Any]: + if not hasattr(self, "_mlflow_torch_models"): + _LOGGER.warning("Loading MLflow PyTorch models on first access") + self._mlflow_torch_models = self._load_torch_models() + return self._mlflow_torch_models + + # --- Linked model URIs ------------------------------------------------ + + def get_all_uris(self) -> dict[str, str]: + return {**self.mlflow_models_uri, **self.mlflow_torch_models_uri} + + # --- Serialization ---------------------------------------------------- + + def clear_cache(self) -> None: + for attr in _CACHED_ATTRS: + if hasattr(self, attr): + delattr(self, attr) + + def __getstate__(self) -> dict: + state = self.__dict__.copy() + for attr in _CACHED_ATTRS: + state.pop(attr, None) + return state + + def __setstate__(self, state: dict) -> None: + self.__dict__.update(state) + self.clear_cache() diff --git a/datamint/mlflow/flavors/prediction_router.py b/datamint/mlflow/flavors/prediction_router.py new file mode 100644 index 00000000..419dccff --- /dev/null +++ b/datamint/mlflow/flavors/prediction_router.py @@ -0,0 +1,198 @@ +""" +Registry-driven prediction dispatcher and ``@prediction_mode`` decorator. + +Replaces the hardcoded ``mode_param_keys`` dict, stub-method pattern, +and fragile ``_is_mode_implemented`` introspection previously in DatamintModel. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from datamint.mlflow.flavors.model import PredictionMode + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class ModeSpec: + """Metadata for a registered prediction mode handler.""" + + mode: PredictionMode + param_keys: tuple[str, ...] = () + fallback_to_default: bool = True + + +def prediction_mode( + mode: PredictionMode, + *, + param_keys: tuple[str, ...] = (), + fallback_to_default: bool = True, +) -> Callable: + """Decorator that registers a method as a prediction mode handler. + + Usage:: + + class MyModel(DatamintModel): + @prediction_mode(PredictionMode.SLICE, param_keys=("slice_index", "axis")) + def predict_slice(self, model_input, *, slice_index, axis="axial", **kw): + ... + """ + + def decorator(fn: Callable) -> Callable: + fn._mode_spec = ModeSpec( # type: ignore[attr-defined] + mode=mode, + param_keys=param_keys, + fallback_to_default=fallback_to_default, + ) + return fn + + return decorator + + +class PredictionRouter: + """Registry-driven prediction dispatcher. + + Discovers mode handlers in two ways (in order of priority): + + 1. Methods decorated with ``@prediction_mode``. + 2. Convention-named methods ``predict_`` **not** defined on the + abstract base (backward compatibility with old-style overrides). + """ + + _RESERVED_PARAMS = frozenset({"mode", "confidence_threshold"}) + + def __init__(self, model_instance: Any, base_class: type) -> None: + from .model import PredictionMode # local import to avoid circular deps + + self._PredictionMode = PredictionMode + self._model = model_instance + self._base_class = base_class + self._registry: dict[PredictionMode, tuple[Callable, ModeSpec]] = {} + self._discover() + + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + + def _discover(self) -> None: + """Build the mode -> handler registry from the model instance.""" + PredictionMode = self._PredictionMode + + # Pass 1: decorator-based + for attr_name in dir(self._model): + if attr_name.startswith("_"): + continue + method = getattr(self._model, attr_name, None) + spec: ModeSpec | None = getattr(method, "_mode_spec", None) + if spec is not None: + self._registry[spec.mode] = (method, spec) + + # Pass 2: convention-named (backward compat), skip if already registered + for mode in PredictionMode: + if mode in self._registry: + continue + method_name = f"predict_{mode.value}" + method = getattr(self._model, method_name, None) + if method is None: + continue + # Skip if the method is the unoverridden base-class stub + base_method = getattr(self._base_class, method_name, None) + if base_method is not None and getattr(method, "__func__", None) is base_method: + continue + self._registry[mode] = (method, ModeSpec(mode=mode)) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def supported_modes(self) -> list[str]: + PredictionMode = self._PredictionMode + return [mode.value for mode in PredictionMode if mode in self._registry] + + def dispatch( + self, + model_input: list, + params: dict[str, Any], + ) -> list: + mode = self._resolve_mode(model_input, params) + _LOGGER.info( + "Received prediction request with %d resources and params %s with mode '%s'", + len(model_input), params, mode.value, + ) + handler, spec = self._get_handler(mode) + _LOGGER.info("Routing to '%s' mode for %d resources", mode.value, len(model_input)) + mode_kw, common_kw = self._split_params(params, spec) + result = handler(model_input, **mode_kw, **common_kw) + return self._post_process(result, params) + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _resolve_mode(self, model_input: list, params: dict) -> Any: + PredictionMode = self._PredictionMode + mode_str = params.get("mode", PredictionMode.DEFAULT.value) + try: + is_all_image = all( + getattr(r, "mimetype", "").startswith("image/") for r in model_input + ) + except Exception: + is_all_image = False + + _LOGGER.debug("Parsing prediction mode: '%s' | is_all_image=%s", mode_str, is_all_image) + + if mode_str == PredictionMode.DEFAULT.value and is_all_image: + mode_str = PredictionMode.IMAGE.value + try: + return PredictionMode(mode_str) + except ValueError: + valid = [m.value for m in PredictionMode] + raise ValueError( + f"Invalid prediction mode: '{mode_str}'\n" + f"Valid modes: {', '.join(valid)}" + ) + + def _get_handler(self, mode: Any) -> tuple[Callable, ModeSpec]: + PredictionMode = self._PredictionMode + if mode in self._registry: + return self._registry[mode] + if PredictionMode.DEFAULT in self._registry: + _LOGGER.info("Mode '%s' not implemented, falling back to default", mode.value) + return self._registry[PredictionMode.DEFAULT] + available = self.supported_modes() + raise NotImplementedError( + f"Prediction mode '{mode.value}' is not supported by this model.\n" + f"Supported modes: {', '.join(available)}\n" + f"Implement predict_{mode.value}() to add support for this mode." + ) + + def _split_params( + self, params: dict[str, Any], spec: ModeSpec + ) -> tuple[dict[str, Any], dict[str, Any]]: + mode_kw: dict[str, Any] = {} + common_kw: dict[str, Any] = {} + spec_keys = set(spec.param_keys) + for k, v in params.items(): + if k in self._RESERVED_PARAMS: + continue + if k in spec_keys: + mode_kw[k] = v + else: + common_kw[k] = v + return mode_kw, common_kw + + @staticmethod + def _post_process(result: list, params: dict[str, Any]) -> list: + threshold = params.get("confidence_threshold") + if threshold is not None: + result = [ + [a for a in preds if getattr(a, "confiability", 1.0) >= threshold] + for preds in result + ] + _LOGGER.debug("Applied confidence threshold: %s", threshold) + return result From 30cd14655c3c220596a6a69dcd4bffc2d861d037 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 18 Mar 2026 11:19:03 -0300 Subject: [PATCH 21/47] Add error handling for empty file content and improve logging in LocalResource --- datamint/api/base_api.py | 2 ++ datamint/entities/resource.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index c7f148e7..bc562ecb 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -720,6 +720,8 @@ def convert_format(bytes_array: bytes, ndata = nib.Nifti1Image.from_stream(f) ndata.get_fdata() # force loading before IO is closed return ndata + elif mimetype == 'application/x-empty': + raise ValueError("Empty file content.") raise ValueError(f"Unsupported mimetype: {mimetype}") diff --git a/datamint/entities/resource.py b/datamint/entities/resource.py index c3358a1e..ddb0d4e4 100644 --- a/datamint/entities/resource.py +++ b/datamint/entities/resource.py @@ -584,8 +584,8 @@ def fetch_file_data( mimetype=mimetype, file_path=local_filepath) except Exception as e: - logger.error(f"Failed to auto-convert local resource: {e}") - logger.error(e, exc_info=True) + logger.error(f"Failed to auto-convert local resource {self}: {e}") + raise return img_data From 92187dcccff019500b183c9c77d8899812447a0c Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 18 Mar 2026 11:20:46 -0300 Subject: [PATCH 22/47] fixed run_id not being passed to log_metrics --- datamint/mlflow/lightning/callbacks/modelcheckpoint.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/datamint/mlflow/lightning/callbacks/modelcheckpoint.py b/datamint/mlflow/lightning/callbacks/modelcheckpoint.py index ddd63cdc..a86773ab 100644 --- a/datamint/mlflow/lightning/callbacks/modelcheckpoint.py +++ b/datamint/mlflow/lightning/callbacks/modelcheckpoint.py @@ -407,6 +407,11 @@ def _log_test_metrics_to_model(self, trainer: L.Trainer) -> None: _LOGGER.debug("No model_id available. Skipping model metrics logging.") return + logger = _get_MLFlowLogger(trainer) + if logger is None or logger.run_id is None: + _LOGGER.warning("No MLFlowLogger run_id found. Skipping model metrics logging.") + return + metrics: dict[str, float] = {} for key, value in trainer.callback_metrics.items(): if not key.startswith(("test/", "test_")): @@ -421,7 +426,7 @@ def _log_test_metrics_to_model(self, trainer: L.Trainer) -> None: return try: - mlflow.log_metrics(metrics, model_id=self._last_model_id) + mlflow.log_metrics(metrics, model_id=self._last_model_id, run_id=logger.run_id) _LOGGER.info(f"Logged {len(metrics)} test metrics to model {self._last_model_id}.") except Exception as e: _LOGGER.warning(f"Failed to log test metrics to model: {e}") From a0bc8d4199318a0312b1b20146417cacd209fabb Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 18 Mar 2026 11:24:31 -0300 Subject: [PATCH 23/47] Fixed saving unnecessary transforms as hyperparameters --- datamint/lightning/datamodule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datamint/lightning/datamodule.py b/datamint/lightning/datamodule.py index a343c414..554de374 100644 --- a/datamint/lightning/datamodule.py +++ b/datamint/lightning/datamodule.py @@ -91,7 +91,7 @@ def __init__( eval_transform: Callable | None = None, ) -> None: super().__init__() - self.save_hyperparameters(ignore=["dataset"]) + self.save_hyperparameters(ignore=["dataset", "train_transform", "eval_transform"]) self._dataset = dataset self._batch_size = batch_size From a8887654b98dc88a6ef42918b1f2a1513e4874e2 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 18 Mar 2026 12:03:03 -0300 Subject: [PATCH 24/47] Refactor tutorial notebook: Update dataset loading section and remove redundant explanations --- .../segmentation_2d-unetpp_BUSI_tutorial.ipynb | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/notebooks/use_cases/segmentation_2d-unetpp_BUSI_tutorial.ipynb b/notebooks/use_cases/segmentation_2d-unetpp_BUSI_tutorial.ipynb index cee1952c..1615eb7f 100644 --- a/notebooks/use_cases/segmentation_2d-unetpp_BUSI_tutorial.ipynb +++ b/notebooks/use_cases/segmentation_2d-unetpp_BUSI_tutorial.ipynb @@ -357,12 +357,7 @@ "id": "1d6fb528", "metadata": {}, "source": [ - "## 3. Custom PyTorch Dataset\n", - "\n", - "In this section, we'll build a PyTorch Dataset that:\n", - "- Fetches images and segmentation masks from Datamint\n", - "- Applies normalization\n", - "- Uses Albumentations for data augmentation" + "## 3. Loading your Dataset\n" ] }, { @@ -447,12 +442,7 @@ "id": "04637e73", "metadata": {}, "source": [ - "### 3.2 Implement Custom PyTorch Dataset\n", - "\n", - "The `MedicalSegmentationDataset` class handles:\n", - "- Loading images from Datamint\n", - "- Applying preprocessing and augmentation\n", - "- Returning image-mask pairs for training" + "### 3.2 Declare the Dataset\n" ] }, { From b42383257fbb105e49a93e29736a156efa827419 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 18 Mar 2026 12:04:22 -0300 Subject: [PATCH 25/47] Trainer modules --- datamint/lightning/__init__.py | 20 +- datamint/lightning/datamodule.py | 1 + datamint/lightning/trainers/__init__.py | 17 ++ datamint/lightning/trainers/base_trainer.py | 282 ++++++++++++++++++ .../trainers/classification_trainer.py | 115 +++++++ .../trainers/lightning_modules/__init__.py | 4 + .../classification_module.py | 99 ++++++ .../lightning_modules/segmentation_module.py | 107 +++++++ datamint/lightning/trainers/seg2d_trainer.py | 197 ++++++++++++ datamint/lightning/trainers/seg3d_trainer.py | 101 +++++++ .../trainers/segmentation_trainer.py | 57 ++++ pyproject.toml | 2 +- 12 files changed, 1000 insertions(+), 2 deletions(-) create mode 100644 datamint/lightning/trainers/__init__.py create mode 100644 datamint/lightning/trainers/base_trainer.py create mode 100644 datamint/lightning/trainers/classification_trainer.py create mode 100644 datamint/lightning/trainers/lightning_modules/__init__.py create mode 100644 datamint/lightning/trainers/lightning_modules/classification_module.py create mode 100644 datamint/lightning/trainers/lightning_modules/segmentation_module.py create mode 100644 datamint/lightning/trainers/seg2d_trainer.py create mode 100644 datamint/lightning/trainers/seg3d_trainer.py create mode 100644 datamint/lightning/trainers/segmentation_trainer.py diff --git a/datamint/lightning/__init__.py b/datamint/lightning/__init__.py index 3ebfdba2..d1df3069 100644 --- a/datamint/lightning/__init__.py +++ b/datamint/lightning/__init__.py @@ -1,5 +1,23 @@ """Datamint Lightning integration.""" from .datamodule import DatamintDataModule +from .trainers import ( + BaseTrainer, + ClassificationTrainer, + ImageClassificationTrainer, + SemanticSegmentation2DTrainer, + SemanticSegmentation3DTrainer, + SegmentationTrainer, + UNetPPTrainer, +) -__all__ = ["DatamintDataModule"] +__all__ = [ + "DatamintDataModule", + "BaseTrainer", + "ClassificationTrainer", + "ImageClassificationTrainer", + "SemanticSegmentation2DTrainer", + "SemanticSegmentation3DTrainer", + "SegmentationTrainer", + "UNetPPTrainer", +] diff --git a/datamint/lightning/datamodule.py b/datamint/lightning/datamodule.py index 554de374..a3e14d79 100644 --- a/datamint/lightning/datamodule.py +++ b/datamint/lightning/datamodule.py @@ -91,6 +91,7 @@ def __init__( eval_transform: Callable | None = None, ) -> None: super().__init__() + # TODO: save the transforms as strings in the hyperparameters self.save_hyperparameters(ignore=["dataset", "train_transform", "eval_transform"]) self._dataset = dataset diff --git a/datamint/lightning/trainers/__init__.py b/datamint/lightning/trainers/__init__.py new file mode 100644 index 00000000..9fac6b07 --- /dev/null +++ b/datamint/lightning/trainers/__init__.py @@ -0,0 +1,17 @@ +"""Specialized trainers for end-to-end Datamint workflows.""" + +from .base_trainer import BaseTrainer +from .segmentation_trainer import SegmentationTrainer +from .seg2d_trainer import SemanticSegmentation2DTrainer, UNetPPTrainer +from .seg3d_trainer import SemanticSegmentation3DTrainer +from .classification_trainer import ClassificationTrainer, ImageClassificationTrainer + +__all__ = [ + "BaseTrainer", + "SegmentationTrainer", + "SemanticSegmentation2DTrainer", + "SemanticSegmentation3DTrainer", + "UNetPPTrainer", + "ClassificationTrainer", + "ImageClassificationTrainer", +] diff --git a/datamint/lightning/trainers/base_trainer.py b/datamint/lightning/trainers/base_trainer.py new file mode 100644 index 00000000..ff4002ac --- /dev/null +++ b/datamint/lightning/trainers/base_trainer.py @@ -0,0 +1,282 @@ +"""Base trainer abstraction for Datamint training workflows. + +Defines the :class:`BaseTrainer` template that orchestrates the full +pipeline: dataset → datamodule → model → Lightning Trainer → MLflow → deploy. +""" +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from typing import Any, TYPE_CHECKING +from functools import cached_property + +import lightning as L +from torch import nn + +from datamint.dataset.base import DatamintBaseDataset +from datamint.lightning.datamodule import DatamintDataModule + +if TYPE_CHECKING: + from albumentations import BaseCompose + from datamint.entities import Project + +_LOGGER = logging.getLogger(__name__) + + +class BaseTrainer(ABC): + """Abstract base trainer encapsulating an end-to-end training workflow. + + Subclasses provide task-specific defaults for model architecture, + transforms, loss, and metrics by overriding the ``_build_*`` / + ``_default_*`` hooks. Users typically only need to specify a + ``project`` (or ``dataset``) and optionally override a few settings. + + Args: + dataset: A pre-built :class:`DatamintBaseDataset`. Mutually + exclusive with *project*. + project: Project name or :class:`Project` object used to + auto-build a dataset when *dataset* is ``None``. + model: A user-provided :class:`~lightning.LightningModule`. + When ``None`` the trainer builds a default one via + :meth:`_build_default_model`. + loss_fn: Custom loss function forwarded to the default model. + Ignored when *model* is provided (the user's module owns + its own loss). + batch_size: Training batch size. + num_workers: DataLoader workers. + train_transform: Albumentations transform for training. When + ``None`` the trainer uses :meth:`_default_train_transform`. + eval_transform: Albumentations transform for val/test. When + ``None`` the trainer uses :meth:`_default_eval_transform`. + image_size: Target image size ``(H, W)`` or a single int for + square images. Forwarded to default transforms. When + ``None`` a sensible default is chosen. + max_epochs: Maximum number of training epochs. + early_stopping_patience: Epochs without improvement before + stopping. Set to ``None`` to disable early stopping. + mlflow_experiment_name: MLflow experiment name. Auto-generated + from the project name when ``None``. + register_model_name: Name for MLflow Model Registry. + Auto-generated when ``None``. + auto_deploy_adapter: When ``True``, auto-generate a + :class:`~datamint.mlflow.flavors.model.DatamintModel` + adapter after training. + trainer_kwargs: Extra keyword arguments forwarded to + :class:`lightning.Trainer`. + """ + + def __init__( + self, + dataset: DatamintBaseDataset | None = None, + project: 'str | Project | None' = None, + *, + model: L.LightningModule | None = None, + loss_fn: nn.Module | None = None, + batch_size: int = 16, + num_workers: int = 4, + train_transform: 'BaseCompose | None' = None, + eval_transform: 'BaseCompose | None' = None, + image_size: int | tuple[int, int] | None = None, + max_epochs: int = 50, + early_stopping_patience: int | None = 10, + mlflow_experiment_name: str | None = None, + register_model_name: str | None = None, + auto_deploy_adapter: bool = True, + trainer_kwargs: dict[str, Any] | None = None, + ) -> None: + if dataset is None and project is None: + raise ValueError("Either 'dataset' or 'project' must be provided.") + if dataset is not None and project is not None: + raise ValueError("'dataset' and 'project' are mutually exclusive.") + + self._user_dataset = dataset + self._user_project = project + self._user_model = model + self._loss_fn = loss_fn + self.batch_size = batch_size + self.num_workers = num_workers + self._user_train_transform = train_transform + self._user_eval_transform = eval_transform + if image_size is None: + self.image_size: tuple[int, int] = (256, 256) + elif isinstance(image_size, int): + self.image_size = (image_size, image_size) + else: + self.image_size = image_size + self.max_epochs = max_epochs + self.early_stopping_patience = early_stopping_patience + self.mlflow_experiment_name = mlflow_experiment_name + self.register_model_name = register_model_name + self.auto_deploy_adapter = auto_deploy_adapter + self.trainer_kwargs = trainer_kwargs or {} + + # Populated during fit() + self._datamodule: DatamintDataModule | None = None + self._model: L.LightningModule | None = None + self._lightning_trainer: L.Trainer | None = None + + @cached_property + def dataset(self) -> DatamintBaseDataset: + return self._resolve_dataset() + + # ── Public API ────────────────────────────────────────────── + def fit(self) -> dict[str, Any]: + """Run the full training pipeline. + + Returns: + Dictionary with keys ``'trainer'``, ``'model'``, + ``'test_results'``, and ``'adapter'`` (when + *auto_deploy_adapter* is enabled). + """ + # 1. Resolve dataset + self.dataset = self._resolve_dataset() + + # 2. Build transforms + train_tf = self._user_train_transform or self._default_train_transform() + eval_tf = self._user_eval_transform or self._default_eval_transform() + + # 3. Build DataModule + self._datamodule = self._build_datamodule(self.dataset, train_tf, eval_tf) + + # 4. Build model + if self._user_model is not None: + self._model = self._user_model + else: + loss = self._loss_fn or self._default_loss() + metrics = self._default_metrics() + self._model = self._build_default_model(loss, metrics) + + # 5. Build callbacks & logger + callbacks = self._build_callbacks() + logger = self._build_logger() + + # 6. Build Lightning Trainer + self._lightning_trainer = L.Trainer( + max_epochs=self.max_epochs, + logger=logger, + callbacks=callbacks, + accelerator='auto', + **self.trainer_kwargs, + ) + + # 7. Train + self._lightning_trainer.fit(self._model, datamodule=self._datamodule) + + # 8. Test + test_results = self._lightning_trainer.test(datamodule=self._datamodule) + + # 9. Deploy adapter + adapter = None + if self.auto_deploy_adapter: + adapter = self._build_deploy_adapter() + + return { + 'trainer': self._lightning_trainer, + 'model': self._model, + 'test_results': test_results, + 'adapter': adapter, + } + + # ── Template hooks (subclasses override these) ────────────── + + @abstractmethod + def _build_default_dataset(self, project: 'str | Project') -> DatamintBaseDataset: + """Build the appropriate dataset for this task.""" + ... + + @abstractmethod + def _build_default_model( + self, + loss_fn: nn.Module, + metrics: dict[str, Any], + ) -> L.LightningModule: + """Build the default LightningModule for this task.""" + ... + + @abstractmethod + def _default_train_transform(self) -> 'BaseCompose': + """Return the default training augmentation pipeline.""" + ... + + @abstractmethod + def _default_eval_transform(self) -> 'BaseCompose': + """Return the default eval/test transform pipeline.""" + ... + + @abstractmethod + def _default_loss(self) -> nn.Module: + """Return the default loss function for this task.""" + ... + + @abstractmethod + def _default_metrics(self) -> dict[str, Any]: + """Return default metrics as ``{name: factory_callable}``.""" + ... + + @abstractmethod + def _monitor_metric(self) -> tuple[str, str]: + """Return ``(metric_name, mode)`` for checkpointing / early stopping.""" + ... + + def _build_deploy_adapter(self) -> Any: + """Build a DatamintModel deployment adapter. Override in subclasses.""" + return None + + # ── Concrete helpers ──────────────────────────────────────── + + def _resolve_dataset(self) -> DatamintBaseDataset: + if self._user_dataset is not None: + return self._user_dataset + assert self._user_project is not None # guaranteed by __init__ validation + return self._build_default_dataset(self._user_project) + + def _build_datamodule( + self, + dataset: DatamintBaseDataset, + train_transform: 'BaseCompose', + eval_transform: 'BaseCompose', + ) -> DatamintDataModule: + return DatamintDataModule( + dataset, + batch_size=self.batch_size, + num_workers=self.num_workers, + train_transform=train_transform, + eval_transform=eval_transform, + ) + + def _build_callbacks(self) -> list: + from datamint.mlflow.lightning.callbacks import MLFlowModelCheckpoint + from lightning.pytorch.callbacks import EarlyStopping + + metric_name, mode = self._monitor_metric() + project_name = self.dataset.project.name if self.dataset.project else 'datamint' + model_name = self.register_model_name or project_name + + callbacks: list = [ + MLFlowModelCheckpoint( + monitor=metric_name, + mode=mode, + save_top_k=1, + register_model_name=model_name, + register_model_on='test', + ), + ] + + if self.early_stopping_patience is not None: + callbacks.append(EarlyStopping( + monitor=metric_name, + mode=mode, + patience=self.early_stopping_patience, + )) + + return callbacks + + def _build_logger(self): + from lightning.pytorch.loggers import MLFlowLogger + from datamint.mlflow import set_project + + project_name = self.dataset.project.name if self.dataset.project else 'datamint' + set_project(project_name) + + experiment_name = self.mlflow_experiment_name or f"{project_name}_training" + return MLFlowLogger(experiment_name=experiment_name) diff --git a/datamint/lightning/trainers/classification_trainer.py b/datamint/lightning/trainers/classification_trainer.py new file mode 100644 index 00000000..4d17be27 --- /dev/null +++ b/datamint/lightning/trainers/classification_trainer.py @@ -0,0 +1,115 @@ +"""Image classification trainers.""" +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +import lightning as L +from torch import nn + +from datamint.dataset import ImageDataset + +from .lightning_modules import ClassificationModule +from .base_trainer import BaseTrainer + +if TYPE_CHECKING: + from albumentations import BaseCompose + from datamint.entities import Project + +class ClassificationTrainer(BaseTrainer): + """Abstract trainer for classification tasks. + + Provides shared defaults: + + * **Loss** – :class:`~torch.nn.CrossEntropyLoss`. + * **Metrics** – Multiclass Accuracy and macro F1 (torchmetrics). + * **Monitor** – ``val/accuracy`` (maximise). + """ + + def _default_loss(self) -> nn.Module: + return nn.CrossEntropyLoss() + + def _default_metrics(self) -> dict[str, Any]: + from torchmetrics.classification import MulticlassAccuracy, MulticlassF1Score + + nc = len(self.dataset.image_categories_set) + return { + 'accuracy': lambda: MulticlassAccuracy(num_classes=nc), + 'f1': lambda: MulticlassF1Score(num_classes=nc, average='macro'), + } + + def _monitor_metric(self) -> tuple[str, str]: + return 'val/accuracy', 'max' + + +class ImageClassificationTrainer(ClassificationTrainer): + """Trainer for image classification tasks. + + Default model: **ResNet-34** (via ``timm``) pretrained on ImageNet. + + Args: + model_name: ``timm`` model name. Defaults to ``'resnet34'``. + pretrained: Use pretrained weights. Defaults to ``True``. + + Example:: + + trainer = ImageClassificationTrainer(project='ChestXray') + results = trainer.fit() + """ + + def __init__( + self, + *, + model_name: str = 'resnet34', + pretrained: bool = True, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self.model_name = model_name + self.pretrained = pretrained + + # ── Template hooks ────────────────────────────────────────── + + def _build_default_dataset(self, project: 'str | Project') -> ImageDataset: + return ImageDataset( + project=project, + return_segmentations=False, + include_unannotated=False, + image_categories_merge_strategy='mode', + ) + + def _build_default_model( + self, + loss_fn: nn.Module, + metrics: dict[str, Any], + ) -> L.LightningModule: + return ClassificationModule( + model_name=self.model_name, + num_classes=len(self.dataset.image_categories_set), + loss_fn=loss_fn, + metrics_factories=metrics, + pretrained=self.pretrained, + ) + + def _default_train_transform(self) -> 'BaseCompose': + import albumentations as A + from albumentations.pytorch import ToTensorV2 + + h, w = self.image_size + return A.Compose([ + A.Resize(h, w), + A.HorizontalFlip(p=0.5), + A.RandomBrightnessContrast(p=0.3), + A.Normalize(), + ToTensorV2(), + ]) + + def _default_eval_transform(self) -> 'BaseCompose': + import albumentations as A + from albumentations.pytorch import ToTensorV2 + + h, w = self.image_size + return A.Compose([ + A.Resize(h, w), + A.Normalize(), + ToTensorV2(), + ]) diff --git a/datamint/lightning/trainers/lightning_modules/__init__.py b/datamint/lightning/trainers/lightning_modules/__init__.py new file mode 100644 index 00000000..2b90bce4 --- /dev/null +++ b/datamint/lightning/trainers/lightning_modules/__init__.py @@ -0,0 +1,4 @@ +from .segmentation_module import SegmentationModule +from .classification_module import ClassificationModule + +__all__ = ["SegmentationModule", "ClassificationModule"] diff --git a/datamint/lightning/trainers/lightning_modules/classification_module.py b/datamint/lightning/trainers/lightning_modules/classification_module.py new file mode 100644 index 00000000..8ef4403f --- /dev/null +++ b/datamint/lightning/trainers/lightning_modules/classification_module.py @@ -0,0 +1,99 @@ +"""LightningModule wrapper for image classification tasks.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import lightning as L +import torch +from torch import Tensor, nn + + +class ClassificationModule(L.LightningModule): + """Generic image classification module backed by ``timm``. + + Args: + model_name: ``timm`` model name (e.g. ``'resnet34'``, ``'efficientnet_b0'``). + num_classes: Number of output classes. + loss_fn: Loss module. + metrics_factories: ``{name: callable}`` – see :class:`SegmentationModule`. + lr: Learning rate for AdamW. + pretrained: Use pretrained weights. + """ + + def __init__( + self, + model_name: str, + num_classes: int, + loss_fn: nn.Module, + metrics_factories: dict[str, Callable[[], Any]], + lr: float = 1e-4, + pretrained: bool = True, + ) -> None: + super().__init__() + self.save_hyperparameters(ignore=['loss_fn', 'metrics_factories']) + + import timm + + self.model = timm.create_model( + model_name, + pretrained=pretrained, + num_classes=num_classes, + ) + self.criterion = loss_fn + + self._metric_names = list(metrics_factories.keys()) + for stage in ('train', 'val', 'test'): + for name, factory in metrics_factories.items(): + self.add_module(f'{stage}_{name}', factory()) + + def forward(self, x: Tensor) -> Tensor: + return self.model(x) + + def _common_step(self, batch: dict, stage: str) -> Tensor: + images = batch['image'] + labels = batch['image_categories'] + logits = self(images) + loss = self.criterion(logits, labels) + + preds = logits.argmax(dim=1) + for name in self._metric_names: + getattr(self, f'{stage}_{name}').update(preds, labels) + + self.log( + f'{stage}/loss', loss, + on_step=(stage == 'train'), on_epoch=True, + prog_bar=True, batch_size=len(images), + ) + return loss + + def _on_epoch_end(self, stage: str) -> None: + for i, name in enumerate(self._metric_names): + metric = getattr(self, f'{stage}_{name}') + self.log(f'{stage}/{name}', metric.compute(), prog_bar=(i == 0)) + metric.reset() + + def training_step(self, batch: dict, batch_idx: int) -> Tensor: + return self._common_step(batch, 'train') + + def validation_step(self, batch: dict, batch_idx: int) -> Tensor: + return self._common_step(batch, 'val') + + def test_step(self, batch: dict, batch_idx: int) -> Tensor: + return self._common_step(batch, 'test') + + def on_train_epoch_end(self) -> None: + self._on_epoch_end('train') + + def on_validation_epoch_end(self) -> None: + self._on_epoch_end('val') + + def on_test_epoch_end(self) -> None: + self._on_epoch_end('test') + + def configure_optimizers(self): + return torch.optim.AdamW( + self.parameters(), + lr=self.hparams['lr'], + weight_decay=1e-4, + ) diff --git a/datamint/lightning/trainers/lightning_modules/segmentation_module.py b/datamint/lightning/trainers/lightning_modules/segmentation_module.py new file mode 100644 index 00000000..e3229c61 --- /dev/null +++ b/datamint/lightning/trainers/lightning_modules/segmentation_module.py @@ -0,0 +1,107 @@ +"""LightningModule wrapper for segmentation tasks.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import lightning as L +import torch +from torch import Tensor, nn + + +class SegmentationModule(L.LightningModule): + """Generic segmentation module backed by ``segmentation_models_pytorch``. + + Args: + arch: SMP architecture name (e.g. ``'UnetPlusPlus'``, ``'DeepLabV3Plus'``). + encoder_name: Backbone encoder (e.g. ``'resnet34'``). + in_channels: Number of input channels. + num_classes: Number of segmentation classes **excluding** background. + loss_fn: Loss module. + metrics_factories: ``{name: callable}`` where each callable returns a + fresh :class:`torchmetrics.Metric`. One instance is created per + stage (train / val / test). + lr: Learning rate for AdamW. + """ + + def __init__( + self, + arch: str, + encoder_name: str, + in_channels: int, + num_classes: int, + loss_fn: nn.Module, + metrics_factories: dict[str, Callable[[], Any]], + lr: float = 1e-4, + ) -> None: + super().__init__() + self.save_hyperparameters(ignore=['loss_fn', 'metrics_factories']) + + import segmentation_models_pytorch as smp + + arch_cls = getattr(smp, arch) + self.model = arch_cls( + encoder_name=encoder_name, + encoder_weights='imagenet', + in_channels=in_channels, + classes=num_classes, + ) + self.criterion = loss_fn + + # Create per-stage metrics + self._metric_names = list(metrics_factories.keys()) + for stage in ('train', 'val', 'test'): + for name, factory in metrics_factories.items(): + self.add_module(f'{stage}_{name}', factory()) + + def forward(self, x: Tensor) -> Tensor: + return self.model(x) + + def _common_step(self, batch: dict, stage: str) -> Tensor: + images = batch['image'] + masks = batch['segmentations'][:, 1:] # exclude background channel + + logits = self(images) + loss = self.criterion(logits, masks) + preds = (logits > 0).long() + + for name in self._metric_names: + getattr(self, f'{stage}_{name}').update(preds, masks.long()) + + self.log( + f'{stage}/loss', loss, + on_step=(stage == 'train'), on_epoch=True, + prog_bar=True, batch_size=len(images), + ) + return loss + + def _on_epoch_end(self, stage: str) -> None: + for i, name in enumerate(self._metric_names): + metric = getattr(self, f'{stage}_{name}') + self.log(f'{stage}/{name}', metric.compute(), prog_bar=(i == 0)) + metric.reset() + + def training_step(self, batch: dict, batch_idx: int) -> Tensor: + return self._common_step(batch, 'train') + + def validation_step(self, batch: dict, batch_idx: int) -> Tensor: + return self._common_step(batch, 'val') + + def test_step(self, batch: dict, batch_idx: int) -> Tensor: + return self._common_step(batch, 'test') + + def on_train_epoch_end(self) -> None: + self._on_epoch_end('train') + + def on_validation_epoch_end(self) -> None: + self._on_epoch_end('val') + + def on_test_epoch_end(self) -> None: + self._on_epoch_end('test') + + def configure_optimizers(self): + return torch.optim.AdamW( + self.parameters(), + lr=self.hparams['lr'], + weight_decay=1e-4, + ) diff --git a/datamint/lightning/trainers/seg2d_trainer.py b/datamint/lightning/trainers/seg2d_trainer.py new file mode 100644 index 00000000..a130340c --- /dev/null +++ b/datamint/lightning/trainers/seg2d_trainer.py @@ -0,0 +1,197 @@ +"""2-D semantic segmentation trainer.""" +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +import lightning as L +from torch import nn + +from datamint.dataset import ImageDataset + +from .lightning_modules import SegmentationModule +from .segmentation_trainer import SegmentationTrainer + +if TYPE_CHECKING: + from albumentations import BaseCompose + from datamint.entities import Project + +class SemanticSegmentation2DTrainer(SegmentationTrainer): + """Trainer for 2-D semantic segmentation. + + Default model: **UNet++** (``segmentation_models_pytorch``) with a + ``resnet34`` encoder pretrained on ImageNet. + + Args: + encoder_name: SMP encoder backbone. Defaults to ``'resnet34'``. + in_channels: Number of input image channels. Defaults to ``3``. + All remaining keyword arguments are forwarded to + :class:`~datamint.lightning.trainers.base_trainer.BaseTrainer`. + + Example:: + + trainer = SemanticSegmentation2DTrainer(project='BUS_Segmentation') + results = trainer.fit() + """ + + def __init__( + self, + *, + encoder_name: str = 'resnet34', + in_channels: int = 3, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self.encoder_name = encoder_name + self.in_channels = in_channels + + # ── Template hooks ────────────────────────────────────────── + + def _build_default_dataset(self, project: 'str | Project') -> ImageDataset: + return ImageDataset( + project=project, + return_as_semantic_segmentation=True, + semantic_seg_merge_strategy='union', + allow_external_annotations=True, + include_unannotated=False, + ) + + def _build_default_model( + self, + loss_fn: nn.Module, + metrics: dict[str, Any], + ) -> L.LightningModule: + return SegmentationModule( + arch='UnetPlusPlus', + encoder_name=self.encoder_name, + in_channels=self.in_channels, + num_classes=len(self.dataset.seglabel_list), + loss_fn=loss_fn, + metrics_factories=metrics, + ) + + def _default_train_transform(self) -> 'BaseCompose': + import albumentations as A + from albumentations.pytorch import ToTensorV2 + + h, w = self.image_size + return A.Compose([ + A.Resize(h, w), + A.HorizontalFlip(p=0.5), + A.VerticalFlip(p=0.5), + A.RandomBrightnessContrast(p=0.5), + A.Normalize(), # Imagenet stats is the default + ToTensorV2(), + ]) + + def _default_eval_transform(self) -> 'BaseCompose': + import albumentations as A + from albumentations.pytorch import ToTensorV2 + + h, w = self.image_size + return A.Compose([ + A.Resize(h, w), + A.Normalize(), + ToTensorV2(), + ]) + + def _build_deploy_adapter(self) -> Any: + from datamint.mlflow.flavors.model import DatamintModel + from datamint.mlflow.flavors import datamint_flavor + from datamint.entities.annotations import ImageSegmentation + import mlflow + + class _SegAdapter(DatamintModel): + """Auto-generated adapter for a trained segmentation model.""" + + def __init__(self, model_name: str, class_names: list[str], image_size: tuple[int, int]): + super().__init__( + mlflow_torch_models_uri={'model': f'models:/{model_name}/latest'}, + settings={'need_gpu': True}, + ) + self._class_names = class_names + self._image_size = image_size + + def predict_default(self, model_input, **kwargs): + import cv2 + import numpy as np + import torch + import albumentations as A + from albumentations.pytorch import ToTensorV2 + + model = self.get_mlflow_torch_models()['model'] + model.eval() + fabric = L.Fabric(accelerator=self.inference_device) + model = fabric.setup_module(model) + + transform = A.Compose([ + A.Resize(*self._image_size), + A.Normalize(), + ToTensorV2(), + ]) + + all_preds: list[list] = [] + with torch.inference_mode(): + for res in model_input: + image = np.array(res.fetch_file_data(auto_convert=True, use_cache=True)) + oh, ow = image.shape[:2] + tensor = transform(image=image)['image'].to(fabric.device) + logits = model(tensor.unsqueeze(0)) + pred = (logits[0] > 0).cpu().numpy().astype(np.uint8) + + anns: list = [] + for i, name in enumerate(self._class_names): + mask = cv2.resize( + pred[i], (ow, oh), + interpolation=cv2.INTER_NEAREST, + ) * 255 + if mask.any(): + anns.append(ImageSegmentation(name=name, mask=mask)) + all_preds.append(anns) + return all_preds + + project_name = self.dataset.project.name if self.dataset.project else 'datamint' + model_name = self.register_model_name or project_name + adapter = _SegAdapter(model_name, list(self.dataset.seglabel_list), self.image_size) + + experiment_name = self.mlflow_experiment_name or f"{project_name}_training" + mlflow.set_experiment(f'{experiment_name}_deployment') + with mlflow.start_run(run_name='auto_adapter'): + datamint_flavor.log_model( + adapter, + registered_model_name=f'{model_name}_adapted', + ) + + return adapter + + +class UNetPPTrainer(SemanticSegmentation2DTrainer): + """Convenience trainer pre-configured for UNet++ with stronger augmentations. + + Adds elastic transform and grid distortion to the default training + pipeline — augmentations that are particularly effective for medical + image segmentation. + + Example:: + + trainer = UNetPPTrainer( + project='BUS_Segmentation', + encoder_name='efficientnet-b4', + ) + results = trainer.fit() + """ + + def _default_train_transform(self) -> 'BaseCompose': + import albumentations as A + from albumentations.pytorch import ToTensorV2 + + h, w = self.image_size + return A.Compose([ + A.Resize(h, w), + A.HorizontalFlip(p=0.5), + A.VerticalFlip(p=0.5), + A.ElasticTransform(alpha=50, sigma=5, p=0.3), + A.GridDistortion(num_steps=5, distort_limit=0.2, p=0.3), + A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5), + A.Normalize(), + ToTensorV2(), + ]) diff --git a/datamint/lightning/trainers/seg3d_trainer.py b/datamint/lightning/trainers/seg3d_trainer.py new file mode 100644 index 00000000..c2a633bf --- /dev/null +++ b/datamint/lightning/trainers/seg3d_trainer.py @@ -0,0 +1,101 @@ +"""3-D semantic segmentation trainer (slice-based).""" +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +import lightning as L +from torch import nn + +from datamint.dataset import VolumeDataset + +from .lightning_modules import SegmentationModule +from .segmentation_trainer import SegmentationTrainer + +if TYPE_CHECKING: + from albumentations import BaseCompose + from datamint.entities import Project + +class SemanticSegmentation3DTrainer(SegmentationTrainer): + """Trainer for 3-D semantic segmentation via per-slice 2-D training. + + Builds a :class:`~datamint.dataset.VolumeDataset`, slices it along + the chosen axis, and trains a 2-D segmentation model on individual + slices. + + Args: + slice_axis: Slicing axis — ``'axial'``, ``'sagittal'``, + ``'coronal'``, or an integer axis index. + encoder_name: SMP encoder backbone. + in_channels: Number of input channels. + + Example:: + + trainer = SemanticSegmentation3DTrainer( + project='CT_Liver', + slice_axis='axial', + ) + results = trainer.fit() + """ + + def __init__( + self, + *, + slice_axis: str | int = 'axial', + encoder_name: str = 'resnet34', + in_channels: int = 3, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self.slice_axis = slice_axis + self.encoder_name = encoder_name + self.in_channels = in_channels + + # ── Template hooks ────────────────────────────────────────── + + def _build_default_dataset(self, project: 'str | Project'): + vol_ds = VolumeDataset( + project=project, + return_as_semantic_segmentation=True, + semantic_seg_merge_strategy='union', + allow_external_annotations=True, + include_unannotated=False, + ) + return vol_ds.slice(axis=self.slice_axis) + + def _build_default_model( + self, + loss_fn: nn.Module, + metrics: dict[str, Any], + ) -> L.LightningModule: + return SegmentationModule( + arch='UnetPlusPlus', + encoder_name=self.encoder_name, + in_channels=self.in_channels, + num_classes=len(self.dataset.seglabel_list), + loss_fn=loss_fn, + metrics_factories=metrics, + ) + + def _default_train_transform(self) -> 'BaseCompose': + import albumentations as A + from albumentations.pytorch import ToTensorV2 + + h, w = self.image_size + return A.Compose([ + A.Resize(h, w), + A.HorizontalFlip(p=0.5), + A.RandomBrightnessContrast(p=0.3), + A.Normalize(), # Imagenet stats is the default + ToTensorV2(), + ]) + + def _default_eval_transform(self) -> 'BaseCompose': + import albumentations as A + from albumentations.pytorch import ToTensorV2 + + h, w = self.image_size + return A.Compose([ + A.Resize(h, w), + A.Normalize(), + ToTensorV2(), + ]) diff --git a/datamint/lightning/trainers/segmentation_trainer.py b/datamint/lightning/trainers/segmentation_trainer.py new file mode 100644 index 00000000..7e5c6e22 --- /dev/null +++ b/datamint/lightning/trainers/segmentation_trainer.py @@ -0,0 +1,57 @@ +"""Shared base for segmentation trainers (2-D and 3-D).""" +from __future__ import annotations + +from typing import Any + +import torch +import torch.nn.functional as F +from torch import nn + +from .base_trainer import BaseTrainer + + +class _BCEDiceLoss(nn.Module): + """Combined binary-cross-entropy-with-logits + soft Dice loss. + + Operates on multi-label masks where each class channel is independent. + + Expects: + pred: ``(B, C, H, W)`` logits + target: ``(B, C, H, W)`` binary masks (float) + """ + + def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + target = target.float() + bce = F.binary_cross_entropy_with_logits(pred, target) + probs = torch.sigmoid(pred) + dims = (0, 2, 3) + intersection = (probs * target).sum(dim=dims) + cardinality = probs.sum(dim=dims) + target.sum(dim=dims) + dice_per_class = (2.0 * intersection + 1e-6) / (cardinality + 1e-6) + return bce + (1.0 - dice_per_class.mean()) + + +class SegmentationTrainer(BaseTrainer): + """Abstract trainer for segmentation tasks. + + Provides shared defaults: + + * **Loss** – combined BCE + Dice (:class:`_BCEDiceLoss`). + * **Metrics** – Mean IoU and Generalised Dice Score (torchmetrics). + * **Monitor** – ``val/iou`` (maximise). + """ + + def _default_loss(self) -> nn.Module: + return _BCEDiceLoss() + + def _default_metrics(self) -> dict[str, Any]: + from torchmetrics.segmentation import GeneralizedDiceScore, MeanIoU + + num_classes = len(self.dataset.seglabel_list) + return { + 'iou': lambda: MeanIoU(num_classes=num_classes, input_format='one-hot'), + 'dice': lambda: GeneralizedDiceScore(num_classes=num_classes, input_format='one-hot'), + } + + def _monitor_metric(self) -> tuple[str, str]: + return 'val/iou', 'max' diff --git a/pyproject.toml b/pyproject.toml index 99e1e19b..ae945996 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ Deprecated = ">=1.2.0" platformdirs = "^4.0.0" pandas = ">=2.0.0" matplotlib = "*" -lightning = { extras = ['extra'], version = ">=2.0.0, !=2.5.1, !=2.5.1.post0" } +lightning = { extras = ['extra'], version = ">=2.5.6" } mlflow-skinny = "==3.8.1" Flask = { version = "<4" } Flask-Cors = { version = "<7" } From a2aa892f38358828553cc7d3c2843aa330e567b2 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 25 Mar 2026 15:29:03 -0300 Subject: [PATCH 26/47] Refactor model handling and prediction routing in Datamint - Introduced BaseDatamintModel to encapsulate common functionality for MLflow models. - Added PredictionMode enumeration for better management of prediction modes. - Enhanced LinkedModelLoader to separate device management from model loading. - Updated DatamintModel to utilize the new base class and improved loading context. - Implemented a new prediction router for dynamic dispatching based on prediction modes. - Refactored MLFlowModelCheckpoint to support multiple flavors and improved signature inference. - Added MLFlowDatamintModelCheckpoint for specific integration with Datamint models. - Updated version to 2.12.0a0 in pyproject.toml. --- datamint/api/endpoints/deploy_model_api.py | 10 +- datamint/api/endpoints/inference_api.py | 4 +- datamint/api/entity_base_api.py | 10 +- datamint/lightning/trainers/base_trainer.py | 82 +++-- .../trainers/classification_trainer.py | 20 +- .../trainers/lightning_modules/__init__.py | 4 +- .../trainers/lightning_modules/base.py | 31 ++ .../classification_module.py | 37 +- .../lightning_modules/segmentation_module.py | 78 ++++- .../segmentation_modules/__init__.py | 4 + .../segmentation_modules/smp_module.py | 32 ++ .../segmentation_modules/unetpp.py | 25 ++ datamint/lightning/trainers/seg2d_trainer.py | 124 ++----- datamint/lightning/trainers/seg3d_trainer.py | 10 +- .../trainers/segmentation_trainer.py | 10 +- datamint/mlflow/__init__.py | 6 +- datamint/mlflow/flavors/datamint_flavor.py | 169 +++++---- datamint/mlflow/flavors/model.py | 329 ++++++++++++------ datamint/mlflow/flavors/model_loader.py | 77 ++-- datamint/mlflow/flavors/prediction_modes.py | 42 +++ datamint/mlflow/flavors/prediction_router.py | 83 +++-- .../mlflow/lightning/callbacks/__init__.py | 6 +- .../lightning/callbacks/modelcheckpoint.py | 324 ++++++++++------- pyproject.toml | 2 +- 24 files changed, 969 insertions(+), 550 deletions(-) create mode 100644 datamint/lightning/trainers/lightning_modules/base.py create mode 100644 datamint/lightning/trainers/lightning_modules/segmentation_modules/__init__.py create mode 100644 datamint/lightning/trainers/lightning_modules/segmentation_modules/smp_module.py create mode 100644 datamint/lightning/trainers/lightning_modules/segmentation_modules/unetpp.py create mode 100644 datamint/mlflow/flavors/prediction_modes.py diff --git a/datamint/api/endpoints/deploy_model_api.py b/datamint/api/endpoints/deploy_model_api.py index 91979f38..55b6178c 100644 --- a/datamint/api/endpoints/deploy_model_api.py +++ b/datamint/api/endpoints/deploy_model_api.py @@ -1,4 +1,6 @@ import httpx + +from datamint.exceptions import ResourceNotFoundError from ..entity_base_api import EntityBaseApi, ApiConfig from datamint.entities.deployjob import DeployJob @@ -16,7 +18,13 @@ def get_by_id(self, entity_id: str) -> DeployJob: data = response.json() if 'job_id' in data: data['id'] = data.pop('job_id') - return self._init_entity_obj(**data) + self._validate_uuid(data['id']) + try: + return self._init_entity_obj(**data) + except ResourceNotFoundError as e: + e.resource_type = 'DeployJob' + e.params = {'id': entity_id} + raise def start(self, model_name: str, diff --git a/datamint/api/endpoints/inference_api.py b/datamint/api/endpoints/inference_api.py index 9382f7ed..a6ac8d1e 100644 --- a/datamint/api/endpoints/inference_api.py +++ b/datamint/api/endpoints/inference_api.py @@ -392,4 +392,6 @@ def predict_volume( ) response = self._make_request('POST', f'/{self.endpoint_base}/predict-volume', json=payload) data = response.json() - return self.get_status(data['job_id']) \ No newline at end of file + return self.get_status(data['job_id']) + + predict = submit # Alias for generic prediction endpoint \ No newline at end of file diff --git a/datamint/api/entity_base_api.py b/datamint/api/entity_base_api.py index 97f7fd63..54033211 100644 --- a/datamint/api/entity_base_api.py +++ b/datamint/api/entity_base_api.py @@ -170,6 +170,12 @@ def get_all(self, limit: int | None = None) -> Sequence[T]: """ return self.get_list(limit=limit) + @staticmethod + def _validate_uuid(entity_id: str) -> None: + if not _UUID_PATTERN.match(entity_id): + raise ValueError(f"Invalid entity ID format: {entity_id!r}. Expected a UUID " + f"(e.g. '1b9f74fe-278e-48a9-82f4-5c3a6fcf2c50').") + def get_by_id(self, entity_id: str) -> T: """Get a specific entity by its ID. @@ -185,9 +191,7 @@ def get_by_id(self, entity_id: str) -> T: ResourceNotFoundError: If the entity is not found. httpx.HTTPStatusError: If the request fails for other reasons. """ - if not _UUID_PATTERN.match(entity_id): - raise ValueError(f"Invalid entity ID format: {entity_id!r}. Expected a UUID " - f"(e.g. '1b9f74fe-278e-48a9-82f4-5c3a6fcf2c50').") + self._validate_uuid(entity_id) response = self._make_entity_request('GET', entity_id) return self._init_entity_obj(**response.json()) diff --git a/datamint/lightning/trainers/base_trainer.py b/datamint/lightning/trainers/base_trainer.py index ff4002ac..7d4939f0 100644 --- a/datamint/lightning/trainers/base_trainer.py +++ b/datamint/lightning/trainers/base_trainer.py @@ -7,6 +7,7 @@ import logging from abc import ABC, abstractmethod +from collections.abc import Callable from typing import Any, TYPE_CHECKING from functools import cached_property @@ -38,16 +39,16 @@ class BaseTrainer(ABC): auto-build a dataset when *dataset* is ``None``. model: A user-provided :class:`~lightning.LightningModule`. When ``None`` the trainer builds a default one via - :meth:`_build_default_model`. + :meth:`_build_model`. loss_fn: Custom loss function forwarded to the default model. Ignored when *model* is provided (the user's module owns its own loss). batch_size: Training batch size. num_workers: DataLoader workers. train_transform: Albumentations transform for training. When - ``None`` the trainer uses :meth:`_default_train_transform`. + ``None`` the trainer uses :meth:`_train_transform`. eval_transform: Albumentations transform for val/test. When - ``None`` the trainer uses :meth:`_default_eval_transform`. + ``None`` the trainer uses :meth:`_eval_transform`. image_size: Target image size ``(H, W)`` or a single int for square images. Forwarded to default transforms. When ``None`` a sensible default is chosen. @@ -112,7 +113,6 @@ def __init__( # Populated during fit() self._datamodule: DatamintDataModule | None = None - self._model: L.LightningModule | None = None self._lightning_trainer: L.Trainer | None = None @cached_property @@ -128,23 +128,22 @@ def fit(self) -> dict[str, Any]: ``'test_results'``, and ``'adapter'`` (when *auto_deploy_adapter* is enabled). """ - # 1. Resolve dataset - self.dataset = self._resolve_dataset() + # clear cached properties in case of multiple calls to fit() + for attr in ('dataset', 'model'): + if attr in self.__dict__: + del self.__dict__[attr] + # 1. Resolve dataset (triggers @cached_property) + _ = self.dataset # 2. Build transforms - train_tf = self._user_train_transform or self._default_train_transform() - eval_tf = self._user_eval_transform or self._default_eval_transform() + train_tf = self._user_train_transform or self._train_transform() + eval_tf = self._user_eval_transform or self._eval_transform() # 3. Build DataModule self._datamodule = self._build_datamodule(self.dataset, train_tf, eval_tf) # 4. Build model - if self._user_model is not None: - self._model = self._user_model - else: - loss = self._loss_fn or self._default_loss() - metrics = self._default_metrics() - self._model = self._build_default_model(loss, metrics) + _ = self.model # triggers @cached_property to build the model # 5. Build callbacks & logger callbacks = self._build_callbacks() @@ -160,19 +159,20 @@ def fit(self) -> dict[str, Any]: ) # 7. Train - self._lightning_trainer.fit(self._model, datamodule=self._datamodule) + self._lightning_trainer.fit(self.model, datamodule=self._datamodule) # 8. Test test_results = self._lightning_trainer.test(datamodule=self._datamodule) - # 9. Deploy adapter + # 9. Deploy adapter (only needed when the model is not already a DatamintModel) + from datamint.mlflow.flavors.model import BaseDatamintModel adapter = None - if self.auto_deploy_adapter: + if self.auto_deploy_adapter and not isinstance(self.model, BaseDatamintModel): adapter = self._build_deploy_adapter() return { 'trainer': self._lightning_trainer, - 'model': self._model, + 'model': self.model, 'test_results': test_results, 'adapter': adapter, } @@ -180,37 +180,46 @@ def fit(self) -> dict[str, Any]: # ── Template hooks (subclasses override these) ────────────── @abstractmethod - def _build_default_dataset(self, project: 'str | Project') -> DatamintBaseDataset: + def _build_dataset(self, project: 'str | Project') -> DatamintBaseDataset: """Build the appropriate dataset for this task.""" ... + @cached_property + def model(self) -> L.LightningModule: + if self._user_model is not None: + return self._user_model + else: + loss = self._loss_fn or self._loss() + metrics = self._metrics() + return self._build_model(loss, metrics) + @abstractmethod - def _build_default_model( + def _build_model( self, loss_fn: nn.Module, - metrics: dict[str, Any], + metrics: dict[str, Callable], ) -> L.LightningModule: """Build the default LightningModule for this task.""" ... @abstractmethod - def _default_train_transform(self) -> 'BaseCompose': - """Return the default training augmentation pipeline.""" + def _train_transform(self) -> 'BaseCompose': + """Return the training augmentation pipeline.""" ... @abstractmethod - def _default_eval_transform(self) -> 'BaseCompose': - """Return the default eval/test transform pipeline.""" + def _eval_transform(self) -> 'BaseCompose': + """Return the eval/test transform pipeline.""" ... @abstractmethod - def _default_loss(self) -> nn.Module: - """Return the default loss function for this task.""" + def _loss(self) -> nn.Module: + """Return the loss function for this task.""" ... @abstractmethod - def _default_metrics(self) -> dict[str, Any]: - """Return default metrics as ``{name: factory_callable}``.""" + def _metrics(self) -> dict[str, Callable]: + """Return metrics as ``{name: factory_callable}``.""" ... @abstractmethod @@ -228,7 +237,7 @@ def _resolve_dataset(self) -> DatamintBaseDataset: if self._user_dataset is not None: return self._user_dataset assert self._user_project is not None # guaranteed by __init__ validation - return self._build_default_dataset(self._user_project) + return self._build_dataset(self._user_project) def _build_datamodule( self, @@ -245,22 +254,27 @@ def _build_datamodule( ) def _build_callbacks(self) -> list: - from datamint.mlflow.lightning.callbacks import MLFlowModelCheckpoint + from datamint.mlflow.lightning.callbacks import MLFlowPyTorchModelCheckpoint, MLFlowDatamintModelCheckpoint from lightning.pytorch.callbacks import EarlyStopping + from mlflow.pyfunc.model import PythonModel metric_name, mode = self._monitor_metric() project_name = self.dataset.project.name if self.dataset.project else 'datamint' model_name = self.register_model_name or project_name + if isinstance(self.model, PythonModel): + checkpoint_cls = MLFlowDatamintModelCheckpoint + else: + checkpoint_cls = MLFlowPyTorchModelCheckpoint + callbacks: list = [ - MLFlowModelCheckpoint( + checkpoint_cls( monitor=metric_name, mode=mode, save_top_k=1, register_model_name=model_name, register_model_on='test', - ), - ] + )] if self.early_stopping_patience is not None: callbacks.append(EarlyStopping( diff --git a/datamint/lightning/trainers/classification_trainer.py b/datamint/lightning/trainers/classification_trainer.py index 4d17be27..50c31259 100644 --- a/datamint/lightning/trainers/classification_trainer.py +++ b/datamint/lightning/trainers/classification_trainer.py @@ -1,12 +1,14 @@ """Image classification trainers.""" from __future__ import annotations +from collections.abc import Callable from typing import Any, TYPE_CHECKING import lightning as L from torch import nn from datamint.dataset import ImageDataset +from functools import partial from .lightning_modules import ClassificationModule from .base_trainer import BaseTrainer @@ -25,16 +27,16 @@ class ClassificationTrainer(BaseTrainer): * **Monitor** – ``val/accuracy`` (maximise). """ - def _default_loss(self) -> nn.Module: + def _loss(self) -> nn.Module: return nn.CrossEntropyLoss() - def _default_metrics(self) -> dict[str, Any]: + def _metrics(self) -> dict[str, Callable]: from torchmetrics.classification import MulticlassAccuracy, MulticlassF1Score nc = len(self.dataset.image_categories_set) return { - 'accuracy': lambda: MulticlassAccuracy(num_classes=nc), - 'f1': lambda: MulticlassF1Score(num_classes=nc, average='macro'), + 'accuracy': partial(MulticlassAccuracy, num_classes=nc), + 'f1': partial(MulticlassF1Score, num_classes=nc, average='macro'), } def _monitor_metric(self) -> tuple[str, str]: @@ -69,7 +71,7 @@ def __init__( # ── Template hooks ────────────────────────────────────────── - def _build_default_dataset(self, project: 'str | Project') -> ImageDataset: + def _build_dataset(self, project: 'str | Project') -> ImageDataset: return ImageDataset( project=project, return_segmentations=False, @@ -77,7 +79,7 @@ def _build_default_dataset(self, project: 'str | Project') -> ImageDataset: image_categories_merge_strategy='mode', ) - def _build_default_model( + def _build_model( self, loss_fn: nn.Module, metrics: dict[str, Any], @@ -87,10 +89,12 @@ def _build_default_model( num_classes=len(self.dataset.image_categories_set), loss_fn=loss_fn, metrics_factories=metrics, + class_names=list(self.dataset.image_categories_set), + image_size=self.image_size, pretrained=self.pretrained, ) - def _default_train_transform(self) -> 'BaseCompose': + def _train_transform(self) -> 'BaseCompose': import albumentations as A from albumentations.pytorch import ToTensorV2 @@ -103,7 +107,7 @@ def _default_train_transform(self) -> 'BaseCompose': ToTensorV2(), ]) - def _default_eval_transform(self) -> 'BaseCompose': + def _eval_transform(self) -> 'BaseCompose': import albumentations as A from albumentations.pytorch import ToTensorV2 diff --git a/datamint/lightning/trainers/lightning_modules/__init__.py b/datamint/lightning/trainers/lightning_modules/__init__.py index 2b90bce4..f000be50 100644 --- a/datamint/lightning/trainers/lightning_modules/__init__.py +++ b/datamint/lightning/trainers/lightning_modules/__init__.py @@ -1,4 +1,6 @@ +from .base import DatamintLightningModule from .segmentation_module import SegmentationModule +from .segmentation_modules import SMPSegmentationModule, UNetPPModule from .classification_module import ClassificationModule -__all__ = ["SegmentationModule", "ClassificationModule"] +__all__ = ["DatamintLightningModule", "SegmentationModule", "SMPSegmentationModule", "UNetPPModule", "ClassificationModule"] diff --git a/datamint/lightning/trainers/lightning_modules/base.py b/datamint/lightning/trainers/lightning_modules/base.py new file mode 100644 index 00000000..7319d50a --- /dev/null +++ b/datamint/lightning/trainers/lightning_modules/base.py @@ -0,0 +1,31 @@ +"""Combined LightningModule + BaseDatamintModel base for built-in trainers.""" +from __future__ import annotations + +import lightning as L + +from datamint.mlflow.flavors.model import BaseDatamintModel, ModelSettings +from mlflow.pyfunc.model import PythonModelContext + + +class DatamintLightningModule(L.LightningModule, BaseDatamintModel): + """A :class:`~lightning.LightningModule` that is also a + :class:`~datamint.mlflow.flavors.model.BaseDatamintModel`. + + Built-in trainers use this as the base for their default models so that + the trained module can be logged once with ``datamint_flavor`` — no + separate adapter step is required. + """ + + def __init__(self, settings: ModelSettings | None = None) -> None: + L.LightningModule.__init__(self) + BaseDatamintModel.__init__(self, settings=settings) + + # ------------------------------------------------------------------ + # MLflow lifecycle + # ------------------------------------------------------------------ + + def load_context(self, context: PythonModelContext) -> None: + """Move weights to the configured device and set eval mode on MLflow load.""" + device = (context.model_config or {}).get('device', 'cpu') + self.to(device) + self.eval() diff --git a/datamint/lightning/trainers/lightning_modules/classification_module.py b/datamint/lightning/trainers/lightning_modules/classification_module.py index 8ef4403f..03be73c6 100644 --- a/datamint/lightning/trainers/lightning_modules/classification_module.py +++ b/datamint/lightning/trainers/lightning_modules/classification_module.py @@ -8,8 +8,10 @@ import torch from torch import Tensor, nn +from .base import DatamintLightningModule -class ClassificationModule(L.LightningModule): + +class ClassificationModule(DatamintLightningModule): """Generic image classification module backed by ``timm``. Args: @@ -27,11 +29,15 @@ def __init__( num_classes: int, loss_fn: nn.Module, metrics_factories: dict[str, Callable[[], Any]], + class_names: list[str], + image_size: tuple[int, int], lr: float = 1e-4, pretrained: bool = True, ) -> None: super().__init__() self.save_hyperparameters(ignore=['loss_fn', 'metrics_factories']) + self.class_names = class_names + self.image_size = image_size import timm @@ -97,3 +103,32 @@ def configure_optimizers(self): lr=self.hparams['lr'], weight_decay=1e-4, ) + + def predict_default( + self, + model_input, + **kwargs: Any, + ): + """Run classification inference, returning :class:`~datamint.entities.annotations.ImageClassification` per resource.""" + import numpy as np + import albumentations as A + from albumentations.pytorch import ToTensorV2 + from datamint.entities.annotations import ImageClassification + + transform = A.Compose([ + A.Resize(*self.image_size), + A.Normalize(), + ToTensorV2(), + ]) + device = self.inference_device + self.eval() + all_preds: list[list] = [] + with torch.inference_mode(): + for res in model_input: + image = np.array(res.fetch_file_data(auto_convert=True, use_cache=True)) + tensor = transform(image=image)['image'].to(device) + logits = self(tensor.unsqueeze(0)) + pred_idx = int(logits.argmax(dim=1).item()) + class_name = self.class_names[pred_idx] + all_preds.append([ImageClassification(name='category', value=class_name)]) + return all_preds diff --git a/datamint/lightning/trainers/lightning_modules/segmentation_module.py b/datamint/lightning/trainers/lightning_modules/segmentation_module.py index e3229c61..53a2f923 100644 --- a/datamint/lightning/trainers/lightning_modules/segmentation_module.py +++ b/datamint/lightning/trainers/lightning_modules/segmentation_module.py @@ -1,51 +1,51 @@ """LightningModule wrapper for segmentation tasks.""" from __future__ import annotations +from abc import abstractmethod from collections.abc import Callable from typing import Any -import lightning as L import torch from torch import Tensor, nn +from .base import DatamintLightningModule -class SegmentationModule(L.LightningModule): - """Generic segmentation module backed by ``segmentation_models_pytorch``. + +class SegmentationModule(DatamintLightningModule): + """Base segmentation module for semantic segmentation tasks. + + Subclasses must implement :meth:`_build_model` to return the model. Args: - arch: SMP architecture name (e.g. ``'UnetPlusPlus'``, ``'DeepLabV3Plus'``). - encoder_name: Backbone encoder (e.g. ``'resnet34'``). in_channels: Number of input channels. num_classes: Number of segmentation classes **excluding** background. loss_fn: Loss module. metrics_factories: ``{name: callable}`` where each callable returns a fresh :class:`torchmetrics.Metric`. One instance is created per stage (train / val / test). + class_names: Human-readable label for each class. + image_size: ``(height, width)`` used during inference. lr: Learning rate for AdamW. """ def __init__( self, - arch: str, - encoder_name: str, in_channels: int, num_classes: int, loss_fn: nn.Module, metrics_factories: dict[str, Callable[[], Any]], + class_names: list[str], + image_size: tuple[int, int], lr: float = 1e-4, ) -> None: super().__init__() + self.in_channels = in_channels + self.num_classes = num_classes self.save_hyperparameters(ignore=['loss_fn', 'metrics_factories']) + self.class_names = class_names + self.image_size = image_size - import segmentation_models_pytorch as smp - - arch_cls = getattr(smp, arch) - self.model = arch_cls( - encoder_name=encoder_name, - encoder_weights='imagenet', - in_channels=in_channels, - classes=num_classes, - ) + self.model = self._build_model() self.criterion = loss_fn # Create per-stage metrics @@ -54,7 +54,13 @@ def __init__( for name, factory in metrics_factories.items(): self.add_module(f'{stage}_{name}', factory()) - def forward(self, x: Tensor) -> Tensor: + @abstractmethod + def _build_model(self) -> nn.Module: + """Instantiate and return the model. Subclasses may access + ``self.in_channels`` and ``self.num_classes``.""" + ... + + def forward(self, x: Tensor) -> Tensor: # type: ignore[override] return self.model(x) def _common_step(self, batch: dict, stage: str) -> Tensor: @@ -105,3 +111,41 @@ def configure_optimizers(self): lr=self.hparams['lr'], weight_decay=1e-4, ) + + def predict_default( + self, + model_input, + **kwargs: Any, + ): + """Run segmentation inference, returning :class:`~datamint.entities.annotations.ImageSegmentation` per resource.""" + import cv2 + import numpy as np + import albumentations as A + from albumentations.pytorch import ToTensorV2 + from datamint.entities.annotations import ImageSegmentation + + transform = A.Compose([ + A.Resize(*self.image_size), + A.Normalize(), + ToTensorV2(), + ]) + device = self.inference_device + self.eval() + all_preds: list[list] = [] + with torch.inference_mode(): + for res in model_input: + image = np.array(res.fetch_file_data(auto_convert=True, use_cache=True)) + oh, ow = image.shape[:2] + tensor = transform(image=image)['image'].to(device) + logits = self(tensor.unsqueeze(0)) + pred = (logits[0] > 0).cpu().numpy().astype(np.uint8) + anns: list = [] + for i, name in enumerate(self.class_names): + mask = cv2.resize( + pred[i], (ow, oh), + interpolation=cv2.INTER_NEAREST, + ) * 255 + if mask.any(): + anns.append(ImageSegmentation(name=name, mask=mask)) + all_preds.append(anns) + return all_preds diff --git a/datamint/lightning/trainers/lightning_modules/segmentation_modules/__init__.py b/datamint/lightning/trainers/lightning_modules/segmentation_modules/__init__.py new file mode 100644 index 00000000..1da35006 --- /dev/null +++ b/datamint/lightning/trainers/lightning_modules/segmentation_modules/__init__.py @@ -0,0 +1,4 @@ +from .smp_module import SMPSegmentationModule +from .unetpp import UNetPPModule + +__all__ = ["SMPSegmentationModule", "UNetPPModule"] diff --git a/datamint/lightning/trainers/lightning_modules/segmentation_modules/smp_module.py b/datamint/lightning/trainers/lightning_modules/segmentation_modules/smp_module.py new file mode 100644 index 00000000..d353cca2 --- /dev/null +++ b/datamint/lightning/trainers/lightning_modules/segmentation_modules/smp_module.py @@ -0,0 +1,32 @@ +"""Base segmentation module for ``segmentation_models_pytorch`` architectures.""" +from __future__ import annotations + +from typing import Any + +from ..segmentation_module import SegmentationModule + + +class SMPSegmentationModule(SegmentationModule): + """Base segmentation module for architectures from ``segmentation_models_pytorch``. + + Handles SMP-specific construction parameters shared across all SMP + architectures. Subclasses implement :meth:`_build_model` to return the + concrete SMP model. + + Args: + encoder_name: Backbone encoder (e.g. ``'resnet34'``). + encoder_weights: Pre-trained weights to initialise the encoder with. + Defaults to ``'imagenet'``. + All remaining keyword arguments are forwarded to + :class:`~datamint.lightning.trainers.lightning_modules.SegmentationModule`. + """ + + def __init__( + self, + encoder_name: str = 'resnet34', + encoder_weights: str | None = 'imagenet', + **kwargs: Any, + ) -> None: + self.encoder_name = encoder_name + self.encoder_weights = encoder_weights + super().__init__(**kwargs) \ No newline at end of file diff --git a/datamint/lightning/trainers/lightning_modules/segmentation_modules/unetpp.py b/datamint/lightning/trainers/lightning_modules/segmentation_modules/unetpp.py new file mode 100644 index 00000000..33336e8e --- /dev/null +++ b/datamint/lightning/trainers/lightning_modules/segmentation_modules/unetpp.py @@ -0,0 +1,25 @@ +"""UNet++ segmentation module.""" +from __future__ import annotations + +from torch import nn + +from .smp_module import SMPSegmentationModule + + +class UNetPPModule(SMPSegmentationModule): + """Segmentation module using the UNet++ architecture from ``segmentation_models_pytorch``. + + Args: + All arguments are forwarded to + :class:`~datamint.lightning.trainers.lightning_modules.segmentation_modules.SMPSegmentationModule`. + """ + + def _build_model(self) -> nn.Module: + import segmentation_models_pytorch as smp + + return smp.UnetPlusPlus( + encoder_name=self.encoder_name, + encoder_weights=self.encoder_weights, + in_channels=self.in_channels, + classes=self.num_classes, + ) diff --git a/datamint/lightning/trainers/seg2d_trainer.py b/datamint/lightning/trainers/seg2d_trainer.py index a130340c..c07cedb9 100644 --- a/datamint/lightning/trainers/seg2d_trainer.py +++ b/datamint/lightning/trainers/seg2d_trainer.py @@ -2,13 +2,14 @@ from __future__ import annotations from typing import Any, TYPE_CHECKING +from collections.abc import Callable import lightning as L from torch import nn from datamint.dataset import ImageDataset -from .lightning_modules import SegmentationModule +from .lightning_modules import UNetPPModule from .segmentation_trainer import SegmentationTrainer if TYPE_CHECKING: @@ -22,7 +23,6 @@ class SemanticSegmentation2DTrainer(SegmentationTrainer): ``resnet34`` encoder pretrained on ImageNet. Args: - encoder_name: SMP encoder backbone. Defaults to ``'resnet34'``. in_channels: Number of input image channels. Defaults to ``3``. All remaining keyword arguments are forwarded to :class:`~datamint.lightning.trainers.base_trainer.BaseTrainer`. @@ -36,17 +36,15 @@ class SemanticSegmentation2DTrainer(SegmentationTrainer): def __init__( self, *, - encoder_name: str = 'resnet34', in_channels: int = 3, **kwargs: Any, ) -> None: super().__init__(**kwargs) - self.encoder_name = encoder_name self.in_channels = in_channels # ── Template hooks ────────────────────────────────────────── - def _build_default_dataset(self, project: 'str | Project') -> ImageDataset: + def _build_dataset(self, project: 'str | Project') -> ImageDataset: return ImageDataset( project=project, return_as_semantic_segmentation=True, @@ -55,21 +53,7 @@ def _build_default_dataset(self, project: 'str | Project') -> ImageDataset: include_unannotated=False, ) - def _build_default_model( - self, - loss_fn: nn.Module, - metrics: dict[str, Any], - ) -> L.LightningModule: - return SegmentationModule( - arch='UnetPlusPlus', - encoder_name=self.encoder_name, - in_channels=self.in_channels, - num_classes=len(self.dataset.seglabel_list), - loss_fn=loss_fn, - metrics_factories=metrics, - ) - - def _default_train_transform(self) -> 'BaseCompose': + def _train_transform(self) -> 'BaseCompose': import albumentations as A from albumentations.pytorch import ToTensorV2 @@ -83,7 +67,7 @@ def _default_train_transform(self) -> 'BaseCompose': ToTensorV2(), ]) - def _default_eval_transform(self) -> 'BaseCompose': + def _eval_transform(self) -> 'BaseCompose': import albumentations as A from albumentations.pytorch import ToTensorV2 @@ -94,75 +78,6 @@ def _default_eval_transform(self) -> 'BaseCompose': ToTensorV2(), ]) - def _build_deploy_adapter(self) -> Any: - from datamint.mlflow.flavors.model import DatamintModel - from datamint.mlflow.flavors import datamint_flavor - from datamint.entities.annotations import ImageSegmentation - import mlflow - - class _SegAdapter(DatamintModel): - """Auto-generated adapter for a trained segmentation model.""" - - def __init__(self, model_name: str, class_names: list[str], image_size: tuple[int, int]): - super().__init__( - mlflow_torch_models_uri={'model': f'models:/{model_name}/latest'}, - settings={'need_gpu': True}, - ) - self._class_names = class_names - self._image_size = image_size - - def predict_default(self, model_input, **kwargs): - import cv2 - import numpy as np - import torch - import albumentations as A - from albumentations.pytorch import ToTensorV2 - - model = self.get_mlflow_torch_models()['model'] - model.eval() - fabric = L.Fabric(accelerator=self.inference_device) - model = fabric.setup_module(model) - - transform = A.Compose([ - A.Resize(*self._image_size), - A.Normalize(), - ToTensorV2(), - ]) - - all_preds: list[list] = [] - with torch.inference_mode(): - for res in model_input: - image = np.array(res.fetch_file_data(auto_convert=True, use_cache=True)) - oh, ow = image.shape[:2] - tensor = transform(image=image)['image'].to(fabric.device) - logits = model(tensor.unsqueeze(0)) - pred = (logits[0] > 0).cpu().numpy().astype(np.uint8) - - anns: list = [] - for i, name in enumerate(self._class_names): - mask = cv2.resize( - pred[i], (ow, oh), - interpolation=cv2.INTER_NEAREST, - ) * 255 - if mask.any(): - anns.append(ImageSegmentation(name=name, mask=mask)) - all_preds.append(anns) - return all_preds - - project_name = self.dataset.project.name if self.dataset.project else 'datamint' - model_name = self.register_model_name or project_name - adapter = _SegAdapter(model_name, list(self.dataset.seglabel_list), self.image_size) - - experiment_name = self.mlflow_experiment_name or f"{project_name}_training" - mlflow.set_experiment(f'{experiment_name}_deployment') - with mlflow.start_run(run_name='auto_adapter'): - datamint_flavor.log_model( - adapter, - registered_model_name=f'{model_name}_adapted', - ) - - return adapter - class UNetPPTrainer(SemanticSegmentation2DTrainer): """Convenience trainer pre-configured for UNet++ with stronger augmentations. @@ -175,12 +90,20 @@ class UNetPPTrainer(SemanticSegmentation2DTrainer): trainer = UNetPPTrainer( project='BUS_Segmentation', - encoder_name='efficientnet-b4', - ) + encoder_name='resnet34',) results = trainer.fit() """ - def _default_train_transform(self) -> 'BaseCompose': + def __init__( + self, + *, + encoder_name: str = 'resnet34', + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self.encoder_name = encoder_name + + def _train_transform(self) -> 'BaseCompose': import albumentations as A from albumentations.pytorch import ToTensorV2 @@ -195,3 +118,18 @@ def _default_train_transform(self) -> 'BaseCompose': A.Normalize(), ToTensorV2(), ]) + + def _build_model( + self, + loss_fn: nn.Module, + metrics: dict[str, Callable], + ) -> L.LightningModule: + return UNetPPModule( + encoder_name=self.encoder_name, + in_channels=self.in_channels, + num_classes=len(self.dataset.seglabel_list), + loss_fn=loss_fn, + metrics_factories=metrics, + class_names=list(self.dataset.seglabel_list), + image_size=self.image_size, + ) \ No newline at end of file diff --git a/datamint/lightning/trainers/seg3d_trainer.py b/datamint/lightning/trainers/seg3d_trainer.py index c2a633bf..299b7b3e 100644 --- a/datamint/lightning/trainers/seg3d_trainer.py +++ b/datamint/lightning/trainers/seg3d_trainer.py @@ -52,7 +52,7 @@ def __init__( # ── Template hooks ────────────────────────────────────────── - def _build_default_dataset(self, project: 'str | Project'): + def _build_dataset(self, project: 'str | Project'): vol_ds = VolumeDataset( project=project, return_as_semantic_segmentation=True, @@ -62,7 +62,7 @@ def _build_default_dataset(self, project: 'str | Project'): ) return vol_ds.slice(axis=self.slice_axis) - def _build_default_model( + def _build_model( self, loss_fn: nn.Module, metrics: dict[str, Any], @@ -74,9 +74,11 @@ def _build_default_model( num_classes=len(self.dataset.seglabel_list), loss_fn=loss_fn, metrics_factories=metrics, + class_names=list(self.dataset.seglabel_list), + image_size=self.image_size, ) - def _default_train_transform(self) -> 'BaseCompose': + def _train_transform(self) -> 'BaseCompose': import albumentations as A from albumentations.pytorch import ToTensorV2 @@ -89,7 +91,7 @@ def _default_train_transform(self) -> 'BaseCompose': ToTensorV2(), ]) - def _default_eval_transform(self) -> 'BaseCompose': + def _eval_transform(self) -> 'BaseCompose': import albumentations as A from albumentations.pytorch import ToTensorV2 diff --git a/datamint/lightning/trainers/segmentation_trainer.py b/datamint/lightning/trainers/segmentation_trainer.py index 7e5c6e22..61adde0d 100644 --- a/datamint/lightning/trainers/segmentation_trainer.py +++ b/datamint/lightning/trainers/segmentation_trainer.py @@ -1,8 +1,10 @@ """Shared base for segmentation trainers (2-D and 3-D).""" from __future__ import annotations +from collections.abc import Callable from typing import Any +from functools import partial import torch import torch.nn.functional as F from torch import nn @@ -41,16 +43,16 @@ class SegmentationTrainer(BaseTrainer): * **Monitor** – ``val/iou`` (maximise). """ - def _default_loss(self) -> nn.Module: + def _loss(self) -> nn.Module: return _BCEDiceLoss() - def _default_metrics(self) -> dict[str, Any]: + def _metrics(self) -> dict[str, Callable]: from torchmetrics.segmentation import GeneralizedDiceScore, MeanIoU num_classes = len(self.dataset.seglabel_list) return { - 'iou': lambda: MeanIoU(num_classes=num_classes, input_format='one-hot'), - 'dice': lambda: GeneralizedDiceScore(num_classes=num_classes, input_format='one-hot'), + 'iou': partial(MeanIoU, num_classes=num_classes, input_format='one-hot'), + 'dice': partial(GeneralizedDiceScore, num_classes=num_classes, input_format='one-hot'), } def _monitor_metric(self) -> tuple[str, str]: diff --git a/datamint/mlflow/__init__.py b/datamint/mlflow/__init__.py index 44de0273..ff5b148c 100644 --- a/datamint/mlflow/__init__.py +++ b/datamint/mlflow/__init__.py @@ -89,7 +89,7 @@ def _configure_mlflow_loggers(): if TYPE_CHECKING: - from .flavors.model import DatamintModel + from .flavors.model import BaseDatamintModel, DatamintModel from .tracking.fluent import set_project else: if mlflow_utils.is_tracking_uri_set(): @@ -107,11 +107,11 @@ def _configure_mlflow_loggers(): __name__, submodules=['flavors.model', 'flavors.datamint_flavor'], submod_attrs={ - "flavors.model": ["DatamintModel"], + "flavors.model": ["BaseDatamintModel", "DatamintModel"], "flavors.datamint_flavor": ["log_model", "load_model"], "tracking.fluent": ["set_project"], }, ) -__all__ = ['set_project', 'setup_mlflow_environment', 'ensure_mlflow_configured', 'DatamintModel'] +__all__ = ['set_project', 'setup_mlflow_environment', 'ensure_mlflow_configured', 'BaseDatamintModel', 'DatamintModel'] diff --git a/datamint/mlflow/flavors/datamint_flavor.py b/datamint/mlflow/flavors/datamint_flavor.py index 26ddc055..159ee4df 100644 --- a/datamint/mlflow/flavors/datamint_flavor.py +++ b/datamint/mlflow/flavors/datamint_flavor.py @@ -1,19 +1,26 @@ +import logging import mlflow from mlflow.models import Model, ModelInputExample, ModelSignature import datamint import datamint.mlflow.flavors from mlflow import pyfunc -from .model import DatamintModel +from .model import BaseDatamintModel, DatamintModel, _DatamintModelWrapper from collections.abc import Sequence from dataclasses import asdict from packaging.requirements import Requirement from typing import Any +import torch +import tempfile +from mlflow.pytorch import pickle_module as mlflow_pytorch_pickle_module + +logger = logging.getLogger(__name__) FLAVOR_NAME = 'datamint' +PYTORCH_DATA_SUBPATH = "pytorch_data" def _process_signature(signature: ModelSignature | None, - python_model: DatamintModel) -> ModelSignature: + python_model: BaseDatamintModel) -> ModelSignature: from mlflow.types import ParamSchema, ParamSpec from mlflow.models.signature import _infer_signature_from_type_hints @@ -24,27 +31,20 @@ def _process_signature(signature: ModelSignature | None, ] ) - if signature is not None: - current_params_sig = signature.params - else: - type_hints = python_model.predict_type_hints - # context is only loaded when input_example exists + if signature is None: signature = _infer_signature_from_type_hints( python_model=python_model, context=None, - type_hints=type_hints, + type_hints=python_model.predict_type_hints, input_example=None, ) - current_params_sig = signature.params + assert signature is not None - # append our params to the existing signature - if current_params_sig is None: - signature.params = params_schema - else: - # Merge existing params with our new params, ensuring no duplicates - existing_param_names = {param.name for param in current_params_sig.params} - new_params = [param for param in params_schema.params if param.name not in existing_param_names] - signature.params = ParamSchema(current_params_sig.params + new_params) + # Merge existing params with our new params, ensuring no duplicates + existing_params: list[ParamSpec] = signature.params.params if signature.params else [] + existing_param_names = {param.name for param in existing_params} + new_params = [param for param in params_schema.params if param.name not in existing_param_names] + signature.params = ParamSchema(existing_params + new_params) return signature @@ -60,15 +60,45 @@ def _process_input_example(input_example: ModelInputExample | None) -> tuple[Mod if not isinstance(input_example, tuple): return (input_example, datamint_params) data_example, params_example = input_example - # merge params_example with datamint_params, giving precedence to datamint_params in case of conflicts - if params_example is not None: - merged_params = {**params_example, **datamint_params} - else: - merged_params = datamint_params + merged_params = {**(params_example or {}), **datamint_params} return (data_example, merged_params) -def save_model(datamint_model: DatamintModel, +def _resolve_requirements(pip_requirements, extra_pip_requirements): + import medimgkit + + def _get_req_name(req): + try: + return Requirement(req).name.lower() + except Exception: + return req.split("==")[0].strip().lower() + + datamint_requirements = [ + f'datamint=={datamint.__version__}', + f'medimgkit=={medimgkit.__version__}', + ] + + user_requirements = [] + if isinstance(pip_requirements, Sequence) and not isinstance(pip_requirements, str): + user_requirements.extend(pip_requirements) + if isinstance(extra_pip_requirements, Sequence) and not isinstance(extra_pip_requirements, str): + user_requirements.extend(extra_pip_requirements) + + user_req_names = {_get_req_name(req) for req in user_requirements} + missing_requirements = [req for req in datamint_requirements if _get_req_name(req) not in user_req_names] + + if missing_requirements: + if extra_pip_requirements is None: + extra_pip_requirements = missing_requirements + elif isinstance(extra_pip_requirements, Sequence) and not isinstance(extra_pip_requirements, str): + extra_pip_requirements = list(extra_pip_requirements) + missing_requirements + elif isinstance(pip_requirements, Sequence) and not isinstance(pip_requirements, str): + pip_requirements = list(pip_requirements) + missing_requirements + + return pip_requirements, extra_pip_requirements + + +def save_model(datamint_model: BaseDatamintModel, path, supported_modes: Sequence[str] | None = None, data_path=None, @@ -85,62 +115,40 @@ def save_model(datamint_model: DatamintModel, model_config=None, streamable=None, **kwargs): - import medimgkit + if not isinstance(datamint_model, DatamintModel): + datamint_model = _DatamintModelWrapper(datamint_model) if mlflow_model is None: mlflow_model = Model() - mlflow_model.add_flavor( - FLAVOR_NAME, - datamint_version=datamint.__version__, - supported_modes=supported_modes or datamint_model.get_supported_modes(), - model_settings=asdict(datamint_model.settings), - linked_models=datamint_model._get_linked_models_uri() - ) - model_config = model_config or {} model_config.setdefault('device', 'cuda' if datamint_model.settings.need_gpu else 'cpu') - def _get_req_name(req): - try: - return Requirement(req).name.lower() - except Exception: - return req.split("==")[0].strip().lower() + pip_requirements, extra_pip_requirements = _resolve_requirements(pip_requirements, extra_pip_requirements) - datamint_requirements = ['datamint=={}'.format(datamint.__version__), 'medimgkit=={}'.format(medimgkit.__version__)] - - user_requirements = [] - # Check if requirements are lists (not strings which are also Sequences) - if pip_requirements and isinstance(pip_requirements, Sequence) and not isinstance(pip_requirements, str): - user_requirements.extend(pip_requirements) - if extra_pip_requirements and isinstance(extra_pip_requirements, Sequence) and not isinstance(extra_pip_requirements, str): - user_requirements.extend(extra_pip_requirements) - - user_req_names = {_get_req_name(req) for req in user_requirements} - - missing_requirements = [req for req in datamint_requirements if _get_req_name(req) not in user_req_names] - - if missing_requirements: - if extra_pip_requirements is None: - extra_pip_requirements = missing_requirements - elif isinstance(extra_pip_requirements, Sequence) and not isinstance(extra_pip_requirements, str): - extra_pip_requirements = list(extra_pip_requirements) + missing_requirements - elif pip_requirements and isinstance(pip_requirements, Sequence) and not isinstance(pip_requirements, str): - pip_requirements = list(pip_requirements) + missing_requirements - - datamint_model._clear_linked_models_cache() + if hasattr(datamint_model, '_clear_linked_models_cache'): + datamint_model._clear_linked_models_cache() if signature is not None: signature = _process_signature(signature, datamint_model) input_example = _process_input_example(input_example) - return mlflow.pyfunc.save_model( + linked_models = datamint_model._get_linked_models_uri() if hasattr(datamint_model, '_get_linked_models_uri') else {} + flavor_params = { + "datamint_version": datamint.__version__, + "supported_modes": supported_modes or datamint_model.get_supported_modes(), + "model_settings": asdict(datamint_model.settings), + "linked_models": linked_models, + } + mlflow_model.add_flavor(FLAVOR_NAME, **flavor_params) + model_config.update(flavor_params) + + pyfunc_kwargs = dict( path=path, python_model=datamint_model, data_path=data_path, conda_env=conda_env, mlflow_model=mlflow_model, - # loader_module=None, artifacts=artifacts, code_paths=code_paths, infer_code_paths=infer_code_paths, @@ -154,9 +162,20 @@ def _get_req_name(req): **kwargs ) + pt_model = datamint_model.get_pytorch_model() if hasattr(datamint_model, 'get_pytorch_model') else None + if pt_model is not None: + datamint_model._clear_ptmodel() + with tempfile.NamedTemporaryFile() as tmp_file: + logger.debug(f"Saving PyTorch model to temporary file {tmp_file.name}") + torch.save(pt_model, tmp_file.name, pickle_module=mlflow_pytorch_pickle_module) + pyfunc_kwargs['artifacts'] = {**(artifacts or {}), DatamintModel._PYTORCH_ARTIFACT_NAME: tmp_file.name} + return mlflow.pyfunc.save_model(**pyfunc_kwargs) + + return mlflow.pyfunc.save_model(**pyfunc_kwargs) + def log_model( - datamint_model: DatamintModel, + datamint_model: BaseDatamintModel, supported_modes: Sequence[str] | None = None, name: str = "datamint_model", data_path=None, @@ -178,7 +197,6 @@ def log_model( supported_modes=supported_modes, name=name, flavor=datamint.mlflow.flavors.datamint_flavor, - # loader_module=loader_module, data_path=data_path, code_paths=code_paths, artifacts=artifacts, @@ -195,14 +213,23 @@ def log_model( def load_model(model_uri: str, device: str | None = None) -> DatamintModel: - if device is not None: - model_config = {'device': device} - else: - model_config = None - return mlflow.pyfunc.load_model(model_uri=model_uri, - model_config=model_config - ).unwrap_python_model() + model_config = {'device': device} if device is not None else None + + from mlflow.tracking.artifact_utils import _download_artifact_from_uri + local_path = _download_artifact_from_uri(artifact_uri=model_uri) + + return _load_pyfunc(local_path, model_config=model_config).unwrap_python_model() + + def _load_pyfunc(path: str, model_config=None) -> pyfunc.PyFuncModel: - return mlflow.pyfunc.load_model(model_uri=path, model_config=model_config) + logger.debug(f"Loading PyFunc model from path: {path} with model_config: {model_config}") + pf_model = mlflow.pyfunc.load_model(model_uri=path, model_config=model_config) + dt_model = pf_model.unwrap_python_model() + if isinstance(dt_model, _DatamintModelWrapper): + logger.debug("Unwrapping DatamintModel from wrapper") + dt_model = dt_model.another_model + pf_model._model_impl.python_model = dt_model + + return pf_model diff --git a/datamint/mlflow/flavors/model.py b/datamint/mlflow/flavors/model.py index af760621..752f9eee 100644 --- a/datamint/mlflow/flavors/model.py +++ b/datamint/mlflow/flavors/model.py @@ -7,14 +7,18 @@ from typing import Any, TypeAlias from abc import ABC -from enum import Enum from dataclasses import dataclass +from mlflow.environment_variables import MLFLOW_DEFAULT_PREDICTION_DEVICE from mlflow.pyfunc import PyFuncModel, PythonModel, PythonModelContext from datamint.entities.annotations import Annotation from datamint.entities.resource import Resource from datamint.mlflow.flavors.model_loader import LinkedModelLoader +from datamint.mlflow.flavors.prediction_modes import PredictionMode from datamint.mlflow.flavors.prediction_router import PredictionRouter import logging +from functools import cached_property +import torch +from mlflow.pytorch import pickle_module as mlflow_pytorch_pickle_module logger = logging.getLogger(__name__) @@ -37,56 +41,167 @@ class ModelSettings: @classmethod def from_dict(cls, data: dict[str, Any]) -> 'ModelSettings': """Create config from dictionary, raising error on unknown keys.""" - valid_fields = {f.name for f in cls.__dataclass_fields__.values()} + valid_fields = set(cls.__dataclass_fields__) invalid_fields = set(data.keys()) - valid_fields if invalid_fields: raise ValueError(f"Invalid fields for ModelSettings: {', '.join(sorted(invalid_fields))}") return cls(**data) -class PredictionMode(str, Enum): - """ - Enumeration of supported prediction modes. +class BaseDatamintModel(PythonModel, ABC): + """Core prediction gateway that any MLflow :class:`~mlflow.pyfunc.PythonModel` can build on. + + Owns: + + * :attr:`settings` — hardware / deployment configuration. + * Device detection (``_detect_device`` / :attr:`inference_device`). + * Prediction dispatch via :class:`~datamint.mlflow.flavors.prediction_router.PredictionRouter`. + * Pickle-safe serialization. + + Use :class:`DatamintModel` when you need to load linked models at serve time. - Each mode corresponds to a specific method signature in DatamintModel. + Subclasses only need to implement :meth:`predict_default` (and optionally + other ``predict_*`` hooks registered with ``@prediction_mode``). """ - # Standard modes - DEFAULT = 'default' # Default: process entire resource as-is - - # Simple modes - IMAGE = 'image' # Process single 2d image resource - - # Video/temporal modes - FRAME = 'frame' # Extract and process specific frame - FRAME_RANGE = 'frame_range' # Process contiguous frame range - ALL_FRAMES = 'all_frames' # Process all frames independently - TEMPORAL_SEQUENCE = 'temporal_sequence' # Process with temporal context window - - # 3D volume modes - SLICE = 'slice' # Extract and process specific slice - SLICE_RANGE = 'slice_range' # Process contiguous slice range - PRIMARY_SLICE = 'primary_slice' # Process center/primary slice - # MULTI_PLANE = 'multi_plane' # Process multiple anatomical planes - VOLUME = 'volume' # Process entire 3D volume - - # Spatial modes - # ROI = 'roi' # Process single region of interest - # MULTI_ROI = 'multi_roi' # Process multiple regions - # TILE = 'tile' # Split into tiles (whole slide imaging) - # PATCH = 'patch' # Extract patches around points - - # Advanced modes - INTERACTIVE = 'interactive' # With user prompts (SAM-like) - FEW_SHOT = 'few_shot' # With context examples - # MULTI_VIEW = 'multi_view' # Multiple views of same subject - - -class DatamintModel(ABC, PythonModel): + + def __init__( + self, + settings: ModelSettings | dict[str, Any] | None = None, + ) -> None: + self.settings = settings + self._inference_device: str | None = None + + @cached_property + def _router(self) -> PredictionRouter: + """The PredictionRouter instance responsible for dispatching predict calls.""" + return PredictionRouter(self, BaseDatamintModel) + + @property + def settings(self) -> ModelSettings: + if not hasattr(self, "_settings"): + self._settings = ModelSettings() + return self._settings + + @settings.setter + def settings(self, value: ModelSettings | dict[str, Any] | None) -> None: + if isinstance(value, dict): + self._settings = ModelSettings.from_dict(value) + elif isinstance(value, ModelSettings): + self._settings = value + else: + self._settings = ModelSettings() + + # ------------------------------------------------------------------ + # Device management + # ------------------------------------------------------------------ + + @property + def inference_device(self) -> str: + """The device that will be used for inference. + + Returns ``_inference_device`` if already set, then falls back to the + ``MLFLOW_DEFAULT_PREDICTION_DEVICE`` environment variable, then ``'cpu'``. + """ + if self._inference_device is not None: + return self._inference_device + env_device = MLFLOW_DEFAULT_PREDICTION_DEVICE.get() + if env_device: + logger.info("Inference device not set; using environment variable (%s)", env_device) + return env_device + logger.warning("Inference device not set; defaulting to 'cpu'") + return "cpu" + + def _detect_device(self, context: PythonModelContext | None) -> str: + """Detect and store the inference device from context / env / hardware. + + Sets :attr:`_inference_device` and returns the detected device string. + Priority: ``context.model_config['device']`` > env var > CUDA > CPU. + """ + device = None + if context and context.model_config: + device = context.model_config.get("device", None) + logger.info("Model config device: %s", device) + if device is None: + env_device = MLFLOW_DEFAULT_PREDICTION_DEVICE.get() + if env_device: + device = env_device + elif torch.cuda.is_available(): + device = "cuda" + else: + device = "cpu" + + logger.info("Set inference device: %s", device) + self._inference_device = device + return device + + # ------------------------------------------------------------------ + # MLflow lifecycle + # ------------------------------------------------------------------ + + def load_context(self, context: PythonModelContext) -> None: + """Detect the inference device. + + Override in subclasses to perform additional loading (e.g. linked + models) — but always call ``super().load_context(context)`` first + so that :attr:`inference_device` is set before any model loading. + """ + logger.info("Loading model context %s and detecting device...", + f'{context.artifacts=} | {context.model_config=}') + self._detect_device(context) + + # ------------------------------------------------------------------ + # Serialization + # ------------------------------------------------------------------ + + def __getstate__(self) -> dict: + state = self.__dict__.copy() + state.pop("_router", None) + return state + + def __setstate__(self, state: dict) -> None: + self.__dict__.update(state) + + # ------------------------------------------------------------------ + # Prediction dispatch + # ------------------------------------------------------------------ + def predict( + self, + model_input: list[Resource], + params: dict[str, Any] | None = None, + ) -> PredictionResult: + """Main prediction entry point. + + Routes to the appropriate handler based on ``params['mode']``. + **Do not override** — implement :meth:`predict_default` (or other + ``predict_*`` hooks) instead. + """ + return self._router.dispatch(model_input, params or {}) + + def get_supported_modes(self) -> list[str]: + """Return the list of prediction modes supported by this model.""" + return self._router.supported_modes() + + def predict_default( + self, + model_input: list[Resource], + **kwargs: Any, + ) -> PredictionResult: + """Default prediction on entire resources. + + Override this in your subclass. + """ + raise NotImplementedError( + "predict_default() must be implemented in your DatamintModel subclass." + ) + + +class DatamintModel(BaseDatamintModel): """Abstract adapter for wrapping ML models to produce Datamint annotations. - Delegates model lifecycle to :class:`LinkedModelLoader` and prediction - dispatch to :class:`PredictionRouter`. Subclasses only need to override - ``predict_default`` (and optionally other ``predict_*`` hooks). + Extends :class:`BaseDatamintModel` with support for loading external + ("linked") MLflow models at serve time via :class:`LinkedModelLoader`. + Subclasses only need to override ``predict_default`` (and optionally + other ``predict_*`` hooks). Quick Start:: @@ -101,50 +216,83 @@ def predict_default(self, model_input, **kwargs): device = self.inference_device model = self.get_mlflow_models()['model'].get_raw_model().to(device) return predictions + + You can also pass pre-instantiated ``torch.nn.Module`` objects directly:: + + class MyModel(DatamintModel): + def __init__(self): + net = MyTorchNet() + super().__init__(torch_model=net) + + def predict_default(self, model_input, **kwargs): + net = self.get_mlflow_torch_models()['net'] + return net(preprocess(model_input)) """ # Keep for backward compat with subclass references LINKED_MODELS_DIR = "linked_models" + _PYTORCH_ARTIFACT_NAME = "pytorch_model" def __init__( self, settings: ModelSettings | dict[str, Any] | None = None, mlflow_torch_models_uri: dict[str, str] | None = None, mlflow_models_uri: dict[str, str] | None = None, + torch_model: torch.nn.Module | None = None, ) -> None: - super().__init__() + super().__init__(settings=settings) self._loader = LinkedModelLoader( mlflow_models_uri=mlflow_models_uri, mlflow_torch_models_uri=mlflow_torch_models_uri, + torch_model=torch_model, ) - if isinstance(settings, dict): - self.settings = ModelSettings.from_dict(settings) - elif isinstance(settings, ModelSettings): - self.settings = settings - else: - self.settings = ModelSettings() - self._router: PredictionRouter | None = None + + @cached_property + def _router(self) -> PredictionRouter: + """The PredictionRouter instance responsible for dispatching predict calls.""" + r = PredictionRouter(self, BaseDatamintModel) + if self._loader._torch_model_instance is not None: + r.update_registry(self._loader._torch_model_instance, BaseDatamintModel) + return r # ------------------------------------------------------------------ - # Lifecycle (delegates to loader) + # Lifecycle — overrides base to also load linked models # ------------------------------------------------------------------ def load_context(self, context: PythonModelContext) -> None: - """Called by MLflow when loading the model.""" - self._loader.load_all(context) + """Detect device and load all linked MLflow models.""" + super().load_context(context) # sets inference_device + self._loader.load_all(self.inference_device) + + if self._PYTORCH_ARTIFACT_NAME in context.artifacts: + model_path = context.artifacts[self._PYTORCH_ARTIFACT_NAME] + self._loader._torch_model_instance = torch.load(model_path, + weights_only=False, + map_location=self.inference_device, + pickle_module=mlflow_pytorch_pickle_module) + self._loader._torch_model_instance.eval() - @property - def inference_device(self) -> str: - return self._loader.inference_device + # ------------------------------------------------------------------ + # Linked-model access + # ------------------------------------------------------------------ def get_mlflow_models(self) -> dict[str, PyFuncModel]: - """Access loaded MLflow models.""" + """Access loaded MLflow pyfunc models.""" return self._loader.mlflow_models def get_mlflow_torch_models(self) -> dict[str, Any]: """Access loaded MLflow PyTorch models.""" return self._loader.torch_models + def get_pytorch_model(self) -> torch.nn.Module | None: + torch_model = self._loader._torch_model_instance + if torch_model is not None: + return torch_model + torch_models = self.get_mlflow_torch_models() + if len(torch_models) == 1: + return next(iter(torch_models.values())) + return torch_models.get("default", None) + # ------------------------------------------------------------------ # Backward-compat aliases # ------------------------------------------------------------------ @@ -171,60 +319,37 @@ def _get_linked_models_uri(self) -> dict[str, Any]: def _clear_linked_models_cache(self) -> None: self._loader.clear_cache() + def _clear_ptmodel(self) -> None: + self._loader._torch_model_instance = None + # ------------------------------------------------------------------ - # Serialization + # Serialization — also clears loader cache on restore # ------------------------------------------------------------------ - def __getstate__(self) -> dict: - state = self.__dict__.copy() - state.pop("_router", None) - return state - def __setstate__(self, state: dict) -> None: - self.__dict__.update(state) - self._router = None + super().__setstate__(state) self._loader.clear_cache() - # ------------------------------------------------------------------ - # Prediction (delegates to router) - # ------------------------------------------------------------------ - - def predict( - self, - model_input: list[Resource], - params: dict[str, Any] | None = None, - ) -> PredictionResult: - """Main prediction entry point. - Routes to the appropriate handler based on ``params['mode']``. - **Do not override** — implement ``predict_default`` (or other - ``predict_*`` hooks) instead. - """ - if self._router is None: - self._router = PredictionRouter(self, DatamintModel) - return self._router.dispatch(model_input, params or {}) +class _DatamintModelWrapper(BaseDatamintModel): + def __init__(self, another_model: Any) -> None: + super().__init__(settings=another_model.settings) + self.another_model = another_model - def get_supported_modes(self) -> list[str]: - """Get list of prediction modes supported by this model.""" - if self._router is None: - self._router = PredictionRouter(self, DatamintModel) - return self._router.supported_modes() + @cached_property + def _router(self) -> PredictionRouter: + """The PredictionRouter instance responsible for dispatching predict calls.""" + return PredictionRouter(self.another_model, type(self.another_model)) - # ------------------------------------------------------------------ - # The only overridable prediction hook in the base - # ------------------------------------------------------------------ - - def predict_default( - self, - model_input: list[Resource], - **kwargs: Any, - ) -> PredictionResult: - """Default prediction on entire resources. - - Override this in your subclass. - """ - raise NotImplementedError( - "predict_default() must be implemented in your DatamintModel subclass." - ) + def load_context(self, context: PythonModelContext) -> None: + self.another_model.load_context(context) + def predict(self, model_input: list[Resource], params: dict[str, Any] | None = None) -> PredictionResult: + return self.another_model.predict(model_input, params) + + def get_supported_modes(self) -> list[str]: + return self.another_model.get_supported_modes() + + def predict_default(self, model_input: list[Resource], **kwargs: Any) -> PredictionResult: + return self.another_model.predict_default(model_input, **kwargs) \ No newline at end of file diff --git a/datamint/mlflow/flavors/model_loader.py b/datamint/mlflow/flavors/model_loader.py index b2c0eb63..05a37eac 100644 --- a/datamint/mlflow/flavors/model_loader.py +++ b/datamint/mlflow/flavors/model_loader.py @@ -1,82 +1,51 @@ """ Extracted model lifecycle management for DatamintModel. -Owns URI resolution, lazy model loading, device detection, cache lifecycle, +Owns URI resolution, lazy model loading, cache lifecycle, and serialization — all previously interleaved in the monolithic DatamintModel. """ -from __future__ import annotations - import logging import os from collections.abc import Callable from typing import Any -from mlflow.environment_variables import MLFLOW_DEFAULT_PREDICTION_DEVICE from mlflow.pyfunc import PyFuncModel from mlflow.pyfunc import load_model as pyfunc_load_model -from mlflow.pyfunc.model import PythonModelContext from mlflow.pytorch import load_model as pytorch_load_model +import torch _LOGGER = logging.getLogger(__name__) LINKED_MODELS_DIR = "linked_models" -_CACHED_ATTRS = frozenset({"_mlflow_models", "_mlflow_torch_models", "_inference_device"}) +_CACHED_ATTRS = frozenset({"_mlflow_models", "_mlflow_torch_models", "_device"}) class LinkedModelLoader: - """Owns URI resolution, lazy model loading, device management, and cache lifecycle. + """Owns URI resolution, lazy model loading, and cache lifecycle. Extracted from DatamintModel so prediction routing and model lifecycle are independent. + Device detection is handled by :class:`~datamint.mlflow.flavors.model.BaseDatamintModel`. """ def __init__( self, mlflow_models_uri: dict[str, str] | None = None, mlflow_torch_models_uri: dict[str, str] | None = None, + torch_model: torch.nn.Module | None = None, ) -> None: self.mlflow_models_uri: dict[str, str] = (mlflow_models_uri or {}).copy() self.mlflow_torch_models_uri: dict[str, str] = (mlflow_torch_models_uri or {}).copy() - - # --- Device ----------------------------------------------------------- - - @property - def inference_device(self) -> str: - if hasattr(self, "_inference_device") and self._inference_device is not None: - return self._inference_device - env_device = MLFLOW_DEFAULT_PREDICTION_DEVICE.get() - if env_device: - _LOGGER.info("Inference device not set; getting from environment variable (%s)", env_device) - return env_device - _LOGGER.warning("Inference device not set; defaulting to 'cpu'") - return "cpu" - - def detect_device(self, context: PythonModelContext | None = None) -> str: - import torch - - device = None - if context and context.model_config: - device = context.model_config.get("device", None) - _LOGGER.info("Model config device: %s", device) - if device is None: - env_device = MLFLOW_DEFAULT_PREDICTION_DEVICE.get() - if env_device: - device = env_device - elif torch.cuda.is_available(): - device = "cuda" - else: - device = "cpu" - - _LOGGER.info("Set inference device: %s", device) - self._inference_device = device - return device + self._torch_model_instance: torch.nn.Module | None = torch_model # --- Loading ---------------------------------------------------------- - def load_all(self, context: PythonModelContext | None = None) -> None: - self.detect_device(context) - self._mlflow_models = self._load_pyfunc_models() - self._mlflow_torch_models = self._load_torch_models() + def load_all(self, device: str) -> None: + self._device = device + self._mlflow_models = self._load_pyfunc_models(device) + self._mlflow_torch_models = self._load_torch_models(device) + if self._torch_model_instance and hasattr(self._torch_model_instance, "eval"): + self._torch_model_instance.eval() def _resolve_uri(self, uri: str) -> str: if os.path.exists(uri): @@ -98,19 +67,19 @@ def _load_generic( _LOGGER.info("Loaded model '%s' from %s", name, resolved) return loaded - def _load_pyfunc_models(self) -> dict[str, PyFuncModel]: + def _load_pyfunc_models(self, device: str) -> dict[str, PyFuncModel]: return self._load_generic( self.mlflow_models_uri, pyfunc_load_model, - model_config={"device": self.inference_device}, + model_config={"device": device}, ) - def _load_torch_models(self) -> dict[str, Any]: + def _load_torch_models(self, device: str) -> dict[str, Any]: models = self._load_generic( self.mlflow_torch_models_uri, pytorch_load_model, - device=self.inference_device, - map_location=self.inference_device, + device=device, + map_location=device, ) for m in models.values(): if hasattr(m, "eval"): @@ -123,15 +92,18 @@ def _load_torch_models(self) -> dict[str, Any]: def mlflow_models(self) -> dict[str, PyFuncModel]: if not hasattr(self, "_mlflow_models"): _LOGGER.warning("Loading MLflow models on first access") - self._mlflow_models = self._load_pyfunc_models() + self._mlflow_models = self._load_pyfunc_models(getattr(self, "_device", "cpu")) return self._mlflow_models @property def torch_models(self) -> dict[str, Any]: if not hasattr(self, "_mlflow_torch_models"): _LOGGER.warning("Loading MLflow PyTorch models on first access") - self._mlflow_torch_models = self._load_torch_models() - return self._mlflow_torch_models + self._mlflow_torch_models = self._load_torch_models(getattr(self, "_device", "cpu")) + ret = self._mlflow_torch_models.copy() + if self._torch_model_instance: + ret["default"] = self._torch_model_instance + return ret # --- Linked model URIs ------------------------------------------------ @@ -153,4 +125,3 @@ def __getstate__(self) -> dict: def __setstate__(self, state: dict) -> None: self.__dict__.update(state) - self.clear_cache() diff --git a/datamint/mlflow/flavors/prediction_modes.py b/datamint/mlflow/flavors/prediction_modes.py new file mode 100644 index 00000000..29962926 --- /dev/null +++ b/datamint/mlflow/flavors/prediction_modes.py @@ -0,0 +1,42 @@ +""" +Prediction mode enumeration for DataMint models. +""" + +from enum import Enum + + +class PredictionMode(str, Enum): + """ + Enumeration of supported prediction modes. + + Each mode corresponds to a specific method signature in DatamintModel. + """ + # Standard modes + DEFAULT = 'default' # Default: process entire resource as-is + + # Simple modes + IMAGE = 'image' # Process single 2d image resource + + # Video/temporal modes + FRAME = 'frame' # Extract and process specific frame + FRAME_RANGE = 'frame_range' # Process contiguous frame range + ALL_FRAMES = 'all_frames' # Process all frames independently + TEMPORAL_SEQUENCE = 'temporal_sequence' # Process with temporal context window + + # 3D volume modes + SLICE = 'slice' # Extract and process specific slice + SLICE_RANGE = 'slice_range' # Process contiguous slice range + PRIMARY_SLICE = 'primary_slice' # Process center/primary slice + # MULTI_PLANE = 'multi_plane' # Process multiple anatomical planes + VOLUME = 'volume' # Process entire 3D volume + + # Spatial modes + # ROI = 'roi' # Process single region of interest + # MULTI_ROI = 'multi_roi' # Process multiple regions + # TILE = 'tile' # Split into tiles (whole slide imaging) + # PATCH = 'patch' # Extract patches around points + + # Advanced modes + INTERACTIVE = 'interactive' # With user prompts (SAM-like) + FEW_SHOT = 'few_shot' # With context examples + # MULTI_VIEW = 'multi_view' # Multiple views of same subject diff --git a/datamint/mlflow/flavors/prediction_router.py b/datamint/mlflow/flavors/prediction_router.py index 419dccff..b6d5d4a1 100644 --- a/datamint/mlflow/flavors/prediction_router.py +++ b/datamint/mlflow/flavors/prediction_router.py @@ -10,10 +10,8 @@ import logging from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from datamint.mlflow.flavors.model import PredictionMode +from typing import Any +from .prediction_modes import PredictionMode _LOGGER = logging.getLogger(__name__) @@ -66,52 +64,70 @@ class PredictionRouter: _RESERVED_PARAMS = frozenset({"mode", "confidence_threshold"}) - def __init__(self, model_instance: Any, base_class: type) -> None: - from .model import PredictionMode # local import to avoid circular deps - - self._PredictionMode = PredictionMode - self._model = model_instance - self._base_class = base_class - self._registry: dict[PredictionMode, tuple[Callable, ModeSpec]] = {} - self._discover() + def __init__(self, model_instance: Any, + base_class: type | None = None) -> None: + self._registry = self.discover(model_instance, base_class) # ------------------------------------------------------------------ # Discovery # ------------------------------------------------------------------ - def _discover(self) -> None: + @staticmethod + def discover(model: Any, base_class: type | None) -> dict[PredictionMode, tuple[Callable, ModeSpec]]: """Build the mode -> handler registry from the model instance.""" - PredictionMode = self._PredictionMode - - # Pass 1: decorator-based - for attr_name in dir(self._model): - if attr_name.startswith("_"): - continue - method = getattr(self._model, attr_name, None) - spec: ModeSpec | None = getattr(method, "_mode_spec", None) - if spec is not None: - self._registry[spec.mode] = (method, spec) + registry: dict[PredictionMode, tuple] = {} + + # Pass 1: decorator-based — walk the MRO __dict__ to avoid triggering + # arbitrary property getters or descriptors on the instance. + for cls in type(model).__mro__: + for attr_name, raw in vars(cls).items(): + if attr_name.startswith("_"): + continue + spec: ModeSpec | None = getattr(raw, "_mode_spec", None) + if spec is not None and spec.mode not in registry: + registry[spec.mode] = (getattr(model, attr_name), spec) # Pass 2: convention-named (backward compat), skip if already registered for mode in PredictionMode: - if mode in self._registry: + if mode in registry: continue method_name = f"predict_{mode.value}" - method = getattr(self._model, method_name, None) + method = getattr(model, method_name, None) if method is None: continue # Skip if the method is the unoverridden base-class stub - base_method = getattr(self._base_class, method_name, None) - if base_method is not None and getattr(method, "__func__", None) is base_method: - continue - self._registry[mode] = (method, ModeSpec(mode=mode)) + if base_class is not None: + base_method = getattr(base_class, method_name, None) + if base_method is not None and getattr(method, "__func__", None) is base_method: + continue + registry[mode] = (method, ModeSpec(mode=mode)) + + return registry + + def update_registry(self, model_instance: Any, + base_class: type | None = None, + overwrite: bool = False) -> None: + """Update the registry with handlers from a new model instance (e.g. linked model).""" + new_entries = self.discover(model_instance, base_class) + for mode, entry in new_entries.items(): + if mode in self._registry: + if not overwrite: + _LOGGER.warning( + "Prediction handler for mode '%s' already exists. Use overwrite=True to replace it.", + mode.value, + ) + continue + _LOGGER.info( + "Updating prediction handler for mode '%s' with new handler from linked model instance.", + mode.value, + ) + self._registry[mode] = entry # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def supported_modes(self) -> list[str]: - PredictionMode = self._PredictionMode return [mode.value for mode in PredictionMode if mode in self._registry] def dispatch( @@ -134,8 +150,7 @@ def dispatch( # Internals # ------------------------------------------------------------------ - def _resolve_mode(self, model_input: list, params: dict) -> Any: - PredictionMode = self._PredictionMode + def _resolve_mode(self, model_input: list, params: dict) -> PredictionMode: mode_str = params.get("mode", PredictionMode.DEFAULT.value) try: is_all_image = all( @@ -157,13 +172,13 @@ def _resolve_mode(self, model_input: list, params: dict) -> Any: f"Valid modes: {', '.join(valid)}" ) - def _get_handler(self, mode: Any) -> tuple[Callable, ModeSpec]: - PredictionMode = self._PredictionMode + def _get_handler(self, mode: PredictionMode) -> tuple[Callable, ModeSpec]: if mode in self._registry: return self._registry[mode] if PredictionMode.DEFAULT in self._registry: _LOGGER.info("Mode '%s' not implemented, falling back to default", mode.value) return self._registry[PredictionMode.DEFAULT] + available = self.supported_modes() raise NotImplementedError( f"Prediction mode '{mode.value}' is not supported by this model.\n" diff --git a/datamint/mlflow/lightning/callbacks/__init__.py b/datamint/mlflow/lightning/callbacks/__init__.py index b2c54ca0..4d5d971f 100644 --- a/datamint/mlflow/lightning/callbacks/__init__.py +++ b/datamint/mlflow/lightning/callbacks/__init__.py @@ -1 +1,5 @@ -from .modelcheckpoint import MLFlowModelCheckpoint \ No newline at end of file +from .modelcheckpoint import ( + MLFlowModelCheckpoint, + MLFlowPyTorchModelCheckpoint, + MLFlowDatamintModelCheckpoint, +) \ No newline at end of file diff --git a/datamint/mlflow/lightning/callbacks/modelcheckpoint.py b/datamint/mlflow/lightning/callbacks/modelcheckpoint.py index a86773ab..2292859d 100644 --- a/datamint/mlflow/lightning/callbacks/modelcheckpoint.py +++ b/datamint/mlflow/lightning/callbacks/modelcheckpoint.py @@ -33,7 +33,14 @@ def help_infer_signature(x): return x -class MLFlowModelCheckpoint(ModelCheckpoint): +class _BaseMLFlowModelCheckpoint(ModelCheckpoint): + """Base class for MLflow-integrated model checkpoint callbacks. + + Provides all shared logic for checkpointing, model registration, signature updates, + and metric logging. Subclasses must implement :meth:`log_model_to_mlflow` and + :meth:`_wrap_forward` for their specific MLflow flavor and signature-inference strategy. + """ + def __init__(self, *args, register_model_name: str | None = None, register_model_on: Literal["train", "val", "test", "predict"] = 'test', @@ -44,20 +51,23 @@ def __init__(self, *args, log_model_metrics: bool = True, **kwargs): """ - MLFlowModelCheckpoint is a custom callback for PyTorch Lightning that integrates with MLFlow to log and register models. - Args: - register_model_name (str | None): The name to register the model under in MLFlow. If None, the model will not be registered. - register_model_on (Literal["train", "val", "test", "predict"]): The stage at which to register the model. It registers at the end of the specified stage. - code_paths (list[str] | None): List of paths to Python files that should be included in the MLFlow model. - log_model_at_end_only (bool): If True, only log the model to MLFlow at the end of the training instead of after every checkpoint save. - additional_metadata (dict[str, Any] | None): Additional metadata to log with the model as a JSON file. - extra_pip_requirements (list[str] | None): Additional pip requirements to include with the MLFlow model. - log_model_metrics (bool): If True, automatically log test metrics to the MLflow LoggedModel entity - after testing. Requires MLflow 3.x with LoggedModel support. Defaults to True. + register_model_name (str | None): The name to register the model under in MLFlow. + If None, the model will not be registered. + register_model_on (Literal["train", "val", "test", "predict"]): The stage at which + to register the model. It registers at the end of the specified stage. + code_paths (list[str] | None): List of paths to Python files that should be + included in the MLFlow model. + log_model_at_end_only (bool): If True, only log the model to MLFlow at the end of + training instead of after every checkpoint save. + additional_metadata (dict[str, Any] | None): Additional metadata to log with the + model as a JSON file. + extra_pip_requirements (list[str] | None): Additional pip requirements to include + with the MLFlow model. + log_model_metrics (bool): If True, automatically log test metrics to the MLflow + LoggedModel entity after testing. Requires MLflow 3.x. Defaults to True. **kwargs: Keyword arguments for ModelCheckpoint. """ - # Ensure MLflow is configured when callback is initialized ensure_mlflow_configured() super().__init__(*args, **kwargs) @@ -87,7 +97,7 @@ def __init__(self, *args, self.code_paths = code_paths self.additional_metadata = additional_metadata or {} self.extra_pip_requirements = extra_pip_requirements or [] - self._last_registered_state_hash: str = "None" + self._last_registered_state_hash: str | None = None self._has_been_trained: bool = False def _compute_registration_state_hash(self) -> str: @@ -133,45 +143,6 @@ def _should_register_model(self) -> bool: _LOGGER.info("Model already registered with same configuration. Skipping registration.") return False - def _infer_params(self, model: nn.Module) -> tuple[dict, ...]: - """Extract metadata from the model's forward method signature. - - Returns: - A tuple of dicts, each containing parameter metadata ordered by position. - """ - forward_method = getattr(model.__class__, 'forward', None) - - if forward_method is None: - return () - - try: - sig = inspect.signature(forward_method) - params_list = [] - - for param_name, param in sig.parameters.items(): - if param_name == 'self': - continue - - param_info = { - 'name': param_name, - 'kind': param.kind.name, - 'annotation': param.annotation if param.annotation != inspect.Parameter.empty else None, - 'default': param.default if param.default != inspect.Parameter.empty else None, - } - params_list.append(param_info) - - # Add return annotation if available as the last element - return_annotation = sig.return_annotation - if return_annotation != inspect.Signature.empty: - return_info = {'_return_annotation': str(return_annotation)} - params_list.append(return_info) - - return tuple(params_list) - - except Exception as e: - _LOGGER.warning(f"Failed to infer forward method parameters: {e}") - return () - def _save_checkpoint(self, trainer: L.Trainer, filepath: str) -> None: trainer.save_checkpoint(filepath, self.save_weights_only) @@ -210,45 +181,12 @@ def log_additional_metadata(self, logger: MLFlowLogger | L.Trainer, def log_model_to_mlflow(self, model: nn.Module, - run_id: str | MLFlowLogger - ) -> None: - """Log the model to MLflow.""" - if isinstance(run_id, MLFlowLogger): - logger = run_id - if logger.run_id is None: - raise ValueError("MLFlowLogger has no run_id. Cannot log model to MLFlow.") - run_id = logger.run_id - - if self._last_checkpoint_saved is None or self._last_checkpoint_saved == '': - _LOGGER.warning("No checkpoint saved yet. Cannot log model to MLFlow.") - return - - orig_device = next(model.parameters()).device - model = model.cpu() # Ensure the model is on CPU for logging - - requirements = list(self.extra_pip_requirements) - # check if lightning is in the requirements - if not any('lightning' in req.lower() for req in requirements): - requirements.append(f'lightning=={L.__version__}') - - modelinfo = mlflow.pytorch.log_model( - pytorch_model=model, - name=Path(self._last_checkpoint_saved).stem, - signature=self._inferred_signature, - run_id=run_id, - extra_pip_requirements=requirements, - code_paths=self.code_paths - ) + run_id: str | MLFlowLogger) -> None: + """Log the model to MLflow using the appropriate flavor. - model.to(device=orig_device) # Move the model back to its original device - self._last_model_uri = modelinfo.model_uri - self._last_model_id = getattr(modelinfo, 'model_id', None) - self.last_saved_model_info = modelinfo - - # Log additional metadata after the model is saved - log_model_metadata(self.additional_metadata, - model_path=modelinfo.artifact_path, - run_id=run_id) + Must be implemented by subclasses. + """ + raise NotImplementedError def _remove_checkpoint(self, trainer: L.Trainer, filepath: str) -> None: super()._remove_checkpoint(trainer, filepath) @@ -297,36 +235,16 @@ def _update_signature(self, trainer): except mlflow.exceptions.MlflowException as e: _LOGGER.warning(f"Failed to update model signature. Check if model actually exists. {e}") - def __wrap_forward(self, pl_module: nn.Module): - original_forward = pl_module.forward - - def wrapped_forward(x, *args, **kwargs): - x0 = help_infer_signature(x) - infered_params = self._infer_params(pl_module) - if len(infered_params) > 1: - infered_params = {param['name']: param['default'] - for param in infered_params[1:] if 'name' in param} - else: - infered_params = None - - self._inferred_signature = mlflow.models.infer_signature(model_input=x0, - params=infered_params) - - # run once and get back to the original forward - pl_module.forward = original_forward - method = getattr(pl_module, 'forward') - out = method(x, *args, **kwargs) - - output_sig = mlflow.models.infer_signature(model_output=help_infer_signature(out)) - self._inferred_signature.outputs = output_sig.outputs - - return out + def _wrap_forward(self, pl_module: nn.Module) -> None: + """Intercept the first forward call to infer the MLflow model signature. - pl_module.forward = wrapped_forward + Must be implemented by subclasses. + """ + raise NotImplementedError def on_train_start(self, trainer, pl_module): self._has_been_trained = True - self.__wrap_forward(pl_module) + self._wrap_forward(pl_module) logger = _get_MLFlowLogger(trainer) if logger._tracking_uri.startswith('file:'): _LOGGER.error("MLFlowLogger tracking URI is a local file path. " @@ -387,12 +305,12 @@ def _restore_model_uri(self, trainer: L.Trainer) -> None: self.last_saved_model_info = None def on_test_start(self, trainer, pl_module): - self.__wrap_forward(pl_module) + self._wrap_forward(pl_module) self._restore_model_uri(trainer) return super().on_test_start(trainer, pl_module) def on_predict_start(self, trainer, pl_module): - self.__wrap_forward(pl_module) + self._wrap_forward(pl_module) self._restore_model_uri(trainer) return super().on_predict_start(trainer, pl_module) @@ -454,3 +372,173 @@ def on_validation_end(self, trainer: L.Trainer, pl_module: L.LightningModule) -> if self.register_model_on == 'val' and self.register_model_name: self._update_signature(trainer) self.register_model(trainer) + + +class MLFlowPyTorchModelCheckpoint(_BaseMLFlowModelCheckpoint): + """MLflow model checkpoint for standard PyTorch Lightning modules. + + Logs models using :func:`mlflow.pytorch.log_model` and infers the MLflow + model signature by intercepting the first call to ``pl_module.forward``. + """ + + def _infer_params(self, model: nn.Module) -> tuple[dict, ...]: + """Extract metadata from the model's forward method signature. + + Returns: + A tuple of dicts, each containing parameter metadata ordered by position. + """ + forward_method = getattr(model.__class__, 'forward', None) + + if forward_method is None: + return () + + try: + sig = inspect.signature(forward_method) + params_list = [] + + for param_name, param in sig.parameters.items(): + if param_name == 'self': + continue + + param_info = { + 'name': param_name, + 'kind': param.kind.name, + 'annotation': param.annotation if param.annotation != inspect.Parameter.empty else None, + 'default': param.default if param.default != inspect.Parameter.empty else None, + } + params_list.append(param_info) + + # Add return annotation if available as the last element + return_annotation = sig.return_annotation + if return_annotation != inspect.Signature.empty: + return_info = {'_return_annotation': str(return_annotation)} + params_list.append(return_info) + + return tuple(params_list) + + except Exception as e: + _LOGGER.warning(f"Failed to infer forward method parameters: {e}") + return () + + def _wrap_forward(self, pl_module: nn.Module) -> None: + """Wrap ``pl_module.forward`` to infer the MLflow signature on the first call.""" + original_forward = pl_module.forward + + def wrapped_forward(x, *args, **kwargs): + x0 = help_infer_signature(x) + infered_params = self._infer_params(pl_module) + if len(infered_params) > 1: + infered_params = {param['name']: param['default'] + for param in infered_params[1:] if 'name' in param} + else: + infered_params = None + + self._inferred_signature = mlflow.models.infer_signature(model_input=x0, + params=infered_params) + + # run once and get back to the original forward + pl_module.forward = original_forward + method = getattr(pl_module, 'forward') + out = method(x, *args, **kwargs) + + output_sig = mlflow.models.infer_signature(model_output=help_infer_signature(out)) + self._inferred_signature.outputs = output_sig.outputs + + return out + + pl_module.forward = wrapped_forward + + def log_model_to_mlflow(self, + model: nn.Module, + run_id: str | MLFlowLogger) -> None: + """Log the model to MLflow using the pytorch flavor.""" + if isinstance(run_id, MLFlowLogger): + logger = run_id + if logger.run_id is None: + raise ValueError("MLFlowLogger has no run_id. Cannot log model to MLFlow.") + run_id = logger.run_id + + if self._last_checkpoint_saved is None or self._last_checkpoint_saved == '': + _LOGGER.warning("No checkpoint saved yet. Cannot log model to MLFlow.") + return + + orig_device = next(model.parameters()).device + model = model.cpu() # Ensure the model is on CPU for logging + + requirements = list(self.extra_pip_requirements) + if not any('lightning' in req.lower() for req in requirements): + requirements.append(f'lightning=={L.__version__}') + + _LOGGER.debug("Logging model using pytorch flavor with name %s", Path(self._last_checkpoint_saved).stem) + modelinfo = mlflow.pytorch.log_model( + pytorch_model=model, + name=Path(self._last_checkpoint_saved).stem, + signature=self._inferred_signature, + run_id=run_id, + extra_pip_requirements=requirements, + code_paths=self.code_paths, + ) + + model.to(device=orig_device) # Move the model back to its original device + self._last_model_uri = modelinfo.model_uri + self._last_model_id = getattr(modelinfo, 'model_id', None) + self.last_saved_model_info = modelinfo + + log_model_metadata(self.additional_metadata, + model_path=modelinfo.artifact_path, + run_id=run_id) + + +class MLFlowDatamintModelCheckpoint(_BaseMLFlowModelCheckpoint): + """MLflow model checkpoint for :class:`~datamint.mlflow.flavors.model.BaseDatamintModel`-based + Lightning modules. + + Logs models using the datamint custom flavor (which wraps ``mlflow.pyfunc``). + Signature inference is delegated to the datamint flavor via ``predict_type_hints``, + so no forward-wrapping is performed. + """ + + def _wrap_forward(self, pl_module: nn.Module) -> None: + # Signature inference is delegated to the datamint flavor; nothing to do here. + pass + + def log_model_to_mlflow(self, + model: nn.Module, + run_id: str | MLFlowLogger) -> None: + """Log the model to MLflow using the datamint flavor.""" + if isinstance(run_id, MLFlowLogger): + logger = run_id + if logger.run_id is None: + raise ValueError("MLFlowLogger has no run_id. Cannot log model to MLFlow.") + run_id = logger.run_id + + if self._last_checkpoint_saved is None or self._last_checkpoint_saved == '': + _LOGGER.warning("No checkpoint saved yet. Cannot log model to MLFlow.") + return + + requirements = list(self.extra_pip_requirements) + if not any('lightning' in req.lower() for req in requirements): + requirements.append(f'lightning=={L.__version__}') + + from datamint.mlflow.flavors import datamint_flavor + _LOGGER.debug("Logging model using datamint flavor with name %s", Path(self._last_checkpoint_saved).stem) + modelinfo = datamint_flavor.log_model( + model, + name=Path(self._last_checkpoint_saved).stem, + signature=self._inferred_signature, + run_id=run_id, + extra_pip_requirements=requirements, + code_paths=self.code_paths, + ) + + self._last_model_uri = modelinfo.model_uri + self._last_model_id = getattr(modelinfo, 'model_id', None) + self.last_saved_model_info = modelinfo + + log_model_metadata(self.additional_metadata, + model_path=modelinfo.artifact_path, + run_id=run_id) + + +# Backward-compatibility alias +MLFlowModelCheckpoint = MLFlowPyTorchModelCheckpoint diff --git a/pyproject.toml b/pyproject.toml index 639c242e..1524517a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "datamint" description = "A library for interacting with the Datamint API, designed for efficient data management, processing and Deep Learning workflows." -version = "2.11.6" +version = "2.12.0a0" dynamic = ["dependencies"] requires-python = ">=3.10" readme = "README.md" From f923ad12badd63e1998e0f894411b91ff0ad5f99 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 25 Mar 2026 17:24:35 -0300 Subject: [PATCH 27/47] Enhance annotation handling and inference job predictions; update version to 2.12.0a1 --- datamint/api/endpoints/inference_api.py | 6 ++-- datamint/entities/annotations/__init__.py | 33 +++++++++++++++++++ datamint/entities/annotations/annotation.py | 2 +- .../entities/annotations/base_segmentation.py | 30 +++++++++++++++-- .../annotations/image_segmentation.py | 24 -------------- datamint/entities/inferencejob.py | 26 +++++++++++++++ pyproject.toml | 2 +- 7 files changed, 91 insertions(+), 32 deletions(-) diff --git a/datamint/api/endpoints/inference_api.py b/datamint/api/endpoints/inference_api.py index a6ac8d1e..1391bf2a 100644 --- a/datamint/api/endpoints/inference_api.py +++ b/datamint/api/endpoints/inference_api.py @@ -75,7 +75,7 @@ def submit( model_version: int | None = None, model_alias: str | None = None, resource_id: str | None = None, - resource_ids: list[str] | None = None, + # resource_ids: list[str] | None = None, file_path: str | None = None, file_paths: list[str] | None = None, save_results: bool = False, @@ -106,8 +106,8 @@ def submit( save_results=save_results, params=params, ) - if resource_ids is not None: - payload["resource_ids"] = resource_ids + # if resource_ids is not None: + # payload["resource_ids"] = resource_ids if file_paths is not None: payload["file_paths"] = file_paths diff --git a/datamint/entities/annotations/__init__.py b/datamint/entities/annotations/__init__.py index 1fbe4705..a2769f56 100644 --- a/datamint/entities/annotations/__init__.py +++ b/datamint/entities/annotations/__init__.py @@ -4,10 +4,43 @@ from .volume_segmentation import VolumeSegmentation from datamint.api.dto import AnnotationType # FIXME: move this to this module + +def annotation_from_dict(data: dict) -> Annotation: + """Factory: map a raw annotation dict to the appropriate Annotation subclass. + + Dispatches on ``annotation_type``: + + * ``'segmentation'`` with a ``class_map`` → :class:`VolumeSegmentation` + * ``'segmentation'`` without ``class_map`` → :class:`ImageSegmentation` + * anything else → :class:`Annotation` + + ``segmentation_data`` dicts are automatically deserialised by the + Pydantic ``BeforeValidator`` defined on + :class:`~.base_segmentation.BaseSegmentationAnnotation`. + ``class_map`` string keys (produced by JSON serialisation) are + coerced to ``int`` by Pydantic's lax validation. + + Args: + data: Raw annotation dict as returned by the API. + + Returns: + A concrete :class:`Annotation` subclass instance. + """ + annotation_type = data.get('annotation_type', '') + + if annotation_type in (AnnotationType.SEGMENTATION, AnnotationType.SEGMENTATION.value): + if data.get('class_map') is not None: + return VolumeSegmentation(**data) + return ImageSegmentation(**data) + + return Annotation(**data) + + __all__ = [ "ImageClassification", "ImageSegmentation", "Annotation", "VolumeSegmentation", "AnnotationType", + "annotation_from_dict", ] diff --git a/datamint/entities/annotations/annotation.py b/datamint/entities/annotations/annotation.py index e78626f7..2bad1994 100644 --- a/datamint/entities/annotations/annotation.py +++ b/datamint/entities/annotations/annotation.py @@ -71,7 +71,7 @@ class Annotation(AnnotationBase): text_value: Optional text value associated with the annotation. numeric_value: Optional numeric value associated with the annotation. units: Optional units for numeric_value. - geometry: Optional geometry payload (e.g., polygons, masks) as a list. + geometry: Optional geometry payload (e.g., polygons) as a list. created_at: ISO timestamp for when the annotation was created. created_by: Email or identifier of the creating user. annotation_worklist_id: Optional worklist ID associated with the annotation. diff --git a/datamint/entities/annotations/base_segmentation.py b/datamint/entities/annotations/base_segmentation.py index 759cf10d..8a6f92b3 100644 --- a/datamint/entities/annotations/base_segmentation.py +++ b/datamint/entities/annotations/base_segmentation.py @@ -15,7 +15,7 @@ import numpy as np from PIL import Image -from pydantic import BeforeValidator, PlainSerializer +from pydantic import BeforeValidator, PlainSerializer, Field from .annotation import Annotation from datamint.types import ImagingData @@ -145,7 +145,7 @@ def _serialize_segmentation_data( def _deserialize_segmentation_data( - value: dict | np.ndarray | Image.Image | Any | None, + value: dict | np.ndarray | Image.Image | Any | list | None, ) -> np.ndarray | Image.Image | Any | None: """Deserialise segmentation data from a JSON-compatible dict or pass-through native types. @@ -159,6 +159,9 @@ def _deserialize_segmentation_data( if isinstance(value, (np.ndarray, Image.Image, Nifti1Image)): return value + if isinstance(value, list): + return np.array(value) + if not isinstance(value, dict): raise ValueError(f"Cannot deserialise segmentation_data from {type(value)}") @@ -258,7 +261,7 @@ class BaseSegmentationAnnotation(Annotation): subclasses that need to convert to/from ``bytes``. """ - segmentation_data: SegmentationDataType = None + segmentation_data: Annotated[SegmentationDataType, Field(alias='mask')] = None # ------------------------------------------------------------------ # fetch_file_data @@ -377,3 +380,24 @@ def _from_raw_bytes(raw: bytes, as_pil: bool = False) -> np.ndarray | Image.Imag pass raise ValueError("Could not decode bytes as NIfTI or PIL Image") + + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + @property + def mask(self) -> np.ndarray | Image.Image | Any | None: + """Alias for :attr:`segmentation_data`.""" + return self.segmentation_data + + @property + def mask_shape(self) -> tuple[int, ...] | None: + """ + Shape of the stored mask. + """ + data = self.segmentation_data + if data is None: + return None + if isinstance(data, Image.Image): + return (data.height, data.width) + return data.shape \ No newline at end of file diff --git a/datamint/entities/annotations/image_segmentation.py b/datamint/entities/annotations/image_segmentation.py index 3a21df05..e07c0db7 100644 --- a/datamint/entities/annotations/image_segmentation.py +++ b/datamint/entities/annotations/image_segmentation.py @@ -101,30 +101,6 @@ def _validate_mask_array(arr: np.ndarray) -> np.ndarray: # Normalise to strict binary {0, 1} return (arr > 0).astype(np.uint8) - # ------------------------------------------------------------------ - # Properties - # ------------------------------------------------------------------ - - @property - def mask(self) -> np.ndarray | Image.Image | None: - """Alias for :attr:`segmentation_data`.""" - return self.segmentation_data - - @property - def mask_shape(self) -> tuple[int, int] | None: - """ - Shape of the stored mask. - - Returns: - ``(H, W)`` or ``None`` if no mask is stored. - """ - data = self.segmentation_data - if data is None: - return None - if isinstance(data, Image.Image): - return (data.height, data.width) - return data.shape # type: ignore[return-value] - # ------------------------------------------------------------------ # Conversion helpers # ------------------------------------------------------------------ diff --git a/datamint/entities/inferencejob.py b/datamint/entities/inferencejob.py index 958bfd6b..03987d1d 100644 --- a/datamint/entities/inferencejob.py +++ b/datamint/entities/inferencejob.py @@ -1,12 +1,19 @@ from __future__ import annotations +import logging from typing import Any, TYPE_CHECKING from collections.abc import Callable from datamint.entities.base_entity import BaseEntity, MISSING_FIELD +from datamint.entities.annotations import annotation_from_dict if TYPE_CHECKING: + import numpy as np + from matplotlib.figure import Figure from datamint.api.endpoints.inference_api import InferenceApi + from datamint.entities.annotations import Annotation + +_LOGGER = logging.getLogger(__name__) class InferenceJob(BaseEntity): @@ -32,6 +39,25 @@ class InferenceJob(BaseEntity): def is_finished(self) -> bool: """Whether the job has reached a terminal state.""" return self.status.lower() in {'completed', 'failed', 'cancelled', 'error'} + + @property + def predictions(self) -> 'list[list[Annotation]] | None': + """ + Returns a list of annotations resulting from this inference job, if available. + + Each element of the outer list corresponds to one input resource; + the inner list contains the annotations produced for that resource. + + Returns: + ``list[list[Annotation]]`` (one outer list for each input resource) or ``None`` when no predictions are + stored in :attr:`result_data`. + """ + if self.result_data and 'predictions' in self.result_data: + return [ + [annotation_from_dict(ann) for ann in group] + for group in self.result_data['predictions'] + ] + return None def wait( self, diff --git a/pyproject.toml b/pyproject.toml index 1524517a..0feb4d34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "datamint" description = "A library for interacting with the Datamint API, designed for efficient data management, processing and Deep Learning workflows." -version = "2.12.0a0" +version = "2.12.0a1" dynamic = ["dependencies"] requires-python = ">=3.10" readme = "README.md" From 82cdd59c01ab8f52a854705e9c3672c39caef21c Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Sat, 28 Mar 2026 10:51:29 -0300 Subject: [PATCH 28/47] Add exists_ok parameter to create methods in ProjectsApi and UsersApi; enhance entity creation handling --- datamint/api/endpoints/projects_api.py | 21 +++++++++--- datamint/api/endpoints/users_api.py | 8 +++-- datamint/api/entity_base_api.py | 46 +++++++++++++++++++++----- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/datamint/api/endpoints/projects_api.py b/datamint/api/endpoints/projects_api.py index d178a645..8c66e097 100644 --- a/datamint/api/endpoints/projects_api.py +++ b/datamint/api/endpoints/projects_api.py @@ -5,6 +5,7 @@ from datamint.entities.project import Project import httpx from datamint.entities.resource import Resource +from datamint.exceptions import EntityAlreadyExistsError if TYPE_CHECKING: from . import AnnotationSetsApi, ResourcesApi from datamint.entities.annotations.annotation_spec import AnnotationSpec @@ -52,7 +53,8 @@ def create(self, two_up_display: bool = False, segmentation_spec: Literal['single_label', 'multi_label'] = 'single_label', *, - return_entity: Literal[True] = True + return_entity: Literal[True] = True, + exists_ok: bool = False ) -> Project: ... @overload @@ -64,7 +66,8 @@ def create(self, two_up_display: bool = False, segmentation_spec: Literal['single_label', 'multi_label'] = 'single_label', *, - return_entity: Literal[False] + return_entity: Literal[False], + exists_ok: bool = False ) -> str: ... def create(self, @@ -75,7 +78,8 @@ def create(self, two_up_display: bool = False, segmentation_spec: Literal['single_label', 'multi_label'] = 'single_label', *, - return_entity: bool = True + return_entity: bool = True, + exists_ok: bool = False ) -> str | Project: """Create a new project. @@ -86,10 +90,19 @@ def create(self, is_active_learning: Whether the project is an active learning project or not. two_up_display: Allow annotators to display multiple resources for annotation. return_entity: Whether to return the created Project instance or just its ID. + exists_ok: If ``True``, do not raise an error when a project with the same + name already exists. Instead, the existing project is returned when + possible. Returns: The id of the created project. """ + proj = self.get_by_name(name, include_archived=True) + if proj is not None: + if exists_ok: + return proj if return_entity else proj.id + else: + raise EntityAlreadyExistsError(entity_type='Project', params={'name': name}) resources_ids = resources_ids or [] project_data = {'name': name, @@ -104,7 +117,7 @@ def create(self, "require_review": False, 'description': description} - return self._create(project_data, return_entity=return_entity) + return self._create(project_data, return_entity=return_entity, exists_ok=exists_ok) # type: ignore[return-value] def get_all(self, limit: int | None = None) -> Sequence[Project]: """Get all projects. diff --git a/datamint/api/endpoints/users_api.py b/datamint/api/endpoints/users_api.py index 0df4053f..4d87803b 100644 --- a/datamint/api/endpoints/users_api.py +++ b/datamint/api/endpoints/users_api.py @@ -14,7 +14,8 @@ def create(self, password: str | None = None, firstname: str | None = None, lastname: str | None = None, - roles: list[str] | None = None + roles: list[str] | None = None, + exists_ok: bool = False ) -> str: """Create a new user. @@ -24,6 +25,9 @@ def create(self, firstname: The user's first name. lastname: The user's last name. roles: List of roles to assign to the user. + exists_ok: If ``True``, do not raise an error when a user with the same + email already exists. Instead, the existing user's id is returned when + possible. Returns: The id of the created user. @@ -35,4 +39,4 @@ def create(self, lastname=lastname, roles=roles ) - return self._create(data) + return self._create(data, exists_ok=exists_ok) diff --git a/datamint/api/entity_base_api.py b/datamint/api/entity_base_api.py index 54033211..f221c000 100644 --- a/datamint/api/entity_base_api.py +++ b/datamint/api/entity_base_api.py @@ -298,26 +298,55 @@ class CreatableEntityApi(EntityBaseApi[T]): @overload def _create(self, entity_data: dict[str, Any], - return_entity: Literal[True] = True) -> T | list[T]: ... + return_entity: Literal[True] = True, + exists_ok: bool = False) -> T | list[T] | None: ... @overload def _create(self, entity_data: dict[str, Any], - return_entity: Literal[False]) -> str | list: ... + return_entity: Literal[False], + exists_ok: bool = False) -> str | list | None: ... def _create(self, entity_data: dict[str, Any], - return_entity: bool = False) -> str | T | list: + return_entity: bool = False, + exists_ok: bool = False) -> str | T | list | None: """Create a new entity. Args: entity_data: Dictionary containing entity data for creation. + exists_ok: If ``True``, do not raise an error when the entity already exists + (HTTP 409 Conflict). Instead, attempt to return the existing entity from + the conflict response body. Returns ``None`` if the existing entity cannot + be recovered from the response. Returns: - The id of the created entity. + The id of the created entity, or the entity instance when *return_entity* is + ``True``. Returns ``None`` when *exists_ok* is ``True`` and the entity already + existed but could not be recovered from the response. Raises: - httpx.HTTPStatusError: If creation fails. + httpx.HTTPStatusError: If creation fails (or if the entity already exists and + *exists_ok* is ``False``). """ - response = self._make_request('POST', f'/{self.endpoint_base}', json=entity_data) + try: + response = self._make_request('POST', f'/{self.endpoint_base}', json=entity_data) + except httpx.HTTPStatusError as e: + if exists_ok and e.response.status_code == 409: + try: + existing_data = e.response.json() + if isinstance(existing_data, dict): + if return_entity: + try: + return self._init_entity_obj(**existing_data) + except Exception: + entity_id = existing_data.get('id') + if entity_id: + return self.get_by_id(entity_id) + else: + return existing_data.get('id') + except Exception: + pass + return None + raise respdata = response.json() if isinstance(respdata, str): if return_entity: @@ -341,13 +370,14 @@ def _create(self, entity_data: dict[str, Any], return respdata @overload - def create(self, *args, return_entity: Literal[True] = True, **kwargs) -> T: ... + def create(self, *args, return_entity: Literal[True] = True, exists_ok: bool = False, **kwargs) -> T: ... @overload - def create(self, *args, return_entity: Literal[False], **kwargs) -> str: ... + def create(self, *args, return_entity: Literal[False], exists_ok: bool = False, **kwargs) -> str: ... def create(self, *args, return_entity: bool = True, + exists_ok: bool = False, **kwargs) -> str | T: raise NotImplementedError("Subclasses must implement the create method with their own custom parameters") From ecc62ea6a9e053032cdfdec138039273a162cff8 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Sat, 28 Mar 2026 10:59:47 -0300 Subject: [PATCH 29/47] Enhance InferenceJob and InferenceApi with rich HTML representation and in-place updates; refactor wait method --- datamint/api/endpoints/inference_api.py | 42 ++++--- datamint/entities/base_entity.py | 114 +++++++++++++++++ datamint/entities/inferencejob.py | 158 ++++++++++++++++++++++-- 3 files changed, 289 insertions(+), 25 deletions(-) diff --git a/datamint/api/endpoints/inference_api.py b/datamint/api/endpoints/inference_api.py index 1391bf2a..5858b13f 100644 --- a/datamint/api/endpoints/inference_api.py +++ b/datamint/api/endpoints/inference_api.py @@ -161,7 +161,7 @@ def wait( on_status: Callable[[InferenceJob], None] | None = None, poll_interval: float = 2.0, timeout: float | None = 1800, - ) -> InferenceJob: + ) -> None: """Block until an inference job reaches a terminal state. First attempts to follow the SSE stream. If the stream is @@ -169,7 +169,7 @@ def wait( ``get_status`` at *poll_interval* seconds. Args: - job: Job ID string or ``InferenceJob`` entity. + job: Job ID string or ``InferenceJob`` entity. In-place updates to the provided ``InferenceJob`` are made on every status change. on_status: Optional callback invoked with an updated ``InferenceJob`` each time a status update is received. poll_interval: Seconds between polls when falling back to @@ -177,32 +177,38 @@ def wait( timeout: Maximum seconds to wait. ``None`` means wait indefinitely. Raises ``TimeoutError`` on expiry. - Returns: - The ``InferenceJob`` in its terminal state. - Raises: TimeoutError: If *timeout* is set and the job has not finished within that duration. """ - job_id = self._entid(job) if not isinstance(job, str) else job + job_id = self._entid(job) deadline = (time.monotonic() + timeout) if timeout is not None else None def _check_timeout() -> None: if deadline is not None and time.monotonic() >= deadline: - raise TimeoutError( - f"Inference job {job_id} did not finish within {timeout}s" - ) + raise TimeoutError(f"Inference job {job_id} did not finish within {timeout}s") + + def _notify(event: dict) -> None: + if on_status is None: + return + if isinstance(job, InferenceJob): + # SSE events are partial updates — apply known fields in-place + for key, value in event.items(): + try: + setattr(job, key, value) + except Exception: + pass + on_status(job) + else: + on_status(self.get_status(job_id)) # --- Try SSE stream first --- try: for event in self.stream_status(job_id): _check_timeout() - status_str = event.get('status', '') - current_job = self._parse_job_response(event) - if on_status is not None: - on_status(current_job) - if status_str.lower() in _TERMINAL_STATUSES: - return current_job + _notify(event) + if event.get('status', '').lower() in _TERMINAL_STATUSES: + return except Exception as e: logger.warning(f"SSE stream ended or failed ({e}); falling back to polling") @@ -213,7 +219,7 @@ def _check_timeout() -> None: if on_status is not None: on_status(current_job) if current_job.status.lower() in _TERMINAL_STATUSES: - return current_job + return time.sleep(poll_interval) def cancel(self, job: str | InferenceJob) -> bool: @@ -393,5 +399,5 @@ def predict_volume( response = self._make_request('POST', f'/{self.endpoint_base}/predict-volume', json=payload) data = response.json() return self.get_status(data['job_id']) - - predict = submit # Alias for generic prediction endpoint \ No newline at end of file + + predict = submit # Alias for generic prediction endpoint diff --git a/datamint/entities/base_entity.py b/datamint/entities/base_entity.py index 7d492794..585ea43d 100644 --- a/datamint/entities/base_entity.py +++ b/datamint/entities/base_entity.py @@ -1,5 +1,6 @@ import logging import sys +from html import escape from typing import Any, TYPE_CHECKING from pydantic import ConfigDict, BaseModel, PrivateAttr @@ -17,6 +18,68 @@ # Track logged warnings to avoid duplicates _LOGGED_WARNINGS: set[tuple[str, str]] = set() +# --------------------------------------------------------------------------- +# Jinja2 HTML template for BaseEntity Jupyter repr +# --------------------------------------------------------------------------- +_ENTITY_HTML_TEMPLATE = """\ +
+ + {# ---- Header ---- #} +
+
Entity
+
+

{{ entity_name }}

+
+
+ + {# ---- Fields table ---- #} + {%- if fields %} +
+ + {%- for name, value in fields %} + + + + + {%- endfor %} +
{{ name }} + {{ value }} +
+
+ {%- else %} +
No non-empty fields to display.
+ {%- endif %} + +
+""" + +_entity_template = None + + +def _get_entity_template(): + """Lazily compile and cache the Jinja2 entity template.""" + global _entity_template + if _entity_template is None: + from jinja2 import Environment + _entity_template = Environment(autoescape=True).from_string(_ENTITY_HTML_TEMPLATE) + return _entity_template + class BaseEntity(BaseModel): """ @@ -37,6 +100,57 @@ class BaseEntity(BaseModel): _api: 'EntityBaseApi[Self] | EntityBaseApi' = PrivateAttr() + + def _get_display_fields(self, max_value_len: int = 120) -> list[tuple[str, str]]: + """Collect non-empty, non-default fields for display purposes.""" + json_schema = self.model_json_schema() + required_fields: set[str] = set(json_schema.get('required', [])) + + fields: list[tuple[str, str]] = [] + for name, field_info in json_schema.get('properties', {}).items(): + if name == 'id': + continue + value = getattr(self, name, None) + if value is None or value == '': + continue + if name not in required_fields: + default_value = field_info.get('default') + if default_value == MISSING_FIELD: + continue + if default_value is not None and value == default_value: + continue + display_value = str(value) + if len(display_value) > max_value_len: + display_value = display_value[:max_value_len - 3] + '...' + fields.append((name.replace('_', ' ').title(), display_value)) + + return fields + + def _repr_html_(self) -> str: + """HTML representation for Jupyter Notebooks.""" + entity_id = getattr(self, 'id', None) + fields = self._get_display_fields() + + return _get_entity_template().render( + entity_name=self.__class__.__name__, + entity_id=str(entity_id) if entity_id else None, + fields=fields, + ) + + def __str__(self) -> str: + # entity_id = getattr(self, 'id', None) + fields = self._get_display_fields() + + header = self.__class__.__name__ + # if entity_id is not None: + # header += f" (id={entity_id})" + + if not fields: + return f"{header}\n (no non-empty fields)" + + lines = [header] + [f" {name}: {value}" for name, value in fields] + return "\n".join(lines) + def __init__(self, **data): super().__init__(**data) # check attributes for MISSING_FIELD and delete them diff --git a/datamint/entities/inferencejob.py b/datamint/entities/inferencejob.py index 03987d1d..d778fad5 100644 --- a/datamint/entities/inferencejob.py +++ b/datamint/entities/inferencejob.py @@ -1,20 +1,144 @@ from __future__ import annotations +from collections.abc import Callable import logging from typing import Any, TYPE_CHECKING -from collections.abc import Callable from datamint.entities.base_entity import BaseEntity, MISSING_FIELD from datamint.entities.annotations import annotation_from_dict if TYPE_CHECKING: - import numpy as np - from matplotlib.figure import Figure from datamint.api.endpoints.inference_api import InferenceApi from datamint.entities.annotations import Annotation _LOGGER = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Jinja2 HTML template for InferenceJob Jupyter repr +# --------------------------------------------------------------------------- +_INFERENCE_JOB_HTML_TEMPLATE = """\ +{%- set status_styles = { + 'completed': ('#166534', 'rgba(34, 197, 94, 0.16)', 'rgba(34, 197, 94, 0.32)'), + 'failed': ('#b91c1c', 'rgba(239, 68, 68, 0.16)', 'rgba(239, 68, 68, 0.28)'), + 'error': ('#b91c1c', 'rgba(239, 68, 68, 0.16)', 'rgba(239, 68, 68, 0.28)'), + 'cancelled': ('#475569', 'rgba(148, 163, 184, 0.18)', 'rgba(148, 163, 184, 0.28)'), +} %} +{%- set accent_color, badge_bg, badge_border = status_styles.get( + status_lower, ('#1d4ed8', 'rgba(59, 130, 246, 0.14)', 'rgba(59, 130, 246, 0.26)') +) %} +
+ + {# ---- Header ---- #} +
+
+
+
Inference Job
+
{{ model_name }}
+
+
{{ status }}
+
+ {%- if frame_idx is not none %} +
+ frame {{ frame_idx }} +
+ {%- endif %} +
+ +
+ + {# ---- Progress bar ---- #} + {%- if show_progress %} +
+
+
Progress
+
{{ progress_value }}%
+
+
+
+
+ {%- if current_step %} +
{{ current_step }}
+ {%- endif %} +
+ {%- endif %} + + {# ---- Metric cards ---- #} + {%- set ns = namespace(has_metrics=false) %} + {%- for label, value in metrics %}{% if value %}{% set ns.has_metrics = true %}{% endif %}{% endfor %} + {%- if ns.has_metrics %} +
+ {%- for label, value in metrics %} + {%- if value %} +
+
{{ label }}
+
+ {{ value[:96] }}{% if value | length > 96 %}…{% endif %} +
+
+ {%- endif %} + {%- endfor %} +
+ {%- endif %} + + {# ---- Error ---- #} + {%- if error_message %} +
+
Error
+
{{ error_message }}
+
+ {%- endif %} + + {# ---- Recent logs ---- #} + {%- if recent_logs %} +
+
Recent logs
+
+ {%- for line in recent_logs %} + {{ line }}{% if not loop.last %}
{% endif %} + {%- endfor %} +
+
+ {%- endif %} + +
+
+""" + +_inference_job_template = None + + +def _get_inference_job_template(): + """Lazily compile and cache the Jinja2 InferenceJob template.""" + global _inference_job_template + if _inference_job_template is None: + from jinja2 import Environment + _inference_job_template = Environment(autoescape=True).from_string(_INFERENCE_JOB_HTML_TEMPLATE) + return _inference_job_template + class InferenceJob(BaseEntity): """Entity representing an inference job.""" @@ -35,6 +159,27 @@ class InferenceJob(BaseEntity): annotation_ids: list | None = None recent_logs: list[str] | None = None + def _repr_html_(self) -> str: + """Rich HTML representation for Jupyter Notebooks.""" + progress_value = max(0, min(self.progress_percentage, 100)) + return _get_inference_job_template().render( + status_lower=self.status.lower(), + status=self.status, + model_name=self.model_name, + frame_idx=self.frame_idx, + progress_value=progress_value, + show_progress=not self.is_finished or progress_value > 0 or self.current_step, + current_step=self.current_step, + metrics=[ + ('Model', self.model_name), + ('Current step', self.current_step), + ('Created', self.created_at), + ('Completed', self.completed_at), + ], + error_message=self.error_message, + recent_logs=self.recent_logs[-3:] if self.recent_logs else None, + ) + @property def is_finished(self) -> bool: """Whether the job has reached a terminal state.""" @@ -69,6 +214,7 @@ def wait( """Block until this job reaches a terminal state. Uses the SSE stream when available, falling back to polling. + In-place updates to this object are made on every status change. Args: on_status: Optional callback invoked with an updated @@ -77,8 +223,6 @@ def wait( timeout: Maximum seconds to wait. Raises ``TimeoutError`` on expiry. - Returns: - ``self``, updated in-place with the final status fields. """ api: InferenceApi = self._api # type: ignore[assignment] @@ -90,5 +234,5 @@ def _sync_self(updated: InferenceJob) -> None: if on_status is not None: on_status(self) - api.wait(self.id, on_status=_sync_self, poll_interval=poll_interval, timeout=timeout) - return self + api.wait(self, on_status=_sync_self, poll_interval=poll_interval, timeout=timeout) + return self \ No newline at end of file From 3567b70b3d317d05152cbace9b6119ec46bd8992 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Sat, 28 Mar 2026 16:22:56 -0300 Subject: [PATCH 30/47] Add tutorial notebook for 2D segmentation using UNet++ on BUSI dataset with Datamint Trainer API --- ...segmentation_2d-unetpp_BUSI_tutorial.ipynb | 1314 ----------------- ...egmentation_2d_trainer_BUSI_tutorial.ipynb | 709 +++++++++ 2 files changed, 709 insertions(+), 1314 deletions(-) delete mode 100644 notebooks/use_cases/segmentation_2d-unetpp_BUSI_tutorial.ipynb create mode 100644 notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb diff --git a/notebooks/use_cases/segmentation_2d-unetpp_BUSI_tutorial.ipynb b/notebooks/use_cases/segmentation_2d-unetpp_BUSI_tutorial.ipynb deleted file mode 100644 index 1615eb7f..00000000 --- a/notebooks/use_cases/segmentation_2d-unetpp_BUSI_tutorial.ipynb +++ /dev/null @@ -1,1314 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "332c2c31", - "metadata": {}, - "source": [ - "# Medical Image Segmentation with UNet++ and Datamint\n", - "\n", - "This notebook demonstrates how to build an end-to-end **semantic segmentation** pipeline using **Datamint** and the **BUSI** (Breast Ultrasound Images) dataset with a **UNet++** architecture.\n", - "\n", - "## Overview\n", - "\n", - "You will learn how to:\n", - "- **Set up a Datamint project** for managing medical imaging data\n", - "- **Upload ultrasound images and segmentation masks** to Datamint\n", - "- **Build a custom PyTorch Dataset** that integrates with Datamint\n", - "- **Implement UNet++ with combined loss functions** (CrossEntropy + Dice)\n", - "- **Train the model** using PyTorch Lightning with MLflow tracking\n", - "- **Deploy the model** for inference using Datamint's model serving\n", - "\n", - "## Table of Contents\n", - "\n", - "1. [Setup: Create Project and Initialize API](#1-setup-create-project-and-initialize-api)\n", - "2. [Dataset Preparation: Download and Upload BUSI](#2-dataset-preparation-download-and-upload-busi)\n", - "3. [Custom PyTorch Dataset](#3-custom-pytorch-dataset)\n", - "4. [Model Architecture: UNet++ with Combined Loss](#4-model-architecture-unet-with-combined-loss)\n", - "5. [Training with MLflow Integration](#5-training-with-mlflow-integration)\n", - "6. [Visualization and Evaluation](#6-visualization-and-evaluation)\n", - "7. [Model Deployment](#7-model-deployment)\n", - "\n", - "## Required Dependencies\n", - "\n", - "```bash\n", - "pip install datamint segmentation-models-pytorch albumentations\n", - "```\n", - "\n", - "## Dataset Overview\n", - "\n", - "The **BUSI** (Breast Ultrasound Images) dataset contains ultrasound images of breast cancer with corresponding segmentation masks.\n", - "Three classes: benign, malignant, and normal tissues.\n", - "\n", - "Dataset reference:\n", - "> Al-Dhabyani W, Gomaa M, Khaled H, Fahmy A. Dataset of breast ultrasound images. Data in Brief. 2020 Feb;28:104863. DOI: 10.1016/j.dib.2019.104863." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f50baf11", - "metadata": {}, - "outputs": [], - "source": [ - "from datamint import Api\n", - "from datamint.mlflow import set_project\n", - "\n", - "PROJECT_NAME = \"UNetPP_Segmentation_Tutorial\"\n", - "api = Api()" - ] - }, - { - "cell_type": "markdown", - "id": "13cf543a", - "metadata": {}, - "source": [ - "## 1. Setup: Create Project and Initialize API\n", - "\n", - "In this section, we will:\n", - "- Create a new Datamint project (or retrieve an existing one)\n", - "- Set up the MLflow project context for experiment tracking" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fbcd7869", - "metadata": {}, - "outputs": [], - "source": [ - "proj = api.projects.get_by_name(PROJECT_NAME)\n", - "if proj is None:\n", - " print(f\"Creating project '{PROJECT_NAME}'\")\n", - " proj = api.projects.create(\n", - " name=PROJECT_NAME,\n", - " description=\"Tutorial project for UNet++ segmentation on BUSI dataset\"\n", - " )\n", - "else:\n", - " print(f\"Using existing project '{PROJECT_NAME}'\")\n", - "\n", - "set_project(PROJECT_NAME) # Important for proper experiment tracking" - ] - }, - { - "cell_type": "markdown", - "id": "bad930c7", - "metadata": {}, - "source": [ - "## 2. Dataset Preparation: Download and Upload BUSI\n", - "\n", - "In this section, we will:\n", - "- Download the BUSI dataset\n", - "- Upload ultrasound images to Datamint\n", - "- Upload corresponding segmentation masks\n", - "- Create train/val/test splits\n", - "\n", - "### 2.1 Download BUSI Dataset\n", - "\n", - "The BUSI dataset is available from Kaggle. For this tutorial, we'll use the breast ultrasound images dataset.\n", - "\n", - "> Al-Dhabyani W, Gomaa M, Khaled H, Fahmy A. Dataset of breast ultrasound images. Data in Brief. 2020 Feb;28:104863. DOI: 10.1016/j.dib.2019.104863." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "17e40043", - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "import requests\n", - "import zipfile\n", - "from pathlib import Path\n", - "\n", - "BUSI_URL = \"https://www.kaggle.com/api/v1/datasets/download/sabahesaraki/breast-ultrasound-images-dataset\"\n", - "DATA_DIR = Path(\"/tmp/BUSI_dataset\") # Change this path as needed\n", - "\n", - "if not DATA_DIR.exists():\n", - " print(\"Downloading BUSI dataset...\")\n", - "\n", - " # Download the dataset\n", - " response = requests.get(BUSI_URL, stream=True)\n", - " response.raise_for_status()\n", - " zip_path = DATA_DIR / \"Dataset_BUSI.zip\"\n", - "\n", - " DATA_DIR.mkdir(parents=True, exist_ok=True)\n", - " with open(zip_path, 'wb') as f:\n", - " for chunk in response.iter_content(chunk_size=8192):\n", - " f.write(chunk)\n", - "\n", - " print(\"Extracting...\")\n", - " with zipfile.ZipFile(zip_path, 'r') as zip_ref:\n", - " zip_ref.extractall(DATA_DIR)\n", - "\n", - " os.remove(zip_path)\n", - " print(\"Download complete!\")\n", - "else:\n", - " print(f\"Dataset already exists at {DATA_DIR}\")" - ] - }, - { - "cell_type": "markdown", - "id": "4d497e3a", - "metadata": {}, - "source": [ - "### 2.2 Explore Dataset Structure\n", - "\n", - "The BUSI dataset contains folders for each class:\n", - "- **benign**: Images and masks for benign cases\n", - "- **malignant**: Images and masks for malignant cases\n", - "- **normal**: Images and masks for normal cases\n", - "\n", - "```\n", - "Dataset_BUSI/\n", - "├── benign/\n", - "│ ├── benign (1).png\n", - "│ ├── benign (1)_mask.png\n", - "│ └── ...\n", - "├── malignant/\n", - "│ └── ...\n", - "└── normal/\n", - " └── ...\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2a6e6673", - "metadata": {}, - "outputs": [], - "source": [ - "# Find image and label paths\n", - "base_dir = DATA_DIR / \"Dataset_BUSI_with_GT\"\n", - "classes = [\"benign\", \"malignant\", \"normal\"]\n", - "\n", - "image_paths = []\n", - "label_paths = []\n", - "\n", - "for cls in classes:\n", - " cls_dir = base_dir / cls\n", - " # Images are files that don't contain \"_mask\"\n", - " cls_images = sorted([p for p in cls_dir.glob(\"*.png\") if \"_mask\" not in p.name])\n", - " for img_p in cls_images:\n", - " # Find corresponding mask (taking the first one if multiple exist)\n", - " mask_p = cls_dir / f\"{img_p.stem}_mask.png\"\n", - " if mask_p.exists():\n", - " image_paths.append(img_p)\n", - " label_paths.append(mask_p)\n", - "\n", - "print(f\"Found {len(image_paths)} ultrasound images\")\n", - "print(f\"Found {len(label_paths)} segmentation masks\")" - ] - }, - { - "cell_type": "markdown", - "id": "464994f5", - "metadata": {}, - "source": [ - "### 2.3 Upload Images to Datamint\n", - "\n", - "We upload each ultrasound image as a resource with appropriate tags." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0165e447", - "metadata": {}, - "outputs": [], - "source": [ - "# Upload images to Datamint\n", - "uploaded_resources = api.resources.upload_resources(\n", - " [str(p) for p in image_paths],\n", - " tags=['busi', 'ultrasound', 'breast'],\n", - " publish_to=proj,\n", - " progress_bar=True\n", - ")\n", - "\n", - "print(f\"Uploaded {len(uploaded_resources)} images to Datamint\")" - ] - }, - { - "cell_type": "markdown", - "id": "63ea53fe", - "metadata": {}, - "source": [ - "### 2.4 Upload Segmentation Masks\n", - "\n", - "Now we upload the corresponding segmentation masks." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "78c233a0", - "metadata": {}, - "outputs": [], - "source": [ - "from tqdm.auto import tqdm\n", - "\n", - "# Get resources from project\n", - "all_resources = list(api.resources.get_list(project_name=PROJECT_NAME, tags=['busi']))\n", - "filename_to_resource = {r.filename: r for r in all_resources}\n", - "\n", - "# Upload segmentation masks\n", - "for img_path, label_path in tqdm(zip(image_paths, label_paths), total=len(image_paths)):\n", - " if 'normal' in img_path.parent.name:\n", - " # Skip normal images (no lesions)\n", - " continue\n", - " resource = filename_to_resource[img_path.name]\n", - " \n", - " # Determine class from file path. Example ``img_path``: 'Dataset_BUSI_with_GT/benign/BUSI_123.png'\n", - " cls_name = img_path.parent.name # 'benign' or 'malignant'\n", - " \n", - " api.annotations.upload_segmentations(\n", - " resource=resource,\n", - " file_path=label_path,\n", - " name=cls_name,\n", - " imported_from=\"Original GT BUSI Dataset\", # source of the masks. Arbitrary string\n", - " )\n", - "\n", - "print(\"Segmentation masks uploaded successfully!\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cb37aa5d", - "metadata": {}, - "outputs": [], - "source": [ - "# Verify uploads - inspect a sample resource\n", - "sample_resource = api.resources.get_list(project_name=PROJECT_NAME, limit=1)[0]\n", - "sample_annotations = api.annotations.get_list(resource=sample_resource, annotation_type='segmentation')\n", - "\n", - "print(f\"Resource: {sample_resource.filename}\")\n", - "print(f\" Number of segmentation annotations: {len(sample_annotations)}\")\n", - "if sample_annotations:\n", - " print(f\" First annotation: {sample_annotations[0].asdict()}\")" - ] - }, - { - "cell_type": "markdown", - "id": "0d4562af", - "metadata": {}, - "source": [ - "### 2.5 Create Train/Validation/Test Splits\n", - "\n", - "We split the dataset into three subsets using tags for reproducibility.\n", - "\n", - "| Split | Percentage | Purpose |\n", - "|-------|------------|---------|\n", - "| Train | 70% | Model training |\n", - "| Validation | 15% | Hyperparameter tuning, early stopping |\n", - "| Test | 15% | Final model evaluation |" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "95dadab5", - "metadata": {}, - "outputs": [], - "source": [ - "import random\n", - "\n", - "# Get all resources\n", - "all_resources = list(api.resources.get_list(project_name=PROJECT_NAME, tags=['busi']))\n", - "all_resources.sort(key=lambda r: r.filename)\n", - "\n", - "# Shuffle with fixed seed\n", - "random.seed(42)\n", - "random.shuffle(all_resources)\n", - "\n", - "# Split ratios\n", - "n_total = len(all_resources)\n", - "n_train = int(0.7 * n_total)\n", - "n_val = int(0.15 * n_total)\n", - "n_test = n_total - n_train - n_val\n", - "\n", - "train_resources = all_resources[:n_train]\n", - "val_resources = all_resources[n_train:n_train + n_val]\n", - "test_resources = all_resources[n_train + n_val:]\n", - "\n", - "print(f\"Total resources: {n_total}\")\n", - "print(f\"Training: {len(train_resources)}\")\n", - "print(f\"Validation: {len(val_resources)}\")\n", - "print(f\"Test: {len(test_resources)}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e4f94f76", - "metadata": {}, - "outputs": [], - "source": [ - "# Apply split tags to resources\n", - "api.resources.add_tags(train_resources, ['split:train'])\n", - "api.resources.add_tags(val_resources, ['split:val'])\n", - "api.resources.add_tags(test_resources, ['split:test'])" - ] - }, - { - "cell_type": "markdown", - "id": "1d6fb528", - "metadata": {}, - "source": [ - "## 3. Loading your Dataset\n" - ] - }, - { - "cell_type": "markdown", - "id": "65e63fde", - "metadata": {}, - "source": [ - "Define Classes\n", - "\n", - "We map class ids to class names for segmentation.\n", - "We need this mapping because training labels are stored as integers (not strings) in the masks.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9c08cd34", - "metadata": {}, - "outputs": [], - "source": [ - "# Class mapping for segmentation. \n", - "# Class 0 is background (no tumor)\n", - "CLASS_NAMES = {\n", - " 1: \"benign\",\n", - " 2: \"malignant\"\n", - "}\n", - "\n", - "NUM_CLASSES = len(CLASS_NAMES)\n", - "\n", - "print(f\"Number of classes: {NUM_CLASSES}\")" - ] - }, - { - "cell_type": "markdown", - "id": "65ef441a", - "metadata": {}, - "source": [ - "### 3.1 Define Data Transforms\n", - "\n", - "We use [Albumentations](https://albumentations.ai/) for image and mask augmentation. The key is that augmentations are applied **consistently** to both image and mask." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9adbc8b7", - "metadata": {}, - "outputs": [], - "source": [ - "import albumentations as A\n", - "from albumentations.pytorch import ToTensorV2\n", - "\n", - "# Image size for UNet++ (should be divisible by 32 for encoder-decoder architectures)\n", - "IMAGE_SIZE = 256\n", - "\n", - "# ImageNet normalization stats — required for the pretrained ResNet34 encoder\n", - "IMAGENET_MEAN = (0.485, 0.456, 0.406)\n", - "IMAGENET_STD = (0.229, 0.224, 0.225)\n", - "\n", - "# Training transforms with augmentation\n", - "train_transforms = A.Compose([\n", - " A.Resize(IMAGE_SIZE, IMAGE_SIZE),\n", - " A.HorizontalFlip(p=0.5),\n", - " A.VerticalFlip(p=0.5),\n", - " A.ElasticTransform(alpha=50, sigma=5, p=0.3),\n", - " A.GridDistortion(num_steps=5, distort_limit=0.2, p=0.3),\n", - " A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5),\n", - " A.Normalize(IMAGENET_MEAN, IMAGENET_STD), # Normalize to ImageNet stats\n", - " ToTensorV2(),\n", - "])\n", - "\n", - "# Validation/Test transforms (no augmentation)\n", - "val_transforms = A.Compose([\n", - " A.Resize(IMAGE_SIZE, IMAGE_SIZE),\n", - " A.Normalize(IMAGENET_MEAN, IMAGENET_STD), # Normalize to ImageNet stats\n", - " ToTensorV2(),\n", - "])" - ] - }, - { - "cell_type": "markdown", - "id": "04637e73", - "metadata": {}, - "source": [ - "### 3.2 Declare the Dataset\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2a346243", - "metadata": {}, - "outputs": [], - "source": [ - "from datamint.dataset import ImageDataset\n", - "from datamint.lightning.datamodule import DatamintDataModule\n", - "\n", - "# Configuration\n", - "BATCH_SIZE = 16 # Adjust based on your GPU memory\n", - "NUM_WORKERS = 4 # Adjust based on your CPU cores\n", - "\n", - "D = ImageDataset(\n", - " project=PROJECT_NAME,\n", - " return_as_semantic_segmentation=True,\n", - " semantic_seg_merge_strategy='union',\n", - " allow_external_annotations=True,\n", - " include_unannotated=False,\n", - "\n", - ")\n", - "splitted_dataset = D.split()\n", - "Dtrain = splitted_dataset['train']\n", - "Dval = splitted_dataset['val']\n", - "Dtest = splitted_dataset['test']\n", - "Dtrain.get_dataloader()\n", - "\n", - "ddm = DatamintDataModule(D,\n", - " num_workers=NUM_WORKERS,\n", - " batch_size=BATCH_SIZE,\n", - " train_transform=train_transforms,\n", - " eval_transform=val_transforms)\n" - ] - }, - { - "cell_type": "markdown", - "id": "c5c05052", - "metadata": {}, - "source": [ - "Optionally, instead of `DatamintDataModule`, you can use:\n", - "```python\n", - "splitted_dataset = D.split()\n", - "Dtrain = splitted_dataset['train'] # Pytorch compatible dataset\n", - "Dval = splitted_dataset['val']\n", - "Dtest = splitted_dataset['test']\n", - "\n", - "Dtrain.set_transforms(train_transforms)\n", - "# (...)\n", - "\n", - "train_loader = Dtrain.get_dataloader(batch_size=16, shuffle=True) # Pytorch compatible DataLoader\n", - "# (...)\n", - "\n", - "# or build custom dataloaders:\n", - "train_loader = DataLoader(Dtrain, batch_size=16, shuffle=True, collate_fn=Dtrain.get_collate_fn())\n", - "# (...)\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5fb31d85", - "metadata": {}, - "outputs": [], - "source": [ - "# Visualize a sample batch\n", - "from datamint.utils.visualization import show, draw_masks\n", - "\n", - "ddm.setup() # Ensure dataloaders are ready\n", - "\n", - "sample_batch = next(iter(ddm.train_dataloader()))\n", - "print(f\"Batch image shape: {sample_batch['image'].shape}\") # (B, C, H, W)\n", - "print(f\"Batch mask shape: {sample_batch['segmentations'].shape}\") # (B, H, W)\n", - "\n", - "# Plot first 2 samples\n", - "for i in range(2):\n", - " img = sample_batch['image'][i]\n", - " mask = sample_batch['segmentations'][i]\n", - " r = sample_batch['resource'][i]\n", - " print(f\"Resource: {r.id=}, {r.filename=}\")\n", - " img_with_mask = draw_masks(img, mask[1:]) # Skip background class\n", - " show(img_with_mask)" - ] - }, - { - "cell_type": "markdown", - "id": "a9af0231", - "metadata": {}, - "source": [ - "## 4. Model Architecture: UNet++ with Combined Loss\n", - "\n", - "In this section, we'll implement:\n", - "- **UNet++** architecture using `segmentation_models_pytorch`\n", - "- **Dice Loss** for handling class imbalance\n", - "- **Combined Loss** (CrossEntropy + Dice) for better segmentation\n", - "- **Lightning Module** for training\n", - "\n", - "### 4.1 UNet++ Architecture\n", - "\n", - "UNet++ is an improved version of U-Net with nested dense skip connections. This architecture enhances feature propagation and reduces the semantic gap between the encoder and decoder." - ] - }, - { - "cell_type": "markdown", - "id": "590980e1", - "metadata": {}, - "source": [ - "### 4.2 Custom Loss Functions\n", - "\n", - "For semantic segmentation, we use a combination of:\n", - "- **CrossEntropyLoss**: Standard classification loss per pixel\n", - "- **Dice Loss**: Optimizes the Dice coefficient directly, handles class imbalance better" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8c94e0d6", - "metadata": {}, - "outputs": [], - "source": [ - "import torch\n", - "import torch.nn as nn\n", - "import torch.nn.functional as F\n", - "\n", - "class CombinedLoss(nn.Module):\n", - " \"\"\"Combined CrossEntropy and Dice Loss.\n", - " \n", - " This combination provides:\n", - " - CrossEntropy: Pixel-level classification accuracy\n", - " - Dice: Global overlap optimization, handles class imbalance\n", - " \n", - " Args:\n", - " num_classes: Number of segmentation classes\n", - " ce_weight: Weight for CrossEntropy loss\n", - " dice_weight: Weight for Dice loss\n", - " \"\"\"\n", - " \n", - " def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:\n", - " \"\"\"\n", - " Args:\n", - " pred: Predicted logits of shape (B, C, H, W).\n", - " target: Multi-hot ground truth mask of shape (B, C, H, W), values in {0, 1}.\n", - " Classes can overlap (non-exclusive).\n", - "\n", - " Returns:\n", - " Combined BCE-with-logits + soft Dice loss (scalar).\n", - " \"\"\"\n", - " if pred.shape != target.shape:\n", - " raise ValueError(\n", - " f\"For non-exclusive classes, pred and target must have the same shape. \"\n", - " f\"Got pred={tuple(pred.shape)} and target={tuple(target.shape)}.\"\n", - " )\n", - "\n", - " target = target.float()\n", - "\n", - " # BCE term for independent per-class pixel classification\n", - " bce = F.binary_cross_entropy_with_logits(pred, target)\n", - "\n", - " # Soft Dice term for overlap quality in multi-label segmentation\n", - " probs = torch.sigmoid(pred)\n", - " dims = (0, 2, 3) # reduce over batch and spatial dims, keep class dim\n", - " intersection = (probs * target).sum(dim=dims)\n", - " cardinality = probs.sum(dim=dims) + target.sum(dim=dims)\n", - " dice_per_class = (2.0 * intersection + 1e-6) / (cardinality + 1e-6)\n", - " dice_loss = 1.0 - dice_per_class.mean()\n", - "\n", - " return bce + dice_loss\n", - "\n", - "\n", - "# Quick test\n", - "dummy_pred = torch.randn(2, NUM_CLASSES, 64, 64) # logits\n", - "dummy_target = torch.randint(0, NUM_CLASSES, (2, 64, 64))\n", - "# one-hot encode dummy_target\n", - "dummy_target = F.one_hot(dummy_target, num_classes=NUM_CLASSES).permute(0, 3, 1, 2).float()\n", - "\n", - "loss_fn = CombinedLoss()\n", - "loss = loss_fn(dummy_pred, dummy_target)\n", - "print(f\"Test loss value: {loss.item():.4f}\")\n" - ] - }, - { - "cell_type": "markdown", - "id": "520b31e6", - "metadata": {}, - "source": [ - "### 4.3 UNet++ Lightning Module\n", - "\n", - "We wrap UNet++ in a PyTorch Lightning module for clean training code." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ab74c328", - "metadata": {}, - "outputs": [], - "source": [ - "import segmentation_models_pytorch as smp\n", - "import lightning as L\n", - "from torchmetrics.segmentation import MeanIoU, GeneralizedDiceScore\n", - "\n", - "\n", - "class UNetPPModule(L.LightningModule):\n", - " \"\"\"PyTorch Lightning module for UNet++ segmentation.\n", - "\n", - " This module handles:\n", - " - Model architecture (UNet++ with pretrained encoder)\n", - " - Combined loss function (CrossEntropy + Dice)\n", - " - Metrics tracking (IoU, Dice)\n", - " - Optimizer configuration with learning rate scheduling\n", - "\n", - " Args:\n", - " num_classes: Number of segmentation classes (excluding background)\n", - " encoder_name: Name of the encoder backbone (e.g., 'resnet34', 'efficientnet-b0')\n", - " learning_rate: Initial learning rate\n", - " \"\"\"\n", - "\n", - " def __init__(\n", - " self,\n", - " num_classes: int,\n", - " encoder_name: str = 'resnet34',\n", - " learning_rate: float = 1e-4,\n", - " ):\n", - " super().__init__()\n", - " self.save_hyperparameters() # Save hyperparameters for logging\n", - "\n", - " self.learning_rate = learning_rate\n", - "\n", - " # UNet++ model from segmentation_models_pytorch\n", - " self.model = smp.UnetPlusPlus(\n", - " encoder_name=encoder_name,\n", - " encoder_weights='imagenet',\n", - " in_channels=3, # RGB input (we repeat grayscale to 3 channels)\n", - " classes=num_classes,\n", - " )\n", - "\n", - " # Loss function\n", - " self.criterion = CombinedLoss(\n", - " ce_weight=1.0,\n", - " dice_weight=1.0,\n", - " )\n", - "\n", - " input_format = 'one-hot'\n", - " self.iou_metrics = {\n", - " 'train': MeanIoU(num_classes=num_classes, input_format=input_format),\n", - " 'val': MeanIoU(num_classes=num_classes, input_format=input_format),\n", - " 'test': MeanIoU(num_classes=num_classes, input_format=input_format),\n", - " }\n", - " # register the iou metrics:\n", - " for stage, metric in self.iou_metrics.items():\n", - " self.add_module(f\"{stage}_mean_iou\", metric)\n", - "\n", - " self.dice_metrics = {\n", - " 'train': GeneralizedDiceScore(num_classes=num_classes, input_format=input_format),\n", - " 'val': GeneralizedDiceScore(num_classes=num_classes, input_format=input_format),\n", - " 'test': GeneralizedDiceScore(num_classes=num_classes, input_format=input_format),\n", - " }\n", - " # register the dice metrics:\n", - " for stage, metric in self.dice_metrics.items():\n", - " self.add_module(f\"{stage}_dice_score\", metric)\n", - "\n", - " def forward(self, x: torch.Tensor) -> torch.Tensor:\n", - " \"\"\"Forward pass through UNet++.\"\"\"\n", - " return self.model(x)\n", - "\n", - " def _common_step(self, batch: dict, stage: str) -> torch.Tensor:\n", - " \"\"\"Common step for train/val/test.\n", - "\n", - " Args:\n", - " batch: Dictionary with 'image' and 'segmentations' tensors\n", - " stage: One of 'train', 'val', 'test'\n", - "\n", - " Returns:\n", - " Loss tensor\n", - " \"\"\"\n", - " images = batch['image']\n", - " masks = batch['segmentations'] # one-hot encoded (B, #classes+1, H, W)\n", - " masks = masks[:, 1:] # exclude background class\n", - "\n", - " # Forward pass\n", - " logits = self(images) # (B, #classes, H, W)\n", - "\n", - " # # Convert masks to one-hot for the loss function\n", - " # masks_onehot = F.one_hot(masks, num_classes=self.hparams.num_classes).permute(0, 3, 1, 2).float() # (B, C, H, W)\n", - "\n", - " # Compute loss\n", - " loss = self.criterion(logits, masks)\n", - "\n", - " preds = (logits > 0).long()\n", - "\n", - " # Update metrics\n", - " if stage is not None:\n", - " self.iou_metrics[stage].update(preds, masks.long())\n", - " self.dice_metrics[stage].update(preds, masks.long())\n", - " self.log(f'{stage}/loss', loss, on_step=(stage == 'train'),\n", - " on_epoch=True, prog_bar=True, batch_size=len(images))\n", - "\n", - " return loss\n", - "\n", - " def training_step(self, batch: dict, batch_idx: int) -> torch.Tensor:\n", - " return self._common_step(batch, 'train')\n", - "\n", - " def validation_step(self, batch: dict, batch_idx: int) -> torch.Tensor:\n", - " return self._common_step(batch, 'val')\n", - "\n", - " def test_step(self, batch: dict, batch_idx: int) -> torch.Tensor:\n", - " return self._common_step(batch, 'test')\n", - "\n", - " def predict_step(self, batch: dict, batch_idx: int) -> torch.Tensor:\n", - " images = batch['image']\n", - " logits = self(images)\n", - " preds = (logits > 0).long()\n", - " return preds\n", - "\n", - " def _common_epoch_end(self, stage: str):\n", - " iou = self.iou_metrics[stage]\n", - " dice = self.dice_metrics[stage]\n", - " self.log(f'{stage}/iou', iou.compute(), prog_bar=True)\n", - " self.log(f'{stage}/dice', dice.compute())\n", - " iou.reset()\n", - " dice.reset()\n", - "\n", - " def on_train_epoch_end(self):\n", - " self._common_epoch_end('train')\n", - "\n", - " def on_validation_epoch_end(self):\n", - " self._common_epoch_end('val')\n", - "\n", - " def on_test_epoch_end(self):\n", - " self._common_epoch_end('test')\n", - "\n", - " def configure_optimizers(self):\n", - " optimizer = torch.optim.AdamW(\n", - " self.parameters(),\n", - " lr=self.learning_rate,\n", - " weight_decay=1e-4,\n", - " )\n", - "\n", - " return optimizer" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e357ff10", - "metadata": {}, - "outputs": [], - "source": [ - "# Instantiate the model\n", - "model = UNetPPModule(\n", - " num_classes=NUM_CLASSES,\n", - " encoder_name='resnet34', # Lightweight encoder for faster training\n", - " learning_rate=1e-4,\n", - ")\n", - "\n", - "# Test forward pass\n", - "with torch.inference_mode():\n", - " sample_input = torch.randn(1, 3, IMAGE_SIZE, IMAGE_SIZE)\n", - " sample_output = model(sample_input)\n", - " print(f\"Output shape: {sample_output.shape}\")\n", - "\n", - " # test loss computation\n", - " sample_target = ddm.train_dataloader().dataset[-1]['segmentations'] # shape: (#classes+1, H, W)\n", - " print(f\"Sample target shape: {sample_target.shape}\")\n", - " sample_loss = model.criterion(sample_output,\n", - " sample_target[1:].unsqueeze(0) # Remove background mask and add batch dimension\n", - " )\n", - " print(f\"Sample loss: {sample_loss.item():.4f}\")" - ] - }, - { - "cell_type": "markdown", - "id": "d8fef5b4", - "metadata": {}, - "source": [ - "## 5. Training with MLflow Integration\n", - "\n", - "In this section, we'll:\n", - "- Configure MLflow for experiment tracking\n", - "- Set up model checkpointing with automatic registration\n", - "- Train the model with early stopping\n", - "- Monitor progress via Datamint dashboard" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "326bc867", - "metadata": {}, - "outputs": [], - "source": [ - "from datamint.mlflow.lightning.callbacks import MLFlowModelCheckpoint\n", - "from lightning.pytorch.loggers import MLFlowLogger\n", - "from lightning.pytorch.callbacks import EarlyStopping\n", - "\n", - "# Ensure project context is set\n", - "set_project(PROJECT_NAME)\n", - "\n", - "# MLflow checkpoint callback\n", - "# This callback automatically:\n", - "# - Saves the best model based on validation IoU\n", - "# - Registers the model in MLflow Model Registry after testing\n", - "checkpoint_callback = MLFlowModelCheckpoint(\n", - " monitor=\"val/iou\", # Metric to monitor\n", - " mode=\"max\", # Save when metric increases\n", - " save_top_k=1, # Keep only the best model\n", - " filename=\"best_unetpp\", # Checkpoint filename\n", - " save_weights_only=True, # Save full model state\n", - " register_model_name=PROJECT_NAME, # Name in Model Registry\n", - " register_model_on='test', # Register after test evaluation\n", - ")\n", - "\n", - "# Early stopping callback\n", - "early_stop_callback = EarlyStopping(\n", - " monitor=\"val/iou\",\n", - " mode=\"max\",\n", - " patience=10 # Stop if no improvement for 10 epochs\n", - ")\n", - "\n", - "# MLflow logger for experiment tracking\n", - "mlflow_logger = MLFlowLogger(\n", - " experiment_name=f\"{PROJECT_NAME}_training\",\n", - " run_name=\"unetpp_resnet34_busi_metrics_fixed\",\n", - ")\n", - "\n", - "print(\"Training callbacks configured!\")" - ] - }, - { - "cell_type": "markdown", - "id": "f00d4f80", - "metadata": {}, - "source": [ - "### 5.1 Start Training\n", - "\n", - "We use PyTorch Lightning's Trainer for clean, scalable training." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1ab7f543", - "metadata": {}, - "outputs": [], - "source": [ - "# Initialize trainer\n", - "trainer = L.Trainer(\n", - " max_epochs=20, # Maximum training epochs\n", - " logger=mlflow_logger, # MLflow logging\n", - " callbacks=[checkpoint_callback, early_stop_callback],\n", - " accelerator='auto', # Auto-detect GPU/CPU\n", - " # precision='16-mixed', # Mixed precision for faster training, if supported\n", - ")\n", - "\n", - "# Start training\n", - "print(\"🚀 Starting training...\")\n", - "trainer.fit(\n", - " model,\n", - " datamodule=ddm,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "f1d829c3", - "metadata": {}, - "source": [ - "### 5.2 Monitor Training Progress\n", - "\n", - "While training runs, you can monitor progress in two ways:\n", - "1. **Terminal output**: Loss and metrics per epoch\n", - "2. **Datamint Dashboard**: Visual experiment tracking\n", - "\n", - "Run `proj.show()` to open the project dashboard in your browser." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "59751c46", - "metadata": {}, - "outputs": [], - "source": [ - "# Open the Datamint project dashboard\n", - "proj.show()" - ] - }, - { - "cell_type": "markdown", - "id": "6b3d3607", - "metadata": {}, - "source": [ - "### 5.3 Evaluate on Test Set\n", - "\n", - "After training, evaluate the model on the test set to get final metrics.\n", - "This also triggers model registration in MLflow (due to `register_model_on='test'`)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4f42eebf", - "metadata": {}, - "outputs": [], - "source": [ - "# Evaluate on test set and register model\n", - "print(\"🔍 Evaluating on test set...\")\n", - "test_results = trainer.test(dataloaders=ddm.test_dataloader())\n", - "\n", - "print(f\"Best model checkpoint: {checkpoint_callback.best_model_path}\")\n", - "print(f\"Best validation IoU: {checkpoint_callback.best_model_score:.4f}\")" - ] - }, - { - "cell_type": "markdown", - "id": "916bf0fc", - "metadata": {}, - "source": [ - "## 6. Visualization and Evaluation\n", - "\n", - "In this section, we'll:\n", - "- Visualize predictions vs ground truth\n", - "- Create overlay visualizations\n", - "- Analyze per-class performance" - ] - }, - { - "cell_type": "markdown", - "id": "d0f7b49e", - "metadata": {}, - "source": [ - "### 6.1 Visualize Predictions\n", - "\n", - "Let's visualize model predictions on the test set." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "88bd7fa3", - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "from datamint.utils.visualization import show, draw_masks\n", - "from torchmetrics.functional.segmentation import mean_iou\n", - "from matplotlib import pyplot as plt\n", - "\n", - "\n", - "def visualize_predictions(model, dataset):\n", - " \"\"\"Visualize model predictions compared to ground truth.\n", - "\n", - " Args:\n", - " model: Trained model\n", - " dataset: Dataset to sample from\n", - " indices: Specific indices to visualize (optional)\n", - " device: Device for inference\n", - " \"\"\"\n", - " model.eval()\n", - "\n", - " idx = np.random.choice(len(dataset), 1)[0]\n", - "\n", - " with torch.inference_mode():\n", - " sample = dataset[idx]\n", - " image = sample['image'].to(model.device) # image.shape: (C, H, W)\n", - " mask_gt = sample['segmentations'] # shape: (#classes+1, H, W) One-hot encoded as float\n", - " mask_gt = mask_gt[1:] # remove background class\n", - "\n", - " # model expects batch dimension, so add it with unsqueeze(0)\n", - " logits = model(image.unsqueeze(0)) # logits.shape: (1, #classes+1, H, W)\n", - " mask_pred = logits[0] > 0 # shape: (#classes+1, H, W). one Binary mask for each class\n", - "\n", - " _, axes = plt.subplots(1, NUM_CLASSES, figsize=(10, 5))\n", - " for i in range(NUM_CLASSES):\n", - " ax = axes[i]\n", - " overlay_mask = draw_masks(image.cpu(),\n", - " torch.stack([mask_gt[i], mask_pred[i]]),\n", - " alpha=0.5,\n", - " )\n", - " ax.set_title(f\"Class: {CLASS_NAMES[i+1]}\")\n", - " show(overlay_mask, ax=ax)\n", - "\n", - " iou = mean_iou(mask_pred.unsqueeze(0).bool(), # IMPORTANT: torchmetrics expects same dtype for both inputs.\n", - " mask_gt.unsqueeze(0).bool(),\n", - " include_background=True, # adds back the bg, since we removed early\n", - " per_class=True,\n", - " input_format='one-hot')\n", - " iou = iou.max() # take the best IoU across classes since we have exclusive classes\n", - " print(f\"IoU: {iou:.1%}\")\n", - "\n", - "\n", - "visualize_predictions(model, ddm.test_dataloader().dataset)" - ] - }, - { - "cell_type": "markdown", - "id": "7cb374d2", - "metadata": {}, - "source": [ - "## 7. Model Deployment\n", - "\n", - "For production use, we wrap our model in a **Datamint Model Adapter**. This adapter:\n", - "- Standardizes input/output format\n", - "- Handles resource loading from Datamint\n", - "- Enables deployment via MLflow Model Serving or Datamint's inference API\n", - "- Returns structured `ImageSegmentation` annotations\n", - "\n", - "### 7.1 Create Datamint Model Adapter\n", - "\n", - "The `DatamintModel` base class provides a consistent interface for model deployment." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "53379296", - "metadata": {}, - "outputs": [], - "source": [ - "from datamint.mlflow.flavors.model import DatamintModel\n", - "from datamint.entities.annotations import ImageSegmentation\n", - "from datamint.entities import Resource\n", - "import torch\n", - "import cv2\n", - "import numpy as np\n", - "from typing_extensions import override\n", - "\n", - "class UNetPPSegmentationAdapter(DatamintModel):\n", - " \"\"\"Datamint adapter for UNet++ segmentation model deployment.\"\"\"\n", - " \n", - " def __init__(self):\n", - " super().__init__(\n", - " mlflow_torch_models_uri={\n", - " 'unetpp': f'models:/{PROJECT_NAME}/latest' # URI in MLflow Model Registry. An efficient way to link your new model to this one\n", - " }, \n", - " settings={'need_gpu': True}\n", - " )\n", - " self.class_names = CLASS_NAMES\n", - " \n", - " @override\n", - " def predict_image(self, model_input: list[Resource], **kwargs):\n", - " pytorch_model = self.get_mlflow_torch_models()['unetpp']\n", - " pytorch_model.eval()\n", - " \n", - " # Use Lightning Fabric for device management, instead of L.Trainer. Lightweight and perfect for inference.\n", - " fabric = L.Fabric(accelerator=self.inference_device)\n", - " pytorch_model = fabric.setup_module(pytorch_model)\n", - "\n", - " all_predictions = []\n", - " with torch.inference_mode():\n", - " for res in model_input:\n", - " image = res.fetch_file_data(auto_convert=True, use_cache=True)\n", - " # image is a PIL.Image object\n", - " original_width = image.width\n", - " original_height = image.height\n", - "\n", - " image = np.array(image)\n", - " image_tensor = val_transforms(image=image)['image'].to(fabric.device) # (3, H, W)\n", - " logits = pytorch_model(image_tensor.unsqueeze(0)) # unsqueeze to (1, 3, H, W)\n", - " pred = torch.argmax(logits, dim=1).squeeze().cpu().numpy()\n", - "\n", - " # Implement here any post-processing if desired\n", - " # reshape prediction to original size\n", - " pred = cv2.resize(pred.astype(np.uint8), \n", - " (original_width, original_height), \n", - " interpolation=cv2.INTER_NEAREST)\n", - " annotations = []\n", - " for class_idx, class_name in self.class_names.items():\n", - " if class_idx == 0: # Skip background class\n", - " continue\n", - " class_mask = (pred == class_idx).astype(np.uint8) * 255 # Convert to {0, 255} for visualization\n", - " if class_mask.any(): # at least one pixel\n", - " pred_ann = ImageSegmentation(name=class_name, mask=class_mask)\n", - " annotations.append(pred_ann)\n", - " all_predictions.append(annotations) \n", - "\n", - " return all_predictions\n", - "\n", - "adapter = UNetPPSegmentationAdapter()" - ] - }, - { - "cell_type": "markdown", - "id": "c3c4d082", - "metadata": {}, - "source": [ - "### 7.2 Log the Adapter to MLflow\n", - "\n", - "We log the adapter model to MLflow, making it available for deployment." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9c8b361e", - "metadata": {}, - "outputs": [], - "source": [ - "from datamint.mlflow.flavors import datamint_flavor\n", - "from mlflow import set_experiment\n", - "import mlflow\n", - "from datamint.mlflow import set_project\n", - "\n", - "# Set project and experiment context\n", - "set_project(PROJECT_NAME)\n", - "set_experiment(f'{PROJECT_NAME}_deployment') # Arbitrary experiment name. Create or use an existing one.\n", - "\n", - "adapter = UNetPPSegmentationAdapter()\n", - "\n", - "# Log the adapter to MLflow\n", - "ADAPTED_MODEL_NAME = f\"{PROJECT_NAME}_adapted\"\n", - "\n", - "with mlflow.start_run(run_name=\"unetpp_segmentation_adapter\"): # Create a new MLflow run. You can use an existing one as well\n", - " model_info = datamint_flavor.log_model(\n", - " adapter,\n", - " registered_model_name=ADAPTED_MODEL_NAME,\n", - " )\n", - "\n", - "print(f\"✅ Adapter logged successfully!\")\n", - "print(f\"Model URI: {model_info.model_uri}\")" - ] - }, - { - "cell_type": "markdown", - "id": "18dcee15", - "metadata": {}, - "source": [ - "### 7.3 Test Local Inference\n", - "\n", - "Before deploying, verify the adapter works correctly with local inference." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "459f75ff", - "metadata": {}, - "outputs": [], - "source": [ - "import mlflow\n", - "\n", - "# Load the registered adapter model\n", - "loaded_model = mlflow.pyfunc.load_model(f'models:/{ADAPTED_MODEL_NAME}/latest')\n", - "\n", - "# Test with a resource from the test set\n", - "test_resource = test_dataset.resources[-1]\n", - "print(f\"Testing with: {test_resource.filename}\")\n", - "\n", - "# Run prediction\n", - "predictions = loaded_model.predict([test_resource])\n", - "\n", - "print(f\"\\n✅ Prediction successful!\")\n", - "print(f\"Number of annotations: {len(predictions[0])}\")\n", - "for ann in predictions[0]:\n", - " n_pixels = (ann.mask > 0).sum()\n", - " print(f\" - {ann.name}: {n_pixels} pixels ({n_pixels / (ann.mask.size) :.1%} of the image)\")\n", - "\n", - "print('Ground truth:')\n", - "gt_annotations = api.annotations.get_list(\n", - " resource=test_resource,\n", - " annotation_type='segmentation'\n", - ")\n", - "for ann in gt_annotations:\n", - " mask = np.array(ann.fetch_file_data(use_cache=True))\n", - " n_pixels = (mask > 0).sum()\n", - " print(f\" - {ann.name}: {n_pixels} pixels ({n_pixels / (mask.size) :.1%} of the image)\")" - ] - }, - { - "cell_type": "markdown", - "id": "8bcf920e", - "metadata": {}, - "source": [ - "### 7.4 Deploy to Datamint Server\n", - "\n", - "Start a deployment job to serve the model via Datamint's inference API." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4d3d0968", - "metadata": {}, - "outputs": [], - "source": [ - "# Start deployment job\n", - "job = api.deploy.start(\n", - " model_name=ADAPTED_MODEL_NAME,\n", - " model_alias=\"latest\",\n", - " with_gpu=True, # Use GPU for inference\n", - ")\n", - "\n", - "print(f\"🚀 Deployment job started!\")\n", - "print(f\"Job ID: {job.id}\")\n", - "print(f\"Status: {job.status}\")\n", - "print(f\"Model: {job.model_name}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e4828d15", - "metadata": {}, - "outputs": [], - "source": [ - "# Check deployment status\n", - "job = api.deploy.get_by_id(job.id)\n", - "\n", - "print(f\"Job Status: {job.status}\")\n", - "print(f\"Progress: {job.progress_percentage}%\")\n", - "\n", - "if job.error_message:\n", - " print(f\"Error: {job.error_message}\")\n", - " print(f\"Build Logs:\\n{job.build_logs}\")" - ] - }, - { - "cell_type": "markdown", - "id": "01c339a7", - "metadata": {}, - "source": [ - "## 8. Summary\n", - "\n", - "Congratulations! 🎉 You've completed the UNet++ Segmentation Tutorial with the BUSI dataset.\n", - "\n", - "### What You Learned\n", - "\n", - "| Step | Description |\n", - "|------|-------------|\n", - "| **Data Management** | Uploaded ultrasound images and masks to Datamint |\n", - "| **Custom Dataset** | Built a PyTorch Dataset for 2D ultrasound images |\n", - "| **Model Architecture** | Implemented UNet++ with combined CrossEntropy + Dice loss |\n", - "| **Training** | Trained with MLflow experiment tracking |\n", - "| **Deployment** | Built a DatamintModel adapter for production inference |\n", - "\n", - "### References\n", - "\n", - "- [Datamint Documentation](https://sonanceai.github.io/datamint-python-api/)\n", - "- [BUSI Dataset](https://www.kaggle.com/datasets/aryashah2k/breast-ultrasound-images-dataset)\n", - "- [Segmentation Models PyTorch](https://github.com/qubvel/segmentation_models.pytorch)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.13" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb b/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb new file mode 100644 index 00000000..d4a8bf3e --- /dev/null +++ b/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb @@ -0,0 +1,709 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0263cedb", + "metadata": {}, + "source": [ + "# 2D Segmentation with the Datamint Trainer API\n", + "\n", + "This notebook shows how to train a **UNet++ semantic segmentation** model on the **BUSI** (Breast Ultrasound Images) dataset using Datamint's **Trainer API** — a high-level wrapper that handles dataset loading, model creation, training, MLflow tracking, and deployment in a single call (given dataset is already uploaded).\n", + "\n", + "## Comparison with the manual Workflow\n", + "\n", + "The conventional way requires you to:\n", + "1. Define transforms, loss function, metrics, and a full LightningModule (~150 lines)\n", + "2. Configure MLflow logger, callbacks, and Trainer (~50 lines)\n", + "3. Build and wire together `DatamintDataModule`, `L.Trainer`, etc.\n", + "\n", + "With the Trainer API, **all of that is replaced by ~3 lines**:\n", + "\n", + "```python\n", + "from datamint.lightning.trainers import UNetPPTrainer\n", + "\n", + "trainer = UNetPPTrainer(project='MyProject')\n", + "results = trainer.fit()\n", + "```\n", + "\n", + "## What You'll Learn\n", + "\n", + "1. Upload data to Datamint\n", + "2. Train with `UNetPPTrainer` using **zero configuration**\n", + "3. Train with `SemanticSegmentation2DTrainer` using **custom overrides**\n", + "4. Visualise predictions\n", + "5. Deploy the model\n", + "\n", + "## Required Dependencies\n", + "\n", + "```bash\n", + "pip install datamint\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ca7b0298", + "metadata": {}, + "outputs": [], + "source": [ + "from datamint import Api\n", + "from datamint.mlflow import set_project\n", + "\n", + "PROJECT_NAME = \"UNetPP_Segmentation_Tutorial\"\n", + "api = Api()" + ] + }, + { + "cell_type": "markdown", + "id": "19abf11f", + "metadata": {}, + "source": [ + "## 1. Setup: Create Project\n", + "\n", + "Create (or retrieve) a Datamint project for this tutorial." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af13262e", + "metadata": {}, + "outputs": [], + "source": [ + "proj = api.projects.create(\n", + " name=PROJECT_NAME,\n", + " description=\"Tutorial project for UNet++ segmentation on BUSI dataset\",\n", + " exists_ok=True # Just return the existing project if it already exists\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "a242d5cd", + "metadata": {}, + "source": [ + "## 2. Dataset Preparation: Download and Upload BUSI\n", + "\n", + "This section downloads the BUSI dataset and uploads it to Datamint.\n", + "If you already have the data inside Datamint, you can **skip to Section 3**.\n", + "\n", + "In this section, we will:\n", + "- Download the BUSI dataset\n", + "- Upload ultrasound images to Datamint\n", + "- Upload corresponding segmentation masks\n", + "- Create train/val/test splits\n", + "\n", + "### 2.1 Download BUSI Dataset\n", + "\n", + "The BUSI dataset is available from Kaggle. For this tutorial, we'll use the breast ultrasound images dataset.\n", + "\n", + "> Al-Dhabyani W, Gomaa M, Khaled H, Fahmy A. Dataset of breast ultrasound images. Data in Brief. 2020 Feb;28:104863. DOI: 10.1016/j.dib.2019.104863." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "97351339", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import requests\n", + "import zipfile\n", + "from pathlib import Path\n", + "\n", + "BUSI_URL = \"https://www.kaggle.com/api/v1/datasets/download/sabahesaraki/breast-ultrasound-images-dataset\"\n", + "DATA_DIR = Path(\"/tmp/BUSI_dataset\")\n", + "\n", + "if not DATA_DIR.exists():\n", + " print(\"Downloading BUSI dataset...\")\n", + " response = requests.get(BUSI_URL, stream=True)\n", + " response.raise_for_status()\n", + " zip_path = DATA_DIR / \"Dataset_BUSI.zip\"\n", + " DATA_DIR.mkdir(parents=True, exist_ok=True)\n", + " with open(zip_path, 'wb') as f:\n", + " for chunk in response.iter_content(chunk_size=8192):\n", + " f.write(chunk)\n", + " print(\"Extracting...\")\n", + " with zipfile.ZipFile(zip_path, 'r') as zip_ref:\n", + " zip_ref.extractall(DATA_DIR)\n", + " os.remove(zip_path)\n", + " print(\"Download complete!\")\n", + "else:\n", + " print(f\"Dataset already exists at {DATA_DIR}\")" + ] + }, + { + "cell_type": "markdown", + "id": "8e6bf5f7", + "metadata": {}, + "source": [ + "### 2.2 Find image and mask paths" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9c0b6f5e", + "metadata": {}, + "outputs": [], + "source": [ + "base_dir = DATA_DIR / \"Dataset_BUSI_with_GT\"\n", + "classes = [\"benign\", \"malignant\", \"normal\"]\n", + "\n", + "image_paths = []\n", + "label_paths = []\n", + "\n", + "for cls in classes:\n", + " cls_dir = base_dir / cls\n", + " cls_images = sorted([p for p in cls_dir.glob(\"*.png\") if \"_mask\" not in p.name])\n", + " for img_p in cls_images:\n", + " mask_p = cls_dir / f\"{img_p.stem}_mask.png\"\n", + " if mask_p.exists():\n", + " image_paths.append(img_p)\n", + " label_paths.append(mask_p)\n", + "\n", + "print(f\"Found {len(image_paths)} images and {len(label_paths)} masks\")" + ] + }, + { + "cell_type": "markdown", + "id": "cedb5070", + "metadata": {}, + "source": [ + "### 2.3 Upload images and masks to Datamint" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0221a1fb", + "metadata": {}, + "outputs": [], + "source": [ + "# Upload images\n", + "uploaded_resources = api.resources.upload_resources(\n", + " [str(p) for p in image_paths],\n", + " tags=['busi', 'ultrasound', 'breast'],\n", + " publish_to=proj,\n", + " progress_bar=True,\n", + ")\n", + "print(f\"Uploaded {len(uploaded_resources)} images\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "396f0f70", + "metadata": {}, + "outputs": [], + "source": [ + "from tqdm.auto import tqdm\n", + "\n", + "# Get resources from project\n", + "all_resources = list(api.resources.get_list(project_name=PROJECT_NAME, tags=['busi']))\n", + "filename_to_resource = {r.filename: r for r in all_resources}\n", + "\n", + "# Upload segmentation masks\n", + "for img_path, label_path in tqdm(zip(image_paths, label_paths), total=len(image_paths)):\n", + " if 'normal' in img_path.parent.name:\n", + " continue # Normal images have no lesion masks\n", + " resource = filename_to_resource[img_path.name]\n", + " cls_name = img_path.parent.name # 'benign' or 'malignant'\n", + "\n", + " api.annotations.upload_segmentations(\n", + " resource=resource,\n", + " file_path=label_path,\n", + " name=cls_name,\n", + " imported_from=\"Original GT BUSI Dataset\",\n", + " )\n", + "\n", + "print(\"Segmentation masks uploaded successfully!\")" + ] + }, + { + "cell_type": "markdown", + "id": "bdc37407", + "metadata": {}, + "source": [ + "### 2.4 Tag train/val/test splits\n", + "\n", + "We split the dataset into three subsets using tags for reproducibility.\n", + "\n", + "| Split | Percentage | Purpose |\n", + "|-------|------------|---------|\n", + "| Train | 70% | Model training |\n", + "| Validation | 15% | Hyperparameter tuning, early stopping |\n", + "| Test | 15% | Final model evaluation |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ba063034", + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "\n", + "all_resources = list(api.resources.get_list(project_name=PROJECT_NAME, tags=['busi']))\n", + "all_resources.sort(key=lambda r: r.filename)\n", + "\n", + "random.seed(42)\n", + "random.shuffle(all_resources)\n", + "\n", + "n_total = len(all_resources)\n", + "n_train = int(0.7 * n_total)\n", + "n_val = int(0.15 * n_total)\n", + "\n", + "train_resources = all_resources[:n_train]\n", + "val_resources = all_resources[n_train:n_train + n_val]\n", + "test_resources = all_resources[n_train + n_val:]\n", + "\n", + "api.resources.add_tags(train_resources, ['split:train'])\n", + "api.resources.add_tags(val_resources, ['split:val'])\n", + "api.resources.add_tags(test_resources, ['split:test'])\n", + "\n", + "print(f\"Train: {len(train_resources)}, Val: {len(val_resources)}, Test: {len(test_resources)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "b2e2f571", + "metadata": {}, + "source": [ + "## 3. Training with the Trainer API\n", + "\n", + "This is where the **Trainer API** shines. Instead of manually defining:\n", + "- A dataset with the correct flags\n", + "- Augmentation pipelines\n", + "- A LightningModule with loss, metrics, optimizer\n", + "- Callbacks, loggers, and a Lightning Trainer\n", + "- A deployment adapter\n", + "\n", + "...you simply create a `UNetPPTrainer` (or `SemanticSegmentation2DTrainer`) and call `fit()`.\n", + "\n", + "The trainer automatically:\n", + "1. Builds an `ImageDataset` configured for semantic segmentation\n", + "2. Creates augmentation pipelines (with medical-image–specific augmentations for `UNetPPTrainer`)\n", + "3. Instantiates a **UNet++ model** with a pretrained ResNet-34 encoder\n", + "4. Sets up **BCE + Dice loss**, **IoU** and **Dice** metrics\n", + "5. Configures MLflow logging, checkpointing, and early stopping\n", + "6. Trains, evaluates on the test set, and generates a deployment adapter" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "572fe5fd", + "metadata": {}, + "outputs": [], + "source": [ + "from datamint.lightning import UNetPPTrainer\n", + "\n", + "trainer = UNetPPTrainer(\n", + " project=PROJECT_NAME,\n", + " image_size=256,\n", + " batch_size=16,\n", + " max_epochs=8,\n", + " early_stopping_patience=10,\n", + ")\n", + "\n", + "results = trainer.fit()" + ] + }, + { + "cell_type": "markdown", + "id": "496bf458", + "metadata": {}, + "source": [ + "That's it! The entire training pipeline — from dataset loading to model deployment — in **3 lines of code**.\n", + "\n", + "Let's inspect the results:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8313df73", + "metadata": {}, + "outputs": [], + "source": [ + "# Inspect the results dictionary\n", + "print(\"Test results:\")\n", + "for metric_dict in results['test_results']:\n", + " for k, v in metric_dict.items():\n", + " print(f\" {k}: {v:.4f}\")\n", + "\n", + "print(f\"\\nModel type: {type(results['model']).__name__}\")" + ] + }, + { + "cell_type": "markdown", + "id": "408a9c66", + "metadata": {}, + "source": [ + "### 3.1 Local Inference" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fb2224b4", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "import mlflow\n", + "from datamint.mlflow import flavors as datamint_flavor\n", + "from datamint.entities.resource import LocalResource\n", + "\n", + "r = trainer.dataset[0]['resource']\n", + "\n", + "model_loaded = datamint_flavor.load_model('models:/UNetPP_Segmentation_Tutorial/latest')\n", + "model_loaded.predict([r])\n", + "\n", + "# Alternatively:\n", + "# model_loaded = mlflow.pyfunc.load_model('models:/UNetPP_Segmentation_Tutorial/latest')\n", + "# model_loaded.predict([r])" + ] + }, + { + "cell_type": "markdown", + "id": "e1eab625", + "metadata": {}, + "source": [ + "## 4. Visualize predictions\n", + "\n", + "Let's visualize the model predictions against ground truth on test samples." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a20cc9f2", + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "import numpy as np\n", + "from matplotlib import pyplot as plt\n", + "from datamint.utils.visualization import show, draw_masks\n", + "from torchmetrics.functional.segmentation import mean_iou\n", + "\n", + "model = results['model']\n", + "model.eval()\n", + "\n", + "# Access the test dataset from the internal datamodule\n", + "test_dataset = trainer._datamodule.test_dataloader().dataset\n", + "class_names = trainer._datamodule.dataset.seglabel_list\n", + "\n", + "fig, axes = plt.subplots(3, 2, figsize=(12, 18))\n", + "\n", + "for row in range(3):\n", + " idx = np.random.choice(len(test_dataset))\n", + " sample = test_dataset[idx]\n", + " image = sample['image'] # (C, H, W)\n", + " mask_gt = sample['segmentations'] # (#classes+1, H, W) — includes background\n", + "\n", + " with torch.inference_mode():\n", + " logits = model(image.unsqueeze(0).to(model.device))\n", + " mask_pred = (logits[0] > 0).cpu()\n", + "\n", + " # Ground truth (skip background channel)\n", + " gt_overlay = draw_masks(image, mask_gt[1:], alpha=0.5)\n", + " axes[row, 0].set_title(f\"Ground Truth (sample {idx})\")\n", + " show(gt_overlay, ax=axes[row, 0])\n", + "\n", + " # Prediction\n", + " pred_overlay = draw_masks(image, mask_pred, alpha=0.5)\n", + " axes[row, 1].set_title(\"Prediction\")\n", + " show(pred_overlay, ax=axes[row, 1])\n", + "\n", + " iou = mean_iou(\n", + " mask_pred.unsqueeze(0).long(),\n", + " mask_gt[1:].unsqueeze(0).long(),\n", + " num_classes=len(class_names),\n", + " input_format='one-hot',\n", + " )\n", + " print(f\"Sample {idx} — IoU: {iou.max():.1%} — Classes: {class_names}\")\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "18a46e62", + "metadata": {}, + "source": [ + "## 5. Customization options\n", + "\n", + "The Trainer API is fully customizable. Here are common overrides:\n", + "\n", + "### 5.1 Change the encoder backbone\n", + "\n", + "Use any encoder supported by [segmentation_models_pytorch](https://github.com/qubvel-org/segmentation_models.pytorch):\n", + "\n", + "```python\n", + "trainer = UNetPPTrainer(\n", + " project=PROJECT_NAME,\n", + " encoder_name='efficientnet-b4', # Larger encoder\n", + " image_size=384, # Higher resolution\n", + " batch_size=8, # Smaller batch for larger model\n", + ")\n", + "```\n", + "\n", + "### 5.2 Custom transforms and loss\n", + "\n", + "```python\n", + "import albumentations as A\n", + "from albumentations.pytorch import ToTensorV2\n", + "\n", + "my_transforms = A.Compose([\n", + " A.Resize(512, 512),\n", + " A.CLAHE(p=0.3),\n", + " A.Normalize(),\n", + " ToTensorV2(),\n", + "])\n", + "\n", + "trainer = UNetPPTrainer(\n", + " project=PROJECT_NAME,\n", + " train_transform=my_transforms,\n", + " loss_fn=torch.nn.BCEWithLogitsLoss(),\n", + ")\n", + "```\n", + "\n", + "### 5.3 Pass extra Lightning Trainer arguments\n", + "\n", + "```python\n", + "trainer = UNetPPTrainer(\n", + " project=PROJECT_NAME,\n", + " trainer_kwargs={\n", + " 'precision': '16-mixed', # Mixed precision training\n", + " 'gradient_clip_val': 1.0, # Gradient clipping\n", + " 'accumulate_grad_batches': 4, # Gradient accumulation\n", + " },\n", + ")\n", + "```\n", + "\n", + "### 5.4 Custom model arch\n", + "\n", + "```python\n", + "from datamint.lightning.trainer.lightning_modules import SegmentationModule\n", + "\n", + "class MyCustomSegModel(SegmentationModule):\n", + " def _build_model(self):\n", + " # my custom model architecture\n", + " return smp.DeepLabV3Plus(encoder_name='resnet50', classes=2)\n", + " # ... add custom layers, etc.\n", + "\n", + "trainer = SemanticSegmentation2DTrainer(\n", + " project=PROJECT_NAME,\n", + " model=MyCustomSegModel(),\n", + ")\n", + "```\n", + "\n", + "### 5.5 Use a completely custom model\n", + "\n", + "Pass any `LightningModule` — the trainer handles everything else:\n", + "\n", + "```python\n", + "import segmentation_models_pytorch as smp\n", + "\n", + "class MyCustomModule(L.LightningModule):\n", + " def __init__(self):\n", + " super().__init__()\n", + " self.model = smp.DeepLabV3Plus(encoder_name='resnet50', classes=2)\n", + "\n", + " def forward(self, x):\n", + " return self.model(x)\n", + "\n", + " def training_step(self, batch, batch_idx):\n", + " images = batch['image']\n", + " masks = batch['segmentations']\n", + " # ... your custom training_step, etc.\n", + "\n", + "trainer = SemanticSegmentation2DTrainer(\n", + " project=PROJECT_NAME,\n", + " model=MyCustomModule(),\n", + ")\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "aafb6276", + "metadata": {}, + "source": [ + "## 6. Deployment\n", + "\n", + "The trainer **automatically** created and registered a deployment adapter in MLflow.\n", + "You can deploy it directly to the Datamint server:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4e4382ba", + "metadata": {}, + "outputs": [], + "source": [ + "job = api.deploy.start(\n", + " model_name=PROJECT_NAME,\n", + " model_alias=\"latest\",\n", + " with_gpu=False,\n", + ")\n", + "\n", + "print(f\"Deployment job started!\")\n", + "print(f\"Job ID: {job.id}\")\n", + "print(f\"Status: {job.status}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1b4933a", + "metadata": {}, + "outputs": [], + "source": [ + "# Check deployment status\n", + "job = api.deploy.get_by_id(job.id)\n", + "\n", + "print(f\"Job Status: {job.status}\")\n", + "print(f\"Progress: {job.progress_percentage}%\")\n", + "\n", + "if job.error_message:\n", + " print(f\"Error: {job.error_message}\")" + ] + }, + { + "cell_type": "markdown", + "id": "8704fd18", + "metadata": {}, + "source": [ + "### 6.1 Remote inference" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "78a21a8b", + "metadata": {}, + "outputs": [], + "source": [ + "from datamint import Api\n", + "PROJECT_NAME = \"UNetPP_Segmentation_Tutorial\"\n", + "api = Api()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ec61ed35", + "metadata": {}, + "outputs": [], + "source": [ + "r = api.resources.get_list(project_name=PROJECT_NAME, limit=1)[0]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "08615277", + "metadata": {}, + "outputs": [], + "source": [ + "_debug('datamint.api.endpoints')\n", + "inf_job = api.inference.submit(\n", + " model_name=PROJECT_NAME,\n", + " model_alias=\"latest\",\n", + " resource_id=r.id\n", + ")\n", + "inf_job.wait() # Wait for inference to complete" + ] + }, + { + "cell_type": "markdown", + "id": "09215ff7", + "metadata": {}, + "source": [ + "visualize predictions:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "273350e8", + "metadata": {}, + "outputs": [], + "source": [ + "from matplotlib import pyplot as plt\n", + "\n", + "# plot all predictions using matplotlib\n", + "preds = inf_job.predictions[0]\n", + "plt.figure(figsize=(6, 6))\n", + "plt.imshow(preds[0].mask, cmap='gray')\n", + "plt.title(\"Predicted Mask\")\n", + "plt.axis('off')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bd662b56", + "metadata": {}, + "outputs": [], + "source": [ + "# Open the Datamint dashboard for this project\n", + "proj.show()" + ] + }, + { + "cell_type": "markdown", + "id": "b95445ca", + "metadata": {}, + "source": [ + "## Summary: What the Trainer API Automates\n", + "\n", + "The Trainer API automatically handles the full segmentation workflow, including:\n", + "\n", + "- Dataset loading and setup\n", + "- Semantic segmentation configuration\n", + "- Medical-image augmentation pipelines\n", + "- Model creation with a pretrained encoder\n", + "- Loss function setup\n", + "- Metric configuration\n", + "- Lightning trainer setup\n", + "- Callbacks, checkpointing, and early stopping\n", + "- MLflow experiment tracking\n", + "- Test-time evaluation\n", + "- Deployment adapter creation\n", + "\n", + "This keeps the training workflow concise while still allowing customization through custom models, transforms, loss functions, and trainer arguments." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 4a0319276cec61dba4af8582d18ed1241eb53d98 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 31 Mar 2026 09:37:52 -0300 Subject: [PATCH 31/47] removed unecessary import --- notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb | 1 - 1 file changed, 1 deletion(-) diff --git a/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb b/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb index d4a8bf3e..14e92d7b 100644 --- a/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb +++ b/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb @@ -48,7 +48,6 @@ "outputs": [], "source": [ "from datamint import Api\n", - "from datamint.mlflow import set_project\n", "\n", "PROJECT_NAME = \"UNetPP_Segmentation_Tutorial\"\n", "api = Api()" From 5eb13e617294adc32780ff1c84e0d5dc56afb7fd Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 31 Mar 2026 09:49:03 -0300 Subject: [PATCH 32/47] Deprecate DatamintBaseDataset and removed hard-coding check of annotation types --- datamint/dataset/base_dataset.py | 10 ++ datamint/dataset/sliced_dataset.py | 2 +- datamint/dataset/sliced_video_dataset.py | 2 +- datamint/mlflow/data/__init__.py | 3 + datamint/mlflow/data/datamint_dataset.py | 131 +++++++++++++++++++++++ 5 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 datamint/mlflow/data/__init__.py create mode 100644 datamint/mlflow/data/datamint_dataset.py diff --git a/datamint/dataset/base_dataset.py b/datamint/dataset/base_dataset.py index fea951f0..a99367a4 100644 --- a/datamint/dataset/base_dataset.py +++ b/datamint/dataset/base_dataset.py @@ -1,3 +1,4 @@ +import warnings import os import requests from typing import Optional, Callable, Any, Literal, Sequence @@ -20,6 +21,7 @@ import cv2 from datamint.entities import Resource import datamint.configs +from deprecated import deprecated _LOGGER = logging.getLogger(__name__) @@ -28,6 +30,8 @@ class DatamintDatasetException(DatamintException): pass +@deprecated(reason="DatamintBaseDataset is deprecated and may be removed in future versions. " + "Please use 'from datamint.dataset import ImageDataset, VolumeDataset' instead.") class DatamintBaseDataset: """Class to download and load datasets from the Datamint API. @@ -80,6 +84,12 @@ def __init__( include_frame_label_names: list[str] | None = None, exclude_frame_label_names: list[str] | None = None, ): + warnings.warn( + "DatamintBaseDataset is deprecated and may be removed in future versions. " + "Please use 'from datamint.dataset import ImageDataset, VolumeDataset' instead.", + DeprecationWarning, + stacklevel=2 + ) _LOGGER.warning( "DatamintBaseDataset is a legacy class and may be removed in future versions. " "Please use 'from datamint.dataset import ImageDataset, VolumeDataset' instead." diff --git a/datamint/dataset/sliced_dataset.py b/datamint/dataset/sliced_dataset.py index 6905bd06..680246ab 100644 --- a/datamint/dataset/sliced_dataset.py +++ b/datamint/dataset/sliced_dataset.py @@ -265,7 +265,7 @@ def _load_sliced_segmentations( - seg_labels: dict[author -> np.ndarray of int codes] - seg_metainfos: dict[author -> list] """ - seg_anns = [ann for ann in annotations if ann.annotation_type == 'segmentation'] + seg_anns = [ann for ann in annotations if ann.is_segmentation()] if not seg_anns: return {}, {}, {} diff --git a/datamint/dataset/sliced_video_dataset.py b/datamint/dataset/sliced_video_dataset.py index e4a61f7a..b76acb91 100644 --- a/datamint/dataset/sliced_video_dataset.py +++ b/datamint/dataset/sliced_video_dataset.py @@ -188,7 +188,7 @@ def _load_frame_segmentations( - seg_labels: dict[author -> np.ndarray of int codes] - seg_metainfos: dict[author -> list] """ - seg_anns = [ann for ann in annotations if ann.annotation_type == 'segmentation'] + seg_anns = [ann for ann in annotations if ann.is_segmentation()] if not seg_anns: return {}, {}, {} diff --git a/datamint/mlflow/data/__init__.py b/datamint/mlflow/data/__init__.py new file mode 100644 index 00000000..5655137f --- /dev/null +++ b/datamint/mlflow/data/__init__.py @@ -0,0 +1,3 @@ +from .datamint_dataset import DatamintMLflowDataset, DatamintDatasetSource + +__all__ = ["DatamintMLflowDataset", "DatamintDatasetSource"] diff --git a/datamint/mlflow/data/datamint_dataset.py b/datamint/mlflow/data/datamint_dataset.py new file mode 100644 index 00000000..c9d8008c --- /dev/null +++ b/datamint/mlflow/data/datamint_dataset.py @@ -0,0 +1,131 @@ +"""MLflow Dataset adapter for Datamint project splits.""" +from __future__ import annotations + +import hashlib +import json +from typing import Any +from collections.abc import Sequence +import logging + +from mlflow.data.dataset import Dataset +from mlflow.data.dataset_source import DatasetSource +from datamint.entities.resource import Resource + + +_LOGGER = logging.getLogger(__name__) + + +class DatamintDatasetSource(DatasetSource): + """Source info pointing to a Datamint project.""" + + def __init__(self, project_id: str, project_name: str, + split: str | None, + extra_params: dict[str, Any] | None = None) -> None: + self._project_id = project_id + self._project_name = project_name + self._split = split + self.extra_params = extra_params + + @staticmethod + def _get_source_type() -> str: + return "datamint" + + def load(self, **kwargs: Any) -> Any: + raise NotImplementedError( + "DatamintDatasetSource.load() is not supported. " + "Use the Datamint API to load data." + ) + + @staticmethod + def _can_resolve(raw_source: str) -> bool: + return False + + @classmethod + def _resolve(cls, raw_source: str) -> DatamintDatasetSource: + raise NotImplementedError + + def to_json(self) -> str: + return json.dumps({ + "project_id": self._project_id, + "project_name": self._project_name, + "split": self._split, + "extra_params": self.extra_params, + }) + + @classmethod + def from_json(cls, source_json: str) -> DatamintDatasetSource: + data = json.loads(source_json) + return cls( + project_id=data["project_id"], + project_name=data["project_name"], + split=data["split"], + extra_params=data.get("extra_params"), + ) + + +class DatamintMLflowDataset(Dataset): + """MLflow Dataset wrapping a Datamint project split for lineage tracking.""" + + def __init__( + self, + project_id: str, + project_name: str, + split: str | None, + resources: Sequence[str] | Sequence[Resource], + extra_params: dict[str, Any] | None = None, + ) -> None: + self.resources = resources + self.extra_params = extra_params + source = DatamintDatasetSource(project_id, project_name, split, + extra_params=extra_params) + super().__init__(source=source, name=project_name) + + def _compute_digest(self) -> str: + dumped_resources = [] + for r in self.resources: + if isinstance(r, str): + dumped_resources.append({'id': r}) + else: + data = { + "id": r.id, + "obj_type": str(type(r)), + } + for attrname in ("slice_index", "slice_axis", "filename"): + a = getattr(r, attrname, None) + if a is not None: + data[attrname] = a + dumped_resources.append(data) + + data: dict = {"resources": dumped_resources} + if self.extra_params: + data["extra_params"] = self.extra_params + + content = json.dumps(data, sort_keys=True) + return hashlib.md5(content.encode()).hexdigest()[:8] + + @property + def profile(self) -> Any | None: + storage_types = [r.storage if isinstance(r, Resource) else None for r in self.resources] + most_common_storage_type = None + if storage_types: + most_common_storage_type = max(set(storage_types), key=storage_types.count) + + _LOGGER.debug(f"Computed profile for DatamintDataset with {len(self.resources)} resources. " + f"Most common storage type: {most_common_storage_type}") + + return {"num_resources": len(self.resources), + "most_common_storage_type": most_common_storage_type} + + def to_dict(self) -> dict[str, str]: + config = super().to_dict() + config.update( + { + "schema": None, + "profile": json.dumps(self.profile), + } + ) + return config + + @property + def schema(self): + return None From 269060f3153d9a20c858ead1f6fb7a8249cfa51b Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 31 Mar 2026 09:51:39 -0300 Subject: [PATCH 33/47] Disable input example processing and enhance logging in model save function; remove base dataset import test --- datamint/mlflow/flavors/datamint_flavor.py | 23 ++++++++++++++++++---- tests/test_imports.py | 8 -------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/datamint/mlflow/flavors/datamint_flavor.py b/datamint/mlflow/flavors/datamint_flavor.py index 159ee4df..d30143e0 100644 --- a/datamint/mlflow/flavors/datamint_flavor.py +++ b/datamint/mlflow/flavors/datamint_flavor.py @@ -50,6 +50,10 @@ def _process_signature(signature: ModelSignature | None, def _process_input_example(input_example: ModelInputExample | None) -> tuple[ModelInputExample | None, dict[str, Any]]: + + logger.info('Processing input example is disabled for now') + raise NotImplementedError('Processing input example is disabled for now') + datamint_params = { "mode": "default", } @@ -66,7 +70,7 @@ def _process_input_example(input_example: ModelInputExample | None) -> tuple[Mod def _resolve_requirements(pip_requirements, extra_pip_requirements): import medimgkit - + def _get_req_name(req): try: return Requirement(req).name.lower() @@ -115,6 +119,10 @@ def save_model(datamint_model: BaseDatamintModel, model_config=None, streamable=None, **kwargs): + logger.debug(f"Saving DatamintModel to path: {path} " + f" class name: {datamint_model.__class__.__name__} " + f' has_input_example: {input_example is not None} ') + if not isinstance(datamint_model, DatamintModel): datamint_model = _DatamintModelWrapper(datamint_model) @@ -131,7 +139,13 @@ def save_model(datamint_model: BaseDatamintModel, if signature is not None: signature = _process_signature(signature, datamint_model) - input_example = _process_input_example(input_example) + try: + input_example = _process_input_example(input_example) + except NotImplementedError: + input_example = None + except Exception as e: + logger.warning(f"Failed to process input example. Proceeding without input example. Error: {e}") + input_example = None linked_models = datamint_model._get_linked_models_uri() if hasattr(datamint_model, '_get_linked_models_uri') else {} flavor_params = { @@ -169,8 +183,11 @@ def save_model(datamint_model: BaseDatamintModel, logger.debug(f"Saving PyTorch model to temporary file {tmp_file.name}") torch.save(pt_model, tmp_file.name, pickle_module=mlflow_pytorch_pickle_module) pyfunc_kwargs['artifacts'] = {**(artifacts or {}), DatamintModel._PYTORCH_ARTIFACT_NAME: tmp_file.name} + + logger.debug(f'Saving PyFunc model with PyTorch artifact for model {datamint_model.__class__.__name__}...') return mlflow.pyfunc.save_model(**pyfunc_kwargs) + logger.debug(f'Saving PyFunc model for model {datamint_model.__class__.__name__}...') return mlflow.pyfunc.save_model(**pyfunc_kwargs) @@ -221,8 +238,6 @@ def load_model(model_uri: str, device: str | None = None) -> DatamintModel: return _load_pyfunc(local_path, model_config=model_config).unwrap_python_model() - - def _load_pyfunc(path: str, model_config=None) -> pyfunc.PyFuncModel: logger.debug(f"Loading PyFunc model from path: {path} with model_config: {model_config}") pf_model = mlflow.pyfunc.load_model(model_uri=path, model_config=model_config) diff --git a/tests/test_imports.py b/tests/test_imports.py index ec4c7ac6..39a0bae9 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -40,14 +40,6 @@ def test_dataset_imports(self) -> None: except ImportError as e: pytest.fail(f"Failed to import DatamintDataset: {e}") - # Test importing base dataset - try: - from datamint.dataset.base_dataset import DatamintBaseDataset - assert DatamintBaseDataset is not None - _LOGGER.info("Successfully imported DatamintBaseDataset") - except ImportError as e: - pytest.fail(f"Failed to import DatamintBaseDataset: {e}") - def test_api_imports(self) -> None: """Test importing API handler modules.""" # Test direct import of APIHandler From 867f3474361ed48327f3db2e4122bab38ae00794 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 31 Mar 2026 10:05:59 -0300 Subject: [PATCH 34/47] feat: Enhance MLflow integration and per-sample metrics logging - Added MLflow project context management in BaseTrainer. - Implemented experiment name generation based on user project. - Introduced per-sample metrics logging in DatamintLightningModule for classification and segmentation tasks. - Enhanced loss computation methods to support per-sample loss for better metric accuracy. - Updated model checkpointing to log additional metadata and support retrieval of logged models. - Improved dataset logging during training and testing phases. - Refactored callback structure to streamline MLflow integration and metrics logging. - Added validation for class names and number of classes in SegmentationModule. - Enhanced logging and error handling throughout the training and logging processes. --- datamint/dataset/base.py | 59 ++++- datamint/entities/annotations/annotation.py | 46 ++-- datamint/lightning/datamodule.py | 80 ++++-- datamint/lightning/trainers/base_trainer.py | 156 +++++++++--- .../trainers/lightning_modules/base.py | 133 ++++++++++ .../classification_module.py | 47 +++- .../lightning_modules/segmentation_module.py | 81 +++++- datamint/lightning/trainers/seg2d_trainer.py | 8 +- .../trainers/segmentation_trainer.py | 5 +- .../lightning/callbacks/modelcheckpoint.py | 240 ++++++++++-------- 10 files changed, 651 insertions(+), 204 deletions(-) diff --git a/datamint/dataset/base.py b/datamint/dataset/base.py index 5c657472..8f6f76f4 100644 --- a/datamint/dataset/base.py +++ b/datamint/dataset/base.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: from datamint.entities import Resource, Project, Annotation from albumentations import BaseCompose + from datamint.mlflow.data import DatamintMLflowDataset _LOGGER = logging.getLogger(__name__) @@ -30,7 +31,7 @@ class DatamintDatasetException(DatamintException): pass -class DatamintBaseDataset(ABC): +class DatamintBaseDataset(ABC, torch.utils.data.Dataset): """Abstract base class for Datamint datasets. This class provides the PyTorch Dataset interface with: @@ -168,6 +169,7 @@ def __init__( # Internal state self._logged_uint16_conversion = False self._is_prepared = False + self.split_name: str | None = None def __getattr__(self, name: str) -> Any: # __getattr__ is only invoked when normal attribute lookup fails — @@ -504,14 +506,14 @@ def _should_include_annotation(self, ann: 'Annotation') -> bool: return False # Check by annotation type - if ann.annotation_type == 'segmentation': + if ann.is_segmentation(): return self._should_include_segmentation(ann.identifier) - elif ann.annotation_type == 'label': + elif ann.is_label(): if ann.frame_index is None: # image-level return self._should_include_image_label(ann.identifier) else: # frame-level return self._should_include_frame_label(ann.identifier) - elif ann.annotation_type == 'category': + elif ann.is_category(): if not self.allow_external_annotations: lsets = self.image_lsets if ann.frame_index is None else self.frame_lsets valid_identifiers = {ident for ident, _ in lsets.get('multiclass', [])} @@ -595,9 +597,9 @@ def _infer_labels_set(self, framed: bool) -> tuple[dict[str, list], dict[str, di if ann_scope != scope: continue - if ann.annotation_type == 'label': + if ann.is_label(): multilabel_set.add(ann.identifier) - elif ann.annotation_type == 'category': + elif ann.is_category(): multiclass_set.add((ann.identifier, ann.value)) multilabel_list = sorted(multilabel_set) @@ -817,6 +819,39 @@ def __add__(self, other: 'DatamintBaseDataset') -> ConcatDataset: """Concatenate datasets.""" return ConcatDataset([self, other]) # type: ignore[list-item] + def build_mlflow_dataset(self) -> 'DatamintMLflowDataset': + """Create a :class:`~datamint.mlflow.data.DatamintMLflowDataset` for this dataset. + + Args: + split: The split name (e.g. ``'train'``, ``'val'``, ``'test'``). + """ + from datamint.mlflow.data import DatamintMLflowDataset + + project = getattr(self, 'project', None) + project_name = getattr(project, 'name', 'unknown') if project is not None else 'unknown' + project_id = getattr(project, 'id', 'unknown') if project is not None else 'unknown' + + extra_params = { + 'return_as_semantic_segmentation': self.return_as_semantic_segmentation, + 'semantic_seg_merge_strategy': str(self.semantic_seg_merge_strategy), + 'include_unannotated': self.include_unannotated, + 'include_annotators': self.include_annotators, + 'exclude_annotators': self.exclude_annotators, + 'include_segmentation_names': self.include_segmentation_names, + 'exclude_segmentation_names': self.exclude_segmentation_names, + 'include_image_label_names': self.include_image_label_names, + 'exclude_image_label_names': self.exclude_image_label_names, + 'include_frame_label_names': self.include_frame_label_names, + 'exclude_frame_label_names': self.exclude_frame_label_names, + } + return DatamintMLflowDataset( + project_id=project_id, + project_name=project_name, + split=self.split_name, + resources=self.resources, + extra_params=extra_params + ) + def get_dataloader(self, *args, **kwargs) -> DataLoader: """Get DataLoader with proper collate function.""" return DataLoader(self, *args, collate_fn=self.get_collate_fn(), **kwargs) # type: ignore[arg-type] @@ -853,7 +888,7 @@ def collate_fn(batch: list[dict]) -> dict: return collated return collate_fn - + def subset(self, indices: list[int]) -> 'DatamintBaseDataset': """Create a dataset subset by slicing resources and annotations.""" import copy @@ -869,6 +904,8 @@ def __repr__(self) -> str: name = self.project.name if self.project else "" head = f"Dataset {name}" body = [f"Number of datapoints: {len(self)}"] + if self.split_name is not None: + body.append(f"Split: {self.split_name}") # if self.manager.root is not None: # body.append(f"Location: {self.manager.dataset_dir}") @@ -970,7 +1007,10 @@ def _split_by_server_tags( "server first or use local splitting (use_server_splits=False)." ) - return {name: self.subset(indices) for name, indices in split_indices.items()} + result = {name: self.subset(indices) for name, indices in split_indices.items()} + for name, ds in result.items(): + ds.split_name = name + return result def _split_locally( self, @@ -1009,6 +1049,7 @@ def _split_locally( else: end = start + round(ratio * n) result[name] = self.subset(indices[start:end]) + result[name].split_name = name start = end return result @@ -1054,7 +1095,7 @@ def filter( ValueError: If no filter criteria are specified. """ if all(v is None for v in (tags, filename_pattern, has_annotations, - annotation_names, custom_fn)): + annotation_names, custom_fn)): raise ValueError("At least one filter criterion must be specified.") import fnmatch diff --git a/datamint/entities/annotations/annotation.py b/datamint/entities/annotations/annotation.py index 2bad1994..8a25c7fb 100644 --- a/datamint/entities/annotations/annotation.py +++ b/datamint/entities/annotations/annotation.py @@ -5,11 +5,11 @@ records returned by the DataMint API. """ -from typing import TYPE_CHECKING, Any, Literal, overload -import logging from datetime import datetime +import logging +from typing import TYPE_CHECKING, Any, Literal, overload -from pydantic import ConfigDict, Field, PrivateAttr +from pydantic import ConfigDict, Field, PrivateAttr, field_validator from datamint.api.dto import AnnotationType from datamint.types import ImagingData @@ -46,9 +46,23 @@ class AnnotationBase(BaseEntity): identifier: str = Field(alias="name") scope: str - annotation_type: AnnotationType + annotation_type: str # AnnotationType # mlflow model signature does not support enum types confiability: float = 1.0 + @field_validator("annotation_type", mode="before") + @classmethod + def _validate_annotation_type(cls, value: AnnotationType | str) -> str: + if isinstance(value, AnnotationType): + return value.value + + try: + return AnnotationType(value).value + except ValueError as exc: + valid_types = ", ".join(member.value for member in AnnotationType) + raise ValueError( + f"Invalid annotation_type {value!r}. Expected one of: {valid_types}" + ) from exc + def __init__(self, **data): """Initialize the annotation base entity.""" super().__init__(**data) @@ -57,6 +71,18 @@ def __init__(self, **data): def name(self) -> str: """Get the annotation name (alias for identifier).""" return self.identifier + + def is_segmentation(self) -> bool: + """Check if this is a segmentation annotation.""" + return self.annotation_type == AnnotationType.SEGMENTATION.value + + def is_label(self) -> bool: + """Check if this is a label annotation.""" + return self.annotation_type == AnnotationType.LABEL.value + + def is_category(self) -> bool: + """Check if this is a category annotation.""" + return self.annotation_type == AnnotationType.CATEGORY.value class Annotation(AnnotationBase): @@ -264,18 +290,6 @@ def added_by(self) -> str: """Get the creator email (alias for created_by).""" return self.created_by - def is_segmentation(self) -> bool: - """Check if this is a segmentation annotation.""" - return self.annotation_type == 'segmentation' - - def is_label(self) -> bool: - """Check if this is a label annotation.""" - return self.annotation_type == 'label' - - def is_category(self) -> bool: - """Check if this is a category annotation.""" - return self.annotation_type == 'category' - def is_frame_scoped(self) -> bool: """Check if this annotation is frame-scoped.""" return self.scope == 'frame' diff --git a/datamint/lightning/datamodule.py b/datamint/lightning/datamodule.py index a3e14d79..a7cc0a0b 100644 --- a/datamint/lightning/datamodule.py +++ b/datamint/lightning/datamodule.py @@ -9,12 +9,16 @@ import logging from collections.abc import Callable +from typing import TYPE_CHECKING import lightning as L from torch.utils.data import DataLoader from datamint.dataset.base import DatamintBaseDataset +if TYPE_CHECKING: + from datamint.mlflow.data import DatamintMLflowDataset + _LOGGER = logging.getLogger(__name__) @@ -94,7 +98,7 @@ def __init__( # TODO: save the transforms as strings in the hyperparameters self.save_hyperparameters(ignore=["dataset", "train_transform", "eval_transform"]) - self._dataset = dataset + self.dataset = dataset self._batch_size = batch_size self._train_batch_size = train_batch_size or batch_size self._val_batch_size = val_batch_size or batch_size @@ -122,23 +126,19 @@ def __init__( # Cache the split result so setup() is idempotent self._splits_resolved = False - @property - def dataset(self) -> DatamintBaseDataset: - """The wrapped Datamint dataset.""" - return self._dataset - # ------------------------------------------------------------------ # LightningDataModule lifecycle # ------------------------------------------------------------------ + def prepare_data(self) -> None: - self._dataset._prepare() + self.dataset._prepare() def setup(self, stage: str | None = None) -> None: if self._splits_resolved: return if self._split or self._split_cfg is not None or self._use_server_splits: - parts = self._dataset.split( + parts = self.dataset.split( seed=self._split_seed, use_server_splits=self._use_server_splits, **(self._split_cfg or {}), @@ -154,9 +154,9 @@ def setup(self, stage: str | None = None) -> None: ) else: # No split config: use the full dataset for every stage. - self._train_dataset = self._dataset + self._train_dataset = self.dataset self._val_dataset = None - self._test_dataset = self._dataset + self._test_dataset = self.dataset # Apply stage-specific transforms after splits are resolved. if self._train_transform is not None and self._train_dataset is not None: @@ -182,7 +182,7 @@ def train_dataloader(self) -> DataLoader: drop_last=self._drop_last_train, num_workers=self._num_workers, pin_memory=self._pin_memory, - collate_fn=self._dataset.get_collate_fn(), + collate_fn=self.dataset.get_collate_fn(), ) def val_dataloader(self) -> DataLoader | None: @@ -194,26 +194,60 @@ def val_dataloader(self) -> DataLoader | None: shuffle=False, num_workers=self._num_workers, pin_memory=self._pin_memory, - collate_fn=self._dataset.get_collate_fn(), + collate_fn=self.dataset.get_collate_fn(), ) def test_dataloader(self) -> DataLoader: - ds = self._test_dataset if self._test_dataset is not None else self._dataset + ds = self._test_dataset if self._test_dataset is not None else self.dataset return DataLoader( ds, batch_size=self._test_batch_size, shuffle=False, num_workers=self._num_workers, pin_memory=self._pin_memory, - collate_fn=self._dataset.get_collate_fn(), + collate_fn=self.dataset.get_collate_fn(), ) - def predict_dataloader(self) -> DataLoader: - return DataLoader( - self._dataset, - batch_size=self._test_batch_size, - shuffle=False, - num_workers=self._num_workers, - pin_memory=self._pin_memory, - collate_fn=self._dataset.get_collate_fn(), - ) + def get_mlflow_dataset_split(self, split: str) -> 'DatamintMLflowDataset | None': + """Return a :class:`~datamint.mlflow.data.DatamintMLflowDataset` for the given split. + + Delegates to the corresponding split dataset's + :meth:`~datamint.dataset.base.DatamintBaseDataset.build_mlflow_dataset`. + Falls back to the full dataset when the requested split is not available. + + Args: + split: One of ``'train'``, ``'val'``, or ``'test'``. + """ + ds = self.get_dataset_split(split) + if ds is None: + return None + mlds = ds.build_mlflow_dataset() + if getattr(mlds.source, "_split", "") != split: + _LOGGER.warning( + f"Requested MLflow dataset for split '{split}', but the dataset's " + f"split is '{getattr(mlds.source, "_split", "")}'. This may cause confusion in MLflow." + ) + return mlds + + def get_mlflow_dataset(self): + """Return an MLflow dataset for the full dataset (without split context).""" + return self.dataset.build_mlflow_dataset() + + def get_dataset_split(self, split: str) -> 'DatamintBaseDataset | None': + """Return the Datamint dataset for the given split. Falls back to the full dataset when the requested split is not available.""" + split_ds_map = { + 'train': self._train_dataset, + 'val': self._val_dataset, + 'test': self._test_dataset, + } + return split_ds_map.get(split) + + # def predict_dataloader(self) -> DataLoader: + # return DataLoader( + # self.dataset, + # batch_size=self._test_batch_size, + # shuffle=False, + # num_workers=self._num_workers, + # pin_memory=self._pin_memory, + # collate_fn=self.dataset.get_collate_fn(), + # ) diff --git a/datamint/lightning/trainers/base_trainer.py b/datamint/lightning/trainers/base_trainer.py index 7d4939f0..3a93d5f8 100644 --- a/datamint/lightning/trainers/base_trainer.py +++ b/datamint/lightning/trainers/base_trainer.py @@ -13,9 +13,12 @@ import lightning as L from torch import nn +import mlflow from datamint.dataset.base import DatamintBaseDataset from datamint.lightning.datamodule import DatamintDataModule +from datamint.mlflow import set_project +from datamint.mlflow.flavors.model import BaseDatamintModel if TYPE_CHECKING: from albumentations import BaseCompose @@ -112,13 +115,30 @@ def __init__( self.trainer_kwargs = trainer_kwargs or {} # Populated during fit() - self._datamodule: DatamintDataModule | None = None self._lightning_trainer: L.Trainer | None = None @cached_property def dataset(self) -> DatamintBaseDataset: return self._resolve_dataset() + @property + def _project_name(self) -> str: + if self._user_project is None: + project_name = self.dataset.project.name if self.dataset.project else 'datamint' + elif isinstance(self._user_project, str): + project_name = self._user_project + else: + project_name = self._user_project.name + + return project_name + + @property + def experiment_name(self) -> str: + return self.mlflow_experiment_name or f"{self._project_name}_training" + + def _with_project(self): + set_project(self._project_name) + # ── Public API ────────────────────────────────────────────── def fit(self) -> dict[str, Any]: """Run the full training pipeline. @@ -132,43 +152,43 @@ def fit(self) -> dict[str, Any]: for attr in ('dataset', 'model'): if attr in self.__dict__: del self.__dict__[attr] - # 1. Resolve dataset (triggers @cached_property) - _ = self.dataset - # 2. Build transforms - train_tf = self._user_train_transform or self._train_transform() - eval_tf = self._user_eval_transform or self._eval_transform() + self._with_project() # ensure MLflow project context is set for the entire training pipeline + exp = mlflow.set_experiment(self.experiment_name) # ensure experiment is set for MLflowLogger and callbacks - # 3. Build DataModule - self._datamodule = self._build_datamodule(self.dataset, train_tf, eval_tf) + with mlflow.start_run(experiment_id=exp.experiment_id) as run: + # 1. Resolve dataset (triggers @cached_property) + _ = self.dataset - # 4. Build model - _ = self.model # triggers @cached_property to build the model + # 2 . Build datamodule (triggers @cached_property) + _ = self.datamodule - # 5. Build callbacks & logger - callbacks = self._build_callbacks() - logger = self._build_logger() + # 3. Build model + _ = self.model # triggers @cached_property to build the model - # 6. Build Lightning Trainer - self._lightning_trainer = L.Trainer( - max_epochs=self.max_epochs, - logger=logger, - callbacks=callbacks, - accelerator='auto', - **self.trainer_kwargs, - ) + # 4. Build callbacks & logger + callbacks = self._build_default_callbacks() + list(self._build_callbacks()) + logger = self._build_logger(run_id=run.info.run_id) + + # 5. Build Lightning Trainer + self._lightning_trainer = L.Trainer( + max_epochs=self.max_epochs, + logger=logger, + callbacks=callbacks, + accelerator='auto', + **self.trainer_kwargs, + ) - # 7. Train - self._lightning_trainer.fit(self.model, datamodule=self._datamodule) + # 6. Train + self._lightning_trainer.fit(self.model, datamodule=self.datamodule) - # 8. Test - test_results = self._lightning_trainer.test(datamodule=self._datamodule) + # 7. Test + test_results = self._lightning_trainer.test(datamodule=self.datamodule) - # 9. Deploy adapter (only needed when the model is not already a DatamintModel) - from datamint.mlflow.flavors.model import BaseDatamintModel - adapter = None - if self.auto_deploy_adapter and not isinstance(self.model, BaseDatamintModel): - adapter = self._build_deploy_adapter() + # 8. Build deploy adapter (only needed when the model is not already a DatamintModel) + adapter = None + if self.auto_deploy_adapter and not isinstance(self.model, BaseDatamintModel): + adapter = self._build_deploy_adapter() return { 'trainer': self._lightning_trainer, @@ -239,6 +259,15 @@ def _resolve_dataset(self) -> DatamintBaseDataset: assert self._user_project is not None # guaranteed by __init__ validation return self._build_dataset(self._user_project) + @cached_property + def datamodule(self) -> DatamintDataModule: + # Build transforms + train_tf = self._user_train_transform or self._train_transform() + eval_tf = self._user_eval_transform or self._eval_transform() + + datamodule = self._build_datamodule(self.dataset, train_tf, eval_tf) + return datamodule + def _build_datamodule( self, dataset: DatamintBaseDataset, @@ -253,20 +282,21 @@ def _build_datamodule( eval_transform=eval_transform, ) - def _build_callbacks(self) -> list: + def _build_default_callbacks(self) -> list: from datamint.mlflow.lightning.callbacks import MLFlowPyTorchModelCheckpoint, MLFlowDatamintModelCheckpoint - from lightning.pytorch.callbacks import EarlyStopping from mlflow.pyfunc.model import PythonModel metric_name, mode = self._monitor_metric() - project_name = self.dataset.project.name if self.dataset.project else 'datamint' - model_name = self.register_model_name or project_name + model_name = self.register_model_name or self._project_name if isinstance(self.model, PythonModel): checkpoint_cls = MLFlowDatamintModelCheckpoint else: checkpoint_cls = MLFlowPyTorchModelCheckpoint + _LOGGER.debug( + f"Using {checkpoint_cls.__name__} for model checkpointing with monitor='{metric_name}' mode='{mode}'") + callbacks: list = [ checkpoint_cls( monitor=metric_name, @@ -274,8 +304,19 @@ def _build_callbacks(self) -> list: save_top_k=1, register_model_name=model_name, register_model_on='test', + log_model_metrics=True, # TODO: move this functionality to a separate callback or here )] + callbacks.append(_LogDatasetSplitsCallback(self)) + + return callbacks + + def _build_callbacks(self) -> list: + from lightning.pytorch.callbacks import EarlyStopping + + metric_name, mode = self._monitor_metric() + + callbacks = [] if self.early_stopping_patience is not None: callbacks.append(EarlyStopping( monitor=metric_name, @@ -285,12 +326,45 @@ def _build_callbacks(self) -> list: return callbacks - def _build_logger(self): + def _build_logger(self, run_id: str | None = None): from lightning.pytorch.loggers import MLFlowLogger - from datamint.mlflow import set_project - - project_name = self.dataset.project.name if self.dataset.project else 'datamint' - set_project(project_name) - experiment_name = self.mlflow_experiment_name or f"{project_name}_training" - return MLFlowLogger(experiment_name=experiment_name) + self._with_project() + + mlflow_logger = MLFlowLogger(experiment_name=self.experiment_name, run_id=run_id) + # Injecting dataset for _BaseMLFlowModelCheckpoint:_log_test_metrics_to_model + dataset = self.datamodule.get_mlflow_dataset_split('test') + if dataset is None: + dataset = self.datamodule.get_mlflow_dataset() + mlflow_logger._mlflow_dataset = dataset + return mlflow_logger + + +class _LogDatasetSplitsCallback(L.Callback): + """Lightning callback to retrieve resolved dataset splits from the datamodule after setup().""" + + LIGHTNING_STAGE_TO_DATAMINT_SPLIT = { + 'fit': 'train', + 'validate': 'val', + 'test': 'test', + } + + def __init__(self, dttrainer: BaseTrainer) -> None: + super().__init__() + self.dttrainer = dttrainer + + def setup(self, trainer: "L.Trainer", pl_module: "L.LightningModule", stage: str) -> None: + + split = self.LIGHTNING_STAGE_TO_DATAMINT_SPLIT.get(stage) + if split is None: + return + + mlflow_dataset = self.dttrainer.datamodule.get_mlflow_dataset_split(split) + if mlflow_dataset is None: + return + try: + _LOGGER.info(f"Logging dataset split '{split}' to MLflow for model context...") + mlflow.log_input(mlflow_dataset, context=split) + _LOGGER.debug(f"Successfully logged dataset split '{split}' to MLflow.") + except Exception as e: + _LOGGER.warning(f"Failed to log dataset input: {e}") diff --git a/datamint/lightning/trainers/lightning_modules/base.py b/datamint/lightning/trainers/lightning_modules/base.py index 7319d50a..cefd0903 100644 --- a/datamint/lightning/trainers/lightning_modules/base.py +++ b/datamint/lightning/trainers/lightning_modules/base.py @@ -1,11 +1,18 @@ """Combined LightningModule + BaseDatamintModel base for built-in trainers.""" from __future__ import annotations +import logging +from typing import Any, TYPE_CHECKING + import lightning as L +from torch import Tensor from datamint.mlflow.flavors.model import BaseDatamintModel, ModelSettings from mlflow.pyfunc.model import PythonModelContext +_LOGGER = logging.getLogger(__name__) +_SAMPLE_META_KEYS = {'resource_id', 'slice_index'} + class DatamintLightningModule(L.LightningModule, BaseDatamintModel): """A :class:`~lightning.LightningModule` that is also a @@ -19,6 +26,132 @@ class DatamintLightningModule(L.LightningModule, BaseDatamintModel): def __init__(self, settings: ModelSettings | None = None) -> None: L.LightningModule.__init__(self) BaseDatamintModel.__init__(self, settings=settings) + self._log_sample_metrics: bool = False + self._sample_buffer: list[dict[str, Any]] = [] + self.mlflow_model_id: str | None = None + self.mlflow_dataset: Any = None # Set at runtime + + # ------------------------------------------------------------------ + # Per-sample metrics logging + # ------------------------------------------------------------------ + + def enable_sample_logging(self, enabled: bool = True) -> None: + """Enable or disable per-sample metric accumulation.""" + self._log_sample_metrics = enabled + + def _accumulate_sample_data( + self, + batch: dict, + logits: Tensor, + loss_unreduced: Tensor, + stage: str, + ) -> None: + """Collect per-sample metrics into ``_sample_buffer``. + + Called from ``_common_step`` when ``_log_sample_metrics`` is enabled. + """ + resources = batch.get('resource', []) + confidences = self._compute_sample_confidence(logits) + sample_metrics = self._compute_sample_metrics(logits, batch) + + for i in range(logits.shape[0]): + entry: dict[str, Any] = {} + if i < len(resources): + res = resources[i] + if hasattr(res, 'parent_resource'): + entry['resource_id'] = res.parent_resource.id + entry['slice_index'] = res.slice_index + else: + entry['resource_id'] = res.id + entry['loss'] = loss_unreduced[i].item() + for key, vals in confidences.items(): + entry[key] = vals[i].item() + for key, vals in sample_metrics.items(): + entry[key] = vals[i].item() + self._sample_buffer.append(entry) + + def _compute_sample_confidence(self, logits: Tensor) -> dict[str, Tensor]: + """Return per-sample confidence scores. Subclasses must override.""" + return {} + + def _compute_sample_metrics(self, logits: Tensor, batch: dict) -> dict[str, Tensor]: + """Return per-sample metric values. Subclasses must override.""" + return {} + + def _flush_sample_metrics_to_mlflow(self) -> None: + """Write accumulated sample data to MLflow and clear the buffer.""" + if not self._sample_buffer: + return + + import mlflow + from datamint.mlflow.models import _get_MLFlowLogger + + logger = _get_MLFlowLogger(self.trainer) + if logger is None or logger.run_id is None: + _LOGGER.warning( + "No MLFlowLogger with run_id found. " + "Skipping per-sample metrics flush." + ) + return + + run_id = logger.run_id + + # Log per-sample metrics with step = sample index. + metric_keys = { + key + for entry in self._sample_buffer + for key in entry + if key not in _SAMPLE_META_KEYS + } + + for step, entry in enumerate(self._sample_buffer): + metrics = { + f"test/sample/{key}": float(entry[key]) + for key in metric_keys + if entry.get(key) is not None + } + if metrics: + try: + mlflow.log_metrics(metrics=metrics, + step=step, + run_id=run_id, + dataset=self.mlflow_dataset, + model_id=self.mlflow_model_id) + except Exception as e: + _LOGGER.error( + f"Failed to log sample metrics at step {step}: {e}" + ) + + # Log resource_id → step mapping table as artifact. + mapping_data: dict[str, list[Any]] = { + "step": [], + "resource_id": [], + "slice_index": [], + } + for step, entry in enumerate(self._sample_buffer): + mapping_data["step"].append(step) + mapping_data["resource_id"].append(entry.get("resource_id")) + mapping_data["slice_index"].append(entry.get("slice_index")) + try: + mlflow.log_table( + data=mapping_data, + artifact_file="test_sample_mapping.json", + ) + except Exception as e: + _LOGGER.warning(f"Failed to log sample mapping table: {e}") + + _LOGGER.info( + f"Flushed {len(self._sample_buffer)} per-sample metrics to MLflow." + ) + self._sample_buffer.clear() + + def on_test_start(self) -> None: + self.enable_sample_logging(True) + self._sample_buffer.clear() + + def on_test_epoch_end(self) -> None: + self._flush_sample_metrics_to_mlflow() + self.enable_sample_logging(False) # ------------------------------------------------------------------ # MLflow lifecycle diff --git a/datamint/lightning/trainers/lightning_modules/classification_module.py b/datamint/lightning/trainers/lightning_modules/classification_module.py index 03be73c6..2463911d 100644 --- a/datamint/lightning/trainers/lightning_modules/classification_module.py +++ b/datamint/lightning/trainers/lightning_modules/classification_module.py @@ -4,9 +4,10 @@ from collections.abc import Callable from typing import Any -import lightning as L import torch from torch import Tensor, nn +import inspect +import warnings from .base import DatamintLightningModule @@ -56,11 +57,28 @@ def __init__( def forward(self, x: Tensor) -> Tensor: return self.model(x) + def _compute_unreduced_loss(self, logits: Tensor, labels: Tensor) -> Tensor: + """Compute per-sample loss without reduction.""" + if 'reduction' in inspect.signature(self.criterion.forward).parameters: + return self.criterion(logits, labels, reduction='none') + else: + warnings.warn( + f"Loss function {self.criterion.__class__.__name__} does not support 'reduction' argument; " + "per-sample logging will be inaccurate.", + ) + return self.criterion(logits, labels).unsqueeze(0).expand(logits.shape[0]) + def _common_step(self, batch: dict, stage: str) -> Tensor: images = batch['image'] labels = batch['image_categories'] logits = self(images) - loss = self.criterion(logits, labels) + + if self._log_sample_metrics: + loss_unreduced = self._compute_unreduced_loss(logits, labels) # (B,) + loss = loss_unreduced.mean() + self._accumulate_sample_data(batch, logits, loss_unreduced, stage) + else: + loss = self.criterion(logits, labels) preds = logits.argmax(dim=1) for name in self._metric_names: @@ -73,6 +91,22 @@ def _common_step(self, batch: dict, stage: str) -> Tensor: ) return loss + def _compute_sample_confidence(self, logits: Tensor) -> dict[str, Tensor]: + """Softmax-based confidence: max probability and per-class probabilities.""" + probs = torch.softmax(logits, dim=1) # (B, C) + result: dict[str, Tensor] = { + 'confidence': probs.max(dim=1).values, # (B,) + } + for i, name in enumerate(self.class_names): + result[f'confidence/{name}'] = probs[:, i] + return result + + def _compute_sample_metrics(self, logits: Tensor, batch: dict) -> dict[str, Tensor]: + """Per-sample accuracy (1.0 if correct, 0.0 otherwise).""" + labels = batch['image_categories'] + correct = (logits.argmax(dim=1) == labels).float() + return {'accuracy': correct} + def _on_epoch_end(self, stage: str) -> None: for i, name in enumerate(self._metric_names): metric = getattr(self, f'{stage}_{name}') @@ -96,6 +130,7 @@ def on_validation_epoch_end(self) -> None: def on_test_epoch_end(self) -> None: self._on_epoch_end('test') + super().on_test_epoch_end() def configure_optimizers(self): return torch.optim.AdamW( @@ -128,7 +163,13 @@ def predict_default( image = np.array(res.fetch_file_data(auto_convert=True, use_cache=True)) tensor = transform(image=image)['image'].to(device) logits = self(tensor.unsqueeze(0)) + # Per-sample confidence + probs = torch.softmax(logits, dim=1) + confidence = float(probs.max(dim=1).values.item()) pred_idx = int(logits.argmax(dim=1).item()) class_name = self.class_names[pred_idx] - all_preds.append([ImageClassification(name='category', value=class_name)]) + all_preds.append([ImageClassification( + name='category', value=class_name, + confiability=confidence, + )]) return all_preds diff --git a/datamint/lightning/trainers/lightning_modules/segmentation_module.py b/datamint/lightning/trainers/lightning_modules/segmentation_module.py index 53a2f923..f65a8946 100644 --- a/datamint/lightning/trainers/lightning_modules/segmentation_module.py +++ b/datamint/lightning/trainers/lightning_modules/segmentation_module.py @@ -4,6 +4,8 @@ from abc import abstractmethod from collections.abc import Callable from typing import Any +import inspect +import warnings import torch from torch import Tensor, nn @@ -41,6 +43,10 @@ def __init__( super().__init__() self.in_channels = in_channels self.num_classes = num_classes + if num_classes <= 0: + raise ValueError("num_classes must be > 0") + if class_names and (len(class_names) != num_classes): + raise ValueError("Length of class_names must match num_classes") self.save_hyperparameters(ignore=['loss_fn', 'metrics_factories']) self.class_names = class_names self.image_size = image_size @@ -68,7 +74,14 @@ def _common_step(self, batch: dict, stage: str) -> Tensor: masks = batch['segmentations'][:, 1:] # exclude background channel logits = self(images) - loss = self.criterion(logits, masks) + + if self._log_sample_metrics: + loss_unreduced = self._compute_unreduced_loss(logits, masks) + loss = loss_unreduced.mean() + self._accumulate_sample_data(batch, logits, loss_unreduced, stage) + else: + loss = self.criterion(logits, masks) + preds = (logits > 0).long() for name in self._metric_names: @@ -81,6 +94,62 @@ def _common_step(self, batch: dict, stage: str) -> Tensor: ) return loss + def _compute_unreduced_loss(self, logits: Tensor, masks: Tensor) -> Tensor: + """Compute per-sample loss (shape ``(B,)``). + + Attempts to call ``self.criterion`` with ``reduction='none'``. + Falls back to ``binary_cross_entropy_with_logits`` if the criterion + does not support that parameter. + """ + b = logits.shape[0] + + # Try criterion with reduction='none' — inspect first to avoid a + # costly forward pass that raises an exception at runtime. + criterion_params = inspect.signature(self.criterion.forward).parameters + if 'reduction' in criterion_params: + loss = self.criterion(logits, masks.float(), reduction='none') + # Flatten spatial dims if needed so result is (B,). + if loss.dim() > 1: + loss = loss.reshape(b, -1).mean(dim=1) + return loss + else: + warnings.warn( + f"{type(self.criterion).__name__} does not accept reduction='none'; " + "falling back to binary_cross_entropy_with_logits for per-sample loss.", + UserWarning, + stacklevel=2, + ) + + # Fallback: BCE with logits averaged over spatial dims per sample. + logits_flat = logits.reshape(b, -1) + masks_flat = masks.reshape(b, -1).float() + return torch.nn.functional.binary_cross_entropy_with_logits( + logits_flat, masks_flat, reduction='none', + ).mean(dim=1) + + def _compute_sample_confidence(self, logits: Tensor) -> dict[str, Tensor]: + """Sigmoid-based aggregate and per-class confidence.""" + probs = torch.sigmoid(logits) # (B, C, H, W) + result: dict[str, Tensor] = { + 'confidence': probs.mean(dim=[1, 2, 3]), # (B,) + } + for i, name in enumerate(self.class_names): + result[f'confidence/{name}'] = probs[:, i].mean(dim=[1, 2]) + return result + + def _compute_sample_metrics(self, logits: Tensor, batch: dict) -> dict[str, Tensor]: + """Per-sample IoU and Dice.""" + masks = batch['segmentations'][:, 1:].float() + preds = (logits > 0).float() + intersection = (preds * masks).sum(dim=[1, 2, 3]) + union = ((preds + masks) > 0).float().sum(dim=[1, 2, 3]) + pred_sum = preds.sum(dim=[1, 2, 3]) + mask_sum = masks.sum(dim=[1, 2, 3]) + eps = 1e-6 + iou = (intersection + eps) / (union + eps) + dice = (2 * intersection + eps) / (pred_sum + mask_sum + eps) + return {'iou': iou, 'dice': dice} + def _on_epoch_end(self, stage: str) -> None: for i, name in enumerate(self._metric_names): metric = getattr(self, f'{stage}_{name}') @@ -104,6 +173,7 @@ def on_validation_epoch_end(self) -> None: def on_test_epoch_end(self) -> None: self._on_epoch_end('test') + super().on_test_epoch_end() def configure_optimizers(self): return torch.optim.AdamW( @@ -138,14 +208,21 @@ def predict_default( oh, ow = image.shape[:2] tensor = transform(image=image)['image'].to(device) logits = self(tensor.unsqueeze(0)) + # Per-sample confidence + probs = torch.sigmoid(logits) + sample_confidence = float(probs.mean()) pred = (logits[0] > 0).cpu().numpy().astype(np.uint8) anns: list = [] for i, name in enumerate(self.class_names): + class_conf = float(probs[0, i].mean()) mask = cv2.resize( pred[i], (ow, oh), interpolation=cv2.INTER_NEAREST, ) * 255 if mask.any(): - anns.append(ImageSegmentation(name=name, mask=mask)) + anns.append(ImageSegmentation( + name=name, mask=mask, + confiability=class_conf, + )) all_preds.append(anns) return all_preds diff --git a/datamint/lightning/trainers/seg2d_trainer.py b/datamint/lightning/trainers/seg2d_trainer.py index c07cedb9..00bdbea2 100644 --- a/datamint/lightning/trainers/seg2d_trainer.py +++ b/datamint/lightning/trainers/seg2d_trainer.py @@ -7,7 +7,7 @@ import lightning as L from torch import nn -from datamint.dataset import ImageDataset +from datamint.dataset import ImageDataset, SlicedVolumeDataset from .lightning_modules import UNetPPModule from .segmentation_trainer import SegmentationTrainer @@ -16,6 +16,7 @@ from albumentations import BaseCompose from datamint.entities import Project + class SemanticSegmentation2DTrainer(SegmentationTrainer): """Trainer for 2-D semantic segmentation. @@ -45,6 +46,7 @@ def __init__( # ── Template hooks ────────────────────────────────────────── def _build_dataset(self, project: 'str | Project') -> ImageDataset: + # TODO: automatically check if project is composed of 3D volumes or 2D images and choose SlicedVolumeDataset vs ImageDataset accordingly. return ImageDataset( project=project, return_as_semantic_segmentation=True, @@ -63,7 +65,7 @@ def _train_transform(self) -> 'BaseCompose': A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomBrightnessContrast(p=0.5), - A.Normalize(), # Imagenet stats is the default + A.Normalize(), # Imagenet stats is the default ToTensorV2(), ]) @@ -132,4 +134,4 @@ def _build_model( metrics_factories=metrics, class_names=list(self.dataset.seglabel_list), image_size=self.image_size, - ) \ No newline at end of file + ) diff --git a/datamint/lightning/trainers/segmentation_trainer.py b/datamint/lightning/trainers/segmentation_trainer.py index 61adde0d..84a1e421 100644 --- a/datamint/lightning/trainers/segmentation_trainer.py +++ b/datamint/lightning/trainers/segmentation_trainer.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any from functools import partial import torch @@ -22,9 +21,9 @@ class _BCEDiceLoss(nn.Module): target: ``(B, C, H, W)`` binary masks (float) """ - def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + def forward(self, pred: torch.Tensor, target: torch.Tensor, reduction: str = 'mean') -> torch.Tensor: target = target.float() - bce = F.binary_cross_entropy_with_logits(pred, target) + bce = F.binary_cross_entropy_with_logits(pred, target, reduction=reduction) probs = torch.sigmoid(pred) dims = (0, 2, 3) intersection = (probs * target).sum(dim=dims) diff --git a/datamint/mlflow/lightning/callbacks/modelcheckpoint.py b/datamint/mlflow/lightning/callbacks/modelcheckpoint.py index 2292859d..64ee0c94 100644 --- a/datamint/mlflow/lightning/callbacks/modelcheckpoint.py +++ b/datamint/mlflow/lightning/callbacks/modelcheckpoint.py @@ -2,7 +2,7 @@ from pathlib import Path from weakref import proxy from mlflow.store.artifact.artifact_repository_registry import get_artifact_repository -from typing import Literal, Any +from typing import Literal, Any, TYPE_CHECKING import inspect from torch import nn import lightning.pytorch as L @@ -11,11 +11,17 @@ import mlflow.models import mlflow.exceptions import mlflow.pytorch +import mlflow.data.dataset +import mlflow.entities.dataset import logging import json import hashlib from lightning.pytorch.loggers import MLFlowLogger +if TYPE_CHECKING: + from datamint.mlflow.flavors.model import BaseDatamintModel + from mlflow.models.model import ModelInfo + _LOGGER = logging.getLogger(__name__) @@ -24,12 +30,9 @@ def help_infer_signature(x): if isinstance(x, torch.Tensor): return x.detach().cpu().numpy() elif isinstance(x, dict): - return {k: v.detach().cpu().numpy() if isinstance(v, torch.Tensor) else v for k, v in x.items()} - elif isinstance(x, list): - return [v.detach().cpu().numpy() if isinstance(v, torch.Tensor) else v for v in x] - elif isinstance(x, tuple): - return tuple(v.detach().cpu().numpy() if isinstance(v, torch.Tensor) else v for v in x) - + return {k: help_infer_signature(v) for k, v in x.items()} + elif isinstance(x, (list, tuple)): + return type(x)(help_infer_signature(v) for v in x) return x @@ -93,13 +96,39 @@ def __init__(self, *args, self._last_model_id: str | None = None self.last_saved_model_info = None self._inferred_signature = None - self._input_example = None self.code_paths = code_paths self.additional_metadata = additional_metadata or {} self.extra_pip_requirements = extra_pip_requirements or [] self._last_registered_state_hash: str | None = None self._has_been_trained: bool = False + def get_last_model_id(self) -> str | None: + """Get the MLflow model ID of the last saved model, if available.""" + return self._last_model_id + + def get_last_model_uri(self) -> str | None: + """Get the MLflow model URI of the last saved model, if available.""" + return self._last_model_uri + + def get_all_saved_models(self): + """Get a list of all MLflow ModelInfo objects for models logged from this callback.""" + if self._last_model_uri is None: + return [] + logger = _get_MLFlowLogger() + if logger is None or logger.run_id is None: + _LOGGER.warning("No MLFlowLogger run_id found. Cannot retrieve saved models.") + return [] + try: + retrieved_logged_models = mlflow.search_logged_models( + filter_string=f"name = '{Path(self._last_model_uri).stem[:256]}' AND source_run_id='{logger.run_id[:64]}'", + order_by=[{"field_name": "last_updated_timestamp", "ascending": False}], + output_format="list" + ) + return retrieved_logged_models + except Exception as e: + _LOGGER.warning(f"Failed to retrieve saved models: {e}") + return [] + def _compute_registration_state_hash(self) -> str: """Compute a hash representing the current model state for registration comparison. @@ -180,7 +209,7 @@ def log_additional_metadata(self, logger: MLFlowLogger | L.Trainer, _LOGGER.warning(f"Failed to log additional metadata: {e}") def log_model_to_mlflow(self, - model: nn.Module, + model: 'nn.Module | L.LightningModule | BaseDatamintModel', run_id: str | MLFlowLogger) -> None: """Log the model to MLflow using the appropriate flavor. @@ -235,12 +264,49 @@ def _update_signature(self, trainer): except mlflow.exceptions.MlflowException as e: _LOGGER.warning(f"Failed to update model signature. Check if model actually exists. {e}") + def _resolve_run_id(self, run_id: str | MLFlowLogger) -> str: + """Extract the run_id string from an MLFlowLogger or pass through a string.""" + if isinstance(run_id, MLFlowLogger): + if run_id.run_id is None: + raise ValueError("MLFlowLogger has no run_id. Cannot log model to MLFlow.") + return run_id.run_id + return run_id + + def _build_requirements(self) -> list[str]: + """Build pip requirements list, ensuring lightning is included.""" + requirements = list(self.extra_pip_requirements) + if not any('lightning' in req.lower() for req in requirements): + requirements.append(f'lightning=={L.__version__}') + return requirements + + def _finalize_logged_model(self, + modelinfo: 'ModelInfo', + run_id: str, + model: 'nn.Module | L.LightningModule | BaseDatamintModel') -> None: + """Store model info and log metadata after logging a model to MLflow.""" + self._last_model_uri = modelinfo.model_uri + self._last_model_id = getattr(modelinfo, 'model_id', None) + self.last_saved_model_info = modelinfo + _LOGGER.debug("Model logged to MLflow with URI: %s and ID: %s", self._last_model_uri, self._last_model_id) + if self.additional_metadata: + _LOGGER.debug("Logging additional metadata for model %s with run_id %s: %s", + modelinfo.model_uri, run_id, self.additional_metadata) + log_model_metadata(self.additional_metadata, + model_path=modelinfo.artifact_path, + run_id=run_id) + + # inject model_id into the model if it has a set_model_id method (e.g. for logging sample metrics with model context) + if hasattr(model, 'set_mlflow_model_id'): + model.set_mlflow_model_id(self._last_model_id) + elif hasattr(model, 'mlflow_model_id'): + setattr(model, 'mlflow_model_id', self._last_model_id) + def _wrap_forward(self, pl_module: nn.Module) -> None: """Intercept the first forward call to infer the MLflow model signature. - Must be implemented by subclasses. + Override in subclasses to customize signature inference. """ - raise NotImplementedError + pass def on_train_start(self, trainer, pl_module): self._has_been_trained = True @@ -258,10 +324,7 @@ def on_train_end(self, trainer: L.Trainer, pl_module: L.LightningModule) -> None if self.log_model_at_end_only and trainer.is_global_zero: logger = _get_MLFlowLogger(trainer) - if logger is None: - _LOGGER.warning("No MLFlowLogger found. Cannot log model to MLFlow.") - else: - self.log_model_to_mlflow(trainer.model, run_id=logger.run_id) + self.log_model_to_mlflow(trainer.model, run_id=logger.run_id) self._update_signature(trainer) @@ -287,13 +350,14 @@ def _restore_model_uri(self, trainer: L.Trainer) -> None: _LOGGER.warning(f"Run ID mismatch between checkpoint path and MLFlowLogger." + " Check `run_id` parameter in MLFlowLogger.") return + model_name = Path(trainer.ckpt_path).stem[:256] retrieved_logged_models = mlflow.search_logged_models( - filter_string=f"name = '{Path(trainer.ckpt_path).stem[:256]}' AND source_run_id='{logger.run_id[:64]}'", + filter_string=f"name = '{model_name}' AND source_run_id='{logger.run_id[:64]}'", order_by=[{"field_name": "last_updated_timestamp", "ascending": False}], output_format="list" ) if not retrieved_logged_models: - _LOGGER.warning(f"No logged model found for checkpoint {trainer.ckpt_path}.") + _LOGGER.warning(f"No logged model found for checkpoint {model_name}.") return # get the most recent one self._last_model_uri = retrieved_logged_models[0].model_uri @@ -321,6 +385,8 @@ def _log_test_metrics_to_model(self, trainer: L.Trainer) -> None: converts tensor values to floats, and logs them to the LoggedModel identified by ``self._last_model_id``. """ + # TODO: Separate this into its own callback that depends on the model checkpoint callback or a injection of a model_id. + # Also consider logging all metrics with a model_id tag instead of just test metrics, and allowing users to configure the prefix filter. if self._last_model_id is None: _LOGGER.debug("No model_id available. Skipping model metrics logging.") return @@ -343,8 +409,16 @@ def _log_test_metrics_to_model(self, trainer: L.Trainer) -> None: _LOGGER.info("No test metrics found in callback_metrics to log.") return + dataset = getattr(logger, '_mlflow_dataset', None) + if not isinstance(dataset, (mlflow.data.dataset.Dataset, mlflow.entities.dataset.Dataset)): + dataset = None + _LOGGER.warning( + "Logger dataset is not an MLflow Dataset. Proceeding without dataset context for metrics logging.") try: - mlflow.log_metrics(metrics, model_id=self._last_model_id, run_id=logger.run_id) + mlflow.log_metrics(metrics, + model_id=self._last_model_id, + run_id=logger.run_id, + dataset=dataset) _LOGGER.info(f"Logged {len(metrics)} test metrics to model {self._last_model_id}.") except Exception as e: _LOGGER.warning(f"Failed to log test metrics to model: {e}") @@ -381,65 +455,42 @@ class MLFlowPyTorchModelCheckpoint(_BaseMLFlowModelCheckpoint): model signature by intercepting the first call to ``pl_module.forward``. """ - def _infer_params(self, model: nn.Module) -> tuple[dict, ...]: - """Extract metadata from the model's forward method signature. + def _infer_forward_defaults(self, model: nn.Module) -> dict[str, Any] | None: + """Extract default values for forward() params beyond the first input. Returns: - A tuple of dicts, each containing parameter metadata ordered by position. + dict of {name: default} or None if no extra params exist. """ forward_method = getattr(model.__class__, 'forward', None) - if forward_method is None: - return () + return None try: sig = inspect.signature(forward_method) - params_list = [] - - for param_name, param in sig.parameters.items(): - if param_name == 'self': - continue - - param_info = { - 'name': param_name, - 'kind': param.kind.name, - 'annotation': param.annotation if param.annotation != inspect.Parameter.empty else None, - 'default': param.default if param.default != inspect.Parameter.empty else None, - } - params_list.append(param_info) - - # Add return annotation if available as the last element - return_annotation = sig.return_annotation - if return_annotation != inspect.Signature.empty: - return_info = {'_return_annotation': str(return_annotation)} - params_list.append(return_info) - - return tuple(params_list) - + params = [p for name, p in sig.parameters.items() if name != 'self'] + # Skip the first input parameter, collect defaults for the rest + defaults = { + p.name: (p.default if p.default != inspect.Parameter.empty else None) + for p in params[1:] + } + return defaults or None except Exception as e: _LOGGER.warning(f"Failed to infer forward method parameters: {e}") - return () + return None def _wrap_forward(self, pl_module: nn.Module) -> None: """Wrap ``pl_module.forward`` to infer the MLflow signature on the first call.""" original_forward = pl_module.forward def wrapped_forward(x, *args, **kwargs): - x0 = help_infer_signature(x) - infered_params = self._infer_params(pl_module) - if len(infered_params) > 1: - infered_params = {param['name']: param['default'] - for param in infered_params[1:] if 'name' in param} - else: - infered_params = None - - self._inferred_signature = mlflow.models.infer_signature(model_input=x0, - params=infered_params) - - # run once and get back to the original forward + self._inferred_signature = mlflow.models.infer_signature( + model_input=help_infer_signature(x), + params=self._infer_forward_defaults(pl_module), + ) + + # Restore original forward and call it pl_module.forward = original_forward - method = getattr(pl_module, 'forward') - out = method(x, *args, **kwargs) + out = original_forward(x, *args, **kwargs) output_sig = mlflow.models.infer_signature(model_output=help_infer_signature(out)) self._inferred_signature.outputs = output_sig.outputs @@ -449,25 +500,22 @@ def wrapped_forward(x, *args, **kwargs): pl_module.forward = wrapped_forward def log_model_to_mlflow(self, - model: nn.Module, - run_id: str | MLFlowLogger) -> None: + model: 'nn.Module | L.LightningModule | BaseDatamintModel', + run_id: str | MLFlowLogger): """Log the model to MLflow using the pytorch flavor.""" - if isinstance(run_id, MLFlowLogger): - logger = run_id - if logger.run_id is None: - raise ValueError("MLFlowLogger has no run_id. Cannot log model to MLFlow.") - run_id = logger.run_id + run_id = self._resolve_run_id(run_id) + + if run_id is None: + _LOGGER.warning("No run_id available from the logger. Skipping MLflow model logging " + "to avoid creating a new run.") + return - if self._last_checkpoint_saved is None or self._last_checkpoint_saved == '': + if not self._last_checkpoint_saved: _LOGGER.warning("No checkpoint saved yet. Cannot log model to MLFlow.") return orig_device = next(model.parameters()).device - model = model.cpu() # Ensure the model is on CPU for logging - - requirements = list(self.extra_pip_requirements) - if not any('lightning' in req.lower() for req in requirements): - requirements.append(f'lightning=={L.__version__}') + model = model.cpu() _LOGGER.debug("Logging model using pytorch flavor with name %s", Path(self._last_checkpoint_saved).stem) modelinfo = mlflow.pytorch.log_model( @@ -475,18 +523,12 @@ def log_model_to_mlflow(self, name=Path(self._last_checkpoint_saved).stem, signature=self._inferred_signature, run_id=run_id, - extra_pip_requirements=requirements, + extra_pip_requirements=self._build_requirements(), code_paths=self.code_paths, ) - model.to(device=orig_device) # Move the model back to its original device - self._last_model_uri = modelinfo.model_uri - self._last_model_id = getattr(modelinfo, 'model_id', None) - self.last_saved_model_info = modelinfo - - log_model_metadata(self.additional_metadata, - model_path=modelinfo.artifact_path, - run_id=run_id) + model.to(device=orig_device) + self._finalize_logged_model(modelinfo, run_id, model=model) class MLFlowDatamintModelCheckpoint(_BaseMLFlowModelCheckpoint): @@ -498,27 +540,20 @@ class MLFlowDatamintModelCheckpoint(_BaseMLFlowModelCheckpoint): so no forward-wrapping is performed. """ - def _wrap_forward(self, pl_module: nn.Module) -> None: - # Signature inference is delegated to the datamint flavor; nothing to do here. - pass - def log_model_to_mlflow(self, - model: nn.Module, + model: 'nn.Module | L.LightningModule | BaseDatamintModel', run_id: str | MLFlowLogger) -> None: """Log the model to MLflow using the datamint flavor.""" - if isinstance(run_id, MLFlowLogger): - logger = run_id - if logger.run_id is None: - raise ValueError("MLFlowLogger has no run_id. Cannot log model to MLFlow.") - run_id = logger.run_id + run_id = self._resolve_run_id(run_id) - if self._last_checkpoint_saved is None or self._last_checkpoint_saved == '': - _LOGGER.warning("No checkpoint saved yet. Cannot log model to MLFlow.") + if run_id is None: + _LOGGER.warning("No run_id available from the logger. Skipping MLflow model logging " + "to avoid creating a new run.") return - requirements = list(self.extra_pip_requirements) - if not any('lightning' in req.lower() for req in requirements): - requirements.append(f'lightning=={L.__version__}') + if not self._last_checkpoint_saved: + _LOGGER.warning("No checkpoint saved yet. Cannot log model to MLFlow.") + return from datamint.mlflow.flavors import datamint_flavor _LOGGER.debug("Logging model using datamint flavor with name %s", Path(self._last_checkpoint_saved).stem) @@ -527,17 +562,14 @@ def log_model_to_mlflow(self, name=Path(self._last_checkpoint_saved).stem, signature=self._inferred_signature, run_id=run_id, - extra_pip_requirements=requirements, + extra_pip_requirements=self._build_requirements(), code_paths=self.code_paths, ) - self._last_model_uri = modelinfo.model_uri - self._last_model_id = getattr(modelinfo, 'model_id', None) - self.last_saved_model_info = modelinfo + self._finalize_logged_model(modelinfo, run_id, model=model) - log_model_metadata(self.additional_metadata, - model_path=modelinfo.artifact_path, - run_id=run_id) + def _update_signature(self, trainer): + return # signature is managed by the datamint flavor, so we don't need to do anything here # Backward-compatibility alias From e29f310788e1787a8fda915bcc24a646fd293d23 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 31 Mar 2026 10:31:40 -0300 Subject: [PATCH 35/47] feat: Improve per-sample metrics logging by batching MLflow requests and adding dataset association --- .../trainers/lightning_modules/base.py | 65 ++++++++++++------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/datamint/lightning/trainers/lightning_modules/base.py b/datamint/lightning/trainers/lightning_modules/base.py index cefd0903..3b75896d 100644 --- a/datamint/lightning/trainers/lightning_modules/base.py +++ b/datamint/lightning/trainers/lightning_modules/base.py @@ -10,6 +10,9 @@ from datamint.mlflow.flavors.model import BaseDatamintModel, ModelSettings from mlflow.pyfunc.model import PythonModelContext +if TYPE_CHECKING: + from datamint.mlflow.data import DatamintMLflowDataset + _LOGGER = logging.getLogger(__name__) _SAMPLE_META_KEYS = {'resource_id', 'slice_index'} @@ -28,8 +31,7 @@ def __init__(self, settings: ModelSettings | None = None) -> None: BaseDatamintModel.__init__(self, settings=settings) self._log_sample_metrics: bool = False self._sample_buffer: list[dict[str, Any]] = [] - self.mlflow_model_id: str | None = None - self.mlflow_dataset: Any = None # Set at runtime + self.mlflow_model_id: str | None = None # Injected by modelcheckpoint at runtime. FIXME: This is a bit hacky # ------------------------------------------------------------------ # Per-sample metrics logging @@ -95,8 +97,25 @@ def _flush_sample_metrics_to_mlflow(self) -> None: return run_id = logger.run_id + mlflow_dataset: DatamintMLflowDataset | None = getattr(logger, '_mlflow_dataset', None) + if mlflow_dataset is None: + _LOGGER.info( + "MLFlowLogger does not have '_mlflow_dataset' attribute. " + "Per-sample metrics will be logged without dataset association." + ) + dataset_name = None + dataset_digest = None + else: + dataset_name = mlflow_dataset.name + dataset_digest = mlflow_dataset.digest # Log per-sample metrics with step = sample index. + # Build a flat list of Metric objects and send in one log_batch call + # instead of one log_metrics call per sample (N → 1 HTTP round-trips). + import time + from mlflow.entities import Metric + from mlflow.tracking import MlflowClient + metric_keys = { key for entry in self._sample_buffer @@ -104,23 +123,27 @@ def _flush_sample_metrics_to_mlflow(self) -> None: if key not in _SAMPLE_META_KEYS } - for step, entry in enumerate(self._sample_buffer): - metrics = { - f"test/sample/{key}": float(entry[key]) - for key in metric_keys - if entry.get(key) is not None - } - if metrics: - try: - mlflow.log_metrics(metrics=metrics, - step=step, - run_id=run_id, - dataset=self.mlflow_dataset, - model_id=self.mlflow_model_id) - except Exception as e: - _LOGGER.error( - f"Failed to log sample metrics at step {step}: {e}" - ) + timestamp_ms = int(time.time() * 1000) + all_metrics: list[Metric] = [ + Metric(key=f"test/sample/{key}", value=float(entry[key]), + timestamp=timestamp_ms, step=step, + dataset_name=dataset_name, dataset_digest=dataset_digest, + run_id=run_id, + model_id=self.mlflow_model_id) + for step, entry in enumerate(self._sample_buffer) + for key in metric_keys + if entry.get(key) is not None + ] + + if all_metrics: + # log_batch accepts at most 1000 metrics per request. + _BATCH_SIZE = 1000 + client = MlflowClient() + try: + for i in range(0, len(all_metrics), _BATCH_SIZE): + client.log_batch(run_id, metrics=all_metrics[i:i + _BATCH_SIZE]) + except Exception as e: + _LOGGER.error(f"Failed to log sample metrics batch: {e}") # Log resource_id → step mapping table as artifact. mapping_data: dict[str, list[Any]] = { @@ -140,9 +163,7 @@ def _flush_sample_metrics_to_mlflow(self) -> None: except Exception as e: _LOGGER.warning(f"Failed to log sample mapping table: {e}") - _LOGGER.info( - f"Flushed {len(self._sample_buffer)} per-sample metrics to MLflow." - ) + _LOGGER.info("Flushed %d per-sample metrics to MLflow.", len(self._sample_buffer)) self._sample_buffer.clear() def on_test_start(self) -> None: From 16c02fd5bd156700b5f190306a4e243fb463be9f Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 31 Mar 2026 10:46:44 -0300 Subject: [PATCH 36/47] feat: Enhance per-sample metrics logging by improving MLflow batch logging and logging messages --- datamint/lightning/trainers/lightning_modules/base.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/datamint/lightning/trainers/lightning_modules/base.py b/datamint/lightning/trainers/lightning_modules/base.py index 3b75896d..a99f0ad8 100644 --- a/datamint/lightning/trainers/lightning_modules/base.py +++ b/datamint/lightning/trainers/lightning_modules/base.py @@ -135,13 +135,12 @@ def _flush_sample_metrics_to_mlflow(self) -> None: if entry.get(key) is not None ] + _LOGGER.info("Flushing %d per-sample metrics (of %d samples) to MLflow...", len(all_metrics), len(self._sample_buffer)) + if all_metrics: - # log_batch accepts at most 1000 metrics per request. - _BATCH_SIZE = 1000 client = MlflowClient() try: - for i in range(0, len(all_metrics), _BATCH_SIZE): - client.log_batch(run_id, metrics=all_metrics[i:i + _BATCH_SIZE]) + client.log_batch(run_id, metrics=all_metrics, synchronous=True) except Exception as e: _LOGGER.error(f"Failed to log sample metrics batch: {e}") @@ -163,7 +162,7 @@ def _flush_sample_metrics_to_mlflow(self) -> None: except Exception as e: _LOGGER.warning(f"Failed to log sample mapping table: {e}") - _LOGGER.info("Flushed %d per-sample metrics to MLflow.", len(self._sample_buffer)) + _LOGGER.info("Flushed per-sample metrics of %d samples to MLflow.", len(self._sample_buffer)) self._sample_buffer.clear() def on_test_start(self) -> None: From b4b64cf6533e7c8daa1cfb039d8dd127aec6f988 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 31 Mar 2026 10:47:42 -0300 Subject: [PATCH 37/47] feat: Add warning for unset MLflow model ID during per-sample metrics logging --- datamint/lightning/trainers/lightning_modules/base.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/datamint/lightning/trainers/lightning_modules/base.py b/datamint/lightning/trainers/lightning_modules/base.py index a99f0ad8..f90bf016 100644 --- a/datamint/lightning/trainers/lightning_modules/base.py +++ b/datamint/lightning/trainers/lightning_modules/base.py @@ -109,6 +109,12 @@ def _flush_sample_metrics_to_mlflow(self) -> None: dataset_name = mlflow_dataset.name dataset_digest = mlflow_dataset.digest + if self.mlflow_model_id is None: + _LOGGER.warning( + "MLflow model ID is not set on the LightningModule. " + "Per-sample metrics will be logged without model association." + ) + # Log per-sample metrics with step = sample index. # Build a flat list of Metric objects and send in one log_batch call # instead of one log_metrics call per sample (N → 1 HTTP round-trips). From 87a37de2ff464cf03d6f103f0a4b71ec61a20257 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 1 Apr 2026 10:47:30 -0300 Subject: [PATCH 38/47] added metadata to sample metrics json --- .../trainers/lightning_modules/base.py | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/datamint/lightning/trainers/lightning_modules/base.py b/datamint/lightning/trainers/lightning_modules/base.py index f90bf016..1dd93a85 100644 --- a/datamint/lightning/trainers/lightning_modules/base.py +++ b/datamint/lightning/trainers/lightning_modules/base.py @@ -9,12 +9,16 @@ from datamint.mlflow.flavors.model import BaseDatamintModel, ModelSettings from mlflow.pyfunc.model import PythonModelContext +import time +from mlflow.entities import Metric +from mlflow.tracking import MlflowClient if TYPE_CHECKING: from datamint.mlflow.data import DatamintMLflowDataset _LOGGER = logging.getLogger(__name__) _SAMPLE_META_KEYS = {'resource_id', 'slice_index'} +SAMPLE_MAPPING_FILE = "test_sample_mapping.json" class DatamintLightningModule(L.LightningModule, BaseDatamintModel): @@ -118,10 +122,6 @@ def _flush_sample_metrics_to_mlflow(self) -> None: # Log per-sample metrics with step = sample index. # Build a flat list of Metric objects and send in one log_batch call # instead of one log_metrics call per sample (N → 1 HTTP round-trips). - import time - from mlflow.entities import Metric - from mlflow.tracking import MlflowClient - metric_keys = { key for entry in self._sample_buffer @@ -141,7 +141,8 @@ def _flush_sample_metrics_to_mlflow(self) -> None: if entry.get(key) is not None ] - _LOGGER.info("Flushing %d per-sample metrics (of %d samples) to MLflow...", len(all_metrics), len(self._sample_buffer)) + _LOGGER.info("Flushing %d per-sample metrics (of %d samples) to MLflow...", + len(all_metrics), len(self._sample_buffer)) if all_metrics: client = MlflowClient() @@ -151,19 +152,26 @@ def _flush_sample_metrics_to_mlflow(self) -> None: _LOGGER.error(f"Failed to log sample metrics batch: {e}") # Log resource_id → step mapping table as artifact. - mapping_data: dict[str, list[Any]] = { + mapping_data = { "step": [], "resource_id": [], "slice_index": [], + 'metadata': { + "dataset_name": dataset_name, + "dataset_digest": dataset_digest, + 'model_id': self.mlflow_model_id, + 'timestamp': timestamp_ms, + } } for step, entry in enumerate(self._sample_buffer): mapping_data["step"].append(step) mapping_data["resource_id"].append(entry.get("resource_id")) mapping_data["slice_index"].append(entry.get("slice_index")) try: - mlflow.log_table( - data=mapping_data, - artifact_file="test_sample_mapping.json", + mlflow.log_dict( + {'test': mapping_data}, + artifact_file=SAMPLE_MAPPING_FILE, + run_id=run_id, ) except Exception as e: _LOGGER.warning(f"Failed to log sample mapping table: {e}") From a7e2634a2b1eb2b314fa81b6fe8fd3a01c9e63af Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 1 Apr 2026 14:05:28 -0300 Subject: [PATCH 39/47] misc --- datamint/api/dto/__init__.py | 4 +--- datamint/lightning/datamodule.py | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/datamint/api/dto/__init__.py b/datamint/api/dto/__init__.py index a96c1455..c1a62952 100644 --- a/datamint/api/dto/__init__.py +++ b/datamint/api/dto/__init__.py @@ -12,7 +12,5 @@ "Geometry", "BoxGeometry", "LineGeometry", - "CoordinateSystem" - "LineGeometry", - "CoordinateSystem" + "CoordinateSystem", ] diff --git a/datamint/lightning/datamodule.py b/datamint/lightning/datamodule.py index a7cc0a0b..f6b5d7d2 100644 --- a/datamint/lightning/datamodule.py +++ b/datamint/lightning/datamodule.py @@ -22,6 +22,16 @@ _LOGGER = logging.getLogger(__name__) +def _serialize_transform(transform: Callable) -> dict | str: + """Serialize a transform to a dict (albumentations) or repr string.""" + if hasattr(transform, 'to_dict'): + try: + return transform.to_dict() + except Exception: + pass + return repr(transform) + + class DatamintDataModule(L.LightningDataModule): """A :class:`~lightning.pytorch.core.LightningDataModule` that wraps a :class:`~datamint.dataset.base.DatamintBaseDataset`. @@ -95,8 +105,11 @@ def __init__( eval_transform: Callable | None = None, ) -> None: super().__init__() - # TODO: save the transforms as strings in the hyperparameters self.save_hyperparameters(ignore=["dataset", "train_transform", "eval_transform"]) + if train_transform is not None: + self.hparams["train_transform"] = _serialize_transform(train_transform) + if eval_transform is not None: + self.hparams["eval_transform"] = _serialize_transform(eval_transform) self.dataset = dataset self._batch_size = batch_size From f909e3f6e108ff782835b5842c10e64dcf63fd3d Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 1 Apr 2026 15:39:02 -0300 Subject: [PATCH 40/47] getting metrics after kernel start --- ...egmentation_2d_trainer_BUSI_tutorial.ipynb | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb b/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb index 14e92d7b..18a92442 100644 --- a/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb +++ b/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb @@ -307,6 +307,7 @@ " batch_size=16,\n", " max_epochs=8,\n", " early_stopping_patience=10,\n", + " # register_model_name='MyModelName' # Default is project name\n", ")\n", "\n", "results = trainer.fit()" @@ -338,6 +339,36 @@ "print(f\"\\nModel type: {type(results['model']).__name__}\")" ] }, + { + "cell_type": "markdown", + "id": "eaaf6d10", + "metadata": {}, + "source": [ + "Getting results after restarting the kernel..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c53566cd", + "metadata": {}, + "outputs": [], + "source": [ + "import mlflow\n", + "from mlflow import MlflowClient\n", + "\n", + "model_uri = \"models:/UNetPP_Segmentation_Tutorial/latest\"\n", + "\n", + "model_info = mlflow.models.get_model_info(model_uri)\n", + "client = MlflowClient()\n", + "run = client.get_run(model_info.run_id)\n", + "metrics = run.data.metrics\n", + "\n", + "print(f\"Metrics for {model_uri}:\")\n", + "for metric_name, value in metrics.items():\n", + " print(f\" - {metric_name}: {value}\")" + ] + }, { "cell_type": "markdown", "id": "408a9c66", From 174bb46283c436702893b1ee3e25d32cc4675e61 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 2 Apr 2026 22:44:22 -0300 Subject: [PATCH 41/47] doc --- .../use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb b/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb index 18a92442..e334fb33 100644 --- a/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb +++ b/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb @@ -369,6 +369,14 @@ " print(f\" - {metric_name}: {value}\")" ] }, + { + "cell_type": "markdown", + "id": "2cdadcfb", + "metadata": {}, + "source": [ + "You can retrieve the trained model from the trainer at `trainer.model`." + ] + }, { "cell_type": "markdown", "id": "408a9c66", From 9b9973e56cc71a56acf5dce366cc2a52f0e44026 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 2 Apr 2026 23:01:21 -0300 Subject: [PATCH 42/47] feat: Introduce TaskType enumeration for MLflow models and update relevant modules --- .../classification_module.py | 2 ++ .../lightning_modules/segmentation_module.py | 2 ++ datamint/mlflow/flavors/__init__.py | 4 ++- datamint/mlflow/flavors/datamint_flavor.py | 20 +++++++++++ datamint/mlflow/flavors/model.py | 14 ++++++-- datamint/mlflow/flavors/task_type.py | 33 +++++++++++++++++++ 6 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 datamint/mlflow/flavors/task_type.py diff --git a/datamint/lightning/trainers/lightning_modules/classification_module.py b/datamint/lightning/trainers/lightning_modules/classification_module.py index 2463911d..7369538f 100644 --- a/datamint/lightning/trainers/lightning_modules/classification_module.py +++ b/datamint/lightning/trainers/lightning_modules/classification_module.py @@ -9,10 +9,12 @@ import inspect import warnings +from datamint.mlflow.flavors.task_type import TaskType from .base import DatamintLightningModule class ClassificationModule(DatamintLightningModule): + task_type = TaskType.IMAGE_CLASSIFICATION """Generic image classification module backed by ``timm``. Args: diff --git a/datamint/lightning/trainers/lightning_modules/segmentation_module.py b/datamint/lightning/trainers/lightning_modules/segmentation_module.py index f65a8946..505ae5f1 100644 --- a/datamint/lightning/trainers/lightning_modules/segmentation_module.py +++ b/datamint/lightning/trainers/lightning_modules/segmentation_module.py @@ -10,10 +10,12 @@ import torch from torch import Tensor, nn +from datamint.mlflow.flavors.task_type import TaskType from .base import DatamintLightningModule class SegmentationModule(DatamintLightningModule): + task_type = TaskType.IMAGE_SEGMENTATION """Base segmentation module for semantic segmentation tasks. Subclasses must implement :meth:`_build_model` to return the model. diff --git a/datamint/mlflow/flavors/__init__.py b/datamint/mlflow/flavors/__init__.py index 64fde03a..95181169 100644 --- a/datamint/mlflow/flavors/__init__.py +++ b/datamint/mlflow/flavors/__init__.py @@ -8,10 +8,12 @@ load_model, _load_pyfunc, ) +from .task_type import TaskType __all__ = [ "save_model", - "log_model", + "log_model", "load_model", "_load_pyfunc", + "TaskType", ] diff --git a/datamint/mlflow/flavors/datamint_flavor.py b/datamint/mlflow/flavors/datamint_flavor.py index d30143e0..23ee4887 100644 --- a/datamint/mlflow/flavors/datamint_flavor.py +++ b/datamint/mlflow/flavors/datamint_flavor.py @@ -5,6 +5,7 @@ import datamint.mlflow.flavors from mlflow import pyfunc from .model import BaseDatamintModel, DatamintModel, _DatamintModelWrapper +from .task_type import TaskType from collections.abc import Sequence from dataclasses import asdict from packaging.requirements import Requirement @@ -104,6 +105,7 @@ def _get_req_name(req): def save_model(datamint_model: BaseDatamintModel, path, + task_type: TaskType | str | None = None, supported_modes: Sequence[str] | None = None, data_path=None, code_paths=None, @@ -148,11 +150,14 @@ def save_model(datamint_model: BaseDatamintModel, input_example = None linked_models = datamint_model._get_linked_models_uri() if hasattr(datamint_model, '_get_linked_models_uri') else {} + resolved_task_type = task_type or getattr(datamint_model, 'task_type', None) + task_type_value = resolved_task_type.value if isinstance(resolved_task_type, TaskType) else resolved_task_type flavor_params = { "datamint_version": datamint.__version__, "supported_modes": supported_modes or datamint_model.get_supported_modes(), "model_settings": asdict(datamint_model.settings), "linked_models": linked_models, + "task_type": task_type_value, } mlflow_model.add_flavor(FLAVOR_NAME, **flavor_params) model_config.update(flavor_params) @@ -193,6 +198,7 @@ def save_model(datamint_model: BaseDatamintModel, def log_model( datamint_model: BaseDatamintModel, + task_type: TaskType | str | None = None, supported_modes: Sequence[str] | None = None, name: str = "datamint_model", data_path=None, @@ -211,6 +217,7 @@ def log_model( return Model.log( artifact_path=None, datamint_model=datamint_model, + task_type=task_type, supported_modes=supported_modes, name=name, flavor=datamint.mlflow.flavors.datamint_flavor, @@ -247,4 +254,17 @@ def _load_pyfunc(path: str, model_config=None) -> pyfunc.PyFuncModel: dt_model = dt_model.another_model pf_model._model_impl.python_model = dt_model + # Restore task_type from flavor metadata if not already set on the model + if not dt_model.task_type: + try: + mlflow_model_meta = Model.load(path) + flavor_data = mlflow_model_meta.flavors.get(FLAVOR_NAME, {}) + task_type_str = flavor_data.get('task_type') + if task_type_str: + dt_model.task_type = TaskType(task_type_str) + except ValueError: + logger.warning(f"Unknown task_type in flavor metadata: {task_type_str}") + except Exception as e: + logger.debug(f"Could not restore task_type from flavor metadata: {e}") + return pf_model diff --git a/datamint/mlflow/flavors/model.py b/datamint/mlflow/flavors/model.py index 752f9eee..186ca1b4 100644 --- a/datamint/mlflow/flavors/model.py +++ b/datamint/mlflow/flavors/model.py @@ -5,7 +5,7 @@ annotation system. It supports various prediction modes for different data types and use cases. """ -from typing import Any, TypeAlias +from typing import Any, ClassVar, TypeAlias from abc import ABC from dataclasses import dataclass from mlflow.environment_variables import MLFLOW_DEFAULT_PREDICTION_DEVICE @@ -14,6 +14,7 @@ from datamint.entities.resource import Resource from datamint.mlflow.flavors.model_loader import LinkedModelLoader from datamint.mlflow.flavors.prediction_modes import PredictionMode +from datamint.mlflow.flavors.task_type import TaskType from datamint.mlflow.flavors.prediction_router import PredictionRouter import logging from functools import cached_property @@ -64,6 +65,10 @@ class BaseDatamintModel(PythonModel, ABC): other ``predict_*`` hooks registered with ``@prediction_mode``). """ + task_type: ClassVar[TaskType | None] = None + """Semantic task category for this model class. Subclasses should override + at the class body level (e.g. ``task_type = TaskType.IMAGE_SEGMENTATION``).""" + def __init__( self, settings: ModelSettings | dict[str, Any] | None = None, @@ -336,6 +341,10 @@ def __init__(self, another_model: Any) -> None: super().__init__(settings=another_model.settings) self.another_model = another_model + @property + def task_type(self) -> TaskType | None: # type: ignore[override] + return getattr(self.another_model, 'task_type', None) + @cached_property def _router(self) -> PredictionRouter: """The PredictionRouter instance responsible for dispatching predict calls.""" @@ -352,4 +361,5 @@ def get_supported_modes(self) -> list[str]: return self.another_model.get_supported_modes() def predict_default(self, model_input: list[Resource], **kwargs: Any) -> PredictionResult: - return self.another_model.predict_default(model_input, **kwargs) \ No newline at end of file + return self.another_model.predict_default(model_input, **kwargs) + \ No newline at end of file diff --git a/datamint/mlflow/flavors/task_type.py b/datamint/mlflow/flavors/task_type.py new file mode 100644 index 00000000..a277c7ad --- /dev/null +++ b/datamint/mlflow/flavors/task_type.py @@ -0,0 +1,33 @@ +""" +Task type enumeration for Datamint MLflow models. +""" + +from enum import Enum + + +class TaskType(str, Enum): + """Medical-AI task categories for Datamint models. + + The ``str`` mixin ensures values are JSON-serialisable in MLflow + metadata without explicit ``.value`` calls. + """ + + # 2D image tasks + IMAGE_CLASSIFICATION = "image_classification" + MULTILABEL_IMAGE_CLASSIFICATION = "multilabel_image_classification" + IMAGE_SEGMENTATION = "image_segmentation" # semantic, 2D + INSTANCE_SEGMENTATION = "instance_segmentation" + OBJECT_DETECTION = "object_detection" + + # 3D/volumetric tasks + VOLUME_SEGMENTATION = "volume_segmentation" # semantic, 3D + VOLUME_CLASSIFICATION = "volume_classification" + + # Video/temporal tasks + VIDEO_FRAME_CLASSIFICATION = "video_frame_classification" + VIDEO_SEGMENTATION = "video_segmentation" + + # Medical-specific + LANDMARK_DETECTION = "landmark_detection" # anatomical keypoints + ANOMALY_DETECTION = "anomaly_detection" + REPORT_GENERATION = "report_generation" # clinical text output From cef5a6674a36677c3427e9455244ed78640364b4 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 2 Apr 2026 23:23:13 -0300 Subject: [PATCH 43/47] up version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0feb4d34..2855187b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "datamint" description = "A library for interacting with the Datamint API, designed for efficient data management, processing and Deep Learning workflows." -version = "2.12.0a1" +version = "2.12.0a2" dynamic = ["dependencies"] requires-python = ">=3.10" readme = "README.md" From b4517e427de25772c0c566319d73a076702499c9 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 6 Apr 2026 09:08:05 -0300 Subject: [PATCH 44/47] fixed warning --- datamint/mlflow/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datamint/mlflow/__init__.py b/datamint/mlflow/__init__.py index ff5b148c..7f15a6c3 100644 --- a/datamint/mlflow/__init__.py +++ b/datamint/mlflow/__init__.py @@ -94,7 +94,7 @@ def _configure_mlflow_loggers(): else: if mlflow_utils.is_tracking_uri_set(): _LOGGER.warning("MLflow tracking URI is already set before patching get_tracking_uri.") - setup_mlflow_environment(set_mlflow=False) + _SETUP_CALLED_SUCCESSFULLY = setup_mlflow_environment(set_mlflow=False) # Replace the original function with our patched version mlflow_utils.get_tracking_uri = _patched_get_tracking_uri try: From 9fc772d741c1943a542fe2552008883e57e8000e Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 6 Apr 2026 09:08:56 -0300 Subject: [PATCH 45/47] Enhance model training and logging: - Add persistent workers to DataModule for improved performance. - Introduce logging for training and testing phases in BaseTrainer. - Implement deferred sample metrics collection in DatamintLightningModule. - Update classification and segmentation modules to check for loss function reduction support. - Improve model logging in MLFlow with thread-safe handling and CPU copies. --- datamint/lightning/datamodule.py | 3 + datamint/lightning/trainers/base_trainer.py | 3 + .../trainers/lightning_modules/base.py | 71 ++++++++-- .../classification_module.py | 14 +- .../lightning_modules/segmentation_module.py | 22 +-- .../lightning/callbacks/modelcheckpoint.py | 126 +++++++++++++++--- 6 files changed, 193 insertions(+), 46 deletions(-) diff --git a/datamint/lightning/datamodule.py b/datamint/lightning/datamodule.py index f6b5d7d2..a26371f5 100644 --- a/datamint/lightning/datamodule.py +++ b/datamint/lightning/datamodule.py @@ -195,6 +195,7 @@ def train_dataloader(self) -> DataLoader: drop_last=self._drop_last_train, num_workers=self._num_workers, pin_memory=self._pin_memory, + persistent_workers=True, collate_fn=self.dataset.get_collate_fn(), ) @@ -207,6 +208,7 @@ def val_dataloader(self) -> DataLoader | None: shuffle=False, num_workers=self._num_workers, pin_memory=self._pin_memory, + persistent_workers=True, collate_fn=self.dataset.get_collate_fn(), ) @@ -218,6 +220,7 @@ def test_dataloader(self) -> DataLoader: shuffle=False, num_workers=self._num_workers, pin_memory=self._pin_memory, + persistent_workers=True, collate_fn=self.dataset.get_collate_fn(), ) diff --git a/datamint/lightning/trainers/base_trainer.py b/datamint/lightning/trainers/base_trainer.py index 3a93d5f8..deabaa04 100644 --- a/datamint/lightning/trainers/base_trainer.py +++ b/datamint/lightning/trainers/base_trainer.py @@ -180,14 +180,17 @@ def fit(self) -> dict[str, Any]: ) # 6. Train + _LOGGER.info("Starting training...") self._lightning_trainer.fit(self.model, datamodule=self.datamodule) # 7. Test + _LOGGER.info("Starting test...") test_results = self._lightning_trainer.test(datamodule=self.datamodule) # 8. Build deploy adapter (only needed when the model is not already a DatamintModel) adapter = None if self.auto_deploy_adapter and not isinstance(self.model, BaseDatamintModel): + _LOGGER.debug("Building deploy adapter...") adapter = self._build_deploy_adapter() return { diff --git a/datamint/lightning/trainers/lightning_modules/base.py b/datamint/lightning/trainers/lightning_modules/base.py index 1dd93a85..04416cd5 100644 --- a/datamint/lightning/trainers/lightning_modules/base.py +++ b/datamint/lightning/trainers/lightning_modules/base.py @@ -6,6 +6,7 @@ import lightning as L from torch import Tensor +import torch from datamint.mlflow.flavors.model import BaseDatamintModel, ModelSettings from mlflow.pyfunc.model import PythonModelContext @@ -35,6 +36,7 @@ def __init__(self, settings: ModelSettings | None = None) -> None: BaseDatamintModel.__init__(self, settings=settings) self._log_sample_metrics: bool = False self._sample_buffer: list[dict[str, Any]] = [] + self._deferred_sample_batches: list[dict[str, Any]] = [] self.mlflow_model_id: str | None = None # Injected by modelcheckpoint at runtime. FIXME: This is a bit hacky # ------------------------------------------------------------------ @@ -52,29 +54,42 @@ def _accumulate_sample_data( loss_unreduced: Tensor, stage: str, ) -> None: - """Collect per-sample metrics into ``_sample_buffer``. + """Collect per-sample metrics into a deferred buffer. Called from ``_common_step`` when ``_log_sample_metrics`` is enabled. + Async CPU transfers are initiated but **not** synchronized here; + resolution happens in :meth:`_resolve_deferred_batches` with a + single sync before the metrics are flushed to MLflow. """ resources = batch.get('resource', []) confidences = self._compute_sample_confidence(logits) sample_metrics = self._compute_sample_metrics(logits, batch) + # Transfer all tensors to CPU asynchronously — no sync per batch. + loss_cpu = loss_unreduced.detach().to('cpu', non_blocking=True) + confidences_cpu = {key: vals.detach().to('cpu', non_blocking=True) for key, vals in confidences.items()} + sample_metrics_cpu = {key: vals.detach().to('cpu', non_blocking=True) for key, vals in sample_metrics.items()} + + # Extract resource metadata (CPU-only, no GPU sync needed) + resource_meta: list[dict[str, Any]] = [] for i in range(logits.shape[0]): - entry: dict[str, Any] = {} + meta: dict[str, Any] = {} if i < len(resources): res = resources[i] if hasattr(res, 'parent_resource'): - entry['resource_id'] = res.parent_resource.id - entry['slice_index'] = res.slice_index + meta['resource_id'] = res.parent_resource.id + meta['slice_index'] = res.slice_index else: - entry['resource_id'] = res.id - entry['loss'] = loss_unreduced[i].item() - for key, vals in confidences.items(): - entry[key] = vals[i].item() - for key, vals in sample_metrics.items(): - entry[key] = vals[i].item() - self._sample_buffer.append(entry) + meta['resource_id'] = res.id + resource_meta.append(meta) + + self._deferred_sample_batches.append({ + 'resource_meta': resource_meta, + 'loss': loss_cpu, + 'confidences': confidences_cpu, + 'sample_metrics': sample_metrics_cpu, + 'batch_size': logits.shape[0], + }) def _compute_sample_confidence(self, logits: Tensor) -> dict[str, Tensor]: """Return per-sample confidence scores. Subclasses must override.""" @@ -84,8 +99,41 @@ def _compute_sample_metrics(self, logits: Tensor, batch: dict) -> dict[str, Tens """Return per-sample metric values. Subclasses must override.""" return {} + def _resolve_deferred_batches(self) -> None: + """Resolve all deferred async-transfer batches into ``_sample_buffer``. + + Issues a single ``torch.cuda.synchronize()`` to ensure all + ``non_blocking`` CPU transfers have completed, then indexes into + the batch tensors to build per-sample entries. + """ + if not self._deferred_sample_batches: + return + + # One sync for all accumulated batches instead of one per batch. + if torch.cuda.is_available(): + torch.cuda.synchronize() + + for batch_data in self._deferred_sample_batches: + resource_meta = batch_data['resource_meta'] + loss_cpu = batch_data['loss'] + confidences_cpu = batch_data['confidences'] + sample_metrics_cpu = batch_data['sample_metrics'] + + for i in range(batch_data['batch_size']): + entry = dict(resource_meta[i]) + entry['loss'] = loss_cpu[i] + for key, vals in confidences_cpu.items(): + entry[key] = vals[i] + for key, vals in sample_metrics_cpu.items(): + entry[key] = vals[i] + self._sample_buffer.append(entry) + + self._deferred_sample_batches.clear() + def _flush_sample_metrics_to_mlflow(self) -> None: """Write accumulated sample data to MLflow and clear the buffer.""" + self._resolve_deferred_batches() + if not self._sample_buffer: return @@ -182,6 +230,7 @@ def _flush_sample_metrics_to_mlflow(self) -> None: def on_test_start(self) -> None: self.enable_sample_logging(True) self._sample_buffer.clear() + self._deferred_sample_batches.clear() def on_test_epoch_end(self) -> None: self._flush_sample_metrics_to_mlflow() diff --git a/datamint/lightning/trainers/lightning_modules/classification_module.py b/datamint/lightning/trainers/lightning_modules/classification_module.py index 7369538f..e211ec9b 100644 --- a/datamint/lightning/trainers/lightning_modules/classification_module.py +++ b/datamint/lightning/trainers/lightning_modules/classification_module.py @@ -61,13 +61,17 @@ def forward(self, x: Tensor) -> Tensor: def _compute_unreduced_loss(self, logits: Tensor, labels: Tensor) -> Tensor: """Compute per-sample loss without reduction.""" - if 'reduction' in inspect.signature(self.criterion.forward).parameters: + if not hasattr(self, '_criterion_supports_reduction_none'): + self._criterion_supports_reduction_none = 'reduction' in inspect.signature(self.criterion.forward).parameters + if not self._criterion_supports_reduction_none: + warnings.warn( + f"Loss function {self.criterion.__class__.__name__} does not support 'reduction' argument; " + "per-sample logging will be inaccurate.", + ) + + if self._criterion_supports_reduction_none: return self.criterion(logits, labels, reduction='none') else: - warnings.warn( - f"Loss function {self.criterion.__class__.__name__} does not support 'reduction' argument; " - "per-sample logging will be inaccurate.", - ) return self.criterion(logits, labels).unsqueeze(0).expand(logits.shape[0]) def _common_step(self, batch: dict, stage: str) -> Tensor: diff --git a/datamint/lightning/trainers/lightning_modules/segmentation_module.py b/datamint/lightning/trainers/lightning_modules/segmentation_module.py index 505ae5f1..e8976122 100644 --- a/datamint/lightning/trainers/lightning_modules/segmentation_module.py +++ b/datamint/lightning/trainers/lightning_modules/segmentation_module.py @@ -105,22 +105,22 @@ def _compute_unreduced_loss(self, logits: Tensor, masks: Tensor) -> Tensor: """ b = logits.shape[0] - # Try criterion with reduction='none' — inspect first to avoid a - # costly forward pass that raises an exception at runtime. - criterion_params = inspect.signature(self.criterion.forward).parameters - if 'reduction' in criterion_params: + if not hasattr(self, '_criterion_supports_reduction_none'): + self._criterion_supports_reduction_none = 'reduction' in inspect.signature(self.criterion.forward).parameters + if not self._criterion_supports_reduction_none: + warnings.warn( + f"{type(self.criterion).__name__} does not accept reduction='none'; " + "falling back to binary_cross_entropy_with_logits for per-sample loss.", + UserWarning, + stacklevel=2, + ) + + if self._criterion_supports_reduction_none: loss = self.criterion(logits, masks.float(), reduction='none') # Flatten spatial dims if needed so result is (B,). if loss.dim() > 1: loss = loss.reshape(b, -1).mean(dim=1) return loss - else: - warnings.warn( - f"{type(self.criterion).__name__} does not accept reduction='none'; " - "falling back to binary_cross_entropy_with_logits for per-sample loss.", - UserWarning, - stacklevel=2, - ) # Fallback: BCE with logits averaged over spatial dims per sample. logits_flat = logits.reshape(b, -1) diff --git a/datamint/mlflow/lightning/callbacks/modelcheckpoint.py b/datamint/mlflow/lightning/callbacks/modelcheckpoint.py index 64ee0c94..88666206 100644 --- a/datamint/mlflow/lightning/callbacks/modelcheckpoint.py +++ b/datamint/mlflow/lightning/callbacks/modelcheckpoint.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from lightning.pytorch.callbacks import ModelCheckpoint from pathlib import Path from weakref import proxy @@ -13,9 +14,11 @@ import mlflow.pytorch import mlflow.data.dataset import mlflow.entities.dataset +import copy import logging import json import hashlib +from concurrent.futures import ThreadPoolExecutor, Future from lightning.pytorch.loggers import MLFlowLogger if TYPE_CHECKING: @@ -25,14 +28,17 @@ _LOGGER = logging.getLogger(__name__) -def help_infer_signature(x): +def _prepare_signature_sample(x: Any) -> Any: import torch + if isinstance(x, torch.Tensor): return x.detach().cpu().numpy() - elif isinstance(x, dict): - return {k: help_infer_signature(v) for k, v in x.items()} - elif isinstance(x, (list, tuple)): - return type(x)(help_infer_signature(v) for v in x) + elif isinstance(x, Mapping): + return {k: _prepare_signature_sample(v) for k, v in x.items()} + elif isinstance(x, list): + return [_prepare_signature_sample(v) for v in x] + elif isinstance(x, tuple): + return tuple(_prepare_signature_sample(v) for v in x) return x @@ -101,17 +107,58 @@ def __init__(self, *args, self.extra_pip_requirements = extra_pip_requirements or [] self._last_registered_state_hash: str | None = None self._has_been_trained: bool = False + self._signature_forward_wrapped: bool = False + self._logging_executor: ThreadPoolExecutor = ThreadPoolExecutor( + max_workers=1, thread_name_prefix='mlflow-log') + self._logging_future: Future | None = None + self._pending_log_model: nn.Module | None = None + + def _wait_for_pending_logging(self) -> None: + """Block until any in-flight background model logging completes.""" + if self._logging_future is None: + return + try: + self._logging_future.result() + except Exception: + _LOGGER.exception("Background model logging failed") + finally: + self._logging_future = None + # Inject model_id into the original training model + if self._pending_log_model is not None: + self._inject_model_id(self._pending_log_model) + self._pending_log_model = None + + def _inject_model_id(self, model: 'nn.Module | L.LightningModule | BaseDatamintModel') -> None: + """Inject the MLflow model ID into the model, if it supports it.""" + if self._last_model_id is None: + return + if hasattr(model, 'set_mlflow_model_id'): + model.set_mlflow_model_id(self._last_model_id) + elif hasattr(model, 'mlflow_model_id'): + setattr(model, 'mlflow_model_id', self._last_model_id) + + def _prepare_loggable_model(self, model: nn.Module) -> nn.Module: + """Prepare a model for MLflow logging, potentially creating a CPU copy. + + Called on the main thread before async logging. + Override in subclasses that need thread-safe model snapshots. + Returns the same model by default (sync logging). + """ + return model def get_last_model_id(self) -> str | None: """Get the MLflow model ID of the last saved model, if available.""" + self._wait_for_pending_logging() return self._last_model_id def get_last_model_uri(self) -> str | None: """Get the MLflow model URI of the last saved model, if available.""" + self._wait_for_pending_logging() return self._last_model_uri def get_all_saved_models(self): """Get a list of all MLflow ModelInfo objects for models logged from this callback.""" + self._wait_for_pending_logging() if self._last_model_uri is None: return [] logger = _get_MLFlowLogger() @@ -173,6 +220,7 @@ def _should_register_model(self) -> bool: return False def _save_checkpoint(self, trainer: L.Trainer, filepath: str) -> None: + self._wait_for_pending_logging() trainer.save_checkpoint(filepath, self.save_weights_only) self._last_global_step_saved = trainer.global_step @@ -183,7 +231,15 @@ def _save_checkpoint(self, trainer: L.Trainer, filepath: str) -> None: for logger in trainer.loggers: logger.after_save_checkpoint(proxy(self)) if isinstance(logger, MLFlowLogger) and not self.log_model_at_end_only: - self.log_model_to_mlflow(trainer.model, run_id=logger.run_id) + loggable = self._prepare_loggable_model(trainer.model) + if loggable is not trainer.model: + # Snapshot created; safe to log in background thread + self._pending_log_model = trainer.model + self._logging_future = self._logging_executor.submit( + self.log_model_to_mlflow, loggable, logger.run_id + ) + else: + self.log_model_to_mlflow(trainer.model, run_id=logger.run_id) def log_additional_metadata(self, logger: MLFlowLogger | L.Trainer, additional_metadata: dict) -> None: @@ -218,6 +274,7 @@ def log_model_to_mlflow(self, raise NotImplementedError def _remove_checkpoint(self, trainer: L.Trainer, filepath: str) -> None: + self._wait_for_pending_logging() super()._remove_checkpoint(trainer, filepath) # remove the checkpoint from mlflow if trainer.is_global_zero: @@ -229,6 +286,7 @@ def _remove_checkpoint(self, trainer: L.Trainer, filepath: str) -> None: def register_model(self, trainer=None): """Register the model in MLFlow Model Registry.""" + self._wait_for_pending_logging() if not self._should_register_model(): return self.registered_model_info @@ -248,6 +306,7 @@ def register_model(self, trainer=None): return self.registered_model_info def _update_signature(self, trainer): + self._wait_for_pending_logging() if self._inferred_signature is None: _LOGGER.warning("No signature found. Cannot update signature.") return @@ -295,11 +354,8 @@ def _finalize_logged_model(self, model_path=modelinfo.artifact_path, run_id=run_id) - # inject model_id into the model if it has a set_model_id method (e.g. for logging sample metrics with model context) - if hasattr(model, 'set_mlflow_model_id'): - model.set_mlflow_model_id(self._last_model_id) - elif hasattr(model, 'mlflow_model_id'): - setattr(model, 'mlflow_model_id', self._last_model_id) + self._inject_model_id(model) + _LOGGER.debug("Finalized logged model with ID %s and URI %s", self._last_model_id, self._last_model_uri) def _wrap_forward(self, pl_module: nn.Module) -> None: """Intercept the first forward call to infer the MLflow model signature. @@ -321,6 +377,7 @@ def on_train_start(self, trainer, pl_module): def on_train_end(self, trainer: L.Trainer, pl_module: L.LightningModule) -> None: super().on_train_end(trainer, pl_module) + self._wait_for_pending_logging() if self.log_model_at_end_only and trainer.is_global_zero: logger = _get_MLFlowLogger(trainer) @@ -369,11 +426,13 @@ def _restore_model_uri(self, trainer: L.Trainer) -> None: self.last_saved_model_info = None def on_test_start(self, trainer, pl_module): + self._wait_for_pending_logging() self._wrap_forward(pl_module) self._restore_model_uri(trainer) return super().on_test_start(trainer, pl_module) def on_predict_start(self, trainer, pl_module): + self._wait_for_pending_logging() self._wrap_forward(pl_module) self._restore_model_uri(trainer) return super().on_predict_start(trainer, pl_module) @@ -480,25 +539,44 @@ def _infer_forward_defaults(self, model: nn.Module) -> dict[str, Any] | None: def _wrap_forward(self, pl_module: nn.Module) -> None: """Wrap ``pl_module.forward`` to infer the MLflow signature on the first call.""" + if self._inferred_signature is not None: + return + if self._signature_forward_wrapped: + return + original_forward = pl_module.forward + self._signature_forward_wrapped = True def wrapped_forward(x, *args, **kwargs): self._inferred_signature = mlflow.models.infer_signature( - model_input=help_infer_signature(x), + model_input=_prepare_signature_sample(x), params=self._infer_forward_defaults(pl_module), ) # Restore original forward and call it pl_module.forward = original_forward + self._signature_forward_wrapped = False out = original_forward(x, *args, **kwargs) - output_sig = mlflow.models.infer_signature(model_output=help_infer_signature(out)) + output_sig = mlflow.models.infer_signature(model_output=_prepare_signature_sample(out)) self._inferred_signature.outputs = output_sig.outputs return out pl_module.forward = wrapped_forward + def _prepare_loggable_model(self, model: nn.Module) -> nn.Module: + """Create a CPU deep copy for thread-safe background logging. + + The training model remains on its original device, avoiding + a costly GPU synchronisation and host-to-device transfer. + """ + import torch + with torch.no_grad(): + cpu_model = copy.deepcopy(model) + cpu_model.cpu() + return cpu_model + def log_model_to_mlflow(self, model: 'nn.Module | L.LightningModule | BaseDatamintModel', run_id: str | MLFlowLogger): @@ -514,12 +592,21 @@ def log_model_to_mlflow(self, _LOGGER.warning("No checkpoint saved yet. Cannot log model to MLFlow.") return - orig_device = next(model.parameters()).device - model = model.cpu() + import torch + + # If model is on a GPU, create a CPU copy instead of moving the + # training model off-device, avoiding a costly sync + H2D transfer. + device = next(model.parameters()).device + if device.type != 'cpu': + with torch.no_grad(): + model_to_log = copy.deepcopy(model) + model_to_log.cpu() + else: + model_to_log = model - _LOGGER.debug("Logging model using pytorch flavor with name %s", Path(self._last_checkpoint_saved).stem) + _LOGGER.info("Logging model using pytorch flavor with name %s", Path(self._last_checkpoint_saved).stem) modelinfo = mlflow.pytorch.log_model( - pytorch_model=model, + pytorch_model=model_to_log, name=Path(self._last_checkpoint_saved).stem, signature=self._inferred_signature, run_id=run_id, @@ -527,7 +614,8 @@ def log_model_to_mlflow(self, code_paths=self.code_paths, ) - model.to(device=orig_device) + if model_to_log is not model: + del model_to_log self._finalize_logged_model(modelinfo, run_id, model=model) @@ -556,7 +644,7 @@ def log_model_to_mlflow(self, return from datamint.mlflow.flavors import datamint_flavor - _LOGGER.debug("Logging model using datamint flavor with name %s", Path(self._last_checkpoint_saved).stem) + _LOGGER.info("Logging model using datamint flavor with name %s", Path(self._last_checkpoint_saved).stem) modelinfo = datamint_flavor.log_model( model, name=Path(self._last_checkpoint_saved).stem, From 26f055677abac4f02078ccfc0a206454d36299a4 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 6 Apr 2026 14:11:50 -0300 Subject: [PATCH 46/47] Refactor ResourcesApi to allow Project type for publish_to parameter and enhance BaseSegmentationAnnotation constructor for better mask handling --- datamint/api/endpoints/resources_api.py | 2 +- datamint/entities/annotations/base_segmentation.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/datamint/api/endpoints/resources_api.py b/datamint/api/endpoints/resources_api.py index 390573c1..99e83581 100644 --- a/datamint/api/endpoints/resources_api.py +++ b/datamint/api/endpoints/resources_api.py @@ -713,7 +713,7 @@ def upload_resource(self, mung_filename: Sequence[int] | Literal['all'] | None = None, channel: str | None = None, publish: bool = False, - publish_to: str | None = None, + publish_to: Project | str | None = None, segmentation_files: dict | None = None, transpose_segmentation: bool = False, modality: str | None = None, diff --git a/datamint/entities/annotations/base_segmentation.py b/datamint/entities/annotations/base_segmentation.py index 8a6f92b3..3ceafcea 100644 --- a/datamint/entities/annotations/base_segmentation.py +++ b/datamint/entities/annotations/base_segmentation.py @@ -267,6 +267,17 @@ class BaseSegmentationAnnotation(Annotation): # fetch_file_data # ------------------------------------------------------------------ + def __init__(self, + segmentation_data: np.ndarray | Image.Image | None = None, + mask: np.ndarray | Image.Image | None = None, + **kwargs + ) -> None: + if mask is not None: + if segmentation_data is not None: + raise ValueError("Cannot specify both 'segmentation_data' and 'mask'") + segmentation_data = mask + super().__init__(segmentation_data=segmentation_data, **kwargs) + @overload def fetch_file_data( self, From 748036d98dc792d1d83e09ebd8c264225884dc4c Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 6 Apr 2026 14:12:51 -0300 Subject: [PATCH 47/47] tutorial for external model deployment --- .../external_model_deployment_tutorial.ipynb | 669 ++++++++++++++++++ 1 file changed, 669 insertions(+) create mode 100644 notebooks/external_model_deployment_tutorial.ipynb diff --git a/notebooks/external_model_deployment_tutorial.ipynb b/notebooks/external_model_deployment_tutorial.ipynb new file mode 100644 index 00000000..9a8c0e9a --- /dev/null +++ b/notebooks/external_model_deployment_tutorial.ipynb @@ -0,0 +1,669 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5794f1e6", + "metadata": {}, + "source": [ + "# Deploying an Externally Trained Model with Datamint\n", + "\n", + "This notebook shows how to take a model **trained outside of Datamint** — in your own script, a Jupyter notebook, or any third-party framework — and register, deploy, and run inference with it using Datamint's serving infrastructure.\n", + "\n", + "## When to use this tutorial\n", + "\n", + "Use this approach when you:\n", + "- Already have a trained PyTorch model (checkpoint, `state_dict`, or full `nn.Module`)\n", + "- Trained with a custom loop, Keras, Hugging Face, or any other framework\n", + "- Want to deploy a third-party pre-trained model (foundation model, off-the-shelf architecture)\n", + "\n", + "## Comparison with the Trainer API\n", + "\n", + "| Step | [Trainer API](segmentation_2d_trainer_BUSI_tutorial.ipynb) | This tutorial |\n", + "|------|-----------------------------------------------------------|---------------|\n", + "| Training | Handled automatically | manual |\n", + "| MLflow logging | Handled automatically | `datamint.mlflow.flavors.log_model(...)` |\n", + "| Adapter code | None required | Subclass `DatamintModel` |\n", + "| Deployment | Handled automatically | `api.deploy.start(...)` |\n", + "\n", + "## What You'll Learn\n", + "\n", + "1. Wrap a plain `nn.Module` in a `DatamintModel` adapter\n", + "2. Implement `predict_default` to produce Datamint annotations\n", + "3. Log and register the model in MLflow\n", + "4. Test inference locally\n", + "5. Deploy the model and run remote inference\n", + "\n", + "## Required Dependencies\n", + "\n", + "```bash\n", + "pip install datamint segmentation-models-pytorch albumentations\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "7a9756c6", + "metadata": {}, + "source": [ + "## 0. Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e8b17edf", + "metadata": {}, + "outputs": [], + "source": [ + "from datamint import Api\n", + "\n", + "PROJECT_NAME = \"ExternalModel_Tutorial\"\n", + "MODEL_NAME = PROJECT_NAME # MLflow registered model name\n", + "\n", + "api = Api()\n", + "proj = api.projects.create(\n", + " name=PROJECT_NAME,\n", + " description=\"Tutorial: deploying an externally trained segmentation model\",\n", + " exists_ok=True,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "69ec0bb2", + "metadata": {}, + "source": [ + "## 1. Train or Load Your Model\n", + "\n", + "In a real scenario you already have a checkpoint. Here we train a minimal UNet++ on a toy dataset\n", + "so the notebook runs end-to-end without external data.\n", + "\n", + "Replace the cell below with your own model loading code, for example:\n", + "\n", + "```python\n", + "import torch\n", + "import segmentation_models_pytorch as smp\n", + "\n", + "model = smp.UnetPlusPlus(encoder_name='resnet34', in_channels=3, classes=1)\n", + "state = torch.load('my_checkpoint.pth', map_location='cpu')\n", + "model.load_state_dict(state)\n", + "model.eval()\n", + "```\n", + "\n", + "EXAMPLE TRAINING CODE:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79833da1", + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "import torch.nn as nn\n", + "import segmentation_models_pytorch as smp\n", + "\n", + "CLASS_NAMES = ['lesion'] # foreground class names; one per output channel\n", + "IMAGE_SIZE = 256\n", + "\n", + "# ── Build architecture ──────────────────────────────────────────────────────\n", + "net = smp.UnetPlusPlus(\n", + " encoder_name='resnet34',\n", + " encoder_weights='imagenet',\n", + " in_channels=3,\n", + " classes=len(CLASS_NAMES),\n", + ")\n", + "\n", + "# ── (Optional) quick training on a toy batch ────────────────────────────────\n", + "# Replace this block with your real training loop / checkpoint loading.\n", + "optimizer = torch.optim.Adam(net.parameters(), lr=1e-3)\n", + "loss_fn = smp.losses.DiceLoss(mode='binary')\n", + "\n", + "net.train()\n", + "for _ in range(3): # 3 fake gradient steps\n", + " x = torch.randn(2, 3, IMAGE_SIZE, IMAGE_SIZE)\n", + " y = (torch.rand(2, len(CLASS_NAMES), IMAGE_SIZE, IMAGE_SIZE) > 0.5).float()\n", + " loss = loss_fn(net(x), y)\n", + " optimizer.zero_grad()\n", + " loss.backward()\n", + " optimizer.step()\n", + "\n", + "net.eval()\n", + "print(f\"Model ready — {sum(p.numel() for p in net.parameters()):,} parameters\")" + ] + }, + { + "cell_type": "markdown", + "id": "a1ef6a92", + "metadata": {}, + "source": [ + "## 2. Create a DatamintModel Adapter\n", + "\n", + "Datamint's deployment server calls your `predict_default` method with a list of\n", + "`Resource` objects and expects a `list[list[Annotation]]` back — one annotation list per resource.\n", + "\n", + "You wrap your model by subclassing `DatamintModel` and implementing `predict_default`:\n", + "\n", + "```\n", + "DatamintModel\n", + "└── predict_default(model_input: list[Resource], **kwargs) → list[list[Annotation]]\n", + "```\n", + "\n", + "### Key helpers available inside `predict_default`\n", + "\n", + "| Helper | Returns |\n", + "|--------|---------|\n", + "| `self.inference_device` | `'cuda'` or `'cpu'` (set by the server) |\n", + "| `self.get_pytorch_model()` | The `nn.Module` you passed as `torch_model` |\n", + "| `resource.fetch_file_data(auto_convert=True)` | PIL Image / DICOM etc. |\n", + "\n", + "### Supported annotation types\n", + "\n", + "Import from `datamint.entities.annotations`:\n", + "- `ImageSegmentation` — 2-D binary mask \n", + "- `ImageClassification` — class label + optional probability \n", + "- `BoundingBox` — `[x, y, w, h]`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7dbd4929", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import albumentations as A\n", + "from albumentations.pytorch import ToTensorV2\n", + "from datamint.mlflow.flavors.model import DatamintModel, ModelSettings\n", + "from datamint.mlflow.flavors.task_type import TaskType\n", + "from datamint.entities.annotations import ImageSegmentation\n", + "import cv2\n", + "\n", + "_debug('datamint.api')\n", + "\n", + "\n", + "class SegmentationAdapter(DatamintModel):\n", + " \"\"\"Wraps a plain nn.Module for Datamint segmentation inference.\"\"\"\n", + "\n", + " task_type = TaskType.IMAGE_SEGMENTATION\n", + "\n", + " def __init__(\n", + " self,\n", + " torch_model: torch.nn.Module,\n", + " class_names: list[str],\n", + " image_size: int = 256,\n", + " threshold: float = 0.5,\n", + " need_gpu: bool = False,\n", + " ) -> None:\n", + " super().__init__(\n", + " torch_model=torch_model,\n", + " settings=ModelSettings(need_gpu=need_gpu),\n", + " )\n", + " self.class_names = class_names\n", + " self.image_size = image_size\n", + " self.threshold = threshold\n", + "\n", + " # Preprocessing applied to every image at inference time\n", + " self._transform = A.Compose([\n", + " A.Resize(image_size, image_size),\n", + " A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n", + " ToTensorV2(),\n", + " ])\n", + "\n", + " def predict_default(\n", + " self,\n", + " model_input: list, # list[Resource]\n", + " **kwargs,\n", + " ) -> list[list[ImageSegmentation]]:\n", + " device = self.inference_device\n", + " model = self.get_pytorch_model().to(device).eval()\n", + "\n", + " results = []\n", + " for resource in model_input:\n", + " # ── Load image ──────────────────────────────────────────────────\n", + " img = np.array(resource.fetch_file_data(auto_convert=True, use_cache=True))\n", + " if img.ndim == 2: # grayscale → 3-channel\n", + " img = np.stack([img, img, img], axis=-1)\n", + " elif img.shape[-1] == 4: # RGBA → RGB\n", + " img = img[..., :3]\n", + " orig_h, orig_w = img.shape[:2]\n", + "\n", + " # ── Preprocess ──────────────────────────────────────────────────\n", + " tensor = self._transform(image=img)['image'].unsqueeze(0).to(device) # (1, 3, H, W)\n", + "\n", + " # ── Forward pass ────────────────────────────────────────────────\n", + " with torch.inference_mode():\n", + " logits = model(tensor) # (1, C, H, W)\n", + " probs = logits.sigmoid().squeeze(0).cpu().numpy() # (C, H, W) [0, 1]\n", + "\n", + " # ── Build annotations ───────────────────────────────────────────\n", + " annotations = [\n", + " ImageSegmentation(\n", + " name=self.class_names[i],\n", + " segmentation_data=cv2.resize(\n", + " (probs[i] > self.threshold).astype(np.uint8),\n", + " (orig_w, orig_h),\n", + " interpolation=cv2.INTER_NEAREST,\n", + " ),\n", + " )\n", + " for i in range(len(self.class_names))\n", + " ]\n", + " results.append(annotations)\n", + "\n", + " return results\n", + "\n", + "\n", + "# Instantiate the adapter with the trained model\n", + "adapter = SegmentationAdapter(\n", + " torch_model=net,\n", + " class_names=CLASS_NAMES,\n", + " image_size=IMAGE_SIZE,\n", + ")\n", + "print(\"Adapter ready.\")" + ] + }, + { + "cell_type": "markdown", + "id": "4b057353", + "metadata": {}, + "source": [ + "### 2.1 Smoke-test the adapter locally\n", + "\n", + "Before logging to MLflow it's worth making sure `predict_default` runs without errors\n", + "using a fake resource that just wraps a local file." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3e253b20", + "metadata": {}, + "outputs": [], + "source": [ + "from datamint.entities.resource import LocalResource\n", + "from PIL import Image\n", + "import io\n", + "\n", + "# Create a dummy in-memory PNG as a LocalResource\n", + "buf = io.BytesIO()\n", + "\n", + "img = Image.fromarray(np.random.randint(0, 255, (300, 400, 3), dtype=np.uint8))\n", + "img.save(buf, format='PNG')\n", + "dummy_resource = LocalResource(raw_data=buf.getvalue(), filename='dummy.png')\n", + "\n", + "predictions = adapter.predict([dummy_resource])\n", + "\n", + "print(f\"Received {len(predictions)} result(s)\")\n", + "print(f\"Annotations per resource: {[len(p) for p in predictions]}\")\n", + "for ann in predictions[0]:\n", + " print(f\" {ann.name!r} mask shape={ann.mask.shape} dtype={ann.mask.dtype}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6376dcce", + "metadata": {}, + "outputs": [], + "source": [ + "# upload resource to Datamint (optional, but simulates real usage more closely)\n", + "import tempfile\n", + "\n", + "with tempfile.NamedTemporaryFile(suffix='.png') as tmp:\n", + " img.save(tmp.name)\n", + " api.resources.upload_resource(tmp.name, tags=['dummy'],\n", + " publish_to=proj)" + ] + }, + { + "cell_type": "markdown", + "id": "687b033a", + "metadata": {}, + "source": [ + "## 3. Log and Register the Model in MLflow\n", + "\n", + "`datamint.mlflow.flavors.log_model` is the single call that:\n", + "\n", + "1. Serialises your adapter (including the embedded `nn.Module`) as an MLflow artifact\n", + "2. Records the `task_type` so the Datamint server knows how to display predictions\n", + "3. Pins the `datamint` and `medimgkit` package versions in `requirements.txt`\n", + "4. (Optionally) registers the model in the MLflow Model Registry under a name you choose\n", + "\n", + "### Why call `datamint.mlflow.set_project` first?\n", + "\n", + "`set_project` configures the MLflow tracking URI to point to your Datamint server and\n", + "associates the run with the correct project, so metrics and artifacts are visible in the\n", + "Datamint dashboard." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f3df96a6", + "metadata": {}, + "outputs": [], + "source": [ + "import mlflow\n", + "import datamint.mlflow as datamint_mlflow\n", + "from datamint.mlflow.flavors import log_model\n", + "\n", + "# Point MLflow at the Datamint server and associate runs with the project\n", + "datamint_mlflow.set_project(PROJECT_NAME)\n", + "mlflow.set_experiment(PROJECT_NAME) # Can be any name; doesn't have to match the project name\n", + "\n", + "with mlflow.start_run(run_name='external_model_upload') as run:\n", + " # Log any metadata you want to track alongside the model\n", + " mlflow.log_params({\n", + " 'encoder': 'resnet34',\n", + " 'image_size': IMAGE_SIZE,\n", + " 'class_names': str(CLASS_NAMES),\n", + " 'framework': 'segmentation_models_pytorch',\n", + " })\n", + "\n", + " model_info = log_model(\n", + " adapter,\n", + " task_type=TaskType.IMAGE_SEGMENTATION,\n", + " name='segmentation_model', # artifact sub-path inside the run\n", + " registered_model_name=MODEL_NAME, # registers in the Model Registry\n", + " )\n", + "\n", + "print(f\"Model URI : {model_info.model_uri}\")\n", + "print(f\"Run ID : {run.info.run_id}\")" + ] + }, + { + "cell_type": "markdown", + "id": "aef7a8a5", + "metadata": {}, + "source": [ + "### 3.1 Assign the `champion` alias\n", + "\n", + "The deployment API (`api.deploy.start`) resolves models by alias.\n", + "Set the `champion` (or any of your choice) alias on the version we just registered." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45320bce", + "metadata": {}, + "outputs": [], + "source": [ + "from mlflow import MlflowClient\n", + "\n", + "client = MlflowClient()\n", + "\n", + "# The newly registered version is always the highest version number\n", + "versions = client.search_model_versions(f\"name='{MODEL_NAME}'\")\n", + "latest_version = max(versions, key=lambda v: int(v.version))\n", + "\n", + "client.set_registered_model_alias(MODEL_NAME, 'champion', latest_version.version)\n", + "print(f\"Alias 'champion' → version {latest_version.version}\")" + ] + }, + { + "cell_type": "markdown", + "id": "c7ab8c6f", + "metadata": {}, + "source": [ + "## 4. Verify: Load and Run Local Inference\n", + "\n", + "Before deploying, load the registered model back from MLflow and verify that\n", + "`predict` works correctly end-to-end." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a96affe7", + "metadata": {}, + "outputs": [], + "source": [ + "from datamint.mlflow import flavors as datamint_flavor\n", + "\n", + "model_uri = f\"models:/{MODEL_NAME}@champion\" # or f\"models:/{MODEL_NAME}/latest\" or f\"models:/{MODEL_NAME}/1\"\n", + "loaded_model = datamint_flavor.load_model(model_uri)\n", + "\n", + "print(f\"Loaded model type : {type(loaded_model).__name__}\")\n", + "print(f\"Task type : {loaded_model.task_type}\")\n", + "print(f\"Inference device : {loaded_model.inference_device}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "980c4859", + "metadata": {}, + "outputs": [], + "source": [ + "# Run inference on a real resource from the project\n", + "resources = list(api.resources.get_list(project_name=PROJECT_NAME, limit=3))\n", + "\n", + "if resources:\n", + " predictions = loaded_model.predict(resources[:1])\n", + " for ann in predictions[0]:\n", + " print(f\" class={ann.name!r} mask={ann.mask.shape} positive_pixels={ann.mask.sum()}\")\n", + "else:\n", + " # Fall back to the dummy resource from section 2.1\n", + " predictions = loaded_model.predict([dummy_resource])\n", + " print(\"No resources in project — tested on dummy image.\")\n", + " for ann in predictions[0]:\n", + " print(f\" class={ann.name!r} mask={ann.mask.shape}\")" + ] + }, + { + "cell_type": "markdown", + "id": "67b232bd", + "metadata": {}, + "source": [ + "## 5. Deploy to the Datamint Server" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "047ea252", + "metadata": {}, + "outputs": [], + "source": [ + "job = api.deploy.start(\n", + " model_name=MODEL_NAME,\n", + " model_alias='champion',\n", + " with_gpu=False,\n", + ")\n", + "\n", + "print(f\"Deployment job started\")\n", + "print(f\"Job ID : {job.id}\")\n", + "print(f\"Status : {job.status}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0944deb0", + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "\n", + "# Poll until the deployment is complete (usually 5–10 minutes)\n", + "while True:\n", + " job = api.deploy.get_by_id(job.id)\n", + " print(f\"Status: {job.status} ({job.progress_percentage:.0f}%)\")\n", + " if job.status in ('completed', 'failed', 'cancelled'):\n", + " break\n", + " time.sleep(15)\n", + "\n", + "if job.status == 'completed':\n", + " print(\"Deployment successful!\")\n", + "else:\n", + " print(f\"Deployment failed: {job.error_message}\")\n", + " print(job.build_logs)" + ] + }, + { + "cell_type": "markdown", + "id": "3335c541", + "metadata": {}, + "source": [ + "## 6. Remote Inference\n", + "\n", + "Once deployed, submit resources for server-side inference." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "639935d4", + "metadata": {}, + "outputs": [], + "source": [ + "# Pick a resource to run inference on\n", + "r = api.resources.get_list(project_name=PROJECT_NAME, limit=1)[0]\n", + "\n", + "inf_job = api.inference.submit(\n", + " model_name=MODEL_NAME,\n", + " model_alias='champion',\n", + " resource_id=r.id,\n", + ")\n", + "inf_job.wait() # blocks until inference is complete\n", + "\n", + "print(f\"Inference complete — {len(inf_job.predictions)} result(s)\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c08a4512", + "metadata": {}, + "outputs": [], + "source": [ + "from matplotlib import pyplot as plt\n", + "\n", + "preds = inf_job.predictions[0]\n", + "\n", + "fig, axes = plt.subplots(1, len(preds), figsize=(5 * max(len(preds), 1), 5))\n", + "if len(preds) == 1:\n", + " axes = [axes]\n", + "\n", + "for ax, ann in zip(axes, preds):\n", + " ax.imshow(ann.mask, cmap='gray')\n", + " ax.set_title(ann.name)\n", + " ax.axis('off')\n", + "\n", + "plt.suptitle(f\"Remote inference on: {r.filename}\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "8dfa590b", + "metadata": {}, + "source": [ + "## 7. Updating the Model (New Version)\n", + "\n", + "When you retrain or fine-tune your model, log a new version and move the `champion` alias:\n", + "\n", + "```python\n", + "# Re-train / fine-tune your net ...\n", + "\n", + "adapter_v2 = SegmentationAdapter(torch_model=net_v2, class_names=CLASS_NAMES)\n", + "\n", + "datamint_mlflow.set_project(PROJECT_NAME)\n", + "mlflow.set_experiment(PROJECT_NAME)\n", + "\n", + "with mlflow.start_run(run_name='external_model_v2'):\n", + " mlflow.log_param('version', 2)\n", + " model_info = log_model(\n", + " adapter_v2,\n", + " task_type=TaskType.IMAGE_SEGMENTATION,\n", + " name='segmentation_model',\n", + " registered_model_name=MODEL_NAME,\n", + " )\n", + "\n", + "# Advance the alias so deployments pick up the new version automatically\n", + "versions = client.search_model_versions(f\"name='{MODEL_NAME}'\")\n", + "latest_version = max(versions, key=lambda v: int(v.version))\n", + "client.set_registered_model_alias(MODEL_NAME, 'champion', latest_version.version)\n", + "\n", + "# Re-deploy\n", + "api.deploy.start(model_name=MODEL_NAME, model_alias='champion', with_gpu=False)\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "7d8df686", + "metadata": {}, + "source": [ + "## 8. Custom Prediction Modes (Advanced)\n", + "\n", + "For large images you may want slice-by-slice or frame-by-frame inference.\n", + "Add extra prediction hooks using `@prediction_mode`:\n", + "\n", + "```python\n", + "from datamint.mlflow.flavors.prediction_router import prediction_mode\n", + "from datamint.mlflow.flavors.prediction_modes import PredictionMode\n", + "\n", + "class MyAdapter(DatamintModel):\n", + " task_type = TaskType.IMAGE_SEGMENTATION\n", + "\n", + " def predict_default(self, model_input, **kwargs):\n", + " ... # full-image inference\n", + "\n", + " @prediction_mode(PredictionMode.SLICE)\n", + " def predict_slice(self, model_input, slice_idx, **kwargs):\n", + " ... # called when mode='slice' is passed as a parameter\n", + "```\n", + "\n", + "Supported modes are defined in `datamint.mlflow.flavors.prediction_modes.PredictionMode`." + ] + }, + { + "cell_type": "markdown", + "id": "d4c1fb67", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "To deploy any externally trained model with Datamint:\n", + "\n", + "| Step | Code |\n", + "|------|------|\n", + "| 1. Wrap your model | Subclass `DatamintModel`, implement `predict_default` |\n", + "| 2. Configure MLflow | `datamint.mlflow.set_project(project_name)` |\n", + "| 3. Log & register | `log_model(adapter, task_type=..., registered_model_name=...)` |\n", + "| 4. Set alias | `client.set_registered_model_alias(name, 'latest', version)` |\n", + "| 5. Deploy | `api.deploy.start(model_name=..., model_alias='latest')` |\n", + "| 6. Infer | `api.inference.submit(model_name=..., resource_id=...)` |\n", + "\n", + "The only part unique to your model is the adapter class — everything else is identical\n", + "regardless of architecture, framework, or task type." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}