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/api/base_api.py b/datamint/api/base_api.py index f3dada0e..fab9aaaf 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 @@ -84,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: @@ -435,7 +432,6 @@ 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): new_error_msg = error_msg.replace('404 Not Found', '') @@ -687,6 +683,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) @@ -722,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}") @@ -732,21 +732,19 @@ 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) """ + 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] # 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/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/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/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/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 5aa84e15..5858b13f 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 @@ -160,8 +160,8 @@ def wait( *, on_status: Callable[[InferenceJob], None] | None = None, poll_interval: float = 2.0, - timeout: float | None = None, - ) -> InferenceJob: + timeout: float | None = 1800, + ) -> 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: @@ -392,4 +398,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 diff --git a/datamint/api/endpoints/projects_api.py b/datamint/api/endpoints/projects_api.py index 48cf7351..8c66e097 100644 --- a/datamint/api/endpoints/projects_api.py +++ b/datamint/api/endpoints/projects_api.py @@ -1,10 +1,14 @@ -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 +from datamint.exceptions import EntityAlreadyExistsError 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 +24,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 +44,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, @@ -48,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 @@ -60,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, @@ -71,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. @@ -82,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, @@ -100,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. @@ -236,3 +253,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/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/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 6694bd7c..2f584355 100644 --- a/datamint/api/entity_base_api.py +++ b/datamint/api/entity_base_api.py @@ -168,6 +168,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. @@ -183,9 +189,7 @@ def get_by_id(self, entity_id: str) -> T: ItemNotFoundError: 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()) @@ -292,26 +296,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: @@ -335,13 +368,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") diff --git a/datamint/dataset/__init__.py b/datamint/dataset/__init__.py index 08ac2d16..a484a904 100644 --- a/datamint/dataset/__init__.py +++ b/datamint/dataset/__init__.py @@ -1 +1,32 @@ -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 .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', + 'VideoDataset', + 'SlicedVolumeDataset', + 'SlicedVideoDataset', +] \ 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..bc93a7f7 --- /dev/null +++ b/datamint/dataset/annotation_processor.py @@ -0,0 +1,672 @@ +""" +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]], + 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 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]: + 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.resolve_seg_code(ann.identifier) + + if seg_code != -1 and seg_code != 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) + if stacked_seg is None: + if depth is None: + depth = ann.resource.get_depth() + 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 + 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 = self.get_author(ann) + 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 = self.get_author(ann) + + try: + seg_code = self.resolve_seg_code(ann.identifier) + seg = self.load_segmentation_data(ann) + + # 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) + + 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: + 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).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().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().to(torch.uint8) + + 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.bool().to(torch.uint8) + + 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,). + """ + 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: + 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 + + 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, + segmentations: dict[str, Tensor], + strategy: MergeStrategy, + ) -> Tensor: ... + + @overload + def apply_merge_strategy( + self, + segmentations: dict[str, np.ndarray], + strategy: MergeStrategy, + ) -> np.ndarray: ... + + def apply_merge_strategy( + self, + segmentations: dict[str, Tensor] | dict[str, np.ndarray], + strategy: MergeStrategy, + ) -> Tensor | np.ndarray: + """Merge semantic segmentations from multiple annotators. + + Args: + segmentations: Dict of author -> semantic segmentation tensor. + strategy: Merge strategy ('union', 'intersection', 'mode'). + + Returns: + Merged tensor if strategy is specified, otherwise original dict. + """ + if len(segmentations) == 0: + 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).numpy() + + 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..8f6f76f4 --- /dev/null +++ b/datamint/dataset/base.py @@ -0,0 +1,1138 @@ +""" +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.apihandler.dto.annotation_dto import AnnotationType +from datamint.exceptions import DatamintException +from .annotation_processor import AnnotationProcessor, MergeStrategy +from datamint.entities.annotations.annotation_spec import AnnotationSpec, CategoryAnnotationSpec + +if TYPE_CHECKING: + from datamint.entities import Resource, Project, Annotation + from albumentations import BaseCompose + from datamint.mlflow.data import DatamintMLflowDataset + +_LOGGER = logging.getLogger(__name__) + + +class DatamintDatasetException(DatamintException): + """Exception raised for dataset errors.""" + pass + + +class DatamintBaseDataset(ABC, torch.utils.data.Dataset): + """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. + 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']] + project: 'Project | None' + + def __init__( + self, + project: 'str | Project | None' = None, + resources: 'Sequence[Resource] | 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 | BaseCompose | 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, + 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( + "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") + + # 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 + 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.set_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 + 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 + 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 β€” + # 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, + ) + + # 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'], + ) -> 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[annotator_id, Tensor] or merged Tensor depending on + :attr:`image_labels_merge_strategy`. + """ + label_annotations = AnnotationProcessor.filter_annotations( + annotations, type='label', scope='image' + ) + 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, + 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', + ) -> tuple['Project', list['Resource']]: + """Initialize dataset from a project (name or object).""" + + # Handle Project object vs string + if isinstance(project, str): + 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 = self._api.projects + + # Fetch resources + resources = list(project.fetch_resources()) + return project, resources + + def _initialize_from_resources( + self, + resources: Sequence['Resource'], + ) -> Sequence['Resource']: + """Initialize dataset from a list of resources.""" + for res in resources: + if not hasattr(res, '_api') or res._api is None: + res._api = self._api.resources + + return resources + + 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.""" + + 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'] + ) + + 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. + + 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, + allow_external_annotations=self.allow_external_annotations, + ) + + 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.is_segmentation(): + return self._should_include_segmentation(ann.identifier) + 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.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', [])} + if ann.identifier not in valid_identifiers: + return False + + 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 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: + return name not in self.exclude_segmentation_names + 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: + return name not in self.exclude_image_label_names + 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: + return name not in self.exclude_frame_label_names + return True + + 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, str]] = 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.is_label(): + multilabel_set.add(ann.identifier) + elif ann.is_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': self.__build_label_codemap(multilabel_list), + 'multiclass': self.__build_label_codemap(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.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]: + """Segmentation label names.""" + return self.seglabel_list + + 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: + # groups = self._api.annotationsets.get_segmentation_group(self.project.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, + 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 = {} + 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 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)+1, *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 + + 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}") + + # 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'] + + segmentations, seg_labels = self._process_segmentations(segmentations, seg_labels, + output_shape=img.shape[1:]) + + result['segmentations'] = segmentations + if seg_labels: + result['seg_labels'] = seg_labels + + # Process image-level labels + result['image_labels'] = self._extract_image_labels(annotations) + result['image_categories'] = self._extract_image_categories(annotations) + + return result + + @abstractmethod + def apply_alb_transform( + self, + 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: + """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 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] + + 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): + 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 "" + 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}") + + 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) + + 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)." + ) + + 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, + 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]) + result[name].split_name = name + 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/base_dataset.py b/datamint/dataset/base_dataset.py index 22629687..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,16 @@ 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." + ) 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..9d73bcee --- /dev/null +++ b/datamint/dataset/image_dataset.py @@ -0,0 +1,111 @@ +""" +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 .base import DatamintDatasetException +from .volume_dataset import VolumeDataset + +_LOGGER = logging.getLogger(__name__) + + +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 + + 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]: + if self.alb_transform is None: + raise ValueError("alb_transform is not set") + 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 (H, W, C) + img = np.transpose(img, (1, 2, 0)) + + replay_alb_transf = albumentations.ReplayCompose([self.alb_transform]) + + 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(): + 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: + 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, (2, 0, 1)) + elif isinstance(aug_img, torch.Tensor): + # 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[0] == img.shape[-1]: # if C is in dim 0 + aug_img = aug_img.permute(0, 1, 2) + else: + 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, + 'segmentations': aug_segmentations, + } + + @override + def __repr__(self) -> str: + base = super(VolumeDataset, self).__repr__() + return f"ImageDataset\n{base}" 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 new file mode 100644 index 00000000..680246ab --- /dev/null +++ b/datamint/dataset/sliced_dataset.py @@ -0,0 +1,593 @@ +""" +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. +""" +from __future__ import annotations +import hashlib +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, 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" + + +# # 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. + + 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: + 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, + *args, + slice_axis: ViewPlane | int = 'axial', + **kwargs, + ): + # --- 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." + ) + 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 + + 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 + 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 slice cache --- + instance._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 = instance._expand_resources( + parent_dataset.resources, + parent_dataset.resource_annotations, + volume_cache, + ) + instance.resources = expanded_resources # type: ignore[assignment] + instance.resource_annotations = expanded_annotations + + return instance + + 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). + """ + from datamint.entities.sliced_resource import SlicedVolumeResource + + sliced_resources: list[SlicedVolumeResource] = [] + sliced_annotations: list[Sequence['Annotation']] = [] + + for i, r in enumerate(resources): + anns = resource_annotations[i] + 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 + + # --- 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.is_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. + + Returns dict with: + - '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() # ndims=3. + + # # 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, DIM1, DIM2) + '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'] + + # Process segmentations + if self.return_segmentations: + seg_anns = AnnotationProcessor.filter_annotations( + annotations, type='segmentation', scope='all' + ) + sliced_segs, seg_labels, _ = self._load_sliced_segmentations(seg_anns, resource) + + # 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, + 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) + 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'] + # _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 + + @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 + 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 orig_dim != 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) if original had it, else keep (C, H, W) + if orig_dim == 4: + 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/sliced_video_dataset.py b/datamint/dataset/sliced_video_dataset.py new file mode 100644 index 00000000..b76acb91 --- /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.is_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 new file mode 100644 index 00000000..6f8dacd0 --- /dev/null +++ b/datamint/dataset/volume_dataset.py @@ -0,0 +1,68 @@ +""" +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 TYPE_CHECKING + +from .multiframe_dataset import MultiFrameDataset + +if TYPE_CHECKING: + from .sliced_dataset import SlicedVolumeDataset + +_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(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`. + """ + + 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.from_dataset( + parent_dataset=self, + slice_axis=axis, + ) diff --git a/datamint/entities/__init__.py b/datamint/entities/__init__.py index ed65c0ff..6e28f400 100644 --- a/datamint/entities/__init__.py +++ b/datamint/entities/__init__.py @@ -9,6 +9,7 @@ from .datasetinfo import DatasetInfo from .cache_manager import CacheManager from .inferencejob import InferenceJob +from .annotations.annotation_spec import AnnotationSpec __all__ = [ 'Annotation', @@ -21,4 +22,5 @@ 'Project', 'Resource', 'User', + 'AnnotationSpec' ] 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..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): @@ -71,7 +97,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. @@ -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/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/annotations/base_segmentation.py b/datamint/entities/annotations/base_segmentation.py index 759cf10d..3ceafcea 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,12 +261,23 @@ 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 # ------------------------------------------------------------------ + 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, @@ -377,3 +391,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/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/base_entity.py b/datamint/entities/base_entity.py index 236c2fbd..585ea43d 100644 --- a/datamint/entities/base_entity.py +++ b/datamint/entities/base_entity.py @@ -229,6 +229,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/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/inferencejob.py b/datamint/entities/inferencejob.py index 958bfd6b..d778fad5 100644 --- a/datamint/entities/inferencejob.py +++ b/datamint/entities/inferencejob.py @@ -1,12 +1,143 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING from collections.abc import Callable +import logging +from typing import Any, TYPE_CHECKING from datamint.entities.base_entity import BaseEntity, MISSING_FIELD +from datamint.entities.annotations import annotation_from_dict if TYPE_CHECKING: 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): @@ -28,10 +159,50 @@ 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.""" 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, @@ -43,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 @@ -51,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] @@ -64,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 diff --git a/datamint/entities/project.py b/datamint/entities/project.py index 7c95b852..02df28df 100644 --- a/datamint/entities/project.py +++ b/datamint/entities/project.py @@ -1,15 +1,16 @@ """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 -from pydantic import PrivateAttr +from pydantic import PrivateAttr, Field 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__) @@ -52,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() @@ -139,8 +140,16 @@ 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, 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 37563329..7313d7b5 100644 --- a/datamint/entities/resource.py +++ b/datamint/entities/resource.py @@ -2,12 +2,11 @@ from datetime import datetime import logging -import shutil import urllib.parse 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 @@ -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 @@ -107,22 +106,21 @@ 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): 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'): - 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, @@ -183,7 +181,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) @@ -279,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 int(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 int(st['nb_frames']) @@ -340,6 +346,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.""" @@ -552,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 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 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) diff --git a/datamint/lightning/__init__.py b/datamint/lightning/__init__.py index 2a3ba90a..d1df3069 100644 --- a/datamint/lightning/__init__.py +++ b/datamint/lightning/__init__.py @@ -1 +1,23 @@ -from .datamintdatamodule import DatamintDataModule \ No newline at end of file +"""Datamint Lightning integration.""" + +from .datamodule import DatamintDataModule +from .trainers import ( + BaseTrainer, + ClassificationTrainer, + ImageClassificationTrainer, + SemanticSegmentation2DTrainer, + SemanticSegmentation3DTrainer, + SegmentationTrainer, + UNetPPTrainer, +) + +__all__ = [ + "DatamintDataModule", + "BaseTrainer", + "ClassificationTrainer", + "ImageClassificationTrainer", + "SemanticSegmentation2DTrainer", + "SemanticSegmentation3DTrainer", + "SegmentationTrainer", + "UNetPPTrainer", +] 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..a26371f5 --- /dev/null +++ b/datamint/lightning/datamodule.py @@ -0,0 +1,269 @@ +""" +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 +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__) + + +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`. + + 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", "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 + 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 + + # ------------------------------------------------------------------ + # 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, + persistent_workers=True, + 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, + persistent_workers=True, + 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, + persistent_workers=True, + 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/__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..deabaa04 --- /dev/null +++ b/datamint/lightning/trainers/base_trainer.py @@ -0,0 +1,373 @@ +"""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 collections.abc import Callable +from typing import Any, TYPE_CHECKING +from functools import cached_property + +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 + 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_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:`_train_transform`. + eval_transform: Albumentations transform for val/test. When + ``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. + 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._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. + + Returns: + Dictionary with keys ``'trainer'``, ``'model'``, + ``'test_results'``, and ``'adapter'`` (when + *auto_deploy_adapter* is enabled). + """ + # clear cached properties in case of multiple calls to fit() + for attr in ('dataset', 'model'): + if attr in self.__dict__: + del self.__dict__[attr] + + 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 + + with mlflow.start_run(experiment_id=exp.experiment_id) as run: + # 1. Resolve dataset (triggers @cached_property) + _ = self.dataset + + # 2 . Build datamodule (triggers @cached_property) + _ = self.datamodule + + # 3. Build model + _ = self.model # triggers @cached_property to build the model + + # 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, + ) + + # 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 { + 'trainer': self._lightning_trainer, + 'model': self.model, + 'test_results': test_results, + 'adapter': adapter, + } + + # ── Template hooks (subclasses override these) ────────────── + + @abstractmethod + 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_model( + self, + loss_fn: nn.Module, + metrics: dict[str, Callable], + ) -> L.LightningModule: + """Build the default LightningModule for this task.""" + ... + + @abstractmethod + def _train_transform(self) -> 'BaseCompose': + """Return the training augmentation pipeline.""" + ... + + @abstractmethod + def _eval_transform(self) -> 'BaseCompose': + """Return the eval/test transform pipeline.""" + ... + + @abstractmethod + def _loss(self) -> nn.Module: + """Return the loss function for this task.""" + ... + + @abstractmethod + def _metrics(self) -> dict[str, Callable]: + """Return 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_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, + 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_default_callbacks(self) -> list: + from datamint.mlflow.lightning.callbacks import MLFlowPyTorchModelCheckpoint, MLFlowDatamintModelCheckpoint + from mlflow.pyfunc.model import PythonModel + + metric_name, mode = self._monitor_metric() + 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, + mode=mode, + 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, + mode=mode, + patience=self.early_stopping_patience, + )) + + return callbacks + + def _build_logger(self, run_id: str | None = None): + from lightning.pytorch.loggers import MLFlowLogger + + 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/classification_trainer.py b/datamint/lightning/trainers/classification_trainer.py new file mode 100644 index 00000000..50c31259 --- /dev/null +++ b/datamint/lightning/trainers/classification_trainer.py @@ -0,0 +1,119 @@ +"""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 + +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 _loss(self) -> nn.Module: + return nn.CrossEntropyLoss() + + def _metrics(self) -> dict[str, Callable]: + from torchmetrics.classification import MulticlassAccuracy, MulticlassF1Score + + nc = len(self.dataset.image_categories_set) + return { + 'accuracy': partial(MulticlassAccuracy, num_classes=nc), + 'f1': partial(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_dataset(self, project: 'str | Project') -> ImageDataset: + return ImageDataset( + project=project, + return_segmentations=False, + include_unannotated=False, + image_categories_merge_strategy='mode', + ) + + def _build_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, + class_names=list(self.dataset.image_categories_set), + image_size=self.image_size, + pretrained=self.pretrained, + ) + + def _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 _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..f000be50 --- /dev/null +++ b/datamint/lightning/trainers/lightning_modules/__init__.py @@ -0,0 +1,6 @@ +from .base import DatamintLightningModule +from .segmentation_module import SegmentationModule +from .segmentation_modules import SMPSegmentationModule, UNetPPModule +from .classification_module import 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..04416cd5 --- /dev/null +++ b/datamint/lightning/trainers/lightning_modules/base.py @@ -0,0 +1,247 @@ +"""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 +import torch + +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): + """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) + 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 + + # ------------------------------------------------------------------ + # 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 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]): + meta: dict[str, Any] = {} + if i < len(resources): + res = resources[i] + if hasattr(res, 'parent_resource'): + meta['resource_id'] = res.parent_resource.id + meta['slice_index'] = res.slice_index + else: + 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.""" + return {} + + def _compute_sample_metrics(self, logits: Tensor, batch: dict) -> dict[str, Tensor]: + """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 + + 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 + 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 + + 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). + metric_keys = { + key + for entry in self._sample_buffer + for key in entry + if key not in _SAMPLE_META_KEYS + } + + 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 + ] + + _LOGGER.info("Flushing %d per-sample metrics (of %d samples) to MLflow...", + len(all_metrics), len(self._sample_buffer)) + + if all_metrics: + client = MlflowClient() + try: + client.log_batch(run_id, metrics=all_metrics, synchronous=True) + except Exception as e: + _LOGGER.error(f"Failed to log sample metrics batch: {e}") + + # Log resource_id β†’ step mapping table as artifact. + 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_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}") + + _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: + 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() + self.enable_sample_logging(False) + + # ------------------------------------------------------------------ + # 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 new file mode 100644 index 00000000..e211ec9b --- /dev/null +++ b/datamint/lightning/trainers/lightning_modules/classification_module.py @@ -0,0 +1,181 @@ +"""LightningModule wrapper for image classification tasks.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import torch +from torch import Tensor, nn +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: + 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]], + 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 + + 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 _compute_unreduced_loss(self, logits: Tensor, labels: Tensor) -> Tensor: + """Compute per-sample loss without reduction.""" + 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: + 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) + + 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: + 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 _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}') + 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') + super().on_test_epoch_end() + + def configure_optimizers(self): + return torch.optim.AdamW( + self.parameters(), + 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)) + # 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, + 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 new file mode 100644 index 00000000..e8976122 --- /dev/null +++ b/datamint/lightning/trainers/lightning_modules/segmentation_module.py @@ -0,0 +1,230 @@ +"""LightningModule wrapper for segmentation tasks.""" +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Callable +from typing import Any +import inspect +import warnings + +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. + + Args: + 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, + 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 + 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 + + self.model = self._build_model() + 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()) + + @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: + images = batch['image'] + masks = batch['segmentations'][:, 1:] # exclude background channel + + logits = self(images) + + 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: + 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 _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] + + 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 + + # 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}') + 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') + super().on_test_epoch_end() + + def configure_optimizers(self): + return torch.optim.AdamW( + self.parameters(), + 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)) + # 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, + confiability=class_conf, + )) + 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 new file mode 100644 index 00000000..00bdbea2 --- /dev/null +++ b/datamint/lightning/trainers/seg2d_trainer.py @@ -0,0 +1,137 @@ +"""2-D semantic segmentation trainer.""" +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, SlicedVolumeDataset + +from .lightning_modules import UNetPPModule +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: + 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, + *, + in_channels: int = 3, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self.in_channels = in_channels + + # ── 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, + semantic_seg_merge_strategy='union', + allow_external_annotations=True, + include_unannotated=False, + ) + + def _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 _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(), + ]) + + +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='resnet34',) + results = trainer.fit() + """ + + 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 + + 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(), + ]) + + 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, + ) diff --git a/datamint/lightning/trainers/seg3d_trainer.py b/datamint/lightning/trainers/seg3d_trainer.py new file mode 100644 index 00000000..299b7b3e --- /dev/null +++ b/datamint/lightning/trainers/seg3d_trainer.py @@ -0,0 +1,103 @@ +"""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_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_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, + class_names=list(self.dataset.seglabel_list), + image_size=self.image_size, + ) + + def _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 _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..84a1e421 --- /dev/null +++ b/datamint/lightning/trainers/segmentation_trainer.py @@ -0,0 +1,58 @@ +"""Shared base for segmentation trainers (2-D and 3-D).""" +from __future__ import annotations + +from collections.abc import Callable + +from functools import partial +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, reduction: str = 'mean') -> torch.Tensor: + target = target.float() + 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) + 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 _loss(self) -> nn.Module: + return _BCEDiceLoss() + + def _metrics(self) -> dict[str, Callable]: + from torchmetrics.segmentation import GeneralizedDiceScore, MeanIoU + + num_classes = len(self.dataset.seglabel_list) + return { + '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]: + return 'val/iou', 'max' diff --git a/datamint/mlflow/__init__.py b/datamint/mlflow/__init__.py index 9b9b33a6..7f15a6c3 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,24 +87,31 @@ 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 .flavors.model import BaseDatamintModel, 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_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: + _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( __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/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 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 26ddc055..23ee4887 100644 --- a/datamint/mlflow/flavors/datamint_flavor.py +++ b/datamint/mlflow/flavors/datamint_flavor.py @@ -1,19 +1,27 @@ +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 .task_type import TaskType 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,32 +32,29 @@ 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 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", } @@ -60,16 +65,47 @@ 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, + task_type: TaskType | str | None = None, supported_modes: Sequence[str] | None = None, data_path=None, code_paths=None, @@ -85,62 +121,53 @@ def save_model(datamint_model: DatamintModel, model_config=None, streamable=None, **kwargs): - import medimgkit + 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) 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() - - 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 + pip_requirements, extra_pip_requirements = _resolve_requirements(pip_requirements, extra_pip_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) + 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 {} + 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) - return mlflow.pyfunc.save_model( + 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 +181,24 @@ 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} + + 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) + def log_model( - datamint_model: DatamintModel, + datamint_model: BaseDatamintModel, + task_type: TaskType | str | None = None, supported_modes: Sequence[str] | None = None, name: str = "datamint_model", data_path=None, @@ -175,10 +217,10 @@ 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, - # loader_module=loader_module, data_path=data_path, code_paths=code_paths, artifacts=artifacts, @@ -195,14 +237,34 @@ 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 + + # 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 cc9f33a5..186ca1b4 100644 --- a/datamint/mlflow/flavors/model.py +++ b/datamint/mlflow/flavors/model.py @@ -5,19 +5,21 @@ annotation system. It supports various prediction modes for different data types and use cases. """ -from typing import Any, TypeAlias -from collections.abc import Callable -from abc import ABC, abstractmethod -from enum import Enum +from typing import Any, ClassVar, TypeAlias +from abc import ABC 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_modes import PredictionMode +from datamint.mlflow.flavors.task_type import TaskType +from datamint.mlflow.flavors.prediction_router import PredictionRouter import logging -import os +from functools import cached_property +import torch +from mlflow.pytorch import pickle_module as mlflow_pytorch_pickle_module logger = logging.getLogger(__name__) @@ -40,839 +42,324 @@ 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. - 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 - - -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. - """ + Owns: - LINKED_MODELS_DIR = "linked_models" - _CACHED_ATTRS = ['_mlflow_models', '_mlflow_torch_models', '_inference_device'] + * :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. - 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']`` + Use :class:`DatamintModel` when you need to load linked models at serve time. - """ - 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 - if isinstance(settings, dict): - self.settings = ModelSettings.from_dict(settings) - elif isinstance(settings, ModelSettings): - self.settings = settings - else: - self.settings = ModelSettings() + Subclasses only need to implement :meth:`predict_default` (and optionally + other ``predict_*`` hooks registered with ``@prediction_mode``). + """ - self._supported_modes_cache = None + 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 load_context(self, context: PythonModelContext): - """ - Called by MLflow when loading the model. + def __init__( + self, + settings: ModelSettings | dict[str, Any] | None = None, + ) -> None: + self.settings = settings + self._inference_device: str | None = None - 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 + @cached_property + def _router(self) -> PredictionRouter: + """The PredictionRouter instance responsible for dispatching predict calls.""" + return PredictionRouter(self, BaseDatamintModel) - 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] + @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() - return state + # ------------------------------------------------------------------ + # Device management + # ------------------------------------------------------------------ - def __setstate__(self, state): - self.__dict__.update(state) - # avoid possible invalid states after unpickling - self._clear_linked_models_cache() + @property + def inference_device(self) -> str: + """The device that will be used for inference. - def _load_inference_device(self, context: PythonModelContext | None = None) -> str: - """ - Load inference device from model config or environment variable. + Returns ``_inference_device`` if already set, then falls back to the + ``MLFLOW_DEFAULT_PREDICTION_DEVICE`` environment variable, then ``'cpu'``. """ - import torch + 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(f"Model config device: {device}") + 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' + device = "cuda" else: - device = 'cpu' + device = "cpu" - logger.info(f"Set inference device: {device}") + logger.info("Set inference device: %s", device) + self._inference_device = device return 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(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} - ) + # ------------------------------------------------------------------ + # MLflow lifecycle + # ------------------------------------------------------------------ - 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 + def load_context(self, context: PythonModelContext) -> None: + """Detect the inference device. - def get_mlflow_models(self) -> dict[str, PyFuncModel]: + 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. """ - Access loaded MLflow models. + logger.info("Loading model context %s and detecting device...", + f'{context.artifacts=} | {context.model_config=}') + self._detect_device(context) - 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 + # ------------------------------------------------------------------ + # Serialization + # ------------------------------------------------------------------ - def get_mlflow_torch_models(self) -> dict[str, Any]: - """ - Access loaded MLflow PyTorch models. + def __getstate__(self) -> dict: + state = self.__dict__.copy() + state.pop("_router", None) + return state - 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. + 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. - Returns: - Tuple of (mode_specific_params, common_params) + Routes to the appropriate handler based on ``params['mode']``. + **Do not override** β€” implement :meth:`predict_default` (or other + ``predict_*`` hooks) instead. """ - # 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 self._router.dispatch(model_input, params or {}) - return mode_specific, common + def get_supported_modes(self) -> list[str]: + """Return the list of prediction modes supported by this model.""" + return self._router.supported_modes() - 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 + def predict_default( + self, + model_input: list[Resource], + **kwargs: Any, + ) -> PredictionResult: + """Default prediction on entire resources. - def get_supported_modes(self) -> list[str]: + Override this in your subclass. """ - Get list of prediction modes supported by this model. + raise NotImplementedError( + "predict_default() must be implemented in your DatamintModel subclass." + ) - 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. +class DatamintModel(BaseDatamintModel): + """Abstract adapter for wrapping ML models to produce Datamint annotations. - Args: - model_input: Resources to process - **kwargs: Additional user-defined parameters + 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). - Returns: - List of annotation lists, one per resource + Quick Start:: + + class MyModel(DatamintModel): + def __init__(self): + super().__init__( + mlflow_models_uri={'model': 'models:/MyModel/latest'}, + settings=ModelSettings(need_gpu=True), + ) - Example: - ```python def predict_default(self, model_input, **kwargs): - dataset = MyDataset(model_input) - dataloader = DataLoader(dataset) - model = self.mlflow_models['model'].get_raw_model() + device = self.inference_device + model = self.get_mlflow_models()['model'].get_raw_model().to(device) + return predictions - predictions = [] - for batch in dataloader: - outputs = model(batch) - predictions.extend(self._outputs_to_annotations(outputs)) + You can also pass pre-instantiated ``torch.nn.Module`` objects directly:: - return predictions - ``` - """ - raise NotImplementedError( - "predict_default() must be implemented in your DatamintModel subclass. " - "This is the default fallback mode for prediction." + 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__(settings=settings) + self._loader = LinkedModelLoader( + mlflow_models_uri=mlflow_models_uri, + mlflow_torch_models_uri=mlflow_torch_models_uri, + torch_model=torch_model, ) - # ======================================================================== - # VIDEO/TEMPORAL MODES - # ======================================================================== + @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 β€” overrides base to also load linked models + # ------------------------------------------------------------------ + + def load_context(self, context: PythonModelContext) -> None: + """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() + + # ------------------------------------------------------------------ + # Linked-model access + # ------------------------------------------------------------------ - 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 get_mlflow_models(self) -> dict[str, PyFuncModel]: + """Access loaded MLflow pyfunc models.""" + return self._loader.mlflow_models - 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 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 + # ------------------------------------------------------------------ - 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 + @property + def mlflow_models_uri(self) -> dict[str, str]: + return self._loader.mlflow_models_uri - 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) + @mlflow_models_uri.setter + def mlflow_models_uri(self, value: dict[str, str]) -> None: + self._loader.mlflow_models_uri = value - def predict_volume(self, - model_input: list[Resource], - **kwargs) -> PredictionResult: - """ - Process entire 3D volume. + @property + def mlflow_torch_models_uri(self) -> dict[str, str]: + return self._loader.mlflow_torch_models_uri - For true 3D models (not slice-by-slice). + @mlflow_torch_models_uri.setter + def mlflow_torch_models_uri(self, value: dict[str, str]) -> None: + self._loader.mlflow_torch_models_uri = value - Args: - model_input: 3D volume resources + def _get_linked_models_uri(self) -> dict[str, Any]: + return self._loader.get_all_uris() - 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 _clear_linked_models_cache(self) -> None: + self._loader.clear_cache() - # ======================================================================== - # ADVANCED MODES - # ======================================================================== + def _clear_ptmodel(self) -> None: + self._loader._torch_model_instance = None - 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) + # ------------------------------------------------------------------ + # Serialization β€” also clears loader cache on restore + # ------------------------------------------------------------------ - def predict_image(self, - model_input: list[Resource], - **kwargs) -> PredictionResult: - """ - Process single 2D image resources. + def __setstate__(self, state: dict) -> None: + super().__setstate__(state) + self._loader.clear_cache() - 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) +class _DatamintModelWrapper(BaseDatamintModel): + 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.""" + return PredictionRouter(self.another_model, type(self.another_model)) + + 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 new file mode 100644 index 00000000..05a37eac --- /dev/null +++ b/datamint/mlflow/flavors/model_loader.py @@ -0,0 +1,127 @@ +""" +Extracted model lifecycle management for DatamintModel. + +Owns URI resolution, lazy model loading, cache lifecycle, +and serialization β€” all previously interleaved in the monolithic DatamintModel. +""" + +import logging +import os +from collections.abc import Callable +from typing import Any + +from mlflow.pyfunc import PyFuncModel +from mlflow.pyfunc import load_model as pyfunc_load_model +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", "_device"}) + + +class LinkedModelLoader: + """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() + self._torch_model_instance: torch.nn.Module | None = torch_model + + # --- Loading ---------------------------------------------------------- + + 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): + 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, device: str) -> dict[str, PyFuncModel]: + return self._load_generic( + self.mlflow_models_uri, + pyfunc_load_model, + model_config={"device": device}, + ) + + def _load_torch_models(self, device: str) -> dict[str, Any]: + models = self._load_generic( + self.mlflow_torch_models_uri, + pytorch_load_model, + device=device, + map_location=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(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(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 ------------------------------------------------ + + 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) 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 new file mode 100644 index 00000000..b6d5d4a1 --- /dev/null +++ b/datamint/mlflow/flavors/prediction_router.py @@ -0,0 +1,213 @@ +""" +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 Any +from .prediction_modes 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 = None) -> None: + self._registry = self.discover(model_instance, base_class) + + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + + @staticmethod + def discover(model: Any, base_class: type | None) -> dict[PredictionMode, tuple[Callable, ModeSpec]]: + """Build the mode -> handler registry from the model instance.""" + 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 registry: + continue + method_name = f"predict_{mode.value}" + method = getattr(model, method_name, None) + if method is None: + continue + # Skip if the method is the unoverridden base-class stub + 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]: + 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) -> 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: 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" + 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 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 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 ddd63cdc..88666206 100644 --- a/datamint/mlflow/lightning/callbacks/modelcheckpoint.py +++ b/datamint/mlflow/lightning/callbacks/modelcheckpoint.py @@ -1,8 +1,9 @@ +from collections.abc import Mapping from lightning.pytorch.callbacks import ModelCheckpoint 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,29 +12,44 @@ import mlflow.models import mlflow.exceptions 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: + from datamint.mlflow.flavors.model import BaseDatamintModel + from mlflow.models.model import ModelInfo + _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: v.detach().cpu().numpy() if isinstance(v, torch.Tensor) else v for k, v in x.items()} + elif isinstance(x, Mapping): + return {k: _prepare_signature_sample(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] + return [_prepare_signature_sample(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 tuple(_prepare_signature_sample(v) for v in 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 +60,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) @@ -83,12 +102,79 @@ 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" + 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() + 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. @@ -133,46 +219,8 @@ 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: + 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: @@ -209,48 +265,16 @@ 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, - 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 - ) - - 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 + model: 'nn.Module | L.LightningModule | BaseDatamintModel', + run_id: str | MLFlowLogger) -> None: + """Log the model to MLflow using the appropriate flavor. - # 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: + self._wait_for_pending_logging() super()._remove_checkpoint(trainer, filepath) # remove the checkpoint from mlflow if trainer.is_global_zero: @@ -262,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 @@ -281,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 @@ -297,36 +323,50 @@ 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 _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 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) + 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 - output_sig = mlflow.models.infer_signature(model_output=help_infer_signature(out)) - self._inferred_signature.outputs = output_sig.outputs + 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) - return out + self._inject_model_id(model) + _LOGGER.debug("Finalized logged model with ID %s and URI %s", self._last_model_id, self._last_model_uri) - pl_module.forward = wrapped_forward + def _wrap_forward(self, pl_module: nn.Module) -> None: + """Intercept the first forward call to infer the MLflow model signature. + + Override in subclasses to customize signature inference. + """ + pass 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. " @@ -337,13 +377,11 @@ 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) - 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) @@ -369,13 +407,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 @@ -387,12 +426,14 @@ 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._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.__wrap_forward(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) @@ -403,10 +444,17 @@ 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 + 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_")): @@ -420,8 +468,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) + 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}") @@ -449,3 +505,160 @@ 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_forward_defaults(self, model: nn.Module) -> dict[str, Any] | None: + """Extract default values for forward() params beyond the first input. + + Returns: + dict of {name: default} or None if no extra params exist. + """ + forward_method = getattr(model.__class__, 'forward', None) + if forward_method is None: + return None + + try: + sig = inspect.signature(forward_method) + 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 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=_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=_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): + """Log the model to MLflow using the pytorch flavor.""" + 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 not self._last_checkpoint_saved: + _LOGGER.warning("No checkpoint saved yet. Cannot log model to MLFlow.") + return + + 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.info("Logging model using pytorch flavor with name %s", Path(self._last_checkpoint_saved).stem) + modelinfo = mlflow.pytorch.log_model( + pytorch_model=model_to_log, + name=Path(self._last_checkpoint_saved).stem, + signature=self._inferred_signature, + run_id=run_id, + extra_pip_requirements=self._build_requirements(), + code_paths=self.code_paths, + ) + + if model_to_log is not model: + del model_to_log + self._finalize_logged_model(modelinfo, run_id, model=model) + + +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 log_model_to_mlflow(self, + model: 'nn.Module | L.LightningModule | BaseDatamintModel', + run_id: str | MLFlowLogger) -> None: + """Log the model to MLflow using the datamint flavor.""" + 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 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.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, + signature=self._inferred_signature, + run_id=run_id, + extra_pip_requirements=self._build_requirements(), + code_paths=self.code_paths, + ) + + self._finalize_logged_model(modelinfo, run_id, model=model) + + 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 +MLFlowModelCheckpoint = MLFlowPyTorchModelCheckpoint 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 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/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 -} 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 +} 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 433a89ae..00000000 --- a/notebooks/use_cases/segmentation_2d-unetpp_BUSI_tutorial.ipynb +++ /dev/null @@ -1,1440 +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": "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", - "\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.5 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.6 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. 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" - ] - }, - { - "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", - "# 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(0,1), # Normalize to [0, 1] by dividing by 255\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", - " ToTensorV2(),\n", - "])" - ] - }, - { - "cell_type": "markdown", - "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" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1d15a7a6", - "metadata": {}, - "outputs": [], - "source": [ - "import torch\n", - "from torch.utils.data import Dataset\n", - "from collections import defaultdict\n", - "import numpy as np\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", - "\n", - " return res" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8ef95ef", - "metadata": {}, - "outputs": [], - "source": [ - "# Test Dataset\n", - "\n", - "train_dataset = MedicalSegmentationDataset(\n", - " split='train',\n", - " transforms=train_transforms,\n", - ")\n", - "\n", - "train_dataset[0].keys() # Fetch first sample to test" - ] - }, - { - "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", - "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", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a6352de7", - "metadata": {}, - "outputs": [], - "source": [ - "# Visualize a sample batch\n", - "from datamint.utils.visualization import show, draw_masks\n", - "\n", - "\n", - "sample_batch = next(iter(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", - "\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", - " 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", - "from torchmetrics.segmentation import DiceScore\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 __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", - " Returns:\n", - " Combined 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", - "\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", - "\n", - "loss_fn = CombinedLoss(num_classes=NUM_CLASSES+1)\n", - "loss = loss_fn(dummy_pred, dummy_target)\n", - "print(f\"Test loss value: {loss.item():.4f}\")" - ] - }, - { - "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 (including 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", - " 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", - " 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", - " }\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", - " }\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 'mask' tensors\n", - " stage: One of 'train', 'val', 'test'\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", - " # Forward pass\n", - " logits = self(images) # (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", - " # 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", - " 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 = torch.argmax(logits, dim=1)\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", - " 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", - " }" - ] - }, - { - "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 = train_dataloader.dataset[-1]['mask'] # (1, H, W)\n", - " sample_loss = model.criterion(sample_output, sample_target.unsqueeze(0))\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", - " train_dataloaders=train_dataloader,\n", - " val_dataloaders=val_dataloader,\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=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": [ - "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, 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", - "\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", - " print(f\"IoU: {iou:.1%}\")\n", - "\n", - "visualize_predictions(model, test_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.12" - } - }, - "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..e334fb33 --- /dev/null +++ b/notebooks/use_cases/segmentation_2d_trainer_BUSI_tutorial.ipynb @@ -0,0 +1,747 @@ +{ + "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", + "\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", + " # register_model_name='MyModelName' # Default is project name\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": "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": "2cdadcfb", + "metadata": {}, + "source": [ + "You can retrieve the trained model from the trainer at `trainer.model`." + ] + }, + { + "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 +} diff --git a/pyproject.toml b/pyproject.toml index f18c63ee..68b31f02 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.8" +version = "2.12.0a2" dynamic = ["dependencies"] requires-python = ">=3.10" readme = "README.md" @@ -37,8 +37,10 @@ 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" } -mlflow = ">=3.8.1" # version 2 has security issues +lightning = { extras = ['extra'], version = ">=2.5.6" } +mlflow-skinny = "==3.8.1" +Flask = { version = "<4" } +Flask-Cors = { version = "<7" } albumentations = ">=2.0.0" lazy-loader = ">=0.3.0" medimgkit = ">=0.14.7" @@ -46,6 +48,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.*" 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