From bd37c963e7d70cf304a99dc0ae6b6e9f1ad78444 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 21 Oct 2025 16:12:50 +0100 Subject: [PATCH 1/3] Make classifications singleton --- qubalab/objects/classification.py | 59 +++++++++++----------------- qubalab/objects/image_feature.py | 4 +- tests/objects/test_classification.py | 44 +++++++++++++++------ tests/objects/test_image_feature.py | 5 +-- 4 files changed, 59 insertions(+), 53 deletions(-) diff --git a/qubalab/objects/classification.py b/qubalab/objects/classification.py index e36eef3..044f659 100644 --- a/qubalab/objects/classification.py +++ b/qubalab/objects/classification.py @@ -8,7 +8,25 @@ class Classification(object): Simple class to store the names and color of a classification. """ - _cached_classifications = {} + _cached_classifications: dict[str, Classification] = {} + + def __new__( + cls, names: Union[str, tuple[str]], color: Optional[tuple[int, int, int]] = None + ): + if isinstance(names, str): + names = (names,) + if isinstance(names, list): + names = tuple(names) + if names is None: + return None + if not isinstance(names, tuple): + raise TypeError("names should be str or tuple[str]") + name = ": ".join(names) + classification = Classification._cached_classifications.get(name) + if classification is None: + classification = super().__new__(cls) + Classification._cached_classifications[name] = classification + return classification def __init__( self, @@ -21,6 +39,10 @@ def __init__( """ if isinstance(names, str): names = (names,) + elif isinstance(names, list): + names = tuple(names) + if not isinstance(names, tuple): + raise TypeError("names should be a tuple, list or string") self._names = names self._color = ( tuple(random.randint(0, 255) for _ in range(3)) if color is None else color @@ -47,39 +69,6 @@ def color(self) -> tuple[int, int, int]: """ return self._color ## todo: pylance type hints problem - @staticmethod - def get_cached_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. - - If no classification with the provided name is present in the cache, a - new classification is created and the cache is updated. - - This is useful if you want to avoid creating multiple classifications with the - same name and use only one instead. - - :param name: the name of the classification (can be None) - :param color: the RGB color (each component between 0 and 255) of the classification. - Can be None to use a random color. This is only used if the cache doesn't - already contain a classification with the provided name - :return: a classification with the provided name, but not always with the provided color - if a classification with the same name already existed in the cache. If the provided - name is None, None is also returned - """ - 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) - Classification._cached_classifications[classification.name] = classification - return classification - def __str__(self): return f"Classification {self.name} of color {self.color}" @@ -88,7 +77,7 @@ def __repr__(self): def __eq__(self, other): if isinstance(other, Classification): - return self.name == other.name and self.color == other.color + return (self is other) or (self.name == other.name) return False def __hash__(self): diff --git a/qubalab/objects/image_feature.py b/qubalab/objects/image_feature.py index e78f706..a321d90 100644 --- a/qubalab/objects/image_feature.py +++ b/qubalab/objects/image_feature.py @@ -222,9 +222,7 @@ def create_from_label_image( feature = cls( geometry=geometry, - classification=Classification.get_cached_classification( - classification_name - ), + classification=Classification(classification_name), measurements={"Label": float(label)} if include_labels else None, object_type=object_type, ) diff --git a/tests/objects/test_classification.py b/tests/objects/test_classification.py index dacb356..c0cccea 100644 --- a/tests/objects/test_classification.py +++ b/tests/objects/test_classification.py @@ -12,24 +12,23 @@ def test_name(): def test_color(): expected_color = (2, 20, 56) - classification = Classification(None, expected_color) + classification = Classification("name", expected_color) color = classification.color assert expected_color == color -def test_cache_when_None_name_provided(): - classification = Classification.get_cached_classification(None) - - assert classification == None +def test_None_when_names_is_None(): + classification = Classification(None) + assert classification is None def test_cache_when_empty(): name = "name" color = (2, 20, 56) - classification = Classification.get_cached_classification(name, color) + classification = Classification(name, color) assert classification == Classification(name, color) @@ -39,11 +38,14 @@ def test_cache_when_not_empty_and_same_name(): cached_color = (2, 20, 56) other_name = cached_name other_color = (4, 65, 7) - cached_classification = Classification.get_cached_classification(cached_name, cached_color) + cached_classification = Classification(cached_name, cached_color) - classification = Classification.get_cached_classification(other_name, other_color) + classification = Classification(other_name, other_color) - assert classification != Classification(other_name, other_color) and classification == cached_classification + assert ( + classification is Classification(other_name, other_color) + and classification is cached_classification + ) def test_cache_when_not_empty_and_different_name(): @@ -51,8 +53,26 @@ def test_cache_when_not_empty_and_different_name(): cached_color = (2, 20, 56) other_name = "other name" other_color = (4, 65, 7) - cached_classification = Classification.get_cached_classification(cached_name, cached_color) + cached_classification = Classification(cached_name, cached_color) + + classification = Classification(other_name, other_color) + + assert ( + classification == Classification(other_name, other_color) + and classification != cached_classification + ) + + +def test_names_input(): + names = ("a", "b") + class1 = Classification(names) + class2 = Classification(list(names)) + assert class1 is class2 - classification = Classification.get_cached_classification(other_name, other_color) - assert classification == Classification(other_name, other_color) and classification != cached_classification +def test_names_with_colon(): + names = ("a", "b") + name = "a: b" + class1 = Classification(names) + class2 = Classification(name) + assert class1 is class2 diff --git a/tests/objects/test_image_feature.py b/tests/objects/test_image_feature.py index 5a32d50..86e696e 100644 --- a/tests/objects/test_image_feature.py +++ b/tests/objects/test_image_feature.py @@ -696,9 +696,8 @@ def test_imagefeature_handles_classification_names(): """ feature = geojson_features_from_string(string) ifeature = ImageFeature.create_from_feature(feature) - assert ( - ifeature.classification.names - == feature["properties"]["classification"]["names"] + assert ifeature.classification.names == tuple( + feature["properties"]["classification"]["names"] ) assert ifeature.classification.name == ": ".join( feature["properties"]["classification"]["names"] From 1ec9b7bca6200ea6c1438bd9513b4e3129ab5fec Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 22 Oct 2025 10:12:43 +0100 Subject: [PATCH 2/3] Remove name method and update tests --- qubalab/objects/classification.py | 49 ++++++++++++++++------------ tests/objects/test_classification.py | 18 +++------- tests/objects/test_image_feature.py | 19 +++++------ 3 files changed, 41 insertions(+), 45 deletions(-) diff --git a/qubalab/objects/classification.py b/qubalab/objects/classification.py index 044f659..ba7a080 100644 --- a/qubalab/objects/classification.py +++ b/qubalab/objects/classification.py @@ -6,26 +6,32 @@ class Classification(object): """ Simple class to store the names and color of a classification. + + Each Classification with the same names is the same object, retrieved from a cache. + Therefore updating the color of a Classification will update all similarly classified objects. """ - _cached_classifications: dict[str, Classification] = {} + __cached_classifications: dict[tuple[str], Classification] = {} def __new__( cls, names: Union[str, tuple[str]], color: Optional[tuple[int, int, int]] = None ): if isinstance(names, str): names = (names,) - if isinstance(names, list): + elif isinstance(names, list): names = tuple(names) if names is None: return None if not isinstance(names, tuple): raise TypeError("names should be str or tuple[str]") - name = ": ".join(names) - classification = Classification._cached_classifications.get(name) + + classification = Classification.__cached_classifications.get(names) if classification is None: classification = super().__new__(cls) - Classification._cached_classifications[name] = classification + Classification.__cached_classifications[names] = classification + + if color is not None: + classification.color = color return classification def __init__( @@ -43,42 +49,43 @@ def __init__( names = tuple(names) if not isinstance(names, tuple): raise TypeError("names should be a tuple, list or string") - self._names = names - self._color = ( + self.__names = names + self.__color: tuple = ( tuple(random.randint(0, 255) for _ in range(3)) if color is None else color ) - @property - def name(self) -> str: - """ - The name of the classification. - """ - return ": ".join(self._names) - @property def names(self) -> tuple[str]: """ - The name of the classification. + The names of the classification. """ - return self._names + return self.__names @property def color(self) -> tuple[int, int, int]: """ The color of the classification. """ - return self._color ## todo: pylance type hints problem + return self.__color ## todo: pylance type hints problem + + @color.setter + def color(self, value: tuple[int, int, int]) -> None: + """ + Change the color of the classification. + :param value: the new 8-bit RGB color + """ + self.__color = value def __str__(self): - return f"Classification {self.name} of color {self.color}" + return f"Classification {self.names} of color {self.color}" def __repr__(self): - return f"Classification('{self.name}', {self.color})" + return f"Classification('{self.names}', {self.color})" def __eq__(self, other): if isinstance(other, Classification): - return (self is other) or (self.name == other.name) + return (self is other) or (self.names == other.names) return False def __hash__(self): - return hash(self.name) + return hash(self.names) diff --git a/tests/objects/test_classification.py b/tests/objects/test_classification.py index c0cccea..1d22fe9 100644 --- a/tests/objects/test_classification.py +++ b/tests/objects/test_classification.py @@ -2,12 +2,12 @@ def test_name(): - expected_name = "name" - classification = Classification(expected_name) + expected_names = ("name",) + classification = Classification(expected_names) - name = classification.name + names = classification.names - assert expected_name == name + assert expected_names == names def test_color(): @@ -30,7 +30,7 @@ def test_cache_when_empty(): classification = Classification(name, color) - assert classification == Classification(name, color) + assert classification is Classification(name, color) def test_cache_when_not_empty_and_same_name(): @@ -68,11 +68,3 @@ def test_names_input(): class1 = Classification(names) class2 = Classification(list(names)) assert class1 is class2 - - -def test_names_with_colon(): - names = ("a", "b") - name = "a: b" - class1 = Classification(names) - class2 = Classification(name) - assert class1 is class2 diff --git a/tests/objects/test_image_feature.py b/tests/objects/test_image_feature.py index 86e696e..963f8e2 100644 --- a/tests/objects/test_image_feature.py +++ b/tests/objects/test_image_feature.py @@ -423,7 +423,7 @@ def test_classification_when_created_from_label_image_and_classification_name_pr ) assert all( - feature.classification.name == expected_classification_name + feature.classification.names == (expected_classification_name,) for feature in features ) @@ -457,7 +457,7 @@ def test_classification_when_created_from_label_image_and_classification_dict_pr assert all( feature.classification is None - or feature.classification.name in expected_classification_names + or feature.classification.names[0] in expected_classification_names for feature in features ) @@ -580,7 +580,7 @@ def test_classification_when_created_from_binary_image_and_classification_name_p ) assert all( - feature.classification.name == expected_classification_name + feature.classification.names == (expected_classification_name,) for feature in features ) @@ -609,7 +609,7 @@ def test_classification_when_created_from_binary_image_and_classification_dict_p ) assert all( - feature.classification.name in expected_classification_names + feature.classification.names[0] in expected_classification_names for feature in features ) @@ -618,7 +618,7 @@ def test_classification_when_set_after_creation(): expected_classification = Classification("name", (1, 1, 1)) image_feature = ImageFeature(None) image_feature.classification = { - "name": expected_classification.name, + "names": expected_classification.names, "color": expected_classification.color, } @@ -630,9 +630,9 @@ def test_classification_when_set_after_creation(): def test_name_when_set_after_creation(): expected_name = "name" image_feature = ImageFeature(None) - image_feature.name = expected_name + image_feature.names = expected_name - name = image_feature.name + name = image_feature.names assert name == expected_name @@ -699,9 +699,6 @@ def test_imagefeature_handles_classification_names(): assert ifeature.classification.names == tuple( feature["properties"]["classification"]["names"] ) - assert ifeature.classification.name == ": ".join( - feature["properties"]["classification"]["names"] - ) def test_imagefeature_handles_classification_name(): @@ -710,6 +707,6 @@ def test_imagefeature_handles_classification_name(): """ feature = geojson_features_from_string(string) ifeature = ImageFeature.create_from_feature(feature) - assert ifeature.classification.name == ": ".join( + assert ifeature.classification.names == tuple( feature["properties"]["classification"]["names"] ) From 36b16d2be143ad79e58211126ebd96a8d3375b31 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 22 Oct 2025 10:27:22 +0100 Subject: [PATCH 3/3] Remove dunder --- qubalab/objects/classification.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/qubalab/objects/classification.py b/qubalab/objects/classification.py index ba7a080..dd59f3a 100644 --- a/qubalab/objects/classification.py +++ b/qubalab/objects/classification.py @@ -11,7 +11,7 @@ class Classification(object): Therefore updating the color of a Classification will update all similarly classified objects. """ - __cached_classifications: dict[tuple[str], Classification] = {} + _cached_classifications: dict[tuple[str], Classification] = {} def __new__( cls, names: Union[str, tuple[str]], color: Optional[tuple[int, int, int]] = None @@ -25,10 +25,10 @@ def __new__( if not isinstance(names, tuple): raise TypeError("names should be str or tuple[str]") - classification = Classification.__cached_classifications.get(names) + classification = Classification._cached_classifications.get(names) if classification is None: classification = super().__new__(cls) - Classification.__cached_classifications[names] = classification + Classification._cached_classifications[names] = classification if color is not None: classification.color = color @@ -49,8 +49,8 @@ def __init__( names = tuple(names) if not isinstance(names, tuple): raise TypeError("names should be a tuple, list or string") - self.__names = names - self.__color: tuple = ( + self._names = names + self._color: tuple = ( tuple(random.randint(0, 255) for _ in range(3)) if color is None else color ) @@ -59,14 +59,14 @@ def names(self) -> tuple[str]: """ The names of the classification. """ - return self.__names + return self._names @property def color(self) -> tuple[int, int, int]: """ The color of the classification. """ - return self.__color ## todo: pylance type hints problem + return self._color ## todo: pylance type hints problem @color.setter def color(self, value: tuple[int, int, int]) -> None: @@ -74,7 +74,7 @@ def color(self, value: tuple[int, int, int]) -> None: Change the color of the classification. :param value: the new 8-bit RGB color """ - self.__color = value + self._color = value def __str__(self): return f"Classification {self.names} of color {self.color}"