diff --git a/README.md b/README.md index 506a1f51..438c2493 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,13 @@ import os os.environ["DATAMINT_API_KEY"] = "my_api_key" ``` -### Method 3: APIHandler constructor +### Method 3: Api constructor -Specify API key in the |APIHandlerClass| constructor: +Specify API key in the Api constructor: ```python -from datamint import APIHandler -api = APIHandler(api_key='my_api_key') +from datamint import Api +api = Api(api_key='my_api_key') ``` ## Tutorials @@ -64,8 +64,9 @@ You can find example notebooks in the `notebooks` folder: and example scripts in [examples](examples) folder: -- [Running an experiment for classification](examples/experiment_traintest_classifier.py) -- [Running an experiment for segmentation](examples/experiment_traintest_segmentation.py) +- [API usage examples](examples/api_usage.ipynb) +- [Project and entity usage](examples/project_entity_usage.ipynb) +- [Channels example](examples/channels_example.ipynb) ## Full documentation diff --git a/datamint/__init__.py b/datamint/__init__.py index 941c8b93..eb77e435 100644 --- a/datamint/__init__.py +++ b/datamint/__init__.py @@ -8,6 +8,7 @@ from .dataset.dataset import DatamintDataset as Dataset from .apihandler.api_handler import APIHandler from .experiment import Experiment + from .api.client import Api else: import lazy_loader as lazy @@ -19,6 +20,7 @@ "dataset": ['Dataset'], "apihandler.api_handler": ["APIHandler"], "experiment": ["Experiment"], + "api.client": ["Api"], }, ) diff --git a/datamint/api/__init__.py b/datamint/api/__init__.py new file mode 100644 index 00000000..5d93a1de --- /dev/null +++ b/datamint/api/__init__.py @@ -0,0 +1,3 @@ +from .client import Api + +__all__ = ['Api'] diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py new file mode 100644 index 00000000..810b6953 --- /dev/null +++ b/datamint/api/base_api.py @@ -0,0 +1,430 @@ +import logging +from typing import Any, Generator, AsyncGenerator, Sequence +import httpx +from dataclasses import dataclass +from datamint.exceptions import DatamintException, ResourceNotFoundError +import aiohttp +import json +import pydicom.dataset +from PIL import Image +import cv2 +import nibabel as nib +from nibabel.filebasedimages import FileBasedImage as nib_FileBasedImage +from io import BytesIO +import gzip +import contextlib +import asyncio + +logger = logging.getLogger(__name__) + +# Generic type for entities +_PAGE_LIMIT = 5000 + + +@dataclass +class ApiConfig: + """Configuration for API client. + + Attributes: + server_url: Base URL for the API. + api_key: Optional API key for authentication. + timeout: Request timeout in seconds. + max_retries: Maximum number of retries for requests. + """ + server_url: str + api_key: str | None = None + timeout: float = 30.0 + max_retries: int = 3 + + +class BaseApi: + """Base class for all API endpoint handlers.""" + + def __init__(self, + config: ApiConfig, + client: httpx.Client | None = None) -> None: + """Initialize the base API handler. + + Args: + config: API configuration containing base URL, API key, etc. + client: Optional HTTP client instance. If None, a new one will be created. + """ + self.config = config + self.client = client or self._create_client() + self.semaphore = asyncio.Semaphore(20) + + def _create_client(self) -> httpx.Client: + """Create and configure HTTP client with authentication and timeouts.""" + headers = None + if self.config.api_key: + headers = {"apikey": self.config.api_key} + + return httpx.Client( + base_url=self.config.server_url, + headers=headers, + timeout=self.config.timeout + ) + + def _stream_request(self, method: str, endpoint: str, **kwargs): + """Make streaming HTTP request with error handling. + + Args: + method: HTTP method (GET, POST, PUT, DELETE) + endpoint: API endpoint path + **kwargs: Additional arguments for the request + + Returns: + HTTP response object configured for streaming + + Raises: + httpx.HTTPStatusError: If the request fails + + Example: + with api._stream_request('GET', '/large-file') as response: + for chunk in response.iter_bytes(): + process_chunk(chunk) + """ + url = endpoint.lstrip('/') # Remove leading slash for httpx + + try: + return self.client.stream(method, url, **kwargs) + except httpx.RequestError as e: + logger.error(f"Request error for streaming {method} {endpoint}: {e}") + raise + + def _make_request(self, method: str, endpoint: str, **kwargs) -> httpx.Response: + """Make HTTP request with error handling and retries. + + Args: + method: HTTP method (GET, POST, PUT, DELETE) + endpoint: API endpoint path + **kwargs: Additional arguments for the request + + Returns: + HTTP response object + + Raises: + httpx.HTTPStatusError: If the request fails + """ + url = endpoint.lstrip('/') # Remove leading slash for httpx + + try: + curl_command = self._generate_curl_command({"method": method, + "url": url, + "headers": self.client.headers, + **kwargs}, fail_silently=True) + logger.debug(f'Equivalent curl command: "{curl_command}"') + response = self.client.request(method, url, **kwargs) + response.raise_for_status() + return response + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error {e.response.status_code} for {method} {endpoint}: {e.response.text}") + raise + except httpx.RequestError as e: + logger.error(f"Request error for {method} {endpoint}: {e}") + raise + + def _generate_curl_command(self, + request_args: dict, + fail_silently: bool = False) -> str: + """ + Generate a curl command for debugging purposes. + + Args: + request_args (dict): Request arguments dictionary containing method, url, headers, etc. + + Returns: + str: Equivalent curl command + """ + try: + method = request_args.get('method', 'GET').upper() + url = request_args['url'] + headers = request_args.get('headers', {}) + data = request_args.get('json') or request_args.get('data') + params = request_args.get('params') + + curl_command = ['curl'] + + # Add method if not GET + if method != 'GET': + curl_command.extend(['-X', method]) + + # Add headers + for key, value in headers.items(): + if key.lower() == 'apikey': + value = '' # Mask API key for security + curl_command.extend(['-H', f"'{key}: {value}'"]) + + # Add query parameters + if params: + param_str = '&'.join([f"{k}={v}" for k, v in params.items()]) + url = f"{url}?{param_str}" + # Add URL + curl_command.append(f"'{url}'") + + # Add data + if data: + if isinstance(data, aiohttp.FormData): # Check if it's aiohttp.FormData + # Handle FormData by extracting fields + form_parts = [] + for options, headers, value in data._fields: + # get the name from options + name = options.get('name', 'file') + if hasattr(value, 'read'): # File-like object + filename = getattr(value, 'name', 'file') + form_parts.extend(['-F', f"'{name}=@{filename}'"]) + else: + form_parts.extend(['-F', f"'{name}={value}'"]) + curl_command.extend(form_parts) + elif isinstance(data, dict): + curl_command.extend(['-d', f"'{json.dumps(data)}'"]) + else: + curl_command.extend(['-d', f"'{data}'"]) + + return ' '.join(curl_command) + except Exception as e: + if fail_silently: + logger.debug(f"Error generating curl command: {e}") + return "" + raise + + @staticmethod + def get_status_code(e: httpx.HTTPStatusError | aiohttp.ClientResponseError) -> int: + if hasattr(e, 'response') and e.response is not None: + # httpx.HTTPStatusError + return e.response.status_code + if hasattr(e, 'status'): + # aiohttp.ClientResponseError + return e.status + if hasattr(e, 'status_code'): + return e.status_code + logger.debug(f"Unable to get status code from exception of type {type(e)}") + return -1 + + @staticmethod + def _has_status_code(e: httpx.HTTPError | aiohttp.ClientResponseError, + status_code: int) -> bool: + return BaseApi.get_status_code(e) == status_code + + def _check_errors_response(self, + response: httpx.Response | aiohttp.ClientResponse, + url: str): + try: + response.raise_for_status() + except (httpx.HTTPStatusError, aiohttp.ClientResponseError) as e: + logger.error(f"HTTP error occurred: {e}") + status_code = BaseApi.get_status_code(e) + if status_code >= 500 and status_code < 600: + logger.error(f"Error in request to {url}: {e}") + if status_code >= 400 and status_code < 500: + if isinstance(e, aiohttp.ClientResponseError): + # aiohttp.ClientResponse does not have .text or .json() methods directly + error_msg = e.message + else: + error_msg = e.response.text + logger.info(f"Error response: {error_msg}") + if ' not found' in error_msg.lower(): + # Will be caught by the caller and properly initialized: + raise ResourceNotFoundError('unknown', {}) + raise + + @contextlib.asynccontextmanager + async def _make_request_async(self, + method: str, + endpoint: str, + session: aiohttp.ClientSession | None = None, + **kwargs) -> AsyncGenerator[aiohttp.ClientResponse, None]: + """Make asynchronous HTTP request with error handling as an async context manager. + + Args: + method: HTTP method (GET, POST, PUT, DELETE) + endpoint: API endpoint path + session: Optional aiohttp session. If None, a new one will be created. + **kwargs: Additional arguments for the request + + Yields: + An aiohttp.ClientResponse object. + + Raises: + aiohttp.ClientError: If the request fails + + Example: + .. code-block:: python + + async with api._make_request_async('GET', '/data') as response: + data = await response.json() + """ + + if session is None: + async with aiohttp.ClientSession() as temp_session: + async with self._make_request_async(method, endpoint, temp_session, **kwargs) as resp: + yield resp + return + + url = f"{self.config.server_url.rstrip('/')}/{endpoint.lstrip('/')}" + + headers = kwargs.pop('headers', {}) + if self.config.api_key: + headers['apikey'] = self.config.api_key + + timeout = aiohttp.ClientTimeout(total=self.config.timeout) + + response = None + curl_cmd = self._generate_curl_command( + {"method": method, "url": url, "headers": headers, **kwargs}, + fail_silently=True + ) + logger.debug(f'Equivalent curl command: "{curl_cmd}"') + async with self.semaphore: + try: + response = await session.request( + method=method, + url=url, + headers=headers, + timeout=timeout, + **kwargs + ) + self._check_errors_response(response, url=url) + yield response + except aiohttp.ClientError as e: + logger.error(f"Request error for {method} {endpoint}: {e}") + raise + finally: + if response is not None: + response.release() + + async def _make_request_async_json(self, + method: str, + endpoint: str, + session: aiohttp.ClientSession | None = None, + **kwargs): + """Make asynchronous HTTP request and parse JSON response. + + Args: + method: HTTP method (GET, POST, etc.) + endpoint: API endpoint path + session: Optional aiohttp session. If None, a new one will be created. + **kwargs: Additional arguments for the request + + Returns: + Parsed JSON response or error information. + """ + async with self._make_request_async(method, endpoint, session=session, **kwargs) as resp: + return await resp.json() + + def _make_request_with_pagination(self, + method: str, + endpoint: str, + return_field: str | None = None, + limit: int | None = None, + **kwargs + ) -> Generator[tuple[httpx.Response, list | dict | str], None, None]: + """Make paginated HTTP requests, yielding each page of results. + + Args: + method: HTTP method (GET, POST, etc.) + endpoint: API endpoint path + return_field: Optional field name to extract from each item in the response + limit: Optional maximum number of items to retrieve + **kwargs: Additional arguments for the request (e.g., params, json) + + Yields: + Tuples of (HTTP response, items from the current page `response.json()`, for convenience) + """ + offset = 0 + total_fetched = 0 + params = dict(kwargs.get('params', {})) + # Ensure kwargs carries our params reference so mutations below take effect + kwargs['params'] = params + + while True: + if limit is not None and total_fetched >= limit: + break + + page_limit = _PAGE_LIMIT + if limit is not None: + remaining = limit - total_fetched + page_limit = min(_PAGE_LIMIT, remaining) + + params['offset'] = offset + params['limit'] = page_limit + + response = self._make_request(method=method, + endpoint=endpoint, + **kwargs) + items = self._convert_array_response(response.json(), return_field=return_field) + + if not items: + break + + items_to_yield = items + if limit is not None: + # This ensures we don't yield more than the limit if the API returns more than requested in the last page + items_to_yield = items[:limit - total_fetched] + + yield response, items_to_yield + total_fetched += len(items_to_yield) + + if len(items) < _PAGE_LIMIT: + break + + offset += len(items) + + def _convert_array_response(self, + data: dict | list, + return_field: str | None = None) -> list | dict | str: + """Normalize array-like responses into a list when possible. + + Args: + data: Parsed JSON response. + return_field: Preferred top-level field to extract when present. + + Returns: + A list of items when identifiable, otherwise the original data. + """ + if isinstance(data, list): + items = data + else: + if 'data' in data: + items = data['data'] + elif 'items' in data: + items = data['items'] + else: + return data + if return_field is not None: + if 'totalCount' in data and len(items) == 1 and return_field in items[0]: + items = items[0][return_field] + return items + + @staticmethod + def convert_format(bytes_array: bytes, + mimetype: str, + file_path: str | None = None + ) -> pydicom.dataset.Dataset | Image.Image | cv2.VideoCapture | bytes | nib_FileBasedImage: + """ Convert the bytes array to the appropriate format based on the mimetype.""" + content_io = BytesIO(bytes_array) + if mimetype.endswith('/dicom'): + return pydicom.dcmread(content_io) + elif mimetype.startswith('image/'): + return Image.open(content_io) + elif mimetype.startswith('video/'): + if file_path is None: + raise NotImplementedError("file_path=None is not implemented yet for video/* mimetypes.") + return cv2.VideoCapture(file_path) + elif mimetype == 'application/json': + return json.loads(bytes_array) + elif mimetype == 'application/octet-stream': + return bytes_array + elif mimetype.endswith('nifti'): + try: + return nib.Nifti1Image.from_stream(content_io) + except Exception as e: + if file_path is not None: + return nib.load(file_path) + raise e + elif mimetype == 'application/gzip': + # let's hope it's a .nii.gz + with gzip.open(content_io, 'rb') as f: + return nib.Nifti1Image.from_stream(f) + + raise ValueError(f"Unsupported mimetype: {mimetype}") diff --git a/datamint/api/client.py b/datamint/api/client.py new file mode 100644 index 00000000..76676f66 --- /dev/null +++ b/datamint/api/client.py @@ -0,0 +1,91 @@ +from typing import Optional +import httpx +from .base_api import ApiConfig +from .endpoints import ProjectsApi, ResourcesApi, AnnotationsApi, ChannelsApi, UsersApi, DatasetsInfoApi +import datamint.configs +from datamint.exceptions import DatamintException +import asyncio + + +class Api: + """Main API client that provides access to all endpoint handlers.""" + DEFAULT_SERVER_URL = 'https://api.datamint.io' + DATAMINT_API_VENV_NAME = datamint.configs.ENV_VARS[datamint.configs.APIKEY_KEY] + + _API_MAP = { + 'projects': ProjectsApi, + 'resources': ResourcesApi, + 'annotations': AnnotationsApi, + 'channels': ChannelsApi, + 'users': UsersApi, + 'datasets': DatasetsInfoApi + } + + def __init__(self, + server_url: str | None = None, + api_key: Optional[str] = None, + timeout: float = 60.0, max_retries: int = 2, + check_connection: bool = True) -> None: + """Initialize the API client. + + Args: + base_url: Base URL for the API + api_key: Optional API key for authentication + timeout: Request timeout in seconds + max_retries: Maximum number of retry attempts + client: Optional HTTP client instance + """ + if server_url is None: + server_url = datamint.configs.get_value(datamint.configs.APIURL_KEY) + if server_url is None: + server_url = Api.DEFAULT_SERVER_URL + server_url = server_url.rstrip('/') + if api_key is None: + api_key = datamint.configs.get_value(datamint.configs.APIKEY_KEY) + if api_key is None: + msg = f"API key not provided! Use the environment variable " + \ + f"{Api.DATAMINT_API_VENV_NAME} or pass it as an argument." + raise DatamintException(msg) + self.config = ApiConfig( + server_url=server_url, + api_key=api_key, + timeout=timeout, + max_retries=max_retries + ) + self._client = None + self._endpoints = {} + if check_connection: + self.check_connection() + + def check_connection(self): + try: + self.projects.get_list(limit=1) + except Exception as e: + raise DatamintException("Error connecting to the Datamint API." + + f" Please check your api_key and/or other configurations. {e}") + + def _get_endpoint(self, name: str): + if name not in self._endpoints: + api_class = self._API_MAP[name] + self._endpoints[name] = api_class(self.config, self._client) + return self._endpoints[name] + + @property + def projects(self) -> ProjectsApi: + return self._get_endpoint('projects') + @property + def resources(self) -> ResourcesApi: + return self._get_endpoint('resources') + @property + def annotations(self) -> AnnotationsApi: + return self._get_endpoint('annotations') + @property + def channels(self) -> ChannelsApi: + return self._get_endpoint('channels') + @property + def users(self) -> UsersApi: + return self._get_endpoint('users') + @property + def _datasetsinfo(self) -> DatasetsInfoApi: + """Internal property to access DatasetsInfoApi.""" + return self._get_endpoint('datasets') \ No newline at end of file diff --git a/datamint/api/dto/__init__.py b/datamint/api/dto/__init__.py new file mode 100644 index 00000000..b789a550 --- /dev/null +++ b/datamint/api/dto/__init__.py @@ -0,0 +1,10 @@ +from datamint.apihandler.dto import annotation_dto +from datamint.apihandler.dto.annotation_dto import AnnotationType, CreateAnnotationDto, Geometry, BoxGeometry + +__all__ = [ + "annotation_dto", + "AnnotationType", + "CreateAnnotationDto", + "Geometry", + "BoxGeometry", +] \ No newline at end of file diff --git a/datamint/api/endpoints/__init__.py b/datamint/api/endpoints/__init__.py new file mode 100644 index 00000000..b19d79f9 --- /dev/null +++ b/datamint/api/endpoints/__init__.py @@ -0,0 +1,17 @@ +"""API endpoint handlers.""" + +from .annotations_api import AnnotationsApi +from .channels_api import ChannelsApi +from .projects_api import ProjectsApi +from .resources_api import ResourcesApi +from .users_api import UsersApi +from .datasetsinfo_api import DatasetsInfoApi + +__all__ = [ + 'AnnotationsApi', + 'ChannelsApi', + 'ProjectsApi', + 'ResourcesApi', + 'UsersApi', + 'DatasetsInfoApi' +] diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py new file mode 100644 index 00000000..8c1ea4f8 --- /dev/null +++ b/datamint/api/endpoints/annotations_api.py @@ -0,0 +1,984 @@ +from typing import Any, Sequence, Literal, BinaryIO, Generator, IO +import httpx +from datetime import date +import logging +from ..entity_base_api import ApiConfig, CreatableEntityApi, DeletableEntityApi +from datamint.entities.annotation import Annotation +from datamint.entities.resource import Resource +from datamint.entities.project import Project +from datamint.apihandler.dto.annotation_dto import AnnotationType, CreateAnnotationDto, LineGeometry, BoxGeometry, CoordinateSystem, Geometry +import numpy as np +import os +import aiohttp +import json +from datamint.exceptions import DatamintException, ResourceNotFoundError +from medimgkit.nifti_utils import DEFAULT_NIFTI_MIME +from medimgkit.format_detection import guess_type +import nibabel as nib +from PIL import Image +from io import BytesIO +import pydicom +from pathlib import Path +from tqdm.auto import tqdm +import asyncio + +_LOGGER = logging.getLogger(__name__) +_USER_LOGGER = logging.getLogger('user_logger') +MAX_NUMBER_DISTINCT_COLORS = 2048 # Maximum number of distinct colors in a segmentation image + + +class AnnotationsApi(CreatableEntityApi[Annotation], DeletableEntityApi[Annotation]): + """API handler for annotation-related endpoints.""" + + def __init__(self, config: ApiConfig, client: httpx.Client | None = None) -> None: + """Initialize the annotations API handler. + + Args: + config: API configuration containing base URL, API key, etc. + client: Optional HTTP client instance. If None, a new one will be created. + """ + super().__init__(config, Annotation, 'annotations', client) + + def get_list(self, + resource: str | Resource | None = None, + annotation_type: AnnotationType | str | None = None, + annotator_email: str | None = None, + date_from: date | None = None, + date_to: date | None = None, + dataset_id: str | None = None, + worklist_id: str | None = None, + status: Literal['new', 'published'] | None = None, + load_ai_segmentations: bool | None = None, + limit: int | None = None + ) -> Sequence[Annotation]: + payload = { + 'resource_id': resource.id if isinstance(resource, Resource) else resource, + 'annotation_type': annotation_type, + 'annotatorEmail': annotator_email, + 'from': date_from.isoformat() if date_from is not None else None, + 'to': date_to.isoformat() if date_to is not None else None, + 'dataset_id': dataset_id, + 'annotation_worklist_id': worklist_id, + 'status': status, + 'load_ai_segmentations': load_ai_segmentations + } + + # remove nones + payload = {k: v for k, v in payload.items() if v is not None} + return super().get_list(limit=limit, params=payload) + + async def _upload_segmentations_async(self, + resource: str | Resource, + frame_index: int | Sequence [int] | None, + file_path: str | np.ndarray, + name: dict[int, str] | dict[tuple, str], + imported_from: str | None = None, + author_email: str | None = None, + discard_empty_segmentations: bool = True, + worklist_id: str | None = None, + model_id: str | None = None, + transpose_segmentation: bool = False, + upload_volume: bool | str = 'auto' + ) -> Sequence[str]: + """ + Upload segmentations asynchronously. + + Args: + resource: The resource unique id or Resource instance. + frame_index: The frame index or None for multiple frames. + file_path: Path to segmentation file or numpy array. + name: The name of the segmentation or mapping of pixel values to names. + imported_from: The imported from value. + author_email: The author email. + discard_empty_segmentations: Whether to discard empty segmentations. + worklist_id: The annotation worklist unique id. + model_id: The model unique id. + transpose_segmentation: Whether to transpose the segmentation. + upload_volume: Whether to upload the volume as a single file or split into frames. + + Returns: + List of annotation IDs created. + """ + if upload_volume == 'auto': + if isinstance(file_path, str) and (file_path.endswith('.nii') or file_path.endswith('.nii.gz')): + upload_volume = True + else: + upload_volume = False + + resource_id = self._entid(resource) + # Handle volume upload + if upload_volume: + if frame_index is not None: + _LOGGER.warning("frame_index parameter ignored when upload_volume=True") + + return await self._upload_volume_segmentation_async( + resource_id=resource_id, + file_path=file_path, + name=name, + imported_from=imported_from, + author_email=author_email, + worklist_id=worklist_id, + model_id=model_id, + transpose_segmentation=transpose_segmentation + ) + + # Handle frame-by-frame upload (existing logic) + nframes, fios = AnnotationsApi._generate_segmentations_ios( + file_path, transpose_segmentation=transpose_segmentation + ) + if frame_index is None: + frames_indices = list(range(nframes)) + elif isinstance(frame_index, int): + frames_indices = [frame_index] + elif isinstance(frame_index, Sequence): + if len(frame_index) != nframes: + raise ValueError("Length of frame_index does not match number of frames in segmentation.") + frames_indices = list(frame_index) + else: + raise ValueError("frame_index must be a list of integers or None.") + + annotids = [] + for fidx, f in zip(frames_indices, fios): + frame_annotids = await self._upload_single_frame_segmentation_async( + resource_id=resource_id, + frame_index=fidx, + fio=f, + name=name, + imported_from=imported_from, + author_email=author_email, + discard_empty_segmentations=discard_empty_segmentations, + worklist_id=worklist_id, + model_id=model_id + ) + annotids.extend(frame_annotids) + return annotids + + async def _upload_single_frame_segmentation_async(self, + resource_id: str, + frame_index: int | None, + fio: IO, + name: dict[int, str] | dict[tuple, str], + imported_from: str | None = None, + author_email: str | None = None, + discard_empty_segmentations: bool = True, + worklist_id: str | None = None, + model_id: str | None = None + ) -> list[str]: + """ + Upload a single frame segmentation asynchronously. + + Args: + resource_id: The resource unique id. + frame_index: The frame index for the segmentation. + fio: File-like object containing the segmentation image. + name: The name of the segmentation, a dictionary mapping pixel values to names, + or a dictionary mapping RGB tuples to names. + imported_from: The imported from value. + author_email: The author email. + discard_empty_segmentations: Whether to discard empty segmentations. + worklist_id: The annotation worklist unique id. + model_id: The model unique id. + + Returns: + List of annotation IDs created. + """ + try: + try: + img_pil = Image.open(fio) + img_array = np.array(img_pil) # shape: (height, width, channels) + # Returns a list of (count, color) tuples + unique_vals = img_pil.getcolors(maxcolors=MAX_NUMBER_DISTINCT_COLORS) + # convert to list of RGB tuples + if unique_vals is None: + raise ValueError(f'Number of unique colors exceeds {MAX_NUMBER_DISTINCT_COLORS}.') + unique_vals = [color for count, color in unique_vals] + # Remove black/transparent pixels + black_pixel = (0, 0, 0) + unique_vals = [rgb for rgb in unique_vals if rgb != black_pixel] + + if discard_empty_segmentations: + if len(unique_vals) == 0: + msg = f"Discarding empty RGB segmentation for frame {frame_index}" + _LOGGER.debug(msg) + _USER_LOGGER.debug(msg) + return [] + segnames = AnnotationsApi._get_segmentation_names_rgb(unique_vals, names=name) + segs_generator = AnnotationsApi._split_rgb_segmentations(img_array, unique_vals) + + fio.seek(0) + # TODO: Optimize this. It is not necessary to open the image twice. + + # Create annotations + annotations: list[CreateAnnotationDto] = [] + for segname in segnames: + ann = CreateAnnotationDto( + type='segmentation', + identifier=segname, + scope='frame', + frame_index=frame_index, + imported_from=imported_from, + import_author=author_email, + model_id=model_id, + annotation_worklist_id=worklist_id + ) + annotations.append(ann) + + # Validate unique identifiers + if len(annotations) != len(set([a.identifier for a in annotations])): + raise ValueError( + "Multiple annotations with the same identifier, frame_index, scope and author is not supported yet." + ) + + annotids = await self._create_async(resource_id=resource_id, annotations_dto=annotations) + + # Upload segmentation files + if len(annotids) != len(segnames): + _LOGGER.warning(f"Number of uploaded annotations ({len(annotids)})" + + f" does not match the number of annotations ({len(segnames)})") + + for annotid, segname, fio_seg in zip(annotids, segnames, segs_generator): + await self.upload_annotation_file_async(resource_id, annotid, fio_seg, + content_type='image/png', + filename=segname) + return annotids + finally: + fio.close() + except ResourceNotFoundError: + raise ResourceNotFoundError('resource', {'resource_id': resource_id}) + + def _prepare_upload_file(self, + file: str | IO, + filename: str | None = None, + content_type: str | None = None + ) -> tuple[IO, str, bool, str | None]: + if isinstance(file, str): + if filename is None: + filename = os.path.basename(file) + f = open(file, 'rb') + close_file = True + else: + f = file + if filename is None: + if hasattr(f, 'name') and isinstance(f.name, str): + filename = f.name + else: + filename = 'unnamed_file' + close_file = False + + if content_type is None: + content_type, _ = guess_type(filename, use_magic=False) + + return f, filename, close_file, content_type + + async def upload_annotation_file_async(self, + resource: str | Resource, + annotation_id: str, + file: str | IO, + content_type: str | None = None, + filename: str | None = None + ): + """ + Upload a file for an existing annotation asynchronously. + + Args: + resource: The resource unique id or Resource instance. + annotation_id: The annotation unique id. + file: Path to the file or a file-like object. + content_type: The MIME type of the file. + filename: Optional filename to use in the upload. If None and file is a path, + the basename of the path will be used. + + Raises: + DatamintException: If the upload fails. + + Example: + .. code-block:: python + + await ann_api.upload_annotation_file_async( + resource='your_resource_id', + annotation_id='your_annotation_id', + file='path/to/your/file.png', + content_type='image/png', + filename='custom_name.png' + ) + """ + f, filename, close_file, content_type = self._prepare_upload_file(file, + filename, + content_type=content_type) + + try: + form = aiohttp.FormData() + form.add_field('file', f, filename=filename, content_type=content_type) + resource_id = self._entid(resource) + endpoint = f'{self.endpoint_base}/{resource_id}/annotations/{annotation_id}/file' + respdata = await self._make_request_async_json('POST', + endpoint=endpoint, + data=form) + if isinstance(respdata, dict) and 'error' in respdata: + raise DatamintException(respdata['error']) + finally: + if close_file: + f.close() + + def upload_annotation_file(self, + resource: str | Resource, + annotation_id: str, + file: str | IO, + content_type: str | None = None, + filename: str | None = None + ): + """ + Upload a file for an existing annotation. + + Args: + resource: The resource unique id or Resource instance. + annotation_id: The annotation unique id. + file: Path to the file or a file-like object. + content_type: The MIME type of the file. + filename: Optional filename to use in the upload. If None and file is a path, + the basename of the path will be used. + + Raises: + DatamintException: If the upload fails. + """ + f, filename, close_file, content_type = self._prepare_upload_file(file, + filename, + content_type=content_type) + try: + files = { + 'file': (filename, f, content_type) + } + resource_id = self._entid(resource) + resp = self._make_request(method='POST', + endpoint=f'{self.endpoint_base}/{resource_id}/annotations/{annotation_id}/file', + files=files) + respdata = resp.json() + if isinstance(respdata, dict) and 'error' in respdata: + raise DatamintException(respdata['error']) + finally: + if close_file: + f.close() + + def create(self, + resource: str | Resource, + annotation_dto: CreateAnnotationDto | Sequence[CreateAnnotationDto] + ) -> str | Sequence[str]: + """Create a new annotation. + + Args: + resource: The resource unique id or Resource instance. + annotation_dto: A CreateAnnotationDto instance or a list of such instances. + + Returns: + The id of the created annotation or a list of ids if multiple annotations were created. + """ + + annotations = [annotation_dto] if isinstance(annotation_dto, CreateAnnotationDto) else annotation_dto + annotations = [ann.to_dict() if isinstance(ann, CreateAnnotationDto) else ann for ann in annotations] + resource_id = resource.id if isinstance(resource, Resource) else resource + respdata = self._make_request('POST', + f'{self.endpoint_base}/{resource_id}/annotations', + json=annotations).json() + for r in respdata: + if isinstance(r, dict) and 'error' in r: + raise DatamintException(r['error']) + if isinstance(annotation_dto, CreateAnnotationDto): + return respdata[0] + return respdata + + def upload_segmentations(self, + resource: str | Resource, + file_path: str | np.ndarray, + name: str | dict[int, str] | dict[tuple, str] | None = None, + frame_index: int | list[int] | None = None, + imported_from: str | None = None, + author_email: str | None = None, + discard_empty_segmentations: bool = True, + worklist_id: str | None = None, + model_id: str | None = None, + transpose_segmentation: bool = False, + ) -> list[str]: + """ + Upload segmentations to a resource. + + Args: + resource: The resource unique ID or Resource instance. + file_path: The path to the segmentation file or a numpy array. + If a numpy array is provided, it can have the shape: + - (height, width, #frames) or (height, width) for grayscale segmentations + - (3, height, width, #frames) for RGB segmentations + For NIfTI files (.nii/.nii.gz), the entire volume is uploaded as a single segmentation. + name: The name of the segmentation. + Can be: + - str: Single name for all segmentations + - dict[int, str]: Mapping pixel values to names for grayscale segmentations + - dict[tuple[int, int, int], str]: Mapping RGB tuples to names for RGB segmentations + Use 'default' as a key for a unnamed classes. + Example: {(255, 0, 0): 'Red_Region', (0, 255, 0): 'Green_Region'} + frame_index: The frame index of the segmentation. + If a list, it must have the same length as the number of frames in the segmentation. + If None, it is assumed that the segmentations are in sequential order starting from 0. + This parameter is ignored for NIfTI files as they are treated as volume segmentations. + imported_from: The imported from value. + author_email: The author email. + discard_empty_segmentations: Whether to discard empty segmentations or not. + worklist_id: The annotation worklist unique id. + model_id: The model unique id. + transpose_segmentation: Whether to transpose the segmentation or not. + + Returns: + List of segmentation unique ids. + + Raises: + ResourceNotFoundError: If the resource does not exist or the segmentation is invalid. + FileNotFoundError: If the file path does not exist. + ValueError: If frame_index is provided for NIfTI files or invalid parameters. + + Example: + .. code-block:: python + + # Grayscale segmentation + api.annotations.upload_segmentations(resource_id, 'path/to/segmentation.png', 'SegmentationName') + + # RGB segmentation with numpy array + seg_data = np.random.randint(0, 3, size=(3, 2140, 1760, 1), dtype=np.uint8) + rgb_names = {(1, 0, 0): 'Red_Region', (0, 1, 0): 'Green_Region', (0, 0, 1): 'Blue_Region'} + api.annotations.upload_segmentations(resource_id, seg_data, rgb_names) + + # Volume segmentation + api.annotations.upload_segmentations(resource_id, 'path/to/segmentation.nii.gz', 'VolumeSegmentation') + """ + import nest_asyncio + + if isinstance(file_path, str) and not os.path.exists(file_path): + raise FileNotFoundError(f"File {file_path} not found.") + + # Handle NIfTI files specially - upload as single volume + if isinstance(file_path, str) and (file_path.endswith('.nii') or file_path.endswith('.nii.gz')): + _LOGGER.info(f"Uploading NIfTI segmentation file: {file_path}") + if frame_index is not None: + raise ValueError("Do not provide frame_index for NIfTI segmentations.") + + # Ensure nest_asyncio is applied for Jupyter compatibility + nest_asyncio.apply() + loop = asyncio.get_event_loop() + task = self._upload_segmentations_async( + resource=resource, + frame_index=None, + file_path=file_path, + name=name, + imported_from=imported_from, + author_email=author_email, + worklist_id=worklist_id, + model_id=model_id, + transpose_segmentation=transpose_segmentation, + upload_volume=True + ) + return loop.run_until_complete(task) + + # All other file types are converted to multiple PNGs and uploaded frame by frame + standardized_name = self.standardize_segmentation_names(name) + _LOGGER.debug(f"Standardized segmentation names: {standardized_name}") + + # Handle frame_index parameter + if isinstance(frame_index, list): + if len(set(frame_index)) != len(frame_index): + raise ValueError("frame_index list contains duplicate values.") + + if isinstance(frame_index, Sequence) and len(frame_index) == 1: + frame_index = frame_index[0] + + nest_asyncio.apply() + loop = asyncio.get_event_loop() + task = self._upload_segmentations_async( + resource=resource, + frame_index=frame_index, + file_path=file_path, + name=standardized_name, + imported_from=imported_from, + author_email=author_email, + discard_empty_segmentations=discard_empty_segmentations, + worklist_id=worklist_id, + model_id=model_id, + transpose_segmentation=transpose_segmentation, + upload_volume=False + ) + return loop.run_until_complete(task) + + @staticmethod + def standardize_segmentation_names(name: str | dict | None + ) -> dict: + """ + Standardize segmentation names to a consistent format. + + Args: + name: The name input in various formats. + + Returns: + Standardized name dictionary. + """ + if name is None: + return {'default': 'default'} # Return a dict with integer key for compatibility + elif isinstance(name, str): + return {'default': name} # Use integer key for single string names + elif isinstance(name, dict): + # Return the dict as-is since it's already in the correct format + return name + else: + raise ValueError("Invalid name format. Must be str, dict[int, str], dict[tuple, str], or None.") + + async def _create_async(self, + resource_id: str, + annotations_dto: list[CreateAnnotationDto] | list[dict]) -> list[str]: + annotations = [ann.to_dict() if isinstance(ann, CreateAnnotationDto) else ann for ann in annotations_dto] + respdata = await self._make_request_async_json('POST', + f'{self.endpoint_base}/{resource_id}/annotations', + json=annotations) + for r in respdata: + if isinstance(r, dict) and 'error' in r: + raise DatamintException(r['error']) + return respdata + + @staticmethod + def _get_segmentation_names_rgb(uniq_rgb_vals: list[tuple[int, int, int]], + names: dict[tuple[int, int, int], str] + ) -> list[str]: + """ + Generate segmentation names for RGB combinations. + + Args: + uniq_rgb_vals: List of unique RGB combinations as (R,G,B) tuples + names: Name mapping for RGB combinations + + Returns: + List of segmentation names + """ + result = [] + for rgb_tuple in uniq_rgb_vals: + seg_name = names.get(rgb_tuple, names.get('default', f'seg_{"_".join(map(str, rgb_tuple))}')) + if seg_name is None: + if rgb_tuple[0] == rgb_tuple[1] and rgb_tuple[1] == rgb_tuple[2]: + msg = f"Provide a name for {rgb_tuple} or {rgb_tuple[0]} or use 'default' key." + else: + msg = f"Provide a name for {rgb_tuple} or use 'default' key." + raise ValueError(f"RGB combination {rgb_tuple} not found in names dictionary. " + + msg) + # If using default prefix, append RGB values + # if rgb_tuple not in names and 'default' in names: + # seg_name = f"{seg_name}_{'_'.join(map(str, rgb_tuple))}" + result.append(seg_name) + return result + + @staticmethod + def _split_rgb_segmentations(img: np.ndarray, + uniq_rgb_vals: list[tuple[int, int, int]] + ) -> Generator[BytesIO, None, None]: + """ + Split RGB segmentations into individual binary masks. + + Args: + img: RGB image array of shape (height, width, channels) + uniq_rgb_vals: List of unique RGB combinations as (R,G,B) tuples + + Yields: + BytesIO objects containing individual segmentation masks + """ + for rgb_tuple in uniq_rgb_vals: + # Create binary mask for this RGB combination + rgb_array = np.array(rgb_tuple[:3]) # Ensure only R,G,B values + mask = np.all(img[:, :, :3] == rgb_array, axis=2) + + # Convert to uint8 and create PNG + mask_img = (mask * 255).astype(np.uint8) + + f_out = BytesIO() + Image.fromarray(mask_img).convert('L').save(f_out, format='PNG') + f_out.seek(0) + yield f_out + + async def _upload_volume_segmentation_async(self, + resource_id: str, + file_path: str | np.ndarray, + name: str | dict[int, str] | dict[tuple, str] | None, + imported_from: str | None = None, + author_email: str | None = None, + worklist_id: str | None = None, + model_id: str | None = None, + transpose_segmentation: bool = False + ) -> Sequence[str]: + """ + Upload a volume segmentation as a single file asynchronously. + + Args: + resource_id: The resource unique id. + file_path: Path to segmentation file or numpy array. + name: The name of the segmentation (string only for volumes). + imported_from: The imported from value. + author_email: The author email. + worklist_id: The annotation worklist unique id. + model_id: The model unique id. + transpose_segmentation: Whether to transpose the segmentation. + + Returns: + List of annotation IDs created. + + Raises: + ValueError: If name is not a string or file format is unsupported for volume upload. + """ + + if isinstance(name, str): + raise NotImplementedError("`name=string` is not supported yet for volume segmentation.") + if isinstance(name, dict): + if any(isinstance(k, tuple) for k in name.keys()): + raise NotImplementedError( + "For volume segmentations, `name` must be a dictionary with integer keys only.") + if 'default' in name: + _LOGGER.warning("Ignoring 'default' key in name dictionary for volume segmentation. Not supported yet.") + + # Prepare file for upload + if isinstance(file_path, str): + if file_path.endswith('.nii') or file_path.endswith('.nii.gz'): + # Upload NIfTI file directly + with open(file_path, 'rb') as f: + filename = os.path.basename(file_path) + form = aiohttp.FormData() + form.add_field('file', f, filename=filename, content_type=DEFAULT_NIFTI_MIME) + if model_id is not None: + form.add_field('model_id', model_id) # Add model_id if provided + if worklist_id is not None: + form.add_field('annotation_worklist_id', worklist_id) + if name is not None: + form.add_field('segmentation_map', json.dumps(name), content_type='application/json') + + respdata = await self._make_request_async_json('POST', + f'{self.endpoint_base}/{resource_id}/segmentations/file', + data=form) + if 'error' in respdata: + raise DatamintException(respdata['error']) + return respdata + else: + raise ValueError(f"Volume upload not supported for file format: {file_path}") + elif isinstance(file_path, np.ndarray): + raise NotImplementedError + else: + raise ValueError(f"Unsupported file_path type for volume upload: {type(file_path)}") + + _USER_LOGGER.info(f'Volume segmentation uploaded for resource {resource_id}') + + @staticmethod + def _generate_segmentations_ios(file_path: str | np.ndarray, + transpose_segmentation: bool = False + ) -> tuple[int, Generator[BinaryIO, None, None]]: + if not isinstance(file_path, (str, np.ndarray)): + raise ValueError(f"Unsupported file type: {type(file_path)}") + + if isinstance(file_path, np.ndarray): + normalized_imgs = AnnotationsApi._normalize_segmentation_array(file_path) + # normalized_imgs shape: (3, height, width, #frames) + + # Apply transpose if requested + if transpose_segmentation: + # (channels, height, width, frames) -> (channels, width, height, frames) + normalized_imgs = normalized_imgs.transpose(0, 2, 1, 3) + + nframes = normalized_imgs.shape[3] + fios = AnnotationsApi._numpy_to_bytesio_png(normalized_imgs) + + elif file_path.endswith('.nii') or file_path.endswith('.nii.gz'): + segs_imgs = nib.load(file_path).get_fdata() + if segs_imgs.ndim != 3 and segs_imgs.ndim != 2: + raise ValueError(f"Invalid segmentation shape: {segs_imgs.shape}") + + # Normalize and apply transpose + normalized_imgs = AnnotationsApi._normalize_segmentation_array(segs_imgs) + if not transpose_segmentation: + # Apply default NIfTI transpose + # (channels, width, height, frames) -> (channels, height, width, frames) + normalized_imgs = normalized_imgs.transpose(0, 2, 1, 3) + + nframes = normalized_imgs.shape[3] + fios = AnnotationsApi._numpy_to_bytesio_png(normalized_imgs) + + elif file_path.endswith('.png'): + with Image.open(file_path) as img: + img_array = np.array(img) + normalized_imgs = AnnotationsApi._normalize_segmentation_array(img_array) + + if transpose_segmentation: + normalized_imgs = normalized_imgs.transpose(0, 2, 1, 3) + + fios = AnnotationsApi._numpy_to_bytesio_png(normalized_imgs) + nframes = 1 + else: + raise ValueError(f"Unsupported file format of '{file_path}'") + + return nframes, fios + + @staticmethod + def _normalize_segmentation_array(seg_imgs: np.ndarray) -> np.ndarray: + """ + Normalize segmentation array to a consistent format. + + Args: + seg_imgs: Input segmentation array in various formats: (height, width, #frames), (height, width), (3, height, width, #frames). + + Returns: + np.ndarray: Shape (#channels, height, width, #frames) + """ + if seg_imgs.ndim == 4: + return seg_imgs # .transpose(1, 2, 0, 3) + + # Handle grayscale segmentations + if seg_imgs.ndim == 2: + # Add frame dimension: (height, width) -> (height, width, 1) + seg_imgs = seg_imgs[..., None] + if seg_imgs.ndim == 3: + # (height, width, #frames) + seg_imgs = seg_imgs[np.newaxis, ...] # Add channel dimension: (1, height, width, #frames) + + return seg_imgs + + @staticmethod + def _numpy_to_bytesio_png(seg_imgs: np.ndarray) -> Generator[BinaryIO, None, None]: + """ + Convert normalized segmentation images to PNG BytesIO objects. + + Args: + seg_imgs: Normalized segmentation array in shape (channels, height, width, frames). + + Yields: + BinaryIO: PNG image data as BytesIO objects + """ + # PIL RGB format is: (height, width, channels) + if seg_imgs.shape[0] not in [1, 3, 4]: + raise ValueError(f"Unsupported number of channels: {seg_imgs.shape[0]}. Expected 1 or 3") + nframes = seg_imgs.shape[3] + for i in range(nframes): + img = seg_imgs[:, :, :, i].astype(np.uint8) + if img.shape[0] == 1: + pil_img = Image.fromarray(img[0]).convert('RGB') + else: + pil_img = Image.fromarray(img.transpose(1, 2, 0)) + img_bytes = BytesIO() + pil_img.save(img_bytes, format='PNG') + img_bytes.seek(0) + yield img_bytes + + def add_line_annotation(self, + point1: tuple[int, int] | tuple[float, float, float], + point2: tuple[int, int] | tuple[float, float, float], + resource_id: str, + identifier: str, + frame_index: int | None = None, + dicom_metadata: pydicom.Dataset | str | None = None, + coords_system: CoordinateSystem = 'pixel', + project: str | None = None, + worklist_id: str | None = None, + imported_from: str | None = None, + author_email: str | None = None, + model_id: str | None = None) -> Sequence[str]: + """ + Add a line annotation to a resource. + + Args: + point1: The first point of the line. Can be a 2d or 3d point. + If `coords_system` is 'pixel', it must be a 2d point and it represents the pixel coordinates of the image. + If `coords_system` is 'patient', it must be a 3d point and it represents the patient coordinates of the image, relative + to the DICOM metadata. + If `coords_system` is 'patient', it must be a 3d point. + point2: The second point of the line. See `point1` for more details. + resource_id: The resource unique id. + identifier: The annotation identifier, also as known as the annotation's label. + frame_index: The frame index of the annotation. + dicom_metadata: The DICOM metadata of the image. If provided, the coordinates will be converted to the + correct coordinates automatically using the DICOM metadata. + coords_system: The coordinate system of the points. Can be 'pixel', or 'patient'. + If 'pixel', the points are in pixel coordinates. If 'patient', the points are in patient coordinates (see DICOM patient coordinates). + project: The project unique id or name. + worklist_id: The annotation worklist unique id. Optional. + imported_from: The imported from source value. + author_email: The email to consider as the author of the annotation. If None, use the customer of the api key. + model_id: The model unique id. Optional. + + Example: + .. code-block:: python + + res_id = 'aa93813c-cef0-4edd-a45c-85d4a8f1ad0d' + api.add_line_annotation([0, 0], (10, 30), + resource_id=res_id, + identifier='Line1', + frame_index=2, + project='Example Project') + """ + + if project is not None and worklist_id is not None: + raise ValueError('Only one of project or worklist_id can be provided.') + + if coords_system == 'pixel': + if dicom_metadata is None: + point1 = (point1[0], point1[1], frame_index) + point2 = (point2[0], point2[1], frame_index) + geom = LineGeometry(point1, point2) + else: + if isinstance(dicom_metadata, str): + dicom_metadata = pydicom.dcmread(dicom_metadata) + geom = LineGeometry.from_dicom(dicom_metadata, point1, point2, slice_index=frame_index) + elif coords_system == 'patient': + geom = LineGeometry(point1, point2) + else: + raise ValueError(f"Unknown coordinate system: {coords_system}") + + return self._create_geometry_annotation( + geometry=geom, + resource_id=resource_id, + identifier=identifier, + frame_index=frame_index, + project=project, + worklist_id=worklist_id, + imported_from=imported_from, + author_email=author_email, + model_id=model_id + ) + + def _create_geometry_annotation(self, + geometry: Geometry, + resource_id: str, + identifier: str, + frame_index: int | None = None, + project: str | None = None, + worklist_id: str | None = None, + imported_from: str | None = None, + author_email: str | None = None, + model_id: str | None = None) -> Sequence[str]: + """ + Create an annotation with the given geometry. + + Args: + geometry: The geometry object (e.g., LineGeometry, BoxGeometry). + resource_id: The resource unique id. + identifier: The annotation identifier/label. + frame_index: The frame index of the annotation. + project: The project unique id or name. + worklist_id: The annotation worklist unique id. + imported_from: The imported from source value. + author_email: The email to consider as the author. + model_id: The model unique id. + + Returns: + List of created annotation IDs. + """ + if project is not None and worklist_id is not None: + raise ValueError('Only one of project or worklist_id can be provided.') + + scope = 'frame' if frame_index is not None else 'image' + annotation_dto = CreateAnnotationDto( + type=geometry.type, + identifier=identifier, + scope=scope, + frame_index=frame_index, + geometry=geometry, + imported_from=imported_from, + import_author=author_email, + model_id=model_id, + annotation_worklist_id=worklist_id + ) + + return self.create(resource_id, annotation_dto) + + def download_file(self, + annotation: str | Annotation, + fpath_out: str | Path | None = None) -> bytes: + """ + Download the segmentation file for a given resource and annotation. + + Args: + annotation: The annotation unique id or an annotation object. + fpath_out: (Optional) The file path to save the downloaded segmentation file. + + Returns: + bytes: The content of the downloaded segmentation file in bytes format. + """ + if isinstance(annotation, Annotation): + annotation_id = annotation.id + resource_id = annotation.resource_id + else: + annotation_id = annotation + resource_id = self.get_by_id(annotation_id).resource_id + + resp = self._make_request('GET', f'/annotations/{resource_id}/annotations/{annotation_id}/file') + if fpath_out: + with open(str(fpath_out), 'wb') as f: + f.write(resp.content) + return resp.content + + async def _async_download_segmentation_file(self, + annotation: str | Annotation, + save_path: str | Path, + session: aiohttp.ClientSession | None = None, + progress_bar: tqdm | None = None): + """ + Asynchronously download a segmentation file. + + Args: + annotation (str | dict): The annotation unique id or an annotation object. + save_path (str | Path): The path to save the file. + session (aiohttp.ClientSession): The aiohttp session to use for the request. + progress_bar (tqdm | None): Optional progress bar to update after download completion. + """ + if isinstance(annotation, Annotation): + annotation_id = annotation.id + resource_id = annotation.resource_id + else: + annotation_id = annotation + resource_id = self.get_by_id(annotation_id).resource_id + + try: + async with self._make_request_async('GET', + f'/annotations/{resource_id}/annotations/{annotation_id}/file', + session=session) as resp: + data_bytes = await resp.read() + with open(save_path, 'wb') as f: + f.write(data_bytes) + if progress_bar: + progress_bar.update(1) + except ResourceNotFoundError as e: + e.set_params('annotation', {'annotation_id': annotation_id}) + raise e + + def download_multiple_files(self, + annotations: Sequence[str | Annotation], + save_paths: Sequence[str | Path] | str + ) -> None: + """ + Download multiple segmentation files and save them to the specified paths. + + Args: + annotations: A list of annotation unique ids or annotation objects. + save_paths: A list of paths to save the files or a directory path. + """ + import nest_asyncio + nest_asyncio.apply() + + async def _download_all_async(): + async with aiohttp.ClientSession() as session: + tasks = [ + self._async_download_segmentation_file( + annotation, save_path=path, session=session, progress_bar=progress_bar) + for annotation, path in zip(annotations, save_paths) + ] + await asyncio.gather(*tasks) + + if isinstance(save_paths, str): + save_paths = [os.path.join(save_paths, self._entid(ann)) + for ann in annotations] + + with tqdm(total=len(annotations), desc="Downloading segmentations", unit="file") as progress_bar: + loop = asyncio.get_event_loop() + loop.run_until_complete(_download_all_async()) + + def bulk_download_file(self, + annotations: Sequence[str | Annotation], + save_paths: Sequence[str | Path] | str + ) -> None: + """Alias for :py:meth:`download_multiple_files`""" + return self.download_multiple_files(annotations, save_paths) diff --git a/datamint/api/endpoints/channels_api.py b/datamint/api/endpoints/channels_api.py new file mode 100644 index 00000000..44cdf5d7 --- /dev/null +++ b/datamint/api/endpoints/channels_api.py @@ -0,0 +1,28 @@ +""" +Channels API endpoint for managing channel resources. + +This module provides functionality to interact with channels, +which are collections of resources grouped together for +batch processing or organization purposes. +""" + +import logging +import httpx +from ..entity_base_api import EntityBaseApi +from datamint.entities.channel import Channel + +logger = logging.getLogger(__name__) + + +class ChannelsApi(EntityBaseApi[Channel]): + """API client for channel-related operations. + """ + + def __init__(self, config, client: httpx.Client | None = None) -> None: + """Initialize the Channels API client. + + Args: + config: API configuration containing base URL, API key, etc. + client: Optional HTTP client instance. If None, a new one will be created. + """ + super().__init__(config, Channel, 'resources/channels', client) diff --git a/datamint/api/endpoints/datasetsinfo_api.py b/datamint/api/endpoints/datasetsinfo_api.py new file mode 100644 index 00000000..0625fb0f --- /dev/null +++ b/datamint/api/endpoints/datasetsinfo_api.py @@ -0,0 +1,16 @@ +from ..entity_base_api import ApiConfig, EntityBaseApi +from datamint.entities.datasetinfo import DatasetInfo +import httpx + + +class DatasetsInfoApi(EntityBaseApi[DatasetInfo]): + def __init__(self, + config: ApiConfig, + client: httpx.Client | None = None) -> None: + """Initialize the datasets API handler. + + Args: + config: API configuration containing base URL, API key, etc. + client: Optional HTTP client instance. If None, a new one will be created. + """ + super().__init__(config, DatasetInfo, 'datasets', client) diff --git a/datamint/api/endpoints/projects_api.py b/datamint/api/endpoints/projects_api.py new file mode 100644 index 00000000..be1983ed --- /dev/null +++ b/datamint/api/endpoints/projects_api.py @@ -0,0 +1,203 @@ +from typing import Sequence, Literal +from ..entity_base_api import ApiConfig, CRUDEntityApi +from datamint.entities.project import Project +from datamint.entities.resource import Resource +import httpx + + +class ProjectsApi(CRUDEntityApi[Project]): + """API handler for project-related endpoints.""" + + def __init__(self, + config: ApiConfig, + client: httpx.Client | None = None) -> None: + """Initialize the projects API handler. + + Args: + config: API configuration containing base URL, API key, etc. + client: Optional HTTP client instance. If None, a new one will be created. + """ + super().__init__(config, Project, 'projects', client) + + def get_project_resources(self, project: Project | str) -> list[Resource]: + """Get resources associated with a specific project. + + Args: + project: The ID or instance of the project to fetch resources for. + + Returns: + A list of resource instances associated with the project. + """ + response = self._get_child_entities(project, 'resources') + resources_data = response.json() + return [Resource(**item) for item in resources_data] + + def create(self, + name: str, + description: str, + resources_ids: list[str] | None = None, + is_active_learning: bool = False, + two_up_display: bool = False + ) -> str: + """Create a new project. + + Args: + name: The name of the project. + description: The description of the project. + resources_ids: The list of resource ids to be included in the project. + is_active_learning: Whether the project is an active learning project or not. + two_up_display: Allow annotators to display multiple resources for annotation. + + Returns: + The id of the created project. + """ + resources_ids = resources_ids or [] + project_data = {'name': name, + 'is_active_learning': is_active_learning, + 'resource_ids': resources_ids, + 'annotation_set': { + "annotators": [], + "resource_ids": resources_ids, + "annotations": [], + "frame_labels": [], + "image_labels": [], + }, + "two_up_display": two_up_display, + "require_review": False, + 'description': description} + + return self._create(project_data) + + def get_all(self, limit: int | None = None) -> Sequence[Project]: + """Get all projects. + + Args: + limit: The maximum number of projects to return. If None, return all projects. + + Returns: + A list of project instances. + """ + return self.get_list(limit=limit, params={'includeArchived': True}) + + def get_by_name(self, + name: str, + include_archived: bool = True) -> Project | None: + """Get a project by its name. + + Args: + name (str): The name of the project. + include_archived (bool): Whether to include archived projects in the search. + + Returns: + The project instance if found, otherwise None. + """ + if include_archived: + projects = self.get_list(params={'includeArchived': True}) + else: + projects = self.get_all() + for project in projects: + if project.name == name: + return project + return None + + def _get_by_name_or_id(self, project: str) -> Project | None: + """Get a project by its name or ID. + + Args: + project (str): The name or ID of the project. + + Returns: + The project instance if found, otherwise None. + """ + projects = self.get_all() + for proj in projects: + if proj.name == project or proj.id == project: + return proj + return None + + def add_resources(self, + resources: str | Sequence[str] | Resource | Sequence[Resource], + project: str | Project, + ) -> None: + """ + Add resources to a project. + + Args: + resources: The resource unique id or a list of resource unique ids. + project: The project name, id or :class:`Project` object to add the resource to. + """ + if isinstance(resources, str): + resources_ids = [resources] + elif isinstance(resources, Resource): + resources_ids = [resources.id] + else: + resources_ids = [res if isinstance(res, str) else res.id for res in resources] + + if isinstance(project, str): + if len(project) == 36: + project_id = project + else: + # get the project id by its name + project_found = self._get_by_name_or_id(project) + if project_found is None: + raise ValueError(f"Project '{project}' not found.") + project_id = project_found.id + else: + project_id = project.id + + self._make_entity_request('POST', project_id, add_path='resources', + json={'resource_ids_to_add': resources_ids, 'all_files_selected': False}) + + def download(self, project: str | Project, + outpath: str, + all_annotations: bool = False, + include_unannotated: bool = False, + ) -> None: + """Download a project by its id. + + Args: + project: The project id or Project instance. + outpath: The path to save the project zip file. + all_annotations: Whether to include all annotations in the downloaded dataset, + even those not made by the provided project. + include_unannotated: Whether to include unannotated resources in the downloaded dataset. + """ + from tqdm.auto import tqdm + params = {'all_annotations': all_annotations} + if include_unannotated: + params['include_unannotated'] = include_unannotated + + project_id = self._entid(project) + with self._stream_entity_request('GET', project_id, + add_path='annotated_dataset', + params=params) as response: + total_size = int(response.headers.get('content-length', 0)) + if total_size == 0: + total_size = None + with tqdm(total=total_size, unit='B', unit_scale=True) as progress_bar: + with open(outpath, 'wb') as file: + for data in response.iter_bytes(1024): + progress_bar.update(len(data)) + file.write(data) + + def set_work_status(self, + resource: str | Resource, + project: str | Project, + status: Literal['opened', 'annotated', 'closed']) -> None: + """ + Set the status of a resource. + + Args: + annotation: The annotation unique id or an annotation object. + status: The new status to set. + """ + resource_id = self._entid(resource) + proj_id = self._entid(project) + + jsondata = { + 'status': status + } + self._make_entity_request('POST', + entity_id=proj_id, + add_path=f'resources/{resource_id}/status', + json=jsondata) diff --git a/datamint/api/endpoints/resources_api.py b/datamint/api/endpoints/resources_api.py new file mode 100644 index 00000000..ec5c7339 --- /dev/null +++ b/datamint/api/endpoints/resources_api.py @@ -0,0 +1,1013 @@ +from typing import Any, Optional, Sequence, TypeAlias, Literal, IO +from ..base_api import ApiConfig, BaseApi +from ..entity_base_api import EntityBaseApi, CreatableEntityApi, DeletableEntityApi +from .annotations_api import AnnotationsApi +from .projects_api import ProjectsApi +from datamint.entities.resource import Resource +from datamint.entities.annotation import Annotation +from datamint.exceptions import DatamintException, ResourceNotFoundError +import httpx +from datetime import date +import json +import logging +import pydicom +from medimgkit.dicom_utils import anonymize_dicom, to_bytesio, is_dicom, is_dicom_report, GeneratorWithLength +from medimgkit import dicom_utils, standardize_mimetype +from medimgkit.io_utils import is_io_object, peek +from medimgkit.format_detection import guess_typez, guess_extension, DEFAULT_MIME_TYPE +from medimgkit.nifti_utils import DEFAULT_NIFTI_MIME, NIFTI_MIMES +import os +import itertools +from tqdm.auto import tqdm +import asyncio +import aiohttp +from pathlib import Path +import nest_asyncio # For running asyncio in jupyter notebooks +import cv2 +from PIL import Image +from nibabel.filebasedimages import FileBasedImage as nib_FileBasedImage +import io + + +_LOGGER = logging.getLogger(__name__) +_USER_LOGGER = logging.getLogger('user_logger') + +ResourceStatus: TypeAlias = Literal['new', 'inbox', 'published', 'archived'] +"""TypeAlias: The available resource status. Possible values: 'new', 'inbox', 'published', 'archived'. +""" +ResourceFields: TypeAlias = Literal['modality', 'created_by', 'published_by', 'published_on', 'filename', 'created_at'] +"""TypeAlias: The available fields to order resources. Possible values: 'modality', 'created_by', 'published_by', 'published_on', 'filename', 'created_at' (default). +""" + + +def _infinite_gen(x): + while True: + yield x + + +def _open_io(file_path: str | Path | IO, mode: str = 'rb') -> IO: + if isinstance(file_path, str) or isinstance(file_path, Path): + return open(file_path, 'rb') + return file_path + + +class ResourcesApi(CreatableEntityApi[Resource], DeletableEntityApi[Resource]): + """API handler for resource-related endpoints.""" + + def __init__(self, config: ApiConfig, client: Optional[httpx.Client] = None) -> None: + """Initialize the resources API handler. + + Args: + config: API configuration containing base URL, API key, etc. + client: Optional HTTP client instance. If None, a new one will be created. + """ + super().__init__(config, Resource, 'resources', client) + nest_asyncio.apply() + self.annotations_api = AnnotationsApi(config, client) + self.projects_api = ProjectsApi(config, client) + + def get_list(self, + status: Optional[ResourceStatus] = None, + from_date: date | str | None = None, + to_date: date | str | None = None, + tags: Optional[Sequence[str]] = None, + modality: Optional[str] = None, + mimetype: Optional[str] = None, + # return_ids_only: bool = False, + order_field: Optional[ResourceFields] = None, + order_ascending: Optional[bool] = None, + channel: Optional[str] = None, + project_name: str | list[str] | None = None, + filename: Optional[str] = None, + limit: int | None = None + ) -> Sequence[Resource]: + """Get resources with optional filtering. + + Args: + status: The resource status. Possible values: 'inbox', 'published', 'archived' or None. If None, it will return all resources. + from_date : The start date. + to_date: The end date. + tags: The tags to filter the resources. + modality: The modality of the resources. + mimetype: The mimetype of the resources. + order_field: The field to order the resources. See :data:`~ResourceFields`. + order_ascending: Whether to order the resources in ascending order. + project_name: The project name or a list of project names to filter resources by project. + If multiple projects are provided, resources will be filtered to include only those belonging to ALL of the specified projects. + """ + + # Convert datetime objects to ISO format + if from_date: + if isinstance(from_date, str): + date.fromisoformat(from_date) + else: + from_date = from_date.isoformat() + if to_date: + if isinstance(to_date, str): + date.fromisoformat(to_date) + else: + to_date = to_date.isoformat() + + # Prepare the payload + payload = { + "from": from_date, + "to": to_date, + "status": status if status is not None else "", + "modality": modality, + "mimetype": mimetype, + # "ids": return_ids_only, + "order_field": order_field, + "order_by_asc": order_ascending, + "channel_name": channel, + "filename": filename, + } + # remove nones from payload + payload = {k: v for k, v in payload.items() if v is not None} + if project_name is not None: + if isinstance(project_name, str): + project_name = [project_name] + payload["project"] = json.dumps({'items': project_name, + 'filterType': 'intersection'}) # union or intersection + + if tags is not None: + if isinstance(tags, str): + tags = [tags] + tags_filter = { + "items": tags, + "filterType": "union" + } + payload['tags'] = json.dumps(tags_filter) + + return super().get_list(limit=limit, params=payload) + + def get_annotations(self, resource: str | Resource) -> Sequence[Annotation]: + """Get annotations for a specific resource. + + Args: + resource: The resource ID or Resource instance to fetch annotations for. + + Returns: + A sequence of Annotation objects associated with the specified resource. + """ + return self.annotations_api.get_list(resource=resource) + + @staticmethod + def __process_files_parameter(file_path: str | Sequence[str | IO | pydicom.Dataset] + ) -> Sequence[str | IO]: + """ + Process the file_path parameter to ensure it is a list of file paths or IO objects. + """ + if isinstance(file_path, str) and os.path.isdir(file_path): + return [f'{file_path}/{f}' for f in os.listdir(file_path) if os.path.isfile(f'{file_path}/{f}')] + + processed_files = [] + for item in file_path: + if isinstance(item, pydicom.Dataset): + processed_files.append(to_bytesio(item, item.filename)) + else: + processed_files.append(item) + return processed_files + + def _assemble_dicoms(self, files_path: Sequence[str | IO], + progress_bar: bool = False + ) -> tuple[Sequence[str | IO], bool, Sequence[int]]: + """ + Assembles DICOM files into a single file. + + Args: + files_path: The paths to the DICOM files to assemble. + + Returns: + A tuple containing: + - The paths to the assembled DICOM files. + - A boolean indicating if the assembly was necessary. + - same length as the output assembled DICOMs, mapping assembled DICOM to original DICOMs. + """ + dicoms_files_path = [] + other_files_path = [] + dicom_original_idxs = [] + others_original_idxs = [] + for i, f in enumerate(files_path): + if is_dicom(f): + dicoms_files_path.append(f) + dicom_original_idxs.append(i) + else: + other_files_path.append(f) + others_original_idxs.append(i) + + orig_len = len(dicoms_files_path) + if orig_len == 0: + _LOGGER.debug("No DICOM files found to assemble.") + return files_path, False, [] + dicoms_files_path = dicom_utils.assemble_dicoms(dicoms_files_path, + return_as_IO=True, + progress_bar=progress_bar) + + new_len = len(dicoms_files_path) + if new_len != orig_len: + _LOGGER.info(f"Assembled {new_len} dicom files out of {orig_len} files.") + mapping_idx = [None] * len(files_path) + + files_path = GeneratorWithLength(itertools.chain(dicoms_files_path, other_files_path), + length=new_len + len(other_files_path)) + assembled = True + for orig_idx, value in zip(dicom_original_idxs, dicoms_files_path.inverse_mapping_idx): + mapping_idx[orig_idx] = value + for i, orig_idx in enumerate(others_original_idxs): + mapping_idx[orig_idx] = new_len + i + else: + assembled = False + mapping_idx = [i for i in range(len(files_path))] + + return files_path, assembled, mapping_idx + + async def _upload_single_resource_async(self, + file_path: str | IO, + mimetype: Optional[str] = None, + anonymize: bool = False, + anonymize_retain_codes: Sequence[tuple] = [], + tags: list[str] = [], + mung_filename: Sequence[int] | Literal['all'] | None = None, + channel: Optional[str] = None, + session=None, + modality: Optional[str] = None, + publish: bool = False, + metadata_file: Optional[str | dict] = None, + ) -> str: + if is_io_object(file_path): + name = file_path.name + else: + name = file_path + + if session is not None and not isinstance(session, aiohttp.ClientSession): + raise ValueError("session must be an aiohttp.ClientSession object.") + + name = os.path.expanduser(os.path.normpath(name)) + if len(Path(name).parts) == 0: + raise ValueError(f"File path '{name}' is not valid.") + name = os.path.join(*[x if x != '..' else '_' for x in Path(name).parts]) + + if mung_filename is not None: + file_parts = Path(name).parts + if file_parts[0] == os.path.sep: + file_parts = file_parts[1:] + if mung_filename == 'all': + new_file_path = '_'.join(file_parts) + else: + folder_parts = file_parts[:-1] + new_file_path = '_'.join([folder_parts[i-1] for i in mung_filename if i <= len(folder_parts)]) + new_file_path += '_' + file_parts[-1] + name = new_file_path + _LOGGER.debug(f"New file path: {name}") + + is_a_dicom_file = None + if mimetype is None: + mimetype_list, ext = guess_typez(file_path, use_magic=True) + for mime in mimetype_list: + if mime in NIFTI_MIMES: + mimetype = DEFAULT_NIFTI_MIME + break + else: + if ext == '.nii.gz' or name.lower().endswith('nii.gz'): + mimetype = DEFAULT_NIFTI_MIME + else: + mimetype = mimetype_list[-1] if mimetype_list else DEFAULT_MIME_TYPE + + mimetype = standardize_mimetype(mimetype) + filename = os.path.basename(name) + _LOGGER.debug(f"File name '{filename}' mimetype: {mimetype}") + + if is_a_dicom_file == True or is_dicom(file_path): + if tags is None: + tags = [] + else: + tags = list(tags) + ds = pydicom.dcmread(file_path) + if anonymize: + _LOGGER.info(f"Anonymizing {file_path}") + ds = anonymize_dicom(ds, retain_codes=anonymize_retain_codes) + lat = dicom_utils.get_dicom_laterality(ds) + if lat == 'L': + tags.append("left") + elif lat == 'R': + tags.append("right") + # make the dicom `ds` object a file-like object in order to avoid unnecessary disk writes + f = to_bytesio(ds, name) + else: + f = _open_io(file_path) + + try: + metadata_content = None + metadata_dict = None + if metadata_file is not None: + if isinstance(metadata_file, dict): + # Metadata is already a dictionary + metadata_dict = metadata_file + metadata_content = json.dumps(metadata_dict) + _LOGGER.debug("Using provided metadata dictionary") + else: + # Metadata is a file path + try: + with open(metadata_file, 'r') as metadata_f: + metadata_content = metadata_f.read() + metadata_dict = json.loads(metadata_content) + except Exception as e: + _LOGGER.warning(f"Failed to read metadata file {metadata_file}: {e}") + + # Extract modality from metadata if available + if metadata_dict is not None: + metadata_dict_lower = {k.lower(): v for k, v in metadata_dict.items() if isinstance(k, str)} + try: + if modality is None: + if 'modality' in metadata_dict_lower: + modality = metadata_dict_lower['modality'] + except Exception as e: + _LOGGER.debug(f"Failed to extract modality from metadata: {e}") + + form = aiohttp.FormData() + file_key = 'resource' + form.add_field('source', 'api') + + form.add_field(file_key, f, filename=filename, content_type=mimetype) + form.add_field('source_filepath', name) # full path to the file + if mimetype is not None: + form.add_field('mimetype', mimetype) + if channel is not None: + form.add_field('channel', channel) + if modality is not None: + form.add_field('modality', modality) + form.add_field('bypass_inbox', 'true' if publish else 'false') + if tags is not None and len(tags) > 0: + # comma separated list of tags + form.add_field('tags', ','.join([l.strip() for l in tags])) + + # Add JSON metadata if provided + if metadata_content is not None: + try: + _LOGGER.debug("Adding metadata to form data") + form.add_field('metadata', metadata_content, content_type='application/json') + except Exception as e: + _LOGGER.warning(f"Failed to add metadata to form: {e}") + + resp_data = await self._make_request_async_json('POST', + endpoint=self.endpoint_base, + data=form) + if 'error' in resp_data: + raise DatamintException(resp_data['error']) + _LOGGER.debug(f"Response on uploading {name}: {resp_data}") + return resp_data['id'] + except Exception as e: + if 'name' in locals(): + _LOGGER.error(f"Error uploading {name}: {e}") + else: + _LOGGER.error(f"Error uploading {file_path}: {e}") + raise + finally: + f.close() + + async def _upload_resources_async(self, + files_path: Sequence[str | IO], + mimetype: Optional[str] = None, + anonymize: bool = False, + anonymize_retain_codes: Sequence[tuple] = [], + on_error: Literal['raise', 'skip'] = 'raise', + tags=None, + mung_filename: Sequence[int] | Literal['all'] | None = None, + channel: Optional[str] = None, + modality: Optional[str] = None, + publish: bool = False, + segmentation_files: Sequence[dict] | None = None, + transpose_segmentation: bool = False, + metadata_files: Sequence[str | dict | None] | None = None, + progress_bar: tqdm | None = None, + ) -> list[str]: + if on_error not in ['raise', 'skip']: + raise ValueError("on_error must be either 'raise' or 'skip'") + + if segmentation_files is None: + segmentation_files = _infinite_gen(None) + + if metadata_files is None: + metadata_files = _infinite_gen(None) + + async with aiohttp.ClientSession() as session: + async def __upload_single_resource(file_path, segfiles: dict[str, list | dict], + metadata_file: str | dict | None): + name = file_path.name if is_io_object(file_path) else file_path + name = os.path.basename(name) + rid = await self._upload_single_resource_async( + file_path=file_path, + mimetype=mimetype, + anonymize=anonymize, + anonymize_retain_codes=anonymize_retain_codes, + tags=tags, + session=session, + mung_filename=mung_filename, + channel=channel, + modality=modality, + publish=publish, + metadata_file=metadata_file, + ) + if progress_bar: + progress_bar.update(1) + progress_bar.set_postfix(file=name) + else: + _USER_LOGGER.info(f'"{name}" uploaded') + + if segfiles is not None: + fpaths = segfiles['files'] + names = segfiles.get('names', _infinite_gen(None)) + if isinstance(names, dict): + names = _infinite_gen(names) + frame_indices = segfiles.get('frame_index', _infinite_gen(None)) + for f, name, frame_index in tqdm(zip(fpaths, names, frame_indices), + desc=f"Uploading segmentations for {file_path}", + total=len(fpaths)): + if f is not None: + await self.annotations_api._upload_segmentations_async( + rid, + file_path=f, + name=name, + frame_index=frame_index, + transpose_segmentation=transpose_segmentation + ) + return rid + + tasks = [__upload_single_resource(f, segfiles, metadata_file) + for f, segfiles, metadata_file in zip(files_path, segmentation_files, metadata_files)] + return await asyncio.gather(*tasks, return_exceptions=on_error == 'skip') + + def upload_resources(self, + files_path: Sequence[str | IO | pydicom.Dataset], + mimetype: str | None = None, + anonymize: bool = False, + anonymize_retain_codes: Sequence[tuple] = [], + on_error: Literal['raise', 'skip'] = 'raise', + tags: Sequence[str] | None = None, + mung_filename: Sequence[int] | Literal['all'] | None = None, + channel: str | None = None, + publish: bool = False, + publish_to: str | None = None, + segmentation_files: Sequence[Sequence[str] | dict] | None = None, + transpose_segmentation: bool = False, + modality: str | None = None, + assemble_dicoms: bool = True, + metadata: Sequence[str | dict | None] | None = None, + discard_dicom_reports: bool = True, + progress_bar: bool = False + ) -> Sequence[str | Exception]: + """ + Upload multiple resources. + + Note: For uploading a single resource, use `upload_resource()` instead. + + Args: + files_path: A sequence of paths to resource files, IO objects, or pydicom.Dataset objects. + Must contain at least 2 items. Supports mixed types within the sequence. + mimetype (str): The mimetype of the resources. If None, it will be guessed. + anonymize (bool): Whether to anonymize the dicoms or not. + anonymize_retain_codes (Sequence[tuple]): The tags to retain when anonymizing the dicoms. + on_error (Literal['raise', 'skip']): Whether to raise an exception when an error occurs or to skip the error. + tags (Optional[Sequence[str]]): The tags to add to the resources. + mung_filename (Sequence[int] | Literal['all']): The parts of the filepath to keep when renaming the resource file. + ''all'' keeps all parts. + channel (Optional[str]): The channel to upload the resources to. An arbitrary name to group the resources. + publish (bool): Whether to directly publish the resources or not. They will have the 'published' status. + publish_to (Optional[str]): The project name or id to publish the resources to. + They will have the 'published' status and will be added to the project. + If this is set, `publish` parameter is ignored. + segmentation_files (Optional[list[Union[list[str], dict]]]): The segmentation files to upload. + If each element is a dict, it should have two keys: 'files' and 'names'. + - files: A list of paths to the segmentation files. Example: ['seg1.nii.gz', 'seg2.nii.gz']. + - names: Can be a list (same size of `files`) of labels for the segmentation files. Example: ['Brain', 'Lung']. + transpose_segmentation (bool): Whether to transpose the segmentation files or not. + modality (Optional[str]): The modality of the resources. + assemble_dicoms (bool): Whether to assemble the dicom files or not based on the SeriesInstanceUID and InstanceNumber attributes. + metadata (Optional[list[str | dict | None]]): JSON metadata to include with each resource. + Must have the same length as `files_path`. + Can be file paths (str) or already loaded dictionaries (dict). + + Raises: + ValueError: If a single resource is provided instead of multiple resources. + ResourceNotFoundError: If `publish_to` is supplied, and the project does not exists. + + Returns: + list[str | Exception]: A list of resource IDs or errors. + """ + + if on_error not in ['raise', 'skip']: + raise ValueError("on_error must be either 'raise' or 'skip'") + + # Check if single resource provided and raise error (list of 1 item is allowed) + if isinstance(files_path, IO) or isinstance(files_path, pydicom.Dataset) or (isinstance(files_path, str) and not os.path.isdir(files_path)): + raise ValueError( + "upload_resources() only accepts multiple resources. For single resource upload, use upload_resource() instead.") + + files_path = ResourcesApi.__process_files_parameter(files_path) + + # Discard DICOM reports + if discard_dicom_reports: + old_size = len(files_path) + # Create filtered lists maintaining index correspondence + filtered_files = [] + filtered_metadata = [] + + for i, f in enumerate(files_path): + if not is_dicom_report(f): + filtered_files.append(f) + if metadata is not None: + filtered_metadata.append(metadata[i]) + + files_path = filtered_files + if metadata is not None: + metadata = filtered_metadata + + if old_size is not None and old_size != len(files_path): + _LOGGER.info(f"Discarded {old_size - len(files_path)} DICOM report files from upload.") + + if isinstance(metadata, (str, dict)): + _LOGGER.debug("Converting metadatas to a list") + metadata = [metadata] + + if metadata is not None and len(metadata) != len(files_path): + raise ValueError("The number of metadata files must match the number of resources.") + if assemble_dicoms: + files_path, assembled, mapping_idx = self._assemble_dicoms(files_path, progress_bar=progress_bar) + assemble_dicoms = assembled + else: + mapping_idx = [i for i in range(len(files_path))] + n_files = len(files_path) + + if n_files <= 1: + # Disable progress bar for single file uploads + progress_bar = False + + if segmentation_files is not None: + if assemble_dicoms: + raise NotImplementedError("Segmentation files cannot be uploaded when assembling dicoms yet.") + if len(segmentation_files) != len(files_path): + raise ValueError("The number of segmentation files must match the number of resources.") + else: + if isinstance(segmentation_files, list) and isinstance(segmentation_files[0], list): + raise ValueError("segmentation_files should not be a list of lists if files_path is not a list.") + if isinstance(segmentation_files, dict): + segmentation_files = [segmentation_files] + + segmentation_files = [segfiles if (isinstance(segfiles, dict) or segfiles is None) else {'files': segfiles} + for segfiles in segmentation_files] + + for segfiles in segmentation_files: + if segfiles is None: + continue + if 'files' not in segfiles: + raise ValueError("segmentation_files must contain a 'files' key with a list of file paths.") + if 'names' in segfiles: + # same length as files + if isinstance(segfiles['names'], (list, tuple)) and len(segfiles['names']) != len(segfiles['files']): + raise ValueError( + "segmentation_files['names'] must have the same length as segmentation_files['files'].") + + loop = asyncio.get_event_loop() + pbar = None + try: + if progress_bar: + pbar = tqdm(total=n_files, desc="Uploading resources", unit="file") + + task = self._upload_resources_async(files_path=files_path, + mimetype=mimetype, + anonymize=anonymize, + anonymize_retain_codes=anonymize_retain_codes, + on_error=on_error, + tags=tags, + mung_filename=mung_filename, + channel=channel, + publish=publish, + segmentation_files=segmentation_files, + transpose_segmentation=transpose_segmentation, + modality=modality, + metadata_files=metadata, + progress_bar=pbar + ) + + resource_ids = loop.run_until_complete(task) + finally: + if pbar: + pbar.close() + + _LOGGER.info(f"Resources uploaded: {resource_ids}") + + if publish_to is not None: + _USER_LOGGER.info('Adding resources to project') + resource_ids_succ = [rid for rid in resource_ids if not isinstance(rid, Exception)] + try: + self.projects_api.add_resources(resource_ids_succ, publish_to) + except Exception as e: + _LOGGER.error(f"Error adding resources to project: {e}") + if on_error == 'raise': + raise e + + if mapping_idx: + _LOGGER.debug(f"Mapping indices for DICOM files: {mapping_idx}") + resource_ids = [resource_ids[idx] for idx in mapping_idx] + + return resource_ids + + def upload_resource(self, + file_path: str | IO | pydicom.Dataset, + mimetype: str | None = None, + anonymize: bool = False, + anonymize_retain_codes: Sequence[tuple] = [], + tags: Sequence[str] | None = None, + mung_filename: Sequence[int] | Literal['all'] | None = None, + channel: str | None = None, + publish: bool = False, + publish_to: str | None = None, + segmentation_files: dict | None = None, + transpose_segmentation: bool = False, + modality: str | None = None, + metadata: dict | str | None = None, + discard_dicom_reports: bool = True + ) -> str: + """ + Upload a single resource. + + This is a convenience method that wraps upload_resources for single file uploads. + It provides a cleaner interface when uploading just one file. + + Args: + file_path: The path to the resource file or IO object. + mimetype: The mimetype of the resource. If None, it will be guessed. + anonymize: Whether to anonymize the DICOM or not. + anonymize_retain_codes: The tags to retain when anonymizing the DICOM. + tags: The tags to add to the resource. + mung_filename: The parts of the filepath to keep when renaming the resource file. + 'all' keeps all parts. + channel: The channel to upload the resource to. An arbitrary name to group the resources. + publish: Whether to directly publish the resource or not. It will have the 'published' status. + publish_to: The project name or id to publish the resource to. + It will have the 'published' status and will be added to the project. + If this is set, `publish` parameter is ignored. + segmentation_files: The segmentation files to upload. Should be a dict with: + - 'files': A list of paths to the segmentation files. Example: ['seg1.nii.gz', 'seg2.nii.gz']. + - 'names': A dict mapping pixel values to class names. Example: {1: 'Brain', 2: 'Lung'}. + transpose_segmentation: Whether to transpose the segmentation files or not. + modality: The modality of the resource. + metadata: JSON metadata to include with the resource. + Can be a file path (str) or already loaded dictionary (dict). + discard_dicom_reports: Whether to discard DICOM reports or not. + + Returns: + str: The resource ID of the uploaded resource. + + Raises: + ResourceNotFoundError: If `publish_to` is supplied, and the project does not exist. + DatamintException: If the upload fails. + + Example: + .. code-block:: python + + # Simple upload + resource_id = api.resources.upload_resource('path/to/file.dcm') + + # Upload with metadata and segmentation + resource_id = api.resources.upload_resource( + 'path/to/file.dcm', + tags=['tutorial', 'case1'], + channel='study_channel', + segmentation_files={ + 'files': ['path/to/segmentation.nii.gz'], + 'names': {1: 'Bone', 2: 'Tissue'} + }, + metadata={'patient_age': 45, 'modality': 'CT'} + ) + """ + # Convert segmentation_files to the format expected by upload_resources + segmentation_files_list: Optional[list[list[str] | dict]] = None + if segmentation_files is not None: + segmentation_files_list = [segmentation_files] + + # Call upload_resources with single file + result = self.upload_resources( + files_path=[file_path], + mimetype=mimetype, + anonymize=anonymize, + anonymize_retain_codes=anonymize_retain_codes, + tags=tags, + mung_filename=mung_filename, + channel=channel, + publish=publish, + publish_to=publish_to, + segmentation_files=segmentation_files_list, + transpose_segmentation=transpose_segmentation, + modality=modality, + metadata=[metadata], + discard_dicom_reports=discard_dicom_reports, + assemble_dicoms=False, # No need to assemble for single file + progress_bar=False # Disable progress bar for single uploads + ) + + # upload_resources returns a list, so we extract the first element + if isinstance(result, Sequence) and len(result) == 1: + r = result[0] + if isinstance(r, Exception): + raise r + return r + else: + # This should not happen with single file uploads, but handle it just in case + raise DatamintException(f"Unexpected return from upload_resources: {type(result)} | {result}") + + def _determine_mimetype(self, + content, + resource: str | Resource) -> tuple[str | None, str | None]: + # 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 mimetype is None or mimetype == DEFAULT_MIME_TYPE: + if not isinstance(resource, Resource): + resource = self.get_by_id(resource) + mimetype = resource.mimetype or mimetype + + return mimetype, ext + + async def _async_download_file(self, + resource: str | Resource, + save_path: str | Path, + session: aiohttp.ClientSession | None = None, + progress_bar: tqdm | None = None, + add_extension: bool = False) -> str: + """ + Asynchronously download a file from the server. + + Args: + resource: The resource unique id or Resource object. + save_path: The path to save the file. + session: The aiohttp session to use for the request. + progress_bar: Optional progress bar to update after download completion. + add_extension: Whether to add the appropriate file extension based on content type. + + Returns: + str: The actual path where the file was saved (important when add_extension=True). + """ + save_path = str(save_path) # Ensure save_path is a string for file operations + resource_id = self._entid(resource) + try: + async with self._make_request_async('GET', + f'{self.endpoint_base}/{resource_id}/file', + session=session, + headers={'accept': 'application/octet-stream'}) as resp: + data_bytes = await resp.read() + + final_save_path = save_path + if add_extension: + # Save to temporary file first to determine mimetype from content + temp_path = f"{save_path}.tmp" + with open(temp_path, 'wb') as f: + f.write(data_bytes) + + # Determine mimetype from file content + mimetype, ext = self._determine_mimetype(content=data_bytes, + resource=resource) + + # Generate final path with extension if needed + if mimetype is not None and mimetype != DEFAULT_MIME_TYPE: + if ext is None: + ext = guess_extension(mimetype) + if ext is not None and not save_path.endswith(ext): + final_save_path = save_path + ext + + # Move file to final location + os.rename(temp_path, final_save_path) + else: + # Standard save without extension detection + with open(final_save_path, 'wb') as f: + f.write(data_bytes) + + if progress_bar: + progress_bar.update(1) + + return final_save_path + + except ResourceNotFoundError as e: + e.set_params('resource', {'resource_id': resource_id}) + raise e + + def download_multiple_resources(self, + resources: Sequence[str] | Sequence[Resource], + save_path: Sequence[str] | str, + add_extension: bool = False, + overwrite: bool = True + ) -> list[str]: + """ + Download multiple resources and save them to the specified paths. + This is faster than downloading them one by one. + + Args: + resources: A list of resource unique ids. + save_path : A list of paths to save the files or a directory path, of same length as resources. + If a directory path is provided, files will be saved in that directory. + add_extension: Whether to add the appropriate file extension to the save_path based on the content type. + + Returns: + list[str]: A list of paths where the files were saved. Important if `add_extension=True`. + """ + if isinstance(resources, str): + raise ValueError("resources must be a list of resources") + + async def _download_all_async(): + async with aiohttp.ClientSession() as session: + tasks = [ + self._async_download_file( + resource=r, + save_path=path, + session=session, + progress_bar=progress_bar, + add_extension=add_extension + ) + for r, path in zip(resources, save_path) + ] + return await asyncio.gather(*tasks) + + if isinstance(save_path, str): + save_path = [os.path.join(save_path, self._entid(r)) for r in resources] + + if len(save_path) != len(resources): + raise ValueError("The number of save paths must match the number of resources.") + + if not overwrite: + new_resources = [] + new_save_path = [] + for i in range(len(resources)): + if not os.path.exists(save_path[i]): + new_resources.append(resources[i]) + new_save_path.append(save_path[i]) + resources = new_resources + save_path = new_save_path + + with tqdm(total=len(resources), desc="Downloading resources", unit="file") as progress_bar: + loop = asyncio.get_event_loop() + final_save_paths = loop.run_until_complete(_download_all_async()) + + return final_save_paths + + def download_resource_file(self, + resource: str | Resource, + save_path: Optional[str] = None, + auto_convert: bool = True, + add_extension: bool = False + ) -> bytes | pydicom.Dataset | Image.Image | cv2.VideoCapture | nib_FileBasedImage | tuple[Any, str]: + """ + Download a resource file. + + Args: + resource: The resource unique id or Resource instance. + save_path: The path to save the file. + auto_convert: Whether to convert the file to a known format or not. + add_extension: Whether to add the appropriate file extension to the save_path based on the content type. + + Returns: + The resource content in bytes (if `auto_convert=False`) or the resource object (if `auto_convert=True`). + if `add_extension=True`, the function will return a tuple of (resource_data, save_path). + + Raises: + ResourceNotFoundError: If the resource does not exists. + + Example: + >>> api_handler.download_resource_file('resource_id', auto_convert=False) + returns the resource content in bytes. + >>> api_handler.download_resource_file('resource_id', auto_convert=True) + Assuming this resource is a dicom file, it will return a pydicom.Dataset object. + >>> api_handler.download_resource_file('resource_id', save_path='path/to/dicomfile.dcm') + saves the file in the specified path. + """ + if save_path is None and add_extension: + raise ValueError("If add_extension is True, save_path must be provided.") + + try: + response = self._make_entity_request('GET', + resource, + add_path='file', + headers={'accept': 'application/octet-stream'}) + + # Get mimetype if needed for auto_convert or add_extension + mimetype = None + ext = None + if auto_convert or add_extension: + mimetype, ext = self._determine_mimetype(content=response.content, + resource=resource) + if auto_convert: + if mimetype is None: + _LOGGER.warning("Could not determine mimetype. Returning a bytes array.") + resource_file = response.content + else: + try: + resource_file = BaseApi.convert_format(response.content, + mimetype, + save_path) + except ValueError as e: + _LOGGER.warning(f"Could not convert file to a known format: {e}") + resource_file = response.content + except NotImplementedError as e: + _LOGGER.warning(f"Conversion not implemented yet for {mimetype} and save_path=None." + + " Returning a bytes array. If you want the conversion for this mimetype, provide a save_path.") + resource_file = response.content + else: + resource_file = response.content + except ResourceNotFoundError as e: + e.set_params('resource', {'resource_id': self._entid(resource)}) + raise e + + if save_path is not None: + if add_extension and mimetype is not None: + if ext is None: + ext = guess_extension(mimetype) + if ext is not None and not save_path.endswith(ext): + save_path += ext + with open(save_path, 'wb') as f: + f.write(response.content) + + if add_extension: + return resource_file, save_path + return resource_file + + def download_resource_frame(self, + resource: str | Resource, + frame_index: int) -> Image.Image: + """ + Download a frame of a resource. + This is faster than downloading the whole resource and then extracting the frame. + + Args: + resource: The resource unique id or Resource object. + frame_index: The index of the frame to download. + + Returns: + Image.Image: The frame as a PIL image. + + Raises: + ResourceNotFoundError: If the resource does not exists. + DatamintException: If the resource is not a video or dicom. + """ + # check if the resource is an single frame image (png,jpeg,...) first. + # If so, download the whole resource file and return the image. + if not isinstance(resource, Resource): + resource = self.get_by_id(resource) + if resource.mimetype.startswith('image/') or resource.storage == 'ImageResource': + if frame_index != 0: + raise DatamintException(f"Resource {resource.id} is a single frame image, " + f"but frame_index is {frame_index}.") + return self.download_resource_file(resource, auto_convert=True) + + try: + response = self._make_entity_request('GET', + resource, + add_path=f'frames/{frame_index}', + headers={'accept': 'image/*'}) + if response.status_code == 200: + return Image.open(io.BytesIO(response.content)) + else: + raise DatamintException( + f"Error downloading frame {frame_index} of resource {self._entid(resource)}: {response.text}") + except ResourceNotFoundError as e: + e.set_params('resource', {'resource_id': self._entid(resource)}) + raise e + + def publish_resources(self, + resources: str | Resource | Sequence[str | Resource]) -> None: + """ + Publish resources, changing their status to 'published'. + + Args: + resources: The resources to publish. Can be a Resource object (instead of a list) + + Raises: + ResourceNotFoundError: If the resource does not exists or the project does not exists. + """ + if isinstance(resources, (Resource, str)): + resources = [resources] + + for resource in resources: + try: + self._make_entity_request('POST', resource, add_path='publish') + except ResourceNotFoundError as e: + e.set_params('resource', {'resource_id': self._entid(resource)}) + raise + except httpx.HTTPError as e: + if BaseApi._has_status_code(e, 400) and 'Resource must be in inbox status to be approved' in e.response.text: + _LOGGER.warning(f"Resource {resource} is not in inbox status. Skipping publishing") + else: + raise + + def set_tags(self, + resource: str | Resource, + tags: Sequence[str], + ): + data = {'tags': tags} + resource_id = self._entid(resource) + + response = self._make_entity_request('PUT', + resource_id, + add_path='tags', + json=data) + return response diff --git a/datamint/api/endpoints/users_api.py b/datamint/api/endpoints/users_api.py new file mode 100644 index 00000000..0df4053f --- /dev/null +++ b/datamint/api/endpoints/users_api.py @@ -0,0 +1,38 @@ +from ..entity_base_api import CreatableEntityApi, ApiConfig +from datamint.entities import User +import httpx + + +class UsersApi(CreatableEntityApi[User]): + def __init__(self, + config: ApiConfig, + client: httpx.Client | None = None) -> None: + super().__init__(config, User, 'users', client) + + def create(self, + email: str, + password: str | None = None, + firstname: str | None = None, + lastname: str | None = None, + roles: list[str] | None = None + ) -> str: + """Create a new user. + + Args: + email: The user's email address. + password: The user's password. If None, a random password will be generated. + firstname: The user's first name. + lastname: The user's last name. + roles: List of roles to assign to the user. + + Returns: + The id of the created user. + """ + data = dict( + email=email, + password=password, + firstname=firstname, + lastname=lastname, + roles=roles + ) + return self._create(data) diff --git a/datamint/api/entity_base_api.py b/datamint/api/entity_base_api.py new file mode 100644 index 00000000..50b702f7 --- /dev/null +++ b/datamint/api/entity_base_api.py @@ -0,0 +1,347 @@ +from typing import Any, TypeVar, Generic, Type, Sequence +import logging +import httpx +from dataclasses import dataclass +from datamint.entities.base_entity import BaseEntity +from datamint.exceptions import DatamintException, ResourceNotFoundError +import aiohttp +import asyncio +from .base_api import ApiConfig, BaseApi +import contextlib +from typing import AsyncGenerator + +logger = logging.getLogger(__name__) +T = TypeVar('T', bound=BaseEntity) + + +class EntityBaseApi(BaseApi, Generic[T]): + """Base API handler for entity-related endpoints with CRUD operations. + + This class provides a template for API handlers that work with specific + entity types, offering common CRUD operations with proper typing. + + Type Parameters: + T: The entity type this API handler manages (must extend BaseEntity) + """ + + def __init__(self, config: ApiConfig, + entity_class: Type[T], + endpoint_base: str, + client: httpx.Client | None = None) -> None: + """Initialize the entity API handler. + + Args: + config: API configuration containing base URL, API key, etc. + entity_class: The entity class this handler manages + endpoint_base: Base endpoint path (e.g., 'projects', 'annotations') + client: Optional HTTP client instance. If None, a new one will be created. + """ + super().__init__(config, client) + self.entity_class = entity_class + self.endpoint_base = endpoint_base.strip('/') + + @staticmethod + def _entid(entity: BaseEntity | str) -> str: + return entity if isinstance(entity, str) else entity.id + + def _make_entity_request(self, + method: str, + entity_id: str | BaseEntity, + add_path: str = '', + **kwargs) -> httpx.Response: + try: + entity_id = self._entid(entity_id) + add_path = '/'.join(add_path.strip().strip('/').split('/')) + return self._make_request(method, f'/{self.endpoint_base}/{entity_id}/{add_path}', **kwargs) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e + raise + + @contextlib.asynccontextmanager + async def _make_entity_request_async(self, + method: str, + entity_id: str | BaseEntity, + add_path: str = '', + session: aiohttp.ClientSession | None = None, + **kwargs) -> AsyncGenerator[aiohttp.ClientResponse, None]: + try: + entity_id = self._entid(entity_id) + add_path = '/'.join(add_path.strip().strip('/').split('/')) + async with self._make_request_async(method, + f'/{self.endpoint_base}/{entity_id}/{add_path}', + session=session, + **kwargs) as resp: + yield resp + except aiohttp.ClientResponseError as e: + if e.status == 404: + raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e + raise + + def _stream_entity_request(self, + method: str, + entity_id: str, + add_path: str = '', + **kwargs): + try: + add_path = '/'.join(add_path.strip().strip('/').split('/')) + return self._stream_request(method, f'/{self.endpoint_base}/{entity_id}/{add_path}', **kwargs) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e + raise + + def get_list(self, limit: int | None = None, + **kwargs) -> Sequence[T]: + """Get entities with optional filtering. + + Returns: + List of entity instances. + + Raises: + httpx.HTTPStatusError: If the request fails. + """ + new_kwargs = dict(kwargs) + + # Remove None values from the payload. + for k in list(new_kwargs.keys()): + if new_kwargs[k] is None: + del new_kwargs[k] + + items_gen = self._make_request_with_pagination('GET', f'/{self.endpoint_base}', + return_field=self.endpoint_base, + limit=limit, + **new_kwargs) + + all_items = [] + for resp, items in items_gen: + all_items.extend(items) + + return [self.entity_class(**item) for item in all_items] + + def get_all(self, limit: int | None = None) -> Sequence[T]: + """Get all entities with optional pagination and filtering. + + Returns: + List of entity instances + + Raises: + httpx.HTTPStatusError: If the request fails + """ + return self.get_list(limit=limit) + + def get_by_id(self, entity_id: str) -> T: + """Get a specific entity by its ID. + + Args: + entity_id: Unique identifier for the entity. + + Returns: + Entity instance. + + Raises: + httpx.HTTPStatusError: If the entity is not found or request fails. + """ + response = self._make_entity_request('GET', entity_id) + return self.entity_class(**response.json()) + + async def _create_async(self, entity_data: dict[str, Any]) -> str | Sequence[str | dict]: + """Create a new entity. + + Args: + entity_data: Dictionary containing entity data for creation. + + Returns: + The id of the created entity. + + Raises: + httpx.HTTPStatusError: If creation fails. + """ + respdata = await self._make_request_async_json('POST', + f'/{self.endpoint_base}', + json=entity_data) + if 'error' in respdata: + raise DatamintException(respdata['error']) + if isinstance(respdata, str): + return respdata + if isinstance(respdata, list): + return respdata + if isinstance(respdata, dict): + return respdata.get('id') + return respdata + + def _get_child_entities(self, + parent_entity: BaseEntity | str, + child_entity_name: str) -> httpx.Response: + response = self._make_entity_request('GET', parent_entity, + add_path=child_entity_name) + return response + + # def bulk_create(self, entities_data: list[dict[str, Any]]) -> list[T]: + # """Create multiple entities in a single request. + + # Args: + # entities_data: List of dictionaries containing entity data + + # Returns: + # List of created entity instances + + # Raises: + # httpx.HTTPStatusError: If bulk creation fails + # """ + # payload = {'items': entities_data} # Common bulk API format + # response = self._make_request('POST', f'/{self.endpoint_base}/bulk', json=payload) + # data = response.json() + + # # Handle response format - may be direct list or wrapped + # items = data if isinstance(data, list) else data.get('items', []) + # return [self.entity_class(**item) for item in items] + + # def count(self, **params: Any) -> int: + # """Get the total count of entities matching the given filters. + + # Args: + # **params: Query parameters for filtering + + # Returns: + # Total count of matching entities + + # Raises: + # httpx.HTTPStatusError: If the request fails + # """ + # response = self._make_request('GET', f'/{self.endpoint_base}/count', params=params) + # data = response.json() + # return data.get('count', 0) if isinstance(data, dict) else data + + +class DeletableEntityApi(EntityBaseApi[T]): + """Extension of EntityBaseApi for entities that support soft deletion. + + This class adds methods to handle soft-deleted entities, allowing + retrieval and restoration of such entities. + """ + + def delete(self, entity: str | BaseEntity) -> None: + """Delete an entity. + + Args: + entity: Unique identifier for the entity to delete or the entity instance itself. + + Raises: + httpx.HTTPStatusError: If deletion fails or entity not found + """ + self._make_entity_request('DELETE', entity) + + def bulk_delete(self, entities: Sequence[str | BaseEntity]) -> None: + """Delete multiple entities. + + Args: + entities: Sequence of unique identifiers for the entities to delete or the entity instances themselves. + + Raises: + httpx.HTTPStatusError: If deletion fails or any entity not found + """ + async def _delete_all_async(): + async with aiohttp.ClientSession() as session: + tasks = [ + self._delete_async(entity, session) + for entity in entities + ] + await asyncio.gather(*tasks) + + loop = asyncio.get_event_loop() + loop.run_until_complete(_delete_all_async()) + + async def _delete_async(self, + entity: str | BaseEntity, + session: aiohttp.ClientSession | None = None) -> None: + """Asynchronously delete an entity by its ID. + + Args: + entity: Unique identifier for the entity to delete or the entity instance itself. + + Raises: + httpx.HTTPStatusError: If deletion fails or entity not found + """ + async with self._make_entity_request_async('DELETE', entity, + session=session) as resp: + await resp.text() # Consume response to complete request + + # def get_deleted(self, **kwargs) -> Sequence[T]: + # pass + + # def restore(self, entity_id: str | BaseEntity) -> T: + # pass + + +class CreatableEntityApi(EntityBaseApi[T]): + """Extension of EntityBaseApi for entities that support creation. + + This class adds methods to handle creation of new entities. + """ + + def _create(self, entity_data: dict[str, Any]) -> str | list[str | dict]: + """Create a new entity. + + Args: + entity_data: Dictionary containing entity data for creation. + + Returns: + The id of the created entity. + + Raises: + httpx.HTTPStatusError: If creation fails. + """ + response = self._make_request('POST', f'/{self.endpoint_base}', json=entity_data) + respdata = response.json() + if isinstance(respdata, str): + return respdata + if isinstance(respdata, list): + return respdata + if isinstance(respdata, dict): + return respdata.get('id') + return respdata + + def create(self, *args, **kwargs) -> str | T: + raise NotImplementedError("Subclasses must implement the create method with their own custom parameters") + + +class UpdatableEntityApi(EntityBaseApi[T]): + # def update(self, entity_id: str, entity_data: dict[str, Any]): + # """Update an existing entity. + + # Args: + # entity_id: Unique identifier for the entity. + # entity_data: Dictionary containing updated entity data. + + # Returns: + # Updated entity instance. + + # Raises: + # httpx.HTTPStatusError: If update fails or entity not found. + # """ + # self._make_entity_request('PUT', entity_id, json=entity_data) + + def patch(self, entity: str | T, entity_data: dict[str, Any]): + """Partially update an existing entity. + + Args: + entity: Unique identifier for the entity or the entity instance. + entity_data: Dictionary containing fields to update. Only provided fields will be updated. + + Returns: + Updated entity instance. + + Raises: + httpx.HTTPStatusError: If update fails or entity not found. + """ + self._make_entity_request('PATCH', entity, json=entity_data) + + def partial_update(self, entity: str | T, entity_data: dict[str, Any]): + """Alias for :py:meth:`patch` to partially update an entity.""" + return self.patch(entity, entity_data) + + +class CRUDEntityApi(CreatableEntityApi[T], UpdatableEntityApi[T], DeletableEntityApi[T]): + """Full CRUD API handler for entities supporting create, read, update, delete operations.""" + pass diff --git a/datamint/apihandler/api_handler.py b/datamint/apihandler/api_handler.py index 63bb2c6a..1ec9d3ad 100644 --- a/datamint/apihandler/api_handler.py +++ b/datamint/apihandler/api_handler.py @@ -1,15 +1,12 @@ from .root_api_handler import RootAPIHandler from .annotation_api_handler import AnnotationAPIHandler from .exp_api_handler import ExperimentAPIHandler +from deprecated.sphinx import deprecated +@deprecated(reason="Please use `from datamint import Api` instead.", version="2.0.0") class APIHandler(RootAPIHandler, ExperimentAPIHandler, AnnotationAPIHandler): """ - Import using this code: - - .. code-block:: python - - from datamint import APIHandler - api = APIHandler() + Deprecated. Use `from datamint import Api` instead. """ pass \ No newline at end of file diff --git a/datamint/apihandler/base_api_handler.py b/datamint/apihandler/base_api_handler.py index 517d6952..887311c0 100644 --- a/datamint/apihandler/base_api_handler.py +++ b/datamint/apihandler/base_api_handler.py @@ -15,7 +15,8 @@ from nibabel.filebasedimages import FileBasedImage as nib_FileBasedImage from datamint import configs import gzip -from datamint.exceptions import DatamintException +from datamint.exceptions import DatamintException, ResourceNotFoundError +from deprecated.sphinx import deprecated _LOGGER = logging.getLogger(__name__) @@ -30,33 +31,7 @@ _PAGE_LIMIT = 5000 -class ResourceNotFoundError(DatamintException): - """ - Exception raised when a resource is not found. - For instance, when trying to get a resource by a non-existing id. - """ - - def __init__(self, - resource_type: str, - params: dict): - """ Constructor. - - Args: - resource_type (str): A resource type. - params (dict): Dict of params identifying the sought resource. - """ - super().__init__() - self.resource_type = resource_type - self.params = params - - def set_params(self, resource_type: str, params: dict): - self.resource_type = resource_type - self.params = params - - def __str__(self): - return f"Resource '{self.resource_type}' not found for parameters: {self.params}" - - +@deprecated(reason="Please use `from datamint import Api` instead.", version="2.0.0") class BaseAPIHandler: """ Class to handle the API requests to the Datamint API @@ -68,6 +43,9 @@ def __init__(self, root_url: Optional[str] = None, api_key: Optional[str] = None, check_connection: bool = True): + # deprecated + _LOGGER.warning("The class APIHandler is deprecated and will be removed in future versions. " + "Please use `from datamint import Api` instead.") nest_asyncio.apply() # For running asyncio in jupyter notebooks self.root_url = root_url if root_url is not None else configs.get_value(configs.APIURL_KEY) if self.root_url is None: diff --git a/datamint/apihandler/dto/__init__.py b/datamint/apihandler/dto/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/datamint/apihandler/dto/annotation_dto.py b/datamint/apihandler/dto/annotation_dto.py index 9e3df144..285d4eb3 100644 --- a/datamint/apihandler/dto/annotation_dto.py +++ b/datamint/apihandler/dto/annotation_dto.py @@ -152,7 +152,7 @@ def __init__(self, type: AnnotationType | str, identifier: str, scope: str, - annotation_worklist_id: str, + annotation_worklist_id: str | None = None, value=None, imported_from: str | None = None, import_author: str | None = None, diff --git a/datamint/client_cmd_tools/datamint_upload.py b/datamint/client_cmd_tools/datamint_upload.py index 13fd80dd..f2663197 100644 --- a/datamint/client_cmd_tools/datamint_upload.py +++ b/datamint/client_cmd_tools/datamint_upload.py @@ -1,6 +1,7 @@ from datamint.exceptions import DatamintException import argparse -from datamint.apihandler.api_handler import APIHandler +# from datamint.apihandler.api_handler import APIHandler +from datamint import Api import os from humanize import naturalsize import logging @@ -780,45 +781,33 @@ def main(): has_a_dicom_file = any(is_dicom(f) for f in files_path) try: - api_handler = APIHandler(check_connection=True) + api = Api(check_connection=True) except DatamintException as e: _USER_LOGGER.error(f'❌ Connection failed: {e}') return try: - results = api_handler.upload_resources(channel=args.channel, - files_path=files_path, - tags=args.tag, - on_error='skip', - anonymize=args.retain_pii == False and has_a_dicom_file, - anonymize_retain_codes=args.retain_attribute, - mung_filename=args.mungfilename, - publish=args.publish, - segmentation_files=segfiles, - transpose_segmentation=args.transpose_segmentation, - assemble_dicoms=True, - metadata=metadata_files, - progress_bar=True - ) + print('>>>', segfiles) + results = api.resources.upload_resources(channel=args.channel, + files_path=files_path, + tags=args.tag, + on_error='skip', + anonymize=args.retain_pii == False and has_a_dicom_file, + anonymize_retain_codes=args.retain_attribute, + mung_filename=args.mungfilename, + publish=args.publish, + publish_to=args.project, + segmentation_files=segfiles, + transpose_segmentation=args.transpose_segmentation, + assemble_dicoms=True, + metadata=metadata_files, + progress_bar=True + ) except pydicom.errors.InvalidDicomError as e: _USER_LOGGER.error(f'❌ Invalid DICOM file: {e}') return _USER_LOGGER.info('Upload finished!') _LOGGER.debug(f"Number of results: {len(results)}") - # Add resources to project if specified - if args.project is not None: - _USER_LOGGER.info(f"Adding uploaded resources to project '{args.project}'...") - try: - # Filter successful uploads to get resource IDs - successful_resource_ids = [r for r in results if not isinstance(r, Exception)] - if successful_resource_ids: - api_handler.add_to_project(project_name=args.project, resource_ids=successful_resource_ids) - _USER_LOGGER.info(f"✅ Successfully added {len(successful_resource_ids)} resources to project '{args.project}'") - else: - _USER_LOGGER.warning("No successful uploads to add to project") - except Exception as e: - _USER_LOGGER.error(f"❌ Failed to add resources to project '{args.project}': {e}") - num_failures = print_results_summary(files_path, results) if num_failures > 0: sys.exit(1) diff --git a/datamint/dataset/base_dataset.py b/datamint/dataset/base_dataset.py index 671d96dc..9e998c12 100644 --- a/datamint/dataset/base_dataset.py +++ b/datamint/dataset/base_dataset.py @@ -13,14 +13,16 @@ from torch.utils.data import DataLoader import torch from torch import Tensor -from datamint.apihandler.base_api_handler import DatamintException +from datamint.exceptions import DatamintException from medimgkit.dicom_utils import is_dicom from medimgkit.readers import read_array_normalized -from medimgkit.format_detection import guess_extension +from medimgkit.format_detection import guess_extension, guess_typez +from medimgkit.nifti_utils import NIFTI_MIMES, get_nifti_shape from datetime import datetime from pathlib import Path -from datamint.dataset.annotation import Annotation +from datamint.entities import Annotation, DatasetInfo import cv2 +from datamint.entities import Resource _LOGGER = logging.getLogger(__name__) @@ -174,23 +176,12 @@ def _initialize_config( def _setup_api_handler(self, server_url: Optional[str], api_key: Optional[str], auto_update: bool) -> None: """Setup API handler and validate connection.""" - from datamint.apihandler.api_handler import APIHandler - - self.api_handler = APIHandler( - root_url=server_url, + from datamint import Api + self.api = Api( + server_url=server_url, api_key=api_key, - check_connection=auto_update + check_connection=self.auto_update ) - self.server_url = self.api_handler.root_url - self.api_key = self.api_handler.api_key - - if self.api_key is None: - _LOGGER.warning( - "API key not provided. If you want to download data, please provide an API key, " - f"either by passing it as an argument, " - f"setting environment variable {configs.ENV_VARS[configs.APIKEY_KEY]} or " - "using datamint-config command line tool." - ) def _setup_directories(self, root: str | None) -> None: """Setup root and dataset directories.""" @@ -242,7 +233,7 @@ def _load_metadata(self) -> bool: if not os.path.isfile(metadata_path): # get the server info self.project_info = self.get_info() - self.metainfo = self._get_datasetinfo().copy() + self.metainfo = self._get_datasetinfo().asdict().copy() self.metainfo['updated_at'] = None self.metainfo['resources'] = [] self.metainfo['all_annotations'] = self.all_annotations @@ -412,19 +403,33 @@ def get_annotations( @staticmethod def read_number_of_frames(filepath: str) -> int: """Read the number of frames in a file.""" - if is_dicom(filepath): + + mimetypes, ext = guess_typez(filepath) + mimetype = mimetypes[0] + if mimetype is None: + raise ValueError(f"Could not determine MIME type for file: {filepath}") + + if mimetype == 'application/dicom': ds = pydicom.dcmread(filepath) return getattr(ds, 'NumberOfFrames', 1) - elif filepath.lower().endswith(('.mp4', '.avi')): + elif mimetype.startswith('video/'): cap = cv2.VideoCapture(filepath) try: return int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) finally: cap.release() - elif filepath.lower().endswith(('.png', '.jpg', '.jpeg')): + elif mimetype in ('image/png', 'image/jpeg', 'image/jpg', 'image/bmp', 'image/tiff'): return 1 + elif mimetype in NIFTI_MIMES: + shape = get_nifti_shape(filepath) + if len(shape) == 3: + return shape[-1] + elif len(shape) > 3: + return shape[3] + else: + return 1 else: - raise ValueError(f"Unsupported file type: {filepath}") + raise ValueError(f"Unsupported file type '{mimetype}' for file {filepath}") def get_resources_ids(self) -> list[str]: """Get list of resource IDs.""" @@ -526,18 +531,18 @@ def _check_integrity(self) -> None: if missing_files: raise DatamintDatasetException(f"Image files not found: {missing_files}") - def _get_datasetinfo(self) -> dict: + def _get_datasetinfo(self) -> DatasetInfo: """Get dataset information from API.""" if self._server_dataset_info is not None: return self._server_dataset_info - all_datasets = self.api_handler.get_datasets() + all_datasets = self.api._datasetsinfo.get_all() for dataset in all_datasets: - if dataset['id'] == self.dataset_id: + if dataset.id == self.dataset_id: self._server_dataset_info = dataset return dataset - available_datasets = [(d['name'], d['id']) for d in all_datasets] + available_datasets = [(d.name, d.id) for d in all_datasets] raise DatamintDatasetException( f"Dataset with id '{self.dataset_id}' not found. " f"Available datasets: {available_datasets}" @@ -547,7 +552,7 @@ def get_info(self) -> dict: """Get project information from API.""" if hasattr(self, 'project_info') and self.project_info is not None: return self.project_info - project = self.api_handler.get_project_by_name(self.project_name) + project = self.api.projects.get_by_name(self.project_name).asdict() if 'error' in project: available_projects = project['all_projects'] raise DatamintDatasetException( @@ -592,31 +597,10 @@ def __repr__(self) -> str: lines = [head] + [" " * 4 + line for line in body] return "\n".join(lines) - def download_project(self) -> None: - """Download project data from API.""" - - dataset_info = self._get_datasetinfo() - self.dataset_id = dataset_info['id'] - self.last_updaded_at = dataset_info['updated_at'] - - self.api_handler.download_project( - self.project_info['id'], - self.dataset_zippath, - all_annotations=self.all_annotations, - include_unannotated=self.include_unannotated - ) - - _LOGGER.debug("Downloaded dataset") - - if os.path.getsize(self.dataset_zippath) == 0: - raise DatamintDatasetException("Download failed.") - - self._extract_and_update_metadata() - def _get_dataset_id(self) -> str: if self.dataset_id is None: dataset_info = self._get_datasetinfo() - self.dataset_id = dataset_info['id'] + self.dataset_id = dataset_info.id return self.dataset_id def _extract_and_update_metadata(self) -> None: @@ -638,7 +622,7 @@ def _extract_and_update_metadata(self) -> None: # Save updated metadata with open(datasetjson_path, 'w') as file: - json.dump(self.metainfo, file, default=lambda o: o.to_dict() if hasattr(o, 'to_dict') else o) + json.dump(self.metainfo, file, default=lambda o: o.asdict() if hasattr(o, 'asdict') else o) self.images_metainfo = self.metainfo['resources'] # self._convert_metainfo_to_clsobj() @@ -646,19 +630,19 @@ def _extract_and_update_metadata(self) -> None: def _update_metadata_timestamps(self) -> None: """Update metadata with correct timestamps.""" if 'updated_at' not in self.metainfo: - self.metainfo['updated_at'] = self.last_updaded_at + self.metainfo['updated_at'] = self.last_updated_at else: try: local_time = datetime.fromisoformat(self.metainfo['updated_at']) - server_time = datetime.fromisoformat(self.last_updaded_at) + server_time = datetime.fromisoformat(self.last_updated_at) if local_time < server_time: _LOGGER.warning( f"Inconsistent updated_at dates detected " - f"({self.metainfo['updated_at']} < {self.last_updaded_at}). " - f"Fixing it to {self.last_updaded_at}" + f"({self.metainfo['updated_at']} < {self.last_updated_at}). " + f"Fixing it to {self.last_updated_at}" ) - self.metainfo['updated_at'] = self.last_updaded_at + self.metainfo['updated_at'] = self.last_updated_at except Exception as e: _LOGGER.warning(f"Failed to parse updated_at date: {e}") @@ -690,6 +674,9 @@ def _process_image_array(self, img: np.ndarray) -> Tensor: 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): @@ -829,7 +816,7 @@ def _check_version(self) -> None: try: external_metadata_info = self._get_datasetinfo() - server_updated_at = external_metadata_info['updated_at'] + server_updated_at = external_metadata_info.updated_at except Exception as e: _LOGGER.warning(f"Failed to check for updates in {self.project_name}: {e}") return @@ -856,20 +843,21 @@ def _check_version(self) -> None: _LOGGER.info('Local version is up to date with the latest version.') def _fetch_new_resources(self, - all_uptodate_resources: list[dict]) -> list[dict]: + all_uptodate_resources: list[Resource]) -> list[dict]: local_resources = self.images_metainfo local_resources_ids = [res['id'] for res in local_resources] new_resources = [] for resource in all_uptodate_resources: + resource = resource.asdict() if resource['id'] not in local_resources_ids: resource['file'] = str(self._get_resource_file_path(resource)) resource['annotations'] = [] new_resources.append(resource) return new_resources - def _fetch_deleted_resources(self, all_uptodate_resources: list[dict]) -> list[dict]: + def _fetch_deleted_resources(self, all_uptodate_resources: list[Resource]) -> list[dict]: local_resources = self.images_metainfo - all_uptodate_resources_ids = [res['id'] for res in all_uptodate_resources] + all_uptodate_resources_ids = [res.id for res in all_uptodate_resources] deleted_resources = [] for resource in local_resources: try: @@ -888,7 +876,7 @@ def _incremental_update(self) -> None: # server_updated_at = external_metadata_info['updated_at'] ### RESOURCES ### - all_uptodate_resources = self.api_handler.get_project_resources(self.get_info()['id']) + all_uptodate_resources = self.api.projects.get_project_resources(self.get_info()['id']) new_resources = self._fetch_new_resources(all_uptodate_resources) deleted_resources = self._fetch_deleted_resources(all_uptodate_resources) @@ -898,9 +886,9 @@ def _incremental_update(self) -> None: new_resources_path = [Path(self.dataset_dir) / r['file'] for r in new_resources] new_resources_ids = [r['id'] for r in new_resources] _LOGGER.info(f"Downloading {len(new_resources)} new resources...") - new_res_paths = self.api_handler.download_multiple_resources(new_resources_ids, - save_path=new_resources_path, - add_extension=True) + new_res_paths = self.api.resources.download_multiple_resources(new_resources_ids, + save_path=new_resources_path, + add_extension=True) for new_rpath, r in zip(new_res_paths, new_resources): r['file'] = str(Path(new_rpath).relative_to(self.dataset_dir)) _LOGGER.info(f"Downloaded {len(new_resources)} new resources.") @@ -910,16 +898,17 @@ def _incremental_update(self) -> None: ################ ### ANNOTATIONS ### - all_annotations = self.api_handler.get_annotations(worklist_id=self.project_info['worklist_id'], - status='published' if self.all_annotations else None) + all_annotations = self.api.annotations.get_list(worklist_id=self.project_info['worklist_id'], + status='published' if self.all_annotations else None) + # group annotations by resource ID - annotations_by_resource = {} + annotations_by_resource: dict[str, list[Annotation]] = {} for ann in all_annotations: # add the local filepath filepath = self._get_annotation_file_path(ann) if filepath is not None: - ann['file'] = str(filepath) - resource_id = ann['resource_id'] + ann.file = str(filepath) + resource_id = ann.resource_id if resource_id not in annotations_by_resource: annotations_by_resource[resource_id] = [] annotations_by_resource[resource_id].append(ann) @@ -937,11 +926,11 @@ def _incremental_update(self) -> None: # check if segmentation annotations need to be downloaded # Also check if annotations need to be deleted old_ann_ids = set([ann.id for ann in old_resource_annotations if hasattr(ann, 'id')]) - new_ann_ids = set([ann['id'] for ann in new_resource_annotations]) + new_ann_ids = set([ann.id for ann in new_resource_annotations]) # Find annotations to add, update, or remove annotations_to_add = [ann for ann in new_resource_annotations - if ann['id'] not in old_ann_ids] + if ann.id not in old_ann_ids] annotations_to_remove = [ann for ann in old_resource_annotations if getattr(ann, 'id', 'NA') not in new_ann_ids] @@ -970,22 +959,23 @@ def _incremental_update(self) -> None: _LOGGER.error(f"Error deleting annotation file {filepath}: {e}") # Update resource annotations list - convert to Annotation objects - resource['annotations'] = [Annotation.from_dict(ann) for ann in new_resource_annotations] + # resource['annotations'] = [Annotation.from_dict(ann) for ann in new_resource_annotations] + resource['annotations'] = new_resource_annotations # Batch download all segmentation files if segmentations_to_download: _LOGGER.info(f"Downloading {len(segmentations_to_download)} segmentation files...") - self.api_handler.download_multiple_segmentations(segmentations_to_download, segmentation_paths) + self.api.annotations.download_multiple_files(segmentations_to_download, segmentation_paths) _LOGGER.info(f"Downloaded {len(segmentations_to_download)} segmentation files.") ################### # update metadata - self.metainfo['updated_at'] = self._get_datasetinfo()['updated_at'] + self.metainfo['updated_at'] = self._get_datasetinfo().updated_at self.metainfo['all_annotations'] = self.all_annotations # save updated metadata datasetjson_path = os.path.join(self.dataset_dir, 'dataset.json') with open(datasetjson_path, 'w') as file: - json.dump(self.metainfo, file, default=lambda o: o.to_dict() if hasattr(o, 'to_dict') else o) + json.dump(self.metainfo, file, default=lambda o: o.asdict() if hasattr(o, 'asdict') else o) def _get_resource_file_path(self, resource: dict) -> Path: """Get the local file path for a resource.""" diff --git a/datamint/dataset/dataset.py b/datamint/dataset/dataset.py index b580b1da..737c657b 100644 --- a/datamint/dataset/dataset.py +++ b/datamint/dataset/dataset.py @@ -7,7 +7,7 @@ import logging from PIL import Image import albumentations -from datamint.dataset.annotation import Annotation +from datamint.entities.annotation import Annotation _LOGGER = logging.getLogger(__name__) @@ -155,7 +155,7 @@ def _load_segmentations(self, annotations: list[Annotation], img_shape) -> tuple # FIXME: avoid enforcing resizing the mask seg = (Image.open(segfilepath) .convert('L') - .resize((w, h), Image.NEAREST) + .resize((w, h), Image.Resampling.NEAREST) ) seg = np.array(seg) diff --git a/datamint/entities/__init__.py b/datamint/entities/__init__.py new file mode 100644 index 00000000..3162ff4b --- /dev/null +++ b/datamint/entities/__init__.py @@ -0,0 +1,20 @@ +"""DataMint entities package.""" + +from .annotation import Annotation +from .base_entity import BaseEntity +from .channel import Channel, ChannelResourceData +from .project import Project +from .resource import Resource +from .user import User # new export +from .datasetinfo import DatasetInfo + +__all__ = [ + 'Annotation', + 'BaseEntity', + 'Channel', + 'ChannelResourceData', + 'Project', + 'Resource', + "User", + 'DatasetInfo', +] diff --git a/datamint/entities/annotation.py b/datamint/entities/annotation.py new file mode 100644 index 00000000..c5a69406 --- /dev/null +++ b/datamint/entities/annotation.py @@ -0,0 +1,178 @@ +# filepath: datamint/entities/annotation.py +"""Annotation entity module for DataMint API. + +This module defines the Annotation model used to represent annotation +records returned by the DataMint API. +""" + +from typing import Any +import logging +from .base_entity import BaseEntity, MISSING_FIELD +from pydantic import Field +from datetime import datetime + +logger = logging.getLogger(__name__) + +# Map API field names to class attributes +_FIELD_MAPPING = { + 'type': 'annotation_type', + 'name': 'identifier', + 'added_by': 'created_by', + 'index': 'frame_index', +} + + +class Annotation(BaseEntity): + """Pydantic Model representing a DataMint annotation. + + Attributes: + id: Unique identifier for the annotation. + identifier: User-friendly identifier or label for the annotation. + scope: Scope of the annotation (e.g., "frame", "image"). + frame_index: Index of the frame if scope is frame-based. + annotation_type: Type of annotation (e.g., "segmentation", "bbox", "label"). + 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. + 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. + status: Lifecycle status of the annotation (e.g., "new", "approved"). + approved_at: Optional ISO timestamp for approval time. + approved_by: Optional identifier of the approver. + resource_id: ID of the resource this annotation belongs to. + associated_file: Path or identifier of any associated file artifact. + deleted: Whether the annotation is marked as deleted. + deleted_at: Optional ISO timestamp for deletion time. + deleted_by: Optional identifier of the user who deleted the annotation. + created_by_model: Optional identifier of the model that created this annotation. + old_geometry: Optional previous geometry payload for change tracking. + set_name: Optional set name this annotation belongs to. + resource_filename: Optional filename of the resource. + resource_modality: Optional modality of the resource (e.g., CT, MR). + annotation_worklist_name: Optional worklist name associated with the annotation. + user_info: Optional user information with keys like firstname and lastname. + values: Optional extra values payload for flexible schemas. + """ + + id: str + identifier: str + scope: str + frame_index: int | None + annotation_type: str + text_value: str | None + numeric_value: float | int | None + units: str | None + geometry: list | dict | None + created_at: str # ISO timestamp string + created_by: str + annotation_worklist_id: str | None + status: str + approved_at: str | None # ISO timestamp string + approved_by: str | None + resource_id: str + associated_file: str | None + deleted: bool + deleted_at: str | None # ISO timestamp string + deleted_by: str | None + created_by_model: str | None + set_name: str | None + resource_filename: str | None + resource_modality: str | None + annotation_worklist_name: str | None + user_info: dict | None + values: list | None = MISSING_FIELD + file: str | None = None # Add file field for segmentations + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'Annotation': + """Create an Annotation instance from a dictionary. + + Args: + data: Dictionary containing annotation data from API + + Returns: + Annotation instance + """ + # Convert field names and filter valid fields + converted_data = {} + for key, value in data.items(): + # Map field names if needed + mapped_key = _FIELD_MAPPING.get(key, key) + converted_data[mapped_key] = value + + if 'scope' not in converted_data: + converted_data['scope'] = 'image' if converted_data.get('frame_index') is None else 'frame' + + if converted_data['annotation_type'] in ['segmentation']: + if converted_data.get('file') is None: + raise ValueError(f"Segmentation annotations must have an associated file. {data}") + + # Create instance with only valid fields + valid_fields = {f for f in cls.model_fields.keys()} + filtered_data = {k: v for k, v in converted_data.items() if k in valid_fields} + + return cls(**filtered_data) + + @property + def type(self) -> str: + """Alias for :attr:`annotation_type`.""" + return self.annotation_type + + @property + def name(self) -> str: + """Get the annotation name (alias for identifier).""" + return self.identifier + + @property + def index(self) -> int | None: + """Get the frame index (alias for frame_index).""" + return self.frame_index + + @property + def value(self) -> str | None: + """Get the annotation value (for category annotations).""" + return self.text_value + + @property + 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' + + def is_image_scoped(self) -> bool: + """Check if this annotation is image-scoped.""" + return self.scope == 'image' + + def get_created_datetime(self) -> datetime | None: + """ + Get the creation datetime as a datetime object. + + Returns: + datetime object or None if created_at is not set + """ + if isinstance(self.created_at, datetime): + return self.created_at + + if self.created_at: + try: + return datetime.fromisoformat(self.created_at.replace('Z', '+00:00')) + except ValueError: + logger.warning(f"Could not parse created_at datetime: {self.created_at}") + return None diff --git a/datamint/entities/base_entity.py b/datamint/entities/base_entity.py new file mode 100644 index 00000000..d857424b --- /dev/null +++ b/datamint/entities/base_entity.py @@ -0,0 +1,51 @@ +import logging +import sys +from typing import Any +from pydantic import ConfigDict, BaseModel + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self +_LOGGER = logging.getLogger(__name__) + +MISSING_FIELD = 'MISSING_FIELD' # Used when a field is sometimes missing for one endpoint but not on another endpoint + +# Track logged warnings to avoid duplicates +_LOGGED_WARNINGS: set[tuple[str, str]] = set() + + +class BaseEntity(BaseModel): + """ + Base class for all entities in the Datamint system. + + This class provides common functionality for all entities, such as + serialization and deserialization from dictionaries, as well as + handling unknown fields gracefully. + """ + + model_config = ConfigDict(extra='allow') # Allow extra fields not defined in the model + + def asdict(self) -> dict[str, Any]: + """Convert the entity to a dictionary, including unknown fields.""" + return self.model_dump(warnings='none') + + def asjson(self) -> str: + """Convert the entity to a JSON string, including unknown fields.""" + return self.model_dump_json(warnings='none') + + def model_post_init(self, __context: Any) -> None: + """Handle unknown fields by logging a warning once per class/field combination in debug mode.""" + if self.__pydantic_extra__ and _LOGGER.isEnabledFor(logging.DEBUG): + class_name = self.__class__.__name__ + + have_to_log = False + for key in self.__pydantic_extra__.keys(): + warning_key = (class_name, key) + + if warning_key not in _LOGGED_WARNINGS: + _LOGGED_WARNINGS.add(warning_key) + have_to_log = True + + if have_to_log: + _LOGGER.warning(f"Unknown fields {list(self.__pydantic_extra__.keys())} found in {class_name}") diff --git a/datamint/entities/channel.py b/datamint/entities/channel.py new file mode 100644 index 00000000..9b6637bb --- /dev/null +++ b/datamint/entities/channel.py @@ -0,0 +1,46 @@ +from pydantic import ConfigDict, BaseModel +from datetime import datetime +from datamint.entities.base_entity import BaseEntity + + +class ChannelResourceData(BaseModel): + """Represents resource data within a channel. + + Attributes: + created_by: Email of the user who created the resource. + customer_id: UUID of the customer. + resource_id: UUID of the resource. + resource_file_name: Original filename of the resource. + resource_mimetype: MIME type of the resource. + """ + model_config = ConfigDict(extra='allow') + + created_by: str + customer_id: str + resource_id: str + resource_file_name: str + resource_mimetype: str + + +class Channel(BaseEntity): + """Represents a channel containing multiple resources. + + A channel is a collection of resources grouped together, + typically for batch processing or organization purposes. + + Attributes: + channel_name: Name identifier for the channel. + resource_data: List of resources contained in this channel. + deleted: Whether the channel has been marked as deleted. + created_at: Timestamp when the channel was created. + updated_at: Timestamp when the channel was last updated. + """ + channel_name: str + resource_data: list[ChannelResourceData] + deleted: bool = False + created_at: str | None = None + updated_at: str | None = None + + def get_resource_ids(self) -> list[str]: + """Get list of all resource IDs in this channel.""" + return [resource.resource_id for resource in self.resource_data] if self.resource_data else [] \ No newline at end of file diff --git a/datamint/entities/datasetinfo.py b/datamint/entities/datasetinfo.py new file mode 100644 index 00000000..470574c2 --- /dev/null +++ b/datamint/entities/datasetinfo.py @@ -0,0 +1,22 @@ +"""Project entity module for DataMint API.""" + +from datetime import datetime +import logging +from .base_entity import BaseEntity, MISSING_FIELD + +logger = logging.getLogger(__name__) + + +class DatasetInfo(BaseEntity): + """Pydantic Model representing a DataMint dataset. + """ + + id: str + name: str + created_at: str # ISO timestamp string + created_by: str + description: str + customer_id: str + updated_at: str | None + total_resource: int + resource_ids: list[str] diff --git a/datamint/entities/project.py b/datamint/entities/project.py new file mode 100644 index 00000000..cc2cff6a --- /dev/null +++ b/datamint/entities/project.py @@ -0,0 +1,64 @@ +"""Project entity module for DataMint API.""" + +from datetime import datetime +import logging +from .base_entity import BaseEntity, MISSING_FIELD + +logger = logging.getLogger(__name__) + + +class Project(BaseEntity): + """Pydantic Model representing a DataMint project. + + This class models a project entity from the DataMint API, containing + information about the project, its dataset, worklist, AI model, and + annotation statistics. + + Attributes: + id: Unique identifier for the project + name: Human-readable name of the project + description: Optional description of the project + created_at: ISO timestamp when the project was created + created_by: Email of the user who created the project + dataset_id: ID of the associated dataset + worklist_id: ID of the associated worklist + ai_model_id: Optional ID of the associated AI model + viewable_ai_segs: Optional configuration for viewable AI segments + editable_ai_segs: Optional configuration for editable AI segments + archived: Whether the project is archived + resource_count: Total number of resources in the project + annotated_resource_count: Number of resources that have been annotated + most_recent_experiment: Optional information about the most recent experiment + closed_resources_count: Number of resources marked as closed/completed + resources_to_annotate_count: Number of resources still needing annotation + annotators: List of annotators assigned to this project + """ + id: str + name: str + created_at: str # ISO timestamp string + created_by: str + dataset_id: str + worklist_id: str + archived: bool + resource_count: int + annotated_resource_count: int + description: str | None + ai_model_id: str | None + viewable_ai_segs: list | None + editable_ai_segs: list | None + closed_resources_count: int = MISSING_FIELD + resources_to_annotate_count: int = MISSING_FIELD + most_recent_experiment: str | None = MISSING_FIELD # ISO timestamp string + annotators: list[dict] = MISSING_FIELD + customer_id: str | None = 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 + + @property + def url(self) -> str: + """Get the URL to access this project in the DataMint web application.""" + base_url = "https://app.datamint.io/projects/edit" + return f"{base_url}/{self.id}" diff --git a/datamint/entities/resource.py b/datamint/entities/resource.py new file mode 100644 index 00000000..84eb53de --- /dev/null +++ b/datamint/entities/resource.py @@ -0,0 +1,130 @@ +"""Resource entity module for DataMint API.""" + +from datetime import datetime +from typing import Optional, Any +import logging +from .base_entity import BaseEntity, MISSING_FIELD +from pydantic import Field + +logger = logging.getLogger(__name__) + +class Resource(BaseEntity): + """Represents a DataMint resource with all its properties and metadata. + + This class models a resource entity from the DataMint API, containing + information about uploaded files, their metadata, and associated projects. + + Attributes: + id: Unique identifier for the resource + resource_uri: URI path to access the resource file + storage: Storage type (e.g., 'DicomResource') + location: Storage location path + upload_channel: Channel used for upload (e.g., 'tmp') + filename: Original filename of the resource + modality: Medical imaging modality + mimetype: MIME type of the file + size: File size in bytes + upload_mechanism: Mechanism used for upload (e.g., 'api') + customer_id: Customer/organization identifier + status: Current status of the resource + created_at: ISO timestamp when resource was created + created_by: Email of the user who created the resource + published: Whether the resource is published + published_on: ISO timestamp when resource was published + published_by: Email of the user who published the resource + publish_transforms: Optional publication transforms + deleted: Whether the resource is deleted + deleted_at: Optional ISO timestamp when resource was deleted + deleted_by: Optional email of the user who deleted the resource + metadata: Resource metadata with DICOM information + source_filepath: Original source file path + tags: List of tags associated with the resource + instance_uid: DICOM SOP Instance UID (top-level) + series_uid: DICOM Series Instance UID (top-level) + study_uid: DICOM Study Instance UID (top-level) + patient_id: Patient identifier (top-level) + segmentations: Optional segmentation data + measurements: Optional measurement data + categories: Optional category data + labels: List of labels associated with the resource + user_info: Information about the user who created the resource + projects: List of projects this resource belongs to + """ + id: str + resource_uri: str + storage: str + location: str + upload_channel: str + filename: str + modality: str + mimetype: str + size: int + upload_mechanism: str + customer_id: str + status: str + created_at: str + created_by: str + published: bool + deleted: bool + source_filepath: str | None + metadata: dict + projects: list[dict] = MISSING_FIELD + published_on: str | None + published_by: str | None + tags: list[str] | None = None + publish_transforms: Optional[Any] = None + deleted_at: Optional[str] = None + deleted_by: Optional[str] = None + instance_uid: Optional[str] = None + series_uid: Optional[str] = None + study_uid: Optional[str] = None + patient_id: Optional[str] = None + segmentations: Optional[Any] = None # TODO: Define proper type when spec available + measurements: Optional[Any] = None # TODO: Define proper type when spec available + categories: Optional[Any] = None # TODO: Define proper type when spec available + user_info: Optional[dict] = None + + @property + def size_mb(self) -> float: + """Get file size in megabytes. + + Returns: + File size in MB rounded to 2 decimal places + """ + return round(self.size / (1024 * 1024), 2) + + def is_dicom(self) -> bool: + """Check if the resource is a DICOM file. + + Returns: + True if the resource is a DICOM file, False otherwise + """ + return self.mimetype == 'application/dicom' or self.storage == 'DicomResource' + + def get_project_names(self) -> list[str]: + """Get list of project names this resource belongs to. + + Returns: + List of project names + """ + return [proj['name'] for proj in self.projects] + + def __str__(self) -> str: + """String representation of the resource. + + Returns: + Human-readable string describing the resource + """ + return f"Resource(id='{self.id}', filename='{self.filename}', size={self.size_mb}MB)" + + def __repr__(self) -> str: + """Detailed string representation of the resource. + + Returns: + Detailed string representation for debugging + """ + return ( + f"Resource(id='{self.id}', filename='{self.filename}', " + f"modality='{self.modality}', status='{self.status}', " + f"published={self.published})" + ) diff --git a/datamint/entities/user.py b/datamint/entities/user.py new file mode 100644 index 00000000..e7cfa508 --- /dev/null +++ b/datamint/entities/user.py @@ -0,0 +1,21 @@ +from .base_entity import BaseEntity + +class User(BaseEntity): + """User entity model. + + Attributes: + email: User email address (unique identifier in most cases). + firstname: First name. + lastname: Last name. + roles: List of role strings assigned to the user. + customer_id: UUID of the owning customer/tenant. + created_at: ISO 8601 timestamp of creation. + """ + email: str + firstname: str | None + lastname: str | None + roles: list[str] + customer_id: str + created_at: str + + # Potential improvement: convert created_at to datetime for easier comparisons. \ No newline at end of file diff --git a/datamint/examples/example_projects.py b/datamint/examples/example_projects.py index 81ffb7bb..6109b7eb 100644 --- a/datamint/examples/example_projects.py +++ b/datamint/examples/example_projects.py @@ -1,75 +1,72 @@ import requests import io -from datamint import APIHandler +from datamint import Api import logging from PIL import Image import numpy as np +from datamint.entities import Project, Resource +from pydicom.data import get_testdata_file _LOGGER = logging.getLogger(__name__) -def _download_pydicom_test_file(filename: str) -> io.BytesIO: - """Download a pydicom test file from GitHub and return its content as a BytesIO object.""" - url = f'https://raw.githubusercontent.com/pydicom/pydicom/master/tests/data/{filename}' - response = requests.get(url) - response.raise_for_status() - content = io.BytesIO(response.content) - content.name = filename - return content - - class ProjectMR: @staticmethod - def upload_resource_emri_small(api: APIHandler = None) -> str: + def upload_resource_emri_small(api: Api | None = None) -> Resource: if api is None: - api = APIHandler() + api = Api() - searched_res = api.get_resources(status='published', tags=['example'], filename='emri_small.dcm') + searched_res = api.resources.get_list(status='published', + tags=['example'], + filename='emri_small.dcm') for res in searched_res: _LOGGER.info('Resource already exists.') - return res['id'] + return res - dcm_content = _download_pydicom_test_file('emri_small.dcm') + dcm_path = get_testdata_file("emri_small.dcm", + read=False) - _LOGGER.info(f'Uploading resource {dcm_content.name}...') - return api.upload_resources(dcm_content, - anonymize=True, - publish=True, - tags=['example']) + _LOGGER.info('Uploading resource emri_small.dcm...') + resid = api.resources.upload_resource(dcm_path, + anonymize=False, + publish=True, + tags=['example']) + return api.resources.get_by_id(resid) @staticmethod - def _upload_annotations(api: APIHandler, - resid: str, - proj) -> None: + def _upload_annotations(api: Api, + res: Resource, + proj: Project) -> None: _LOGGER.info('Uploading annotations...') - proj_id = proj['id'] - proj_info = api.get_project_by_id(proj_id) segurl = 'https://github.com/user-attachments/assets/8c5d7dfe-1b5a-497d-b76e-fe790f09bb90' resp = requests.get(segurl, stream=True) resp.raise_for_status() img = Image.open(io.BytesIO(resp.content)).convert('L') - api.upload_segmentations(resid, np.array(img), - name='object1', frame_index=1, - worklist_id=proj_info['worklist_id']) - api.set_annotation_status(project_id=proj_id, - resource_id=resid, - status='closed') + api.annotations.upload_segmentations(res, np.array(img), + name='object1', frame_index=1, + worklist_id=proj.worklist_id) + api.projects.set_work_status(resource=res, + project=proj, + status='closed') @staticmethod def create(project_name: str = 'Example Project MR', - with_annotations=True) -> str: - api = APIHandler() + with_annotations=True) -> Project: + api = Api() + + res = ProjectMR.upload_resource_emri_small(api) + proj = api.projects.get_by_name(name=project_name) + if proj: + _LOGGER.warning(f'Project {project_name} already exists. Returning it without modifications...') + return proj - resid = ProjectMR.upload_resource_emri_small(api) - proj = api.get_project_by_name(project_name) - if 'id' in proj: - msg = f'Project {project_name} already exists. Delete it first or choose another name.' - raise ValueError(msg) _LOGGER.info(f'Creating project {project_name}...') - proj = api.create_project(name=project_name, - description='This is an example project', - resources_ids=[resid]) + projid = api.projects.create(name=project_name, + description='This is an example project', + resources_ids=[res.id]) + proj = api.projects.get_by_id(projid) + if with_annotations: - ProjectMR._upload_annotations(api, resid, proj) + ProjectMR._upload_annotations(api, res, proj) - return proj['id'] + return proj diff --git a/datamint/exceptions.py b/datamint/exceptions.py index 872a0c7c..93e30c40 100644 --- a/datamint/exceptions.py +++ b/datamint/exceptions.py @@ -2,4 +2,30 @@ class DatamintException(Exception): """ Base class for exceptions in this module. """ - pass \ No newline at end of file + pass + +class ResourceNotFoundError(DatamintException): + """ + Exception raised when a resource is not found. + For instance, when trying to get a resource by a non-existing id. + """ + + def __init__(self, + resource_type: str, + params: dict): + """ Constructor. + + Args: + resource_type (str): A resource type. + params (dict): Dict of params identifying the sought resource. + """ + super().__init__() + self.resource_type = resource_type + self.params = params + + def set_params(self, resource_type: str, params: dict): + self.resource_type = resource_type + self.params = params + + def __str__(self): + return f"Resource '{self.resource_type}' not found for parameters: {self.params}" \ No newline at end of file diff --git a/docs/source/client_api.rst b/docs/source/client_api.rst index 33ab691a..a0e71247 100644 --- a/docs/source/client_api.rst +++ b/docs/source/client_api.rst @@ -2,8 +2,8 @@ Client Python API ================= -This chapter describes how to use the |APIHandlerClass| class in Python, -in order to interact with the Datamint API. +This chapter describes how to use the |ApiClass| class in Python, +to interact with the Datamint API. Before continuing, you may want to check the :ref:`setup_api_key` section to easily set up your API key, if you haven't done so yet. .. toctree:: diff --git a/docs/source/client_api_content.rst b/docs/source/client_api_content.rst index eeeaca94..d4ba373e 100644 --- a/docs/source/client_api_content.rst +++ b/docs/source/client_api_content.rst @@ -1,174 +1,185 @@ -Upload DICOMs or other resources ----------------------------------- +Getting Started with the API Client +------------------------------------ -First, import the |APIHandlerClass| class and create an instance: ``api_handler = APIHandler(...)``. -This class is responsible for interacting with the Datamint server. - -Upload resource files -++++++++++++++++++++++++++++++++ - -Use the :py:meth:`upload_resources() ` method to upload any resource type, such as DICOMs, videos, and image files: +First, import the |ApiClass| class and create an instance: .. code-block:: python - # Upload a single file - resource_id = api_handler.upload_resources("/path/to/dicom.dcm") + from datamint import Api + api = Api() # Uses API key from environment or config - # Upload multiple files at once - resoures_ids = api_handler.upload_resources(["/path/to/dicom.dcm", - "/path/to/video.mp4"] - ) +The |ApiClass| class provides access to different endpoint handlers: -You can see the list of all uploaded resources by calling the :py:meth:`get_resources() ` method: +- ``api.resources`` - For uploading, downloading, and managing resources +- ``api.annotations`` - For creating and managing annotations/segmentations +- ``api.projects`` - For creating and managing projects +- ``api.channels`` - For organizing resources into channels +- ``api.users`` - For user management operations -.. code-block:: python +Working with Resources +---------------------- - resources = api_handler.get_resources(status='inbox') # status can be any of {'inbox', 'published', 'archived'} - for res in resources: - print(res) - # Alternatively, you can use apihandler.get_resources_by_ids(resources_ids) - -Group up resources using channels -++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +Upload resource files +++++++++++++++++++++++++++++++++ -For a better organization of resources, you can group them into channels: +Use :py:meth:`api.resources.upload_resource() ` to upload any resource type, such as DICOMs, videos, and image files: .. code-block:: python - # Uploads a resource and creates a new channel named 'CT scans': - resource_id = api_handler.upload_resources("/path/to/dicom.dcm", - channel='CT scans' - ) + # Upload a single file + resource_id = api.resources.upload_resource("/path/to/dicom.dcm") - # This uploads a new resource to the same channel: - resource_id = api_handler.upload_resources("/path/to/dicom2.dcm", - channel='CT scans' - ) - - # Get all resources from channel 'CT scans': - resources = api_handler.get_resources(channel='CT scans') - + # Upload multiple files at once + resource_ids = api.resources.upload_resources(["/path/to/dicom.dcm", + "/path/to/video.mp4"]) -Upload, anonymize and add a label -++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +List and filter resources +++++++++++++++++++++++++++++++++ -To anonymize and add labels to a DICOM file, use the parameters `anonymize` -and `labels` of :py:meth:`upload_resources() `. -Adding labels is useful for searching and filtering resources in the Datamint platform later. +You can see the list of all uploaded resources by calling :py:meth:`api.resources.get_list() `: .. code-block:: python - dicom_id = api_handler.upload_resources(files_path='/path/to/dicom.dcm', - anonymize=True, - labels=['label1', 'label2'] - ) - - + # Get resources with different filters + resources = api.resources.get_list(status='inbox') # status: 'inbox', 'published', 'archived' + resources = api.resources.get_list(mimetype='application/dicom') # filter by mimetype + resources = api.resources.get_list(channel='CT scans') # filter by channel + + for resource in resources: + print(f"Resource {resource.id}: {resource.filename}") -Changing the uploaded filename +Upload with options ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -By default, the filename that is uploaded is the basename of the file. -For instance, if you upload a file named 'path/to/dicom.dcm', the filename will be 'dicom.dcm'. -To include the path into the filename, use the `mung_filename` parameter: +You can customize the upload with various parameters: .. code-block:: python - # filename='dicom.dcm' (DEFAULT) - resource_ids = api_handler.upload_resources(files_path='path/to/dicom.dcm', - mung_filename=None, - ) - - # filename='path_to_dicom.dcm' - resource_ids = api_handler.upload_resources(files_path='path/to/dicom.dcm', - mung_filename='all', - ) - - # filename='to_dicom.dcm' - resource_ids = api_handler.upload_resources(files_path='path/to/dicom.dcm', - mung_filename=1, - ) + # Upload with channel organization + resource_id = api.resources.upload_resource("/path/to/dicom.dcm", + channel='CT scans') + # Upload with anonymization and labels + resource_id = api.resources.upload_resource("/path/to/dicom.dcm", + anonymize=True, + tags=['label1', 'label2']) + # Upload and publish directly to a project + resource_id = api.resources.upload_resource("/path/to/dicom.dcm", + publish=True, + publish_to='ProjectName') Download resources ------------------ -To download a resource, use the :py:meth:`~datamint.apihandler.api_handler.APIHandler.download_resource_file` method: +To download a resource, use :py:meth:`api.resources.download_resource_file() `: .. code-block:: python - resources = api_handler.get_resources(status='inbox', mimetype='application/dicom') - resource_id = resources[0]['id'] + # Get a resource + resources = api.resources.get_list(status='inbox', mimetype='application/dicom') + resource = resources[0] - # returns the resource content in bytes: - bytes_obj = api_handler.download_resource_file(resource_id, auto_convert=False) + # Download as bytes + bytes_obj = api.resources.download_resource_file(resource.id, auto_convert=False) - # Assuming this resource is a dicom file, it will return a pydicom.dataset.Dataset object. - dicom_obj = api_handler.download_resource_file(resource_id, auto_convert=True) + # Auto-convert to appropriate object (e.g., pydicom.Dataset for DICOM files) + dicom_obj = api.resources.download_resource_file(resource.id, auto_convert=True) - # saves the file in the specified path. - api_handler.download_resource_file(resource_id, save_path='path/to/dicomfile.dcm') - -With ``auto_convert=True``, the function uses the resource mimetype to automatically convert to a proper object type (`pydicom.dataset.Dataset`, in this case.) -If you do not want this, but the bytes itself, use the ``auto_convert=False``. + # Save directly to file + api.resources.download_resource_file(resource.id, save_path='path/to/dicomfile.dcm') +With ``auto_convert=True``, the function uses the resource mimetype to automatically convert to the appropriate object type (``pydicom.Dataset`` for DICOM, etc.). Publishing resources --------------------- -To publish a resource, use :py:meth:`~datamint.apihandler.api_handler.APIHandler.publish_resources`: +To publish a resource, use :py:meth:`api.resources.publish_resources() `: .. code-block:: python - resources = api_handler.get_resources(status='inbox') - resource_id = resources[0]['id'] # assuming there is at least one resource in the inbox + resources = api.resources.get_list(status='inbox') + resource = resources[0] # assuming there is at least one resource in the inbox # Change status from 'inbox' to 'published' - api_handler.publish_resources(resource_id) + api.resources.publish_resources(resource.id) + + # Publish to a specific project + api.resources.publish_resources(resource.id, project_name='ProjectName') -To publish to a project, pass the project name or id as an argument: +Working with Annotations +------------------------ + +Upload segmentations +++++++++++++++++++++++++++++++++ + +To upload a segmentation, use :py:meth:`api.annotations.upload_segmentations() `: .. code-block:: python + + # Upload a resource first (or use an existing resource_id) + resource_id = api.resources.upload_resources("/path/to/dicom.dcm") + + # Upload segmentation + api.annotations.upload_segmentations(resource_id, + 'path/to/segmentation.nii.gz', # NIfTI or PNG file + name='SegmentationName') - api_handler.publish_resources(resource_id, project_name='ProjectName') +Multi-class segmentations +++++++++++++++++++++++++++++++++ -You can also publish resources while uploading them: +If your segmentation has multiple classes, you can pass a dictionary mapping pixel values to class names: .. code-block:: python - resource_id = api_handler.upload_resources(files_path='/path/to/video_data.mp4', - publish=True, - # publish_to='ProjectName' # optional - ) + class_names = { + # Background (0) is automatic, don't specify it + 1: "tumor", + 2: "metal", + } + + api.annotations.upload_segmentations(resource_id, + 'path/to/segmentation.nii.gz', + name=class_names) -Upload segmentation -------------------- +Working with Projects +--------------------- -To upload a segmentation, use :py:meth:`upload_segmentations() `: +Create and manage projects +++++++++++++++++++++++++++++++++ .. code-block:: python + + # Create a new project + project_id = api.projects.create( + name='My Project', + description='Project description', + resources_ids=[resource_id1, resource_id2] # optional + ) + + # Get project details + project = api.projects.get_by_id(project_id) + + # List all projects + projects = api.projects.get_list() - resource_id = api_handler.upload_resources("/path/to/dicom1.dcm") # or use an existing resource_id - api_handler.upload_segmentations(resource_id, - 'path/to/segmentation.nii.gz', # Can be a nifti file or an png file - name='SegmentationName') + # Get resources in a project + project_resources = api.projects.get_project_resources(project_id) +Use :py:meth:`api.projects.create() ` to create projects, ``api.projects.get_by_id()`` to retrieve them, and :py:meth:`api.projects.get_project_resources() ` to get associated resources. -If your segmentation has multiple classes, you can pass a dictionary mapping pixel values to class names. -Let's say you have a segmentation with 2 classes, where pixel value 0 is background, 1 is 'tumor', and 2 is 'metal': +Working with Channels +--------------------- -.. code-block:: python +Organize resources with channels +++++++++++++++++++++++++++++++++ - class_names = { - # Do not specify the background class, it is always 0 - 1: "tumor", - 2: "metal", - } +.. code-block:: python - api_handler.upload_segmentations(resource_id, - 'path/to/segmentation.nii.gz', # Can be a nifti file or an png file - name=class_names - ) + # List all channels + channels = api.channels.get_list() + + # Create a new channel + channel_id = api.channels.create(name='CT Scans', description='CT scan images') -See also the tutorial notebook on uploading data: `upload_data.ipynb `_ +See also the tutorial notebooks: `upload_data.ipynb `_ diff --git a/docs/source/conf.py b/docs/source/conf.py index 10df391e..15871d27 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -20,11 +20,17 @@ nitpick_ignore = { ("py:class", "pydicom.dataset.Dataset"), ("py:class", "PIL.Image.Image"), + ("py:class", "Image.Image"), ("py:class", "np.ndarray"), ("py:class", "numpy.ndarray"), ("py:class", "torch.nn.Module"), ("py:class", "cv2.VideoCapture"), ("py:class", "nibabel.filebasedimages.FileBasedImage"), + ("py:class", "pydantic.main.BaseModel"), + ("py:class", "httpx.HTTPStatusError"), + ("py:class", "httpx.Response"), + ("py:class", "httpx.Client"), + ("py:class", "aiohttp.client_exceptions.ClientResponseError"), } # -- General configuration --------------------------------------------------- @@ -42,9 +48,8 @@ ] rst_prolog = """ -.. |ExperimentClass| replace:: :py:class:`~datamint.experiment.experiment.Experiment` .. |DatamintDatasetClass| replace:: :py:class:`~datamint.dataset.dataset.DatamintDataset` -.. |APIHandlerClass| replace:: :py:class:`~datamint.apihandler.api_handler.APIHandler` +.. |ApiClass| replace:: :py:class:`~datamint.api.client.Api` """ napoleon_google_docstring = True @@ -84,3 +89,10 @@ ] html_favicon = "favicon.png" + +# Ensure all modules are discoverable +autodoc_mock_imports = [] + +# Add type hints support +autodoc_typehints = 'description' +autodoc_typehints_description_target = 'documented' diff --git a/docs/source/datamint.api.base_classes.rst b/docs/source/datamint.api.base_classes.rst new file mode 100644 index 00000000..febd72de --- /dev/null +++ b/docs/source/datamint.api.base_classes.rst @@ -0,0 +1,20 @@ +Base API Classes +================ + +This section covers the foundational classes that provide common functionality for all API endpoints. + +Base API +-------- + +.. automodule:: datamint.api.base_api + :members: + :undoc-members: + :show-inheritance: + +Entity Base API +--------------- + +.. automodule:: datamint.api.entity_base_api + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/datamint.api.client.rst b/docs/source/datamint.api.client.rst new file mode 100644 index 00000000..33d01598 --- /dev/null +++ b/docs/source/datamint.api.client.rst @@ -0,0 +1,10 @@ +Main API Module +=============== + +This is the main API module that provides the core functionality for interacting with the DataMint API. + + +.. automodule:: datamint.api.client + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/datamint.api.dto.rst b/docs/source/datamint.api.dto.rst new file mode 100644 index 00000000..17e06260 --- /dev/null +++ b/docs/source/datamint.api.dto.rst @@ -0,0 +1,7 @@ +datamint.api.dto +============ + +.. automodule:: datamint.api.dto + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/datamint.api.endpoints.rst b/docs/source/datamint.api.endpoints.rst new file mode 100644 index 00000000..69928177 --- /dev/null +++ b/docs/source/datamint.api.endpoints.rst @@ -0,0 +1,44 @@ +API Endpoints +============= + +This section documents all available API endpoints for interacting with DataMint resources. + +Projects API +------------ + +.. automodule:: datamint.api.endpoints.projects_api + :members: + :undoc-members: + :show-inheritance: + +Resources API +------------- + +.. automodule:: datamint.api.endpoints.resources_api + :members: + :undoc-members: + :show-inheritance: + +Annotations API +--------------- + +.. automodule:: datamint.api.endpoints.annotations_api + :members: + :undoc-members: + :show-inheritance: + +Channels API +------------ + +.. automodule:: datamint.api.endpoints.channels_api + :members: + :undoc-members: + :show-inheritance: + +Users API +--------- + +.. automodule:: datamint.api.endpoints.users_api + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/datamint.apihandler.rst b/docs/source/datamint.apihandler.rst index 8a3e636a..fcb69c11 100644 --- a/docs/source/datamint.apihandler.rst +++ b/docs/source/datamint.apihandler.rst @@ -1,11 +1,12 @@ -datamint.APIHandler -=================== +Client API +========== -.. automodule:: datamint.apihandler.api_handler - :members: - :inherited-members: +.. toctree:: + :maxdepth: 2 + :caption: API Documentation -.. automodule:: datamint.apihandler.base_api_handler - :members: - :exclude-members: BaseAPIHandler, validate_call - \ No newline at end of file + datamint.api.client + datamint.api.endpoints + datamint.api.base_classes + datamint.exceptions + datamint.api.dto diff --git a/docs/source/datamint.entities.rst b/docs/source/datamint.entities.rst new file mode 100644 index 00000000..44962c4f --- /dev/null +++ b/docs/source/datamint.entities.rst @@ -0,0 +1,11 @@ +Entities +======== + +The ``datamint.entities`` module provides the core data structures that represent +various objects within the DataMint ecosystem. These entities are built using `Pydantic `_ models, ensuring robust data validation, +type safety, and seamless serialization/deserialization when interacting with the DataMint API. + +.. automodule:: datamint.entities + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/datamint.exceptions.rst b/docs/source/datamint.exceptions.rst new file mode 100644 index 00000000..56c319a5 --- /dev/null +++ b/docs/source/datamint.exceptions.rst @@ -0,0 +1,7 @@ +datamint.exceptions +=================== + +.. automodule:: datamint.exceptions + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/datamint.experiment.rst b/docs/source/datamint.experiment.rst deleted file mode 100644 index 0eec6212..00000000 --- a/docs/source/datamint.experiment.rst +++ /dev/null @@ -1,5 +0,0 @@ -datamint.Experiment -=================== - -.. automodule:: datamint.experiment.experiment - :members: diff --git a/docs/source/index.rst b/docs/source/index.rst index 845e9ebb..a709e1ba 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -17,7 +17,7 @@ Datamint -------- - `Homepage `_ -- `Datamint Platform `_ +- `Datamint Platform `_ - `Github `_ .. toctree:: @@ -28,7 +28,6 @@ Datamint setup_api_key command_line_tools client_api - running_experiments pytorch_integration @@ -38,7 +37,7 @@ Datamint datamint.apihandler datamint.dataset - datamint.experiment + datamint.entities Indices and tables diff --git a/docs/source/running_experiments.rst b/docs/source/running_experiments.rst deleted file mode 100644 index aaf1d460..00000000 --- a/docs/source/running_experiments.rst +++ /dev/null @@ -1,179 +0,0 @@ - -Running Experiments -=================== - -The :py:class:`~datamint.experiment.experiment.Experiment` class allows you to log your experiments to the server. -It contains mechanisms to automatically log the model, the dataset, the hyperparameters, -and the results of your experiments without any extra effort. -Here is an example on how to use it: - -.. code-block:: python - - from datamint import Experiment - - # Create an instance of the Experiment class - exp = Experiment(name="Experiment", - project_name='Project Name' - ) - train_dataset = exp.get_dataset('train') - test_dataset = exp.get_dataset('test') - - # Train/Test your model here - # (...) - exp.finish() - -The above code will automatically collect and log all these information to the server: - -.. list-table:: Experiment Logging - :header-rows: 1 - - * - Automatically Logged - - Method to Log Manually - - Frequency (when automatically logged) - * - Model - - :py:meth:`~datamint.experiment.experiment.Experiment.log_model` - - Once, at :py:meth:`~datamint.experiment.experiment.Experiment.finish` - * - Dataset - - :py:meth:`~datamint.experiment.experiment.Experiment.log_dataset_stats` - - When :py:meth:`~datamint.experiment.experiment.Experiment.get_dataset` is called - * - Hyperparameters - - :py:meth:`~datamint.experiment.experiment.Experiment.log_model` - - Once, at :py:meth:`~datamint.experiment.experiment.Experiment.finish` - * - Metrics - - :py:meth:`~datamint.experiment.experiment.Experiment.log_metrics` and :py:meth:`~datamint.experiment.experiment.Experiment.log_metric` - - Per epoch and per dataloader - * - Predictions - - :py:meth:`~datamint.experiment.experiment.Experiment.log_predictions` - - Per evaluation and per dataloader - -Check an full functional example at `experiment_traintest_classifier.py `_ - - -Manual logging --------------- -For complex experiments, you may need to log manually log additional information that is not automatically collected by the |ExperimentClass|. -You can do that by using the |ExperimentClass| methods. -To disable automatic logging, set ``auto_log=False`` when creating the |ExperimentClass| object. - -Manual Summary logging -++++++++++++++++++++++ -Here is a complete example that manually logs everything required by the summary UI tab: - -.. _experiment_example_code_1: -.. code-block:: python - - from datamint import Experiment - import numpy as np - - # Create an experiment object - exp = Experiment(name='My first experiment', - project_name='testproject', - allow_existing=True, - auto_log=False) - - # Logs metrics - exp.log_metric('test/Sensitivity', 0.7) # a.k.a. Recall - exp.log_metric('test/Positive Predictive Value', 0.8) # a.k.a. Precision - exp.log_metric('test/F1Score', 0.75) - exp.log_metric('test/Accuracy', 0.8) - - # Logs predictions. - predictions_conf = np.array([[0.5, 0.2], [0.1, 0.4]]) - resource_ids = exp.get_dataset('test').get_resources_ids()[:2] - label_names = ['fracture', 'tumor'] # or `exp.get_dataset('test').labels_set` - exp.log_classification_predictions(predictions_conf, - label_names=label_names, - resource_ids=resource_ids, - dataset_split='test') - exp.finish() - -The effects of the above code can be seen in the image below: - -.. image:: ../images/experiment_mapping_code_UI.png - :alt: Experiment Mapping Code UI - :align: center - :name: experiment_mapping_code_UI - -Manual Model details logging -+++++++++++++++++++++++++++++ - -There are multiple detailed info that can be logged during the training of a model: - -- **Metrics along epochs/steps** such as loss, accuracy and sensitivity. For this, use :py:meth:`~datamint.experiment.experiment.Experiment.log_metric` with ``epoch=i`` and ``name="train/{METRIC_NAME}"`` or ``name="val/{METRIC_NAME}"``. -- **Predictions:** The model's predictions on the validation/test set. Useful to build curves such as ROC and Precision-Recall. For this, use :py:meth:`~datamint.experiment.experiment.Experiment.log_classification_predictions`. -- **Hyperparameters:** the hyperparameters used to train the model. For this, use the ``hyper_params`` parameter of :py:meth:`~datamint.experiment.experiment.Experiment.log_model`. Some hyperparameters are automatically logged by default, such as the number of layers, number of parameters, and the model attributes. -- **Environment:** The environment used to train the model. This is automatically collected by default. Disable it by ``log_enviroment=False``` when creating the |ExperimentClass| object. -- **Model:** The model itself. For this, use :py:meth:`~datamint.experiment.experiment.Experiment.log_model`. - -Here is an example of how to log the metrics along epochs during the training of a model: - -.. code-block:: python - - # Logs metrics at epoch 0 - cur_epoch = 0 - exp.log_metric('train/Sensitivity', 0.5, epoch=cur_epoch) - exp.log_metric('train/loss', 0.9, epoch=cur_epoch) - exp.log_metric('val/Sensitivity', 0.4, epoch=cur_epoch) - exp.log_metric('val/loss', 1.1, epoch=cur_epoch) - # (...) - - # Logs metrics at epoch 1 - cur_epoch = 1 - exp.log_metric('train/Sensitivity', 0.55, epoch=cur_epoch) - exp.log_metric('train/loss', 0.8, epoch=cur_epoch) - exp.log_metric('val/Sensitivity', 0.45, epoch=cur_epoch) - exp.log_metric('val/loss', 1.0, epoch=cur_epoch) - # (...) - -To log you model, you can use the following code: - -.. code-block:: python - - # definition of a custom model - class MyModel(nn.Module): - def __init__(self, hidden_size=32): - super().__init__() - self.hidden_size = hidden_size - self.fc = nn.Sequential( - nn.Linear(64, self.hidden_size), - nn.ReLU(), - nn.Linear(self.hidden_size, 1) - ) - - def forward(self, x): - return self.fc(x) - - model = MyModel(hidden_size=32) - hyper_params = {'learning_rate': 0.001, 'batch_size': 32} - exp.log_model(model, hyper_params=hyper_params) # `hidden_size`` will be automatically logged - # use `log_model_attributes=False` to avoid logging the model attributes - -It is possible to pass the file path of the model to be logged as well: - -.. code-block:: python - - # (...) - exp.log_model('model.pth') - -To log predictions at a given step/epoch, you can use the same :ref:`example code ` from Section `Manual Summary logging`_, -but with the ``epoch`` or ``step`` parameter set to the desired value: - -.. code-block:: python - - # (...) - exp.log_classification_predictions(predictions_conf, - label_names=label_names, - resource_ids=resource_ids, - dataset_split='test', - epoch=0) - - - - -Best Practices --------------- -- When logging metrics, use '/' to separate different levels of metrics. For example, 'train/loss' and 'test/loss'. -- Use :py:meth:`~datamint.experiment.experiment.Experiment.get_dataset` to get the dataset object, instead of directly using |DatamintDatasetClass|. This ensures that all relevant metadata and configurations are correctly applied and that the dataset stats are logged (when ``auto_log=True``). -- Regularly log metrics and other relevant information to keep track of the experiment's progress. Don't forget to provide epoch/step when possible. -- Use meaningful names for your experiments, datasets, to make it easier to identify and compare different runs. -- Use ``dry_run=True`` parameter of |ExperimentClass| for testing/debugging purposes. It will not log anything to the server. \ No newline at end of file diff --git a/docs/source/setup_api_key.rst b/docs/source/setup_api_key.rst index 2a57297f..37e33a52 100644 --- a/docs/source/setup_api_key.rst +++ b/docs/source/setup_api_key.rst @@ -25,12 +25,12 @@ Specify the API key as an environment variable. import os os.environ["DATAMINT_API_KEY"] = "my_api_key" -Method 3: APIHandler constructor ---------------------------------- -Specify API key in the |APIHandlerClass| constructor: +Method 3: Api constructor +------------------------- +Specify API key in the |ApiClass| constructor: .. code-block:: python - from datamint import APIHandler + from datamint import Api - api = APIHandler(api_key='my_api_key') \ No newline at end of file + api = Api(api_key='my_api_key') \ No newline at end of file diff --git a/notebooks/dataset_loading.ipynb b/notebooks/dataset_loading.ipynb index ac7af28e..5ccbe771 100644 --- a/notebooks/dataset_loading.ipynb +++ b/notebooks/dataset_loading.ipynb @@ -77,26 +77,15 @@ "cell_type": "code", "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'a8bab606-f750-4656-94f9-e446c5443b7c'" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "from datamint.examples import ProjectMR\n", "\n", - "PROJECT_NAME = 'Example Project MR4'\n", + "PROJECT_NAME = 'Example Project MR'\n", "\n", - "proj_id = ProjectMR.create(project_name=PROJECT_NAME,\n", - " with_annotations=True)\n", - "print(f\"Check your project at https://app.datamint.io/projects/edit/{proj_id}\")" + "proj = ProjectMR.create(project_name=PROJECT_NAME,\n", + " with_annotations=True)\n", + "print(f\"Check your project at {proj.url}\")" ] }, { @@ -117,29 +106,7 @@ "cell_type": "code", "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "e8b0cf829e344fcd89d83f2ee44064d1", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "0.00B [00:00, ?B/s]" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Dataset length: 1\n" - ] - } - ], + "outputs": [], "source": [ "from datamint import Dataset\n", "\n", @@ -169,6 +136,19 @@ "execution_count": 4, "metadata": {}, "outputs": [ + { + "data": { + "text/html": [ + "
[09/18/25 16:57:44] INFO     Original image is uint16, converting to uint8                      base_dataset.py:668\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[2;36m[09/18/25 16:57:44]\u001b[0m\u001b[2;36m \u001b[0m\u001b[34mINFO \u001b[0m Original image is uint16, converting to uint8 \u001b]8;id=502300;file:///home/lhsmello/projects/Sonance/datamint-python-api/datamint/dataset/base_dataset.py\u001b\\\u001b[2mbase_dataset.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=369607;file:///home/lhsmello/projects/Sonance/datamint-python-api/datamint/dataset/base_dataset.py#668\u001b\\\u001b[2m668\u001b[0m\u001b]8;;\u001b\\\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "name": "stdout", "output_type": "stream", @@ -189,6 +169,33 @@ "print('Image.shape:', img.shape)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "you can get more advanced information from the 'metainfo' attribute:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "dict_keys(['id', 'resource_uri', 'storage', 'location', 'upload_channel', 'filename', 'modality', 'mimetype', 'size', 'upload_mechanism', 'customer_id', 'status', 'created_at', 'created_by', 'published', 'deleted', 'source_filepath', 'metadata', 'projects', 'published_on', 'published_by', 'tags', 'publish_transforms', 'deleted_at', 'deleted_by', 'instance_uid', 'series_uid', 'study_uid', 'patient_id', 'segmentations', 'measurements', 'categories', 'user_info', 'labels', 'file'])" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "item['metainfo'].keys() # dictionary with metadata about the resource" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -205,22 +212,22 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 5, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -244,7 +251,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "metadata": {}, "outputs": [ { @@ -253,7 +260,7 @@ "{'lucas@mail.com': torch.Size([10, 2, 64, 64])}" ] }, - "execution_count": 6, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -288,12 +295,12 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 9, "metadata": {}, "outputs": [ { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -319,12 +326,12 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 10, "metadata": {}, "outputs": [ { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] diff --git a/notebooks/upload_data.ipynb b/notebooks/upload_data.ipynb index 3aeaf810..74ea1af4 100644 --- a/notebooks/upload_data.ipynb +++ b/notebooks/upload_data.ipynb @@ -32,7 +32,15 @@ "source": [ "# Setup & Connection\n", "\n", - "Initialize the Datamint API connection. Make sure you've run `datamint-config` in your terminal first." + "Initialize the Datamint API connection using the new **modular API client**. This provides a cleaner, more organized interface with dedicated modules for different operations:\n", + "\n", + "- `api.resources` - Resource management (upload, download, etc.)\n", + "- `api.projects` - Project management and collaboration\n", + "- `api.annotations` - Annotation handling and segmentations\n", + "- `api.channels` - Channel organization\n", + "- `api.users` - User management\n", + "\n", + "Make sure you've run `datamint-config` in your terminal first." ] }, { @@ -41,14 +49,12 @@ "metadata": {}, "outputs": [], "source": [ - "from datamint import APIHandler\n", - "import json\n", + "from datamint import Api\n", "from pathlib import Path\n", - "\n", - "# Creates a connection with the server\n", + "# Creates a connection with the server.\n", "# Don't forget to run `datamint-config` in a terminal, if you haven't already.\n", - "# Or use api_key parameter in APIHandler\n", - "api = APIHandler()" + "# Or use api_key parameter in Api()\n", + "api = Api()" ] }, { @@ -68,7 +74,7 @@ "source": [ "# Single file upload with comprehensive options\n", "dicom_file = '../data/Case14.dcm'\n", - "new_resource_id = api.upload_resource(\n", + "new_resource_id = api.resources.upload_resource(\n", " dicom_file,\n", " channel='tutorial_channel', # arbitrary channel name for organization\n", " tags=['tutorial', 'case14'], # tags for easy searching later\n", @@ -79,6 +85,20 @@ "print(f\"Uploaded resource ID: {new_resource_id}\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## API Method Comparison\n", + "\n", + "| Method | Use Case | Example |\n", + "|--------|----------|---------|\n", + "| `upload_resource()` | Single file upload | `api.resources.upload_resource('file.dcm')` |\n", + "| `upload_resources()` | Batch upload | `api.resources.upload_resources(['file1.dcm', 'file2.png'])` |\n", + "\n", + "Both methods support the same parameters (tags, channels, segmentations, metadata, etc.)." + ] + }, { "cell_type": "code", "execution_count": null, @@ -86,7 +106,7 @@ "outputs": [], "source": [ "# Get all the resources with specific tags\n", - "all_resources = list(api.get_resources(\n", + "all_resources = list(api.resources.get_list(\n", " status='inbox',\n", " tags=['tutorial']\n", "))\n", @@ -140,7 +160,7 @@ " '../data/sample_video.mp4' # Replace with actual video file\n", "]\n", "\n", - "resource_ids = api.upload_resources(\n", + "resource_ids = api.resources.upload_resources(\n", " files_to_upload,\n", " channel='batch_upload_demo',\n", " tags=['batch', 'mixed_types'],\n", @@ -184,11 +204,12 @@ "# Define pixel value to anatomical region mapping\n", "class_names = {\n", " 1: \"Femur\", # Pixel value 1 represents femur\n", - " 2: \"Tibia\" # Pixel value 2 represents tibia\n", + " 2: \"Tibia\", # Pixel value 2 represents tibia\n", "}\n", "\n", - "segmentation_ids = api.upload_segmentations(\n", - " resource_id=resource_id,\n", + "# Use the new annotations API with the upload_segmentations method\n", + "segmentation_ids = api.annotations.upload_segmentations(\n", + " resource=resource_id,\n", " file_path=seg_file,\n", " name=class_names,\n", " imported_from='manual_annotation' # Track the source of annotations\n", @@ -228,7 +249,7 @@ " 'names': class_names # mapping pixel values to class names\n", "}\n", "\n", - "new_resource_id = api.upload_resource(\n", + "new_resource_id = api.resources.upload_resource(\n", " dicom_file,\n", " segmentation_files=segfiles,\n", " channel='with_segmentation',\n", @@ -271,18 +292,18 @@ " \"modality\": \"CT\"\n", "}\n", "# Upload with metadata\n", - "resource_with_metadata = api.upload_resource(\n", + "resource_with_metadata = api.resources.upload_resource(\n", " nifti_file,\n", " channel='with_metadata',\n", " tags=['tutorial', 'metadata_example'],\n", - " metadata=metadata_example # List of metadata files\n", + " metadata=metadata_example\n", ")\n", "\n", "print(f\"Uploaded resource with metadata: {resource_with_metadata}\")\n", "\n", "# Verify the metadata was included\n", - "resource_info = api.get_resources_by_ids(resource_with_metadata)\n", - "print(\"Resource modality:\", resource_info.get('modality', 'Not specified'))\n" + "resource_info = api.resources.get_by_id(resource_with_metadata)\n", + "print(\"Resource modality:\", resource_info.modality)" ] }, { @@ -307,37 +328,38 @@ "outputs": [], "source": [ "# Get some resources to add to a project\n", - "tutorial_resources = list(api.get_resources(\n", + "tutorial_resources = list(api.resources.get_list(\n", " tags=['tutorial'],\n", " status='inbox'\n", "))\n", "\n", "if tutorial_resources:\n", - " resource_ids_for_project = [r['id'] for r in tutorial_resources[:3]] # Take first 3 resources\n", + " resource_ids_for_project = [r.id for r in tutorial_resources[:3]] # Take first 3 resources\n", "\n", " # Create a new project\n", " try:\n", - " project = api.create_project(\n", + " project_id = api.projects.create(\n", " name=\"Tutorial Project\",\n", " description=\"A project created for demonstration purposes\",\n", " resources_ids=resource_ids_for_project\n", " )\n", + " project = api.projects.get_by_id(project_id)\n", "\n", - " print(f\"Created project: {project['name']} (ID: {project['id']})\")\n", + " print(f\"Created project: {project.name} (ID: {project.id})\")\n", "\n", " # List all projects\n", - " all_projects = api.get_projects()\n", + " all_projects = api.projects.get_list()\n", " print(f\"\\nAll projects ({len(all_projects)}):\")\n", " for proj in all_projects:\n", - " print(f\" - {proj['name']} (ID: {proj['id']})\")\n", + " print(f\" - {proj.name} (ID: {proj.id})\")\n", "\n", " except Exception as e:\n", " print(f\"Error creating project (may already exist): {e}\")\n", "\n", " # Try to find existing project\n", - " existing_projects = [p for p in api.get_projects() if p['name'] == \"Tutorial Project\"]\n", - " if existing_projects:\n", - " print(f\"Found existing project: {existing_projects[0]['name']}\")\n", + " existing_project = api.projects.get_by_name(\"Tutorial Project\")\n", + " if existing_project:\n", + " print(f\"Found existing project: {existing_project.name}\")\n", "else:\n", " print(\"No tutorial resources found to add to project\")" ] @@ -363,23 +385,23 @@ "outputs": [], "source": [ "# Download a resource file\n", - "api.download_resource_file(\n", + "api.resources.download_resource_file(\n", " new_resource_id,\n", " auto_convert=False,\n", " save_path='downloaded_resource.dcm' # Save to a specific file\n", ")\n", "\n", "# Download and auto-convert (for DICOM files, returns pydicom Dataset)\n", - "resource_object = api.download_resource_file(\n", + "resource_object = api.resources.download_resource_file(\n", " new_resource_id,\n", " auto_convert=True\n", ")\n", "print(f\"Auto-converted to: {type(resource_object)}\") # `pydicom.Dataset` object\n", "\n", "# Get annotations for this resource\n", - "annotations = list(api.get_annotations(resource_id=new_resource_id))\n", + "annotations = list(api.annotations.get_list(resource=new_resource_id))\n", "for ann in annotations:\n", - " print(f\" - {ann.get('identifier', 'Unknown')}: {ann.get('type', 'Unknown type')}\")" + " print(f\" - {ann.identifier}: {ann.type}\")" ] }, { diff --git a/pyproject.toml b/pyproject.toml index 4d61cb43..34dfedef 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 = "1.9.3" +version = "2.0.0" dynamic = ["dependencies"] requires-python = ">=3.10" readme = "README.md" @@ -40,7 +40,9 @@ matplotlib = "*" lightning = "*" albumentations = ">=2.0.0" lazy-loader = ">=0.3.0" -medimgkit = ">=0.5.0" +medimgkit = ">=0.6.0" +typing_extensions = ">=4.0.0" +pydantic = ">=2.6.4" # For compatibility with the datamintapi package datamintapi = "0.0.*" # Extra dependencies for docs