From bc7872554d83f504ce2e5c9fa66278dc4fd49c5a Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Mon, 20 Oct 2025 15:00:02 +0100 Subject: [PATCH 1/2] Refactor classification code to handle multiple classifications Previously the assumption was a classification has one name. However QuPath features can have multiple classifications: an object with class "A: B: C" is converted to JSON something like {[blah], "properties": {"classification": {"names": ["A", "B", "C"]}}} whereas an object with class "A" is converted something like {[blah], "properties": {"classification": {"name": "A"}}} This PR removes the assumption of a single class while ensuring a single name can be fetched using a mocked `name` property that joins on ": ", similar to objects with multiple classes in QuPath. Resolve #43 --- qubalab/objects/classification.py | 44 ++++++++++++++++++++--------- qubalab/objects/image_feature.py | 12 +++++--- tests/objects/test_image_feature.py | 35 +++++++++++++++++++++-- 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/qubalab/objects/classification.py b/qubalab/objects/classification.py index c88bb32..dd00518 100644 --- a/qubalab/objects/classification.py +++ b/qubalab/objects/classification.py @@ -1,21 +1,27 @@ from __future__ import annotations import random -from typing import Optional +from typing import Optional, Union class Classification(object): """ - Simple class to store the name and color of a classification. + Simple class to store the names and color of a classification. """ _cached_classifications = {} - def __init__(self, name: str, color: Optional[tuple[int, int, int]] = None): + def __init__( + self, + names: Union[str, tuple[str]], + color: Optional[tuple[int, int, int]] = None, + ): """ - :param name: the name of the classification + :param names: the names of the classification :param color: the RGB color (each component between 0 and 255) of the classification. Can be None to use a random color """ - self._name = name + if isinstance(names, str): + names = (names,) + self._names = names self._color = ( tuple(random.randint(0, 255) for _ in range(3)) if color is None else color ) @@ -25,7 +31,16 @@ def name(self) -> str: """ The name of the classification. """ - return self._name + if self._names is None: + print("lol") + return ": ".join(self._names) + + @property + def names(self) -> tuple[str]: + """ + The name of the classification. + """ + return self._names @property def color(self) -> tuple[int, int, int]: @@ -36,8 +51,9 @@ def color(self) -> tuple[int, int, int]: @staticmethod def get_cached_classification( - name: str, color: Optional[tuple[int, int, int]] = None - ) -> Classification: + name: Optional[Union[str, tuple[str]]], + color: Optional[tuple[int, int, int]] = None, + ) -> Optional[Classification]: """ Return a classification by looking at an internal cache. @@ -57,7 +73,9 @@ def get_cached_classification( """ if name is None: return None - + if isinstance(name, str): + name = (name,) + name = ": ".join(name) classification = Classification._cached_classifications.get(name) if classification is None: classification = Classification(name, color) @@ -65,15 +83,15 @@ def get_cached_classification( return classification def __str__(self): - return f"Classification {self._name} of color {self._color}" + return f"Classification {self.name} of color {self.color}" def __repr__(self): - return f"Classification('{self._name}', {self._color})" + return f"Classification('{self.name}', {self.color})" def __eq__(self, other): if isinstance(other, Classification): - return self._name == other._name and self._color == other._color + return self.name == other.name and self.color == other.color return False def __hash__(self): - return hash(self._name) + return hash(self.name) diff --git a/qubalab/objects/image_feature.py b/qubalab/objects/image_feature.py index 2af01f6..e78f706 100644 --- a/qubalab/objects/image_feature.py +++ b/qubalab/objects/image_feature.py @@ -72,12 +72,14 @@ def __init__( if classification is not None: if isinstance(classification, Classification): props["classification"] = { - "name": classification.name, + "names": classification.names, "color": classification.color, } else: props["classification"] = { - "name": classification.get("name"), + "names": classification.get("names") + if "names" in classification.keys() + else (classification.get("name"),), "color": classification.get("color"), } if name is not None: @@ -169,7 +171,7 @@ def create_from_label_image( connectivity: int = 4, scale: float = 1.0, include_labels=False, - classification_names: Union[str, dict[int, str]] = None, + classification_names: Optional[Union[str, dict[int, str]]] = None, ) -> list[ImageFeature]: """ Create a list of ImageFeatures from a binary or labeled image. @@ -243,7 +245,9 @@ def classification(self) -> Classification: """ if "classification" in self.properties: return Classification( - self.properties["classification"].get("name"), + self.properties["classification"].get("names") + if "names" in self.properties["classification"].keys() + else (self.properties["classification"].get("name"),), self.properties["classification"].get("color"), ) else: diff --git a/tests/objects/test_image_feature.py b/tests/objects/test_image_feature.py index e54ca5b..5a32d50 100644 --- a/tests/objects/test_image_feature.py +++ b/tests/objects/test_image_feature.py @@ -1,9 +1,12 @@ import geojson import math import numpy as np -from qubalab.objects.image_feature import ImageFeature -from qubalab.objects.classification import Classification -from qubalab.objects.object_type import ObjectType +from qubalab.objects import ( + ObjectType, + ImageFeature, + Classification, + geojson_features_from_string, +) def test_geometry(): @@ -685,3 +688,29 @@ def test_nucleus_geometry_when_set_after_creation(): nucleus_geometry = image_feature.nucleus_geometry assert nucleus_geometry == expected_nucleus_geometry + + +def test_imagefeature_handles_classification_names(): + string = """ + {"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]},"properties":{"objectType":"annotation","classification":{"names":["a","b"]}}} + """ + feature = geojson_features_from_string(string) + ifeature = ImageFeature.create_from_feature(feature) + assert ( + ifeature.classification.names + == feature["properties"]["classification"]["names"] + ) + assert ifeature.classification.name == ": ".join( + feature["properties"]["classification"]["names"] + ) + + +def test_imagefeature_handles_classification_name(): + string = """ + {"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]},"properties":{"objectType":"annotation","classification":{"names":["a","b"]}}} + """ + feature = geojson_features_from_string(string) + ifeature = ImageFeature.create_from_feature(feature) + assert ifeature.classification.name == ": ".join( + feature["properties"]["classification"]["names"] + ) From f880565889a379166b7b9271961fae526562cffe Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Mon, 20 Oct 2025 16:30:19 +0100 Subject: [PATCH 2/2] Apply suggestion from @alanocallaghan --- qubalab/objects/classification.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/qubalab/objects/classification.py b/qubalab/objects/classification.py index dd00518..e36eef3 100644 --- a/qubalab/objects/classification.py +++ b/qubalab/objects/classification.py @@ -31,8 +31,6 @@ def name(self) -> str: """ The name of the classification. """ - if self._names is None: - print("lol") return ": ".join(self._names) @property