diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py index 7d652138..669ac7ba 100644 --- a/datamint/api/endpoints/annotations_api.py +++ b/datamint/api/endpoints/annotations_api.py @@ -30,6 +30,7 @@ CoordinateSystem, ImageClassification, LineAnnotation, + NumericAnnotation, annotation_from_dict, ) from datamint.exceptions import ItemNotFoundError, ServerError @@ -1151,6 +1152,46 @@ def create_image_classification(self, raise TypeError('Expected a single annotation id for image classification creation.') return created + def create_numeric_annotation(self, + resource: str | Resource, + identifier: str, + value: int | float, + units: str | None = None, + imported_from: str | None = None, + model_id: str | None = None, + source: str | None = 'imported', + ) -> str: + """ + Create a numeric value annotation (e.g. a measurement or count). + + Args: + resource: The resource unique id or Resource instance. + identifier: The annotation identifier/label. + value: The numeric value. ``int`` maps to :attr:`AnnotationType.INTEGER`, + ``float`` maps to :attr:`AnnotationType.FLOAT`. + units: Optional unit label for the value (e.g. 'years', 'mm'). + imported_from: The imported from source value. + model_id: The model unique id. + source: Annotation source tag. Defaults to 'imported' since this is a direct API + entry point; :meth:`upload_predictions` overrides it with 'model_pipeline'/'model_deploy'. + + Returns: + The id of the created annotation. + """ + annotation = NumericAnnotation( + name=identifier, + value=value, + units=units, + imported_from=imported_from, + model_id=model_id, + source=source, + ) + + created = self.create(resource, annotation) + if not isinstance(created, str): + raise TypeError('Expected a single annotation id for numeric annotation creation.') + return created + def upload_predictions( self, resource: str | Resource, diff --git a/datamint/entities/annotations/__init__.py b/datamint/entities/annotations/__init__.py index 2bc23c05..fcb3c3a2 100644 --- a/datamint/entities/annotations/__init__.py +++ b/datamint/entities/annotations/__init__.py @@ -4,6 +4,7 @@ from .box_annotation import BoxAnnotation from .geometry import BoxGeometry, CoordinateSystem, Geometry, LineGeometry from .line_annotation import LineAnnotation +from .numeric_annotation import NumericAnnotation from .volume_segmentation import VolumeSegmentation from .types import AnnotationType @@ -15,6 +16,10 @@ def annotation_from_dict(data: dict) -> Annotation: * ``'segmentation'`` with a ``class_map`` → :class:`VolumeSegmentation` * ``'segmentation'`` without ``class_map`` → :class:`ImageSegmentation` + * ``'category'`` → :class:`ImageClassification` + * ``'integer'``/``'float'`` → :class:`NumericAnnotation` + * ``'line'`` → :class:`LineAnnotation` + * ``'square'`` → :class:`BoxAnnotation` * anything else → :class:`Annotation` ``segmentation_data`` dicts are automatically deserialised by the @@ -40,6 +45,9 @@ def annotation_from_dict(data: dict) -> Annotation: if annotation_type in (AnnotationType.CATEGORY, AnnotationType.CATEGORY.value): return ImageClassification(**normalized_data) + if annotation_type in (AnnotationType.INTEGER, AnnotationType.FLOAT): + return NumericAnnotation(**normalized_data) + if annotation_type in (AnnotationType.LINE, AnnotationType.LINE.value): return LineAnnotation(**normalized_data) @@ -59,6 +67,7 @@ def annotation_from_dict(data: dict) -> Annotation: "Geometry", "LineAnnotation", "LineGeometry", + "NumericAnnotation", "VolumeSegmentation", "AnnotationType", "annotation_from_dict", diff --git a/datamint/entities/annotations/annotation.py b/datamint/entities/annotations/annotation.py index fa34fc14..0d46ded6 100644 --- a/datamint/entities/annotations/annotation.py +++ b/datamint/entities/annotations/annotation.py @@ -47,6 +47,11 @@ def _normalize_annotation_data(data: dict[str, Any]) -> dict[str, Any]: if 'scope' not in converted_data: converted_data['scope'] = 'image' if converted_data.get('frame_index') is None else 'frame' + if converted_data.get('annotation_type') in (AnnotationType.INTEGER, AnnotationType.FLOAT): + raw_value = converted_data.pop('text_value', None) + if raw_value is not None and converted_data.get('numeric_value') is None: + converted_data['numeric_value'] = raw_value + return converted_data @@ -290,7 +295,7 @@ def _to_create_dto(self): identifier=self.identifier, scope=self.scope, annotation_worklist_id=self.annotation_worklist_id, - value=self.text_value, + value=self.text_value if self.text_value is not None else self.numeric_value, imported_from=self.imported_from, import_author=self.import_author, frame_index=self.frame_index, diff --git a/datamint/entities/annotations/numeric_annotation.py b/datamint/entities/annotations/numeric_annotation.py new file mode 100644 index 00000000..e093bfff --- /dev/null +++ b/datamint/entities/annotations/numeric_annotation.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any + +from .annotation import Annotation +from .types import AnnotationType + + +class NumericAnnotation(Annotation): + def __init__( + self, + name: str | None = None, + value: int | float | None = None, + units: str | None = None, + confiability: float = 1.0, + **kwargs: Any, + ) -> None: + if name is not None: + kwargs.setdefault('identifier', name) + if value is not None: + kwargs.setdefault('numeric_value', value) + is_int = isinstance(value, int) and not isinstance(value, bool) + kwargs.setdefault('annotation_type', AnnotationType.INTEGER if is_int else AnnotationType.FLOAT) + + if units is not None: + kwargs.setdefault('units', units) + + kwargs.setdefault('confiability', confiability) + kwargs.setdefault('scope', 'image') + super().__init__(**kwargs) diff --git a/datamint/entities/annotations/types.py b/datamint/entities/annotations/types.py index 9bd17679..5e7f8c86 100644 --- a/datamint/entities/annotations/types.py +++ b/datamint/entities/annotations/types.py @@ -17,4 +17,6 @@ class AnnotationType(StrEnum): SQUARE = 'square' CIRCLE = 'circle' CATEGORY = 'category' - LABEL = 'label' \ No newline at end of file + LABEL = 'label' + INTEGER = 'integer' + FLOAT = 'float' \ No newline at end of file