From d4cea65a9ec89d1c8a959725de4707a905d3c136 Mon Sep 17 00:00:00 2001 From: aljazkonec1 Date: Tue, 10 Sep 2024 12:35:06 +0200 Subject: [PATCH 1/8] PaddleOCR model returns a sequence of classification probabilities for characters. This PR adds a classification_sequence_message that returns a sequence of charecters corresponding to maximum probability in the sequence. The message has optional parameters to remove duplicates and ignore background class indexes. In addition this PR also adds a PaddleOCR Parser, which utilizes the classification_sequence message. --- .../ml/messages/creators/__init__.py | 2 + .../creators/classification_sequence.py | 88 +++++++++++++++++++ depthai_nodes/ml/parsers/__init__.py | 2 + depthai_nodes/ml/parsers/ppocr.py | 88 +++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 depthai_nodes/ml/messages/creators/classification_sequence.py create mode 100644 depthai_nodes/ml/parsers/ppocr.py diff --git a/depthai_nodes/ml/messages/creators/__init__.py b/depthai_nodes/ml/messages/creators/__init__.py index 8f708fc..e0206b5 100644 --- a/depthai_nodes/ml/messages/creators/__init__.py +++ b/depthai_nodes/ml/messages/creators/__init__.py @@ -1,4 +1,5 @@ from .classification import create_classification_message +from .classification_sequence import create_classification_sequence_message from .detection import create_detection_message, create_line_detection_message from .image import create_image_message from .keypoints import create_hand_keypoints_message, create_keypoints_message @@ -19,4 +20,5 @@ "create_sam_message", "create_age_gender_message", "create_map_message", + "create_classification_sequence_message", ] diff --git a/depthai_nodes/ml/messages/creators/classification_sequence.py b/depthai_nodes/ml/messages/creators/classification_sequence.py new file mode 100644 index 0000000..bb439fa --- /dev/null +++ b/depthai_nodes/ml/messages/creators/classification_sequence.py @@ -0,0 +1,88 @@ +from typing import List, Union + +import numpy as np + +from .. import Classifications + + +def create_classification_sequence_message( + classes: List, + scores: Union[np.ndarray, List], + remove_duplicates: bool = False, + ignored_indexes: List[int] = None, +) -> Classifications: + """Creates a message for a multi-class sequence. The 'scores' array is a sequence of + probabilities for each class at each position in the sequence. The message contains + the class names and their respective scores, ordered according to the sequence. + + @param classes: A list of class names, with length 'n_classes'. + @type classes: List + @param scores: A numpy array of shape (sequence_length, n_classes) containing the (row-wise) probability distributions over the classes. + @type scores: np.ndarray + @param remove_duplicates: If True, removes consecutive duplicates from the sequence. + @type remove_duplicates: bool + @param ignored_indexes: A list of indexes to ignore during classification generation (e.g., background class, padding class) + @type ignored_indexes: List[int] + + @return: A message with attributes `classes` and `scores`, both ordered by the sequence. + @rtype: Classifications + + @raises ValueError: If 'classes' is not a list of strings. + @raises ValueError: If 'scores' is not a 2D array of list of shape (sequence_length, n_classes). + @raises ValueError: If the number of classes does not match the number of columns in 'scores'. + @raises ValueError: If any score is not in the range [0, 1]. + @raises ValueError: If the probabilities in any row of 'scores' do not sum to 1. + @raises ValueError: If 'ignored_indexes' in not None or a list of valid indexes within the range [0, n_classes - 1]. + """ + + if not isinstance(classes, List): + raise ValueError(f"Classes should be a list, got {type(classes)}.") + + if isinstance(scores, List): + scores = np.array(scores) + + if len(scores.shape) != 2: + raise ValueError(f"Scores should be a 2D array, got {scores.shape}.") + + if scores.shape[1] != len(classes): + raise ValueError( + f"Number of labels and scores mismatch. Provided {len(classes)} class names and {scores.shape[1]} scores." + ) + + if np.any(scores < 0) or np.any(scores > 1): + raise ValueError("Scores should be in the range [0, 1].") + + if np.any(np.isclose(scores.sum(axis=1), 1.0, atol=1e-2)): + raise ValueError("Each row of scores should sum to 1.") + + if ignored_indexes is not None: + if not isinstance(ignored_indexes, List): + raise ValueError( + f"Ignored indexes should be a list, got {type(ignored_indexes)}." + ) + if np.any(np.array(ignored_indexes) < 0) or np.any( + np.array(ignored_indexes) >= len(classes) + ): + raise ValueError( + "Ignored indexes should be integers in the range [0, num_classes -1]." + ) + + selection = np.ones(len(scores), dtype=bool) + + indexes = np.argmax(scores, axis=1) + + if remove_duplicates: + selection[1:] = indexes[1:] != indexes[:-1] + + if ignored_indexes is not None: + selection &= indexes != ignored_indexes + + class_list = [classes[i] for i in indexes[selection]] + score_list = scores[selection].tolist() + + classification_msg = Classifications() + + classification_msg.classes = class_list + classification_msg.scores = score_list + + return classification_msg diff --git a/depthai_nodes/ml/parsers/__init__.py b/depthai_nodes/ml/parsers/__init__.py index f205433..23e3892 100644 --- a/depthai_nodes/ml/parsers/__init__.py +++ b/depthai_nodes/ml/parsers/__init__.py @@ -8,6 +8,7 @@ from .mediapipe_hand_landmarker import MPHandLandmarkParser from .mediapipe_palm_detection import MPPalmDetectionParser from .mlsd import MLSDParser +from .ppocr import PaddleOCRParser from .scrfd import SCRFDParser from .segmentation import SegmentationParser from .superanimal_landmarker import SuperAnimalParser @@ -32,4 +33,5 @@ "AgeGenderParser", "HRNetParser", "MapOutputParser", + "PaddleOCRParser", ] diff --git a/depthai_nodes/ml/parsers/ppocr.py b/depthai_nodes/ml/parsers/ppocr.py new file mode 100644 index 0000000..6debfab --- /dev/null +++ b/depthai_nodes/ml/parsers/ppocr.py @@ -0,0 +1,88 @@ +from typing import List + +import depthai as dai +import numpy as np + +from ..messages.creators import create_classification_sequence_message +from .classification import ClassificationParser + + +class PaddleOCRParser(ClassificationParser): + """""" + + def __init__( + self, + classes: List[str] = None, + is_softmax: bool = True, + remove_duplicates: bool = True, + ignored_indexes: List[int] = None, + ): + """Initializes the PaddleOCR Parser node. + + @param classes: List of class names to be + """ + super().__init__(classes, is_softmax) + self.out = self.createOutput() + self.input = self.createInput() + self.remove_duplicates = remove_duplicates + self.ignored_indexes = [0] if ignored_indexes is None else ignored_indexes + + def setRemoveDuplicates(self, remove_duplicates: bool): + """Sets the remove_duplicates flag for the classification sequence model. + + @param remove_duplicates: If True, removes consecutive duplicates from the + sequence. + """ + self.remove_duplicates = remove_duplicates + + def setIgnoredIndexes(self, ignored_indexes: List[int]): + """Sets the ignored_indexes for the classification sequence model. + + @param ignored_indexes: A list of indexes to ignore during classification + generation. + """ + self.ignored_indexes = ignored_indexes + + def run(self): + while self.isRunning(): + try: + output: dai.NNData = self.input.get() + + except dai.MessageQueue.QueueException: + break + + output_layer_names = output.getAllLayerNames() + if len(output_layer_names) != 1: + raise ValueError(f"Expected 1 output layer, got {len(output_layer_names)}.") + + if self.n_classes == 0: + raise ValueError("Classes must be provided for classification.") + + scores = output.getTensor(output_layer_names[0], dequantize=True).astype( + np.float32 + ) + + if len(scores.shape) != 3: + raise ValueError(f"Scores should be a 3D array, got {scores.shape}.") + + if scores.shape[0] == 1: + scores = scores[0] + elif scores.shape[2] == 1: + scores = scores[:, :, 0] + else: + raise ValueError( + "Scores should be a 3D array of shape (1, sequence_length, n_classes) or (sequence_length, n_classes, 1)." + ) + + if not self.is_softmax: + scores = np.exp(scores) / np.sum(np.exp(scores), axis=1, keepdims=True) + + msg = create_classification_sequence_message( + classes=self.classes, + scores=scores, + remove_duplicates=self.remove_duplicates, + ignored_indexes=self.ignored_indexes, + ) + msg.setTimestamp(output.getTimestamp()) + + self.out.send(msg) From 5fbfdd8116c92808c1a62c3749801c35cd0870e1 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 10 Sep 2024 10:51:08 +0000 Subject: [PATCH 2/8] [Automated] Updated coverage badge --- media/coverage_badge.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/media/coverage_badge.svg b/media/coverage_badge.svg index 53e7fcb..ccbab79 100644 --- a/media/coverage_badge.svg +++ b/media/coverage_badge.svg @@ -9,13 +9,13 @@ - + coverage coverage - 40% - 40% + 39% + 39% From 7529fec6d59b75ab9e3e9aa926cccaf9d15dbaf1 Mon Sep 17 00:00:00 2001 From: aljazkonec1 Date: Wed, 11 Sep 2024 12:10:31 +0200 Subject: [PATCH 3/8] PaddleOCR Parser and Classification Sequence message --- .../creators/classification_sequence.py | 30 +++++-- depthai_nodes/ml/parsers/ppocr.py | 81 +++++++++++-------- 2 files changed, 69 insertions(+), 42 deletions(-) diff --git a/depthai_nodes/ml/messages/creators/classification_sequence.py b/depthai_nodes/ml/messages/creators/classification_sequence.py index bb439fa..3d6fa43 100644 --- a/depthai_nodes/ml/messages/creators/classification_sequence.py +++ b/depthai_nodes/ml/messages/creators/classification_sequence.py @@ -8,8 +8,9 @@ def create_classification_sequence_message( classes: List, scores: Union[np.ndarray, List], - remove_duplicates: bool = False, ignored_indexes: List[int] = None, + remove_duplicates: bool = False, + concatenate_text: bool = False, ) -> Classifications: """Creates a message for a multi-class sequence. The 'scores' array is a sequence of probabilities for each class at each position in the sequence. The message contains @@ -19,10 +20,12 @@ def create_classification_sequence_message( @type classes: List @param scores: A numpy array of shape (sequence_length, n_classes) containing the (row-wise) probability distributions over the classes. @type scores: np.ndarray - @param remove_duplicates: If True, removes consecutive duplicates from the sequence. - @type remove_duplicates: bool @param ignored_indexes: A list of indexes to ignore during classification generation (e.g., background class, padding class) @type ignored_indexes: List[int] + @param remove_duplicates: If True, removes consecutive duplicates from the sequence. + @type remove_duplicates: bool + @param concatenate_text: If True, concatenates consecutive words based on the space character. + @type concatenate_text: bool @return: A message with attributes `classes` and `scores`, both ordered by the sequence. @rtype: Classifications @@ -52,7 +55,7 @@ def create_classification_sequence_message( if np.any(scores < 0) or np.any(scores > 1): raise ValueError("Scores should be in the range [0, 1].") - if np.any(np.isclose(scores.sum(axis=1), 1.0, atol=1e-2)): + if not np.any(np.isclose(scores.sum(axis=1), 1.0, atol=1e-3)): raise ValueError("Each row of scores should sum to 1.") if ignored_indexes is not None: @@ -68,17 +71,30 @@ def create_classification_sequence_message( ) selection = np.ones(len(scores), dtype=bool) - indexes = np.argmax(scores, axis=1) if remove_duplicates: selection[1:] = indexes[1:] != indexes[:-1] if ignored_indexes is not None: - selection &= indexes != ignored_indexes + selection &= np.array([index not in ignored_indexes for index in indexes]) class_list = [classes[i] for i in indexes[selection]] - score_list = scores[selection].tolist() + score_list = np.max(scores, axis=1)[selection] + + if concatenate_text and len(class_list) > 1: + concatenated_scores = [] + concatenated_words = "".join(class_list).split() + cumsumlist = np.cumsum([len(word) for word in concatenated_words]) + + start_index = 0 + for num_spaces, end_index in enumerate(cumsumlist): + word_scores = score_list[start_index + num_spaces : end_index + num_spaces] + concatenated_scores.append(np.mean(word_scores)) + start_index = end_index + + class_list = concatenated_words + score_list = concatenated_scores classification_msg = Classifications() diff --git a/depthai_nodes/ml/parsers/ppocr.py b/depthai_nodes/ml/parsers/ppocr.py index 6debfab..2a30e89 100644 --- a/depthai_nodes/ml/parsers/ppocr.py +++ b/depthai_nodes/ml/parsers/ppocr.py @@ -13,19 +13,19 @@ class PaddleOCRParser(ClassificationParser): def __init__( self, classes: List[str] = None, - is_softmax: bool = True, - remove_duplicates: bool = True, ignored_indexes: List[int] = None, + remove_duplicates: bool = False, + concatenate_text: bool = True, + is_softmax: bool = True, ): """Initializes the PaddleOCR Parser node. @param classes: List of class names to be """ super().__init__(classes, is_softmax) - self.out = self.createOutput() - self.input = self.createInput() - self.remove_duplicates = remove_duplicates self.ignored_indexes = [0] if ignored_indexes is None else ignored_indexes + self.remove_duplicates = remove_duplicates + self.concatenate_text = concatenate_text def setRemoveDuplicates(self, remove_duplicates: bool): """Sets the remove_duplicates flag for the classification sequence model. @@ -43,6 +43,14 @@ def setIgnoredIndexes(self, ignored_indexes: List[int]): """ self.ignored_indexes = ignored_indexes + def setConcatenateText(self, concatenate_text: bool): + """Sets the concatenate_text flag for the classification sequence model. + + @param concatenate_text: If True, concatenates consecutive words based on + predicted spaces. + """ + self.concatenate_text = concatenate_text + def run(self): while self.isRunning(): try: @@ -51,38 +59,41 @@ def run(self): except dai.MessageQueue.QueueException: break - output_layer_names = output.getAllLayerNames() - if len(output_layer_names) != 1: - raise ValueError(f"Expected 1 output layer, got {len(output_layer_names)}.") - - if self.n_classes == 0: - raise ValueError("Classes must be provided for classification.") - - scores = output.getTensor(output_layer_names[0], dequantize=True).astype( - np.float32 - ) + output_layer_names = output.getAllLayerNames() + if len(output_layer_names) != 1: + raise ValueError( + f"Expected 1 output layer, got {len(output_layer_names)}." + ) - if len(scores.shape) != 3: - raise ValueError(f"Scores should be a 3D array, got {scores.shape}.") + if self.n_classes == 0: + raise ValueError("Classes must be provided for classification.") - if scores.shape[0] == 1: - scores = scores[0] - elif scores.shape[2] == 1: - scores = scores[:, :, 0] - else: - raise ValueError( - "Scores should be a 3D array of shape (1, sequence_length, n_classes) or (sequence_length, n_classes, 1)." + scores = output.getTensor(output_layer_names[0], dequantize=True).astype( + np.float32 ) - if not self.is_softmax: - scores = np.exp(scores) / np.sum(np.exp(scores), axis=1, keepdims=True) - - msg = create_classification_sequence_message( - classes=self.classes, - scores=scores, - remove_duplicates=self.remove_duplicates, - ignored_indexes=self.ignored_indexes, - ) - msg.setTimestamp(output.getTimestamp()) + if len(scores.shape) != 3: + raise ValueError(f"Scores should be a 3D array, got {scores.shape}.") + + if scores.shape[0] == 1: + scores = scores[0] + elif scores.shape[2] == 1: + scores = scores[:, :, 0] + else: + raise ValueError( + "Scores should be a 3D array of shape (1, sequence_length, n_classes) or (sequence_length, n_classes, 1)." + ) + + if not self.is_softmax: + scores = np.exp(scores) / np.sum(np.exp(scores), axis=1, keepdims=True) + + msg = create_classification_sequence_message( + classes=self.classes, + scores=scores, + remove_duplicates=self.remove_duplicates, + ignored_indexes=self.ignored_indexes, + concatenate_text=self.concatenate_text, + ) + msg.setTimestamp(output.getTimestamp()) - self.out.send(msg) + self.out.send(msg) From 57b03e308a58e485c46a5b08b1d084cfad33f0c1 Mon Sep 17 00:00:00 2001 From: aljazkonec1 Date: Wed, 11 Sep 2024 13:32:55 +0200 Subject: [PATCH 4/8] Added unit tests and modified some assertions --- depthai_nodes/ml/messages/classification.py | 14 +- .../creators/classification_sequence.py | 32 ++- depthai_nodes/ml/parsers/ppocr.py | 52 +++- .../test_classification_sequence.py | 235 ++++++++++++++++++ 4 files changed, 314 insertions(+), 19 deletions(-) create mode 100644 tests/unittests/test_creators/test_classification_sequence.py diff --git a/depthai_nodes/ml/messages/classification.py b/depthai_nodes/ml/messages/classification.py index 57bf979..8b678c1 100644 --- a/depthai_nodes/ml/messages/classification.py +++ b/depthai_nodes/ml/messages/classification.py @@ -1,4 +1,4 @@ -from typing import List +from typing import Sequence import depthai as dai @@ -18,21 +18,21 @@ def __init__(self): """Initializes the Classifications object and sets the classes and scores to empty lists.""" dai.Buffer.__init__(self) - self._classes: List[str] = [] - self._scores: List[float] = [] + self._classes: Sequence[str] = [] + self._scores: Sequence[float] = [] @property - def classes(self) -> List: + def classes(self) -> Sequence: """Returns the list of classes.""" return self._classes @property - def scores(self) -> List: + def scores(self) -> Sequence: """Returns the list of scores.""" return self._scores @classes.setter - def classes(self, class_names: List[str]): + def classes(self, class_names: Sequence[str]): """Sets the list of classes. @param classes: A list of class names. @@ -40,7 +40,7 @@ def classes(self, class_names: List[str]): self._classes = class_names @scores.setter - def scores(self, scores: List[float]): + def scores(self, scores: Sequence[float]): """Sets the list of scores. @param scores: A list of scores. diff --git a/depthai_nodes/ml/messages/creators/classification_sequence.py b/depthai_nodes/ml/messages/creators/classification_sequence.py index 3d6fa43..9c68c8a 100644 --- a/depthai_nodes/ml/messages/creators/classification_sequence.py +++ b/depthai_nodes/ml/messages/creators/classification_sequence.py @@ -6,7 +6,7 @@ def create_classification_sequence_message( - classes: List, + classes: List[str], scores: Union[np.ndarray, List], ignored_indexes: List[int] = None, remove_duplicates: bool = False, @@ -27,8 +27,10 @@ def create_classification_sequence_message( @param concatenate_text: If True, concatenates consecutive words based on the space character. @type concatenate_text: bool - @return: A message with attributes `classes` and `scores`, both ordered by the sequence. - @rtype: Classifications + Returns + ------- + **Type**: Classifications + A message with attributes `classes` and `scores`, where `classes` is a list of class names and `scores` is a list of corresponding scores. @raises ValueError: If 'classes' is not a list of strings. @raises ValueError: If 'scores' is not a 2D array of list of shape (sequence_length, n_classes). @@ -49,13 +51,13 @@ def create_classification_sequence_message( if scores.shape[1] != len(classes): raise ValueError( - f"Number of labels and scores mismatch. Provided {len(classes)} class names and {scores.shape[1]} scores." + f"Number of classes and scores mismatch. Provided {len(classes)} class names and {scores.shape[1]} scores." ) if np.any(scores < 0) or np.any(scores > 1): raise ValueError("Scores should be in the range [0, 1].") - if not np.any(np.isclose(scores.sum(axis=1), 1.0, atol=1e-3)): + if np.any(~np.isclose(scores.sum(axis=1), 1.0, atol=1e-3)): raise ValueError("Each row of scores should sum to 1.") if ignored_indexes is not None: @@ -63,6 +65,8 @@ def create_classification_sequence_message( raise ValueError( f"Ignored indexes should be a list, got {type(ignored_indexes)}." ) + if not all(isinstance(index, int) for index in ignored_indexes): + raise ValueError("Ignored indexes should be integers.") if np.any(np.array(ignored_indexes) < 0) or np.any( np.array(ignored_indexes) >= len(classes) ): @@ -82,7 +86,11 @@ def create_classification_sequence_message( class_list = [classes[i] for i in indexes[selection]] score_list = np.max(scores, axis=1)[selection] - if concatenate_text and len(class_list) > 1: + if ( + concatenate_text + and len(class_list) > 1 + and all(len(word) <= 1 for word in class_list) + ): concatenated_scores = [] concatenated_words = "".join(class_list).split() cumsumlist = np.cumsum([len(word) for word in concatenated_words]) @@ -94,11 +102,19 @@ def create_classification_sequence_message( start_index = end_index class_list = concatenated_words - score_list = concatenated_scores + score_list = np.array(concatenated_scores) + + elif ( + concatenate_text + and len(class_list) > 1 + and any(len(word) >= 2 for word in class_list) + ): + class_list = [" ".join(class_list)] + score_list = np.mean(score_list) classification_msg = Classifications() classification_msg.classes = class_list - classification_msg.scores = score_list + classification_msg.scores = score_list.tolist() return classification_msg diff --git a/depthai_nodes/ml/parsers/ppocr.py b/depthai_nodes/ml/parsers/ppocr.py index 2a30e89..b329f8f 100644 --- a/depthai_nodes/ml/parsers/ppocr.py +++ b/depthai_nodes/ml/parsers/ppocr.py @@ -8,11 +8,40 @@ class PaddleOCRParser(ClassificationParser): - """""" + """Postprocessing logic for PaddleOCR text recognition model. + + Attributes + ---------- + input : Node.Input + Node's input. It is a linking point to which the Neural Network's output is linked. It accepts the output of the Neural Network node. + out : Node.Output + Parser sends the processed network results to this output in a form of DepthAI message. It is a linking point from which the processed network results are retrieved. + characters: List[str] + List of available characters for the text recognition model. + ignored_indexes: List[int] + List of indexes to ignore during classification generation (e.g., background class, blank space). + remove_duplicates: bool + If True, removes consecutive duplicates from the sequence. + concatenate_text: bool + If True, concatenates consecutive words based on the predicted spaces. + is_softmax: bool + If False, the scores are converted to probabilities using softmax function. + + Output Message/s + ---------------- + **Type**: Classifications(dai.Buffer) + + **Description**: An object with attributes `classes` and `scores`. `classes` is a list containing the predicted text. `scores` is a list of corresponding probability scores. + + See also + -------- + Official PaddleOCR repository: + https://github.com/PaddlePaddle/PaddleOCR + """ def __init__( self, - classes: List[str] = None, + characters: List[str] = None, ignored_indexes: List[int] = None, remove_duplicates: bool = False, concatenate_text: bool = True, @@ -20,9 +49,21 @@ def __init__( ): """Initializes the PaddleOCR Parser node. - @param classes: List of class names to be + @param characters: List of available characters for the text recognition model. + @type characters: List[str] + @param ignored_indexes: List of indexes to ignore during classification + generation (e.g., background class, blank space). + @type ignored_indexes: List[int] + @param remove_duplicates: If True, removes consecutive duplicates from the + sequence. + @type remove_duplicates: bool + @param concatenate_text: If True, concatenates consecutive words based on the + predicted spaces. + @type concatenate_text: bool + @param is_softmax: If False, the scores are converted to probabilities using + softmax function. """ - super().__init__(classes, is_softmax) + super().__init__(characters, is_softmax) self.ignored_indexes = [0] if ignored_indexes is None else ignored_indexes self.remove_duplicates = remove_duplicates self.concatenate_text = concatenate_text @@ -68,6 +109,9 @@ def run(self): if self.n_classes == 0: raise ValueError("Classes must be provided for classification.") + if any([len(ch) > 1 for ch in self.classes]): + raise ValueError("Each character should only be a single character.") + scores = output.getTensor(output_layer_names[0], dequantize=True).astype( np.float32 ) diff --git a/tests/unittests/test_creators/test_classification_sequence.py b/tests/unittests/test_creators/test_classification_sequence.py new file mode 100644 index 0000000..f4017e1 --- /dev/null +++ b/tests/unittests/test_creators/test_classification_sequence.py @@ -0,0 +1,235 @@ +import re + +import numpy as np +import pytest + +from depthai_nodes.ml.messages.creators.classification_sequence import ( + create_classification_sequence_message, +) + + +def test_none_classes(): + with pytest.raises(ValueError): + create_classification_sequence_message(None, [0.5, 0.2, 0.3]) + + +def test_1D_scores(): + with pytest.raises( + ValueError, match=re.escape("Scores should be a 2D array, got (3,).") + ): + create_classification_sequence_message(["cat", "dog", "bird"], [0.5, 0.2, 0.3]) + + +def test_empty_scores(): + with pytest.raises( + ValueError, + match=re.escape( + "Number of classes and scores mismatch. Provided 3 class names and 0 scores." + ), + ): + create_classification_sequence_message(["cat", "dog", "bird"], [[]]) + + +def test_scores(): + with pytest.raises( + ValueError, match=re.escape("Scores should be in the range [0, 1].") + ): + create_classification_sequence_message( + ["cat", "dog", "bird"], [[0.5, -0.2, 1.3]] + ) + + +def test_probabilities(): + with pytest.raises( + ValueError, match=re.escape("Each row of scores should sum to 1.") + ): + create_classification_sequence_message( + ["cat", "dog", "bird"], [[0.5, 0.2, 0.3], [0.5, 0.2, 0.4]] + ) + + +def test_non_list_ignored_indexes(): + with pytest.raises( + ValueError, + match=re.escape("Ignored indexes should be a list, got ."), + ): + create_classification_sequence_message( + ["cat", "dog", "bird"], [[0.5, 0.2, 0.3]], ignored_indexes=1.0 + ) + + +def test_integer_ignored_indexes(): + with pytest.raises( + ValueError, match=re.escape("Ignored indexes should be integers.") + ): + create_classification_sequence_message( + ["cat", "dog", "bird"], [[0.5, 0.2, 0.3]], ignored_indexes=[1.0] + ) + + +def test_2D_list_integers(): + with pytest.raises( + ValueError, match=re.escape("Ignored indexes should be integers.") + ): + create_classification_sequence_message( + ["cat", "dog", "bird"], [[0.5, 0.2, 0.3]], ignored_indexes=[[3]] + ) + + +def test_upper_limit_integers(): + with pytest.raises( + ValueError, + match=re.escape( + "Ignored indexes should be integers in the range [0, num_classes -1]." + ), + ): + create_classification_sequence_message( + ["cat", "dog", "bird"], [[0.5, 0.2, 0.3]], ignored_indexes=[3] + ) + + +def test_lower_limit_integers(): + with pytest.raises( + ValueError, + match=re.escape( + "Ignored indexes should be integers in the range [0, num_classes -1]." + ), + ): + create_classification_sequence_message( + ["cat", "dog", "bird"], [[0.5, 0.2, 0.3]], ignored_indexes=[-1] + ) + + +def test_remove_duplicates(): + res = create_classification_sequence_message( + ["cat", "dog", "bird"], + [[0.5, 0.2, 0.3], [0.5, 0.2, 0.3]], + remove_duplicates=True, + ) + assert res.classes == ["cat"] + assert res.scores == [0.5] + + +def test_ignored_indexes(): + res = create_classification_sequence_message( + ["cat", "dog", "bird"], [[0.5, 0.2, 0.3], [0.1, 0.6, 0.3]], ignored_indexes=[1] + ) + assert res.classes == ["cat"] + assert res.scores == [0.5] + + +def test_all_ignored_indexes(): + res = create_classification_sequence_message( + ["cat", "dog", "bird"], + [[0.5, 0.2, 0.3], [0.1, 0.6, 0.3]], + ignored_indexes=[0, 1, 2], + ) + assert res.classes == [] + assert res.scores == [] + + +def test_two_ignored_indexes(): + res = create_classification_sequence_message( + ["cat", "dog", "bird"], + [[0.5, 0.2, 0.3], [0.1, 0.6, 0.3]], + ignored_indexes=[0, 2], + ) + assert res.classes == ["dog"] + assert res.scores == [0.6] + + +def test_concatenate_chars_nospace(): + res = create_classification_sequence_message( + ["c", "a", "t"], + [[0.5, 0.2, 0.3], [0.3, 0.5, 0.2], [0.2, 0.1, 0.7]], + concatenate_text=True, + ) + assert res.classes == ["cat"] + assert np.allclose(res.scores, [0.5666666]) + + +def test_concatenate_chars_space(): + probs = [ + [0.5, 0.1, 0.1, 0.1, 0.2], + [0.1, 0.5, 0.1, 0.1, 0.2], + [0.1, 0.1, 0.5, 0.1, 0.2], + [0.1, 0.1, 0.1, 0.5, 0.2], + [0.2, 0.1, 0.1, 0.1, 0.5], + ] + res = create_classification_sequence_message( + ["c", "a", "t", " ", "d"], probs, concatenate_text=True + ) + assert res.classes == ["cat", "d"] + assert np.allclose(res.scores, [0.5, 0.5]) + + +def test_concatenate_multiple_spaces(): + probs = [ + [0.5, 0.1, 0.1, 0.1, 0.2], + [0.1, 0.5, 0.1, 0.1, 0.2], + [0.1, 0.1, 0.5, 0.1, 0.2], + [0.1, 0.1, 0.1, 0.5, 0.2], + [0.1, 0.1, 0.1, 0.5, 0.2], + [0.2, 0.1, 0.1, 0.1, 0.5], + [0.1, 0.1, 0.1, 0.5, 0.2], + ] + res = create_classification_sequence_message( + ["c", "a", "t", " ", "d"], probs, concatenate_text=True + ) + assert res.classes == ["cat", "d"] + assert np.allclose(res.scores, [0.5, 0.5]) + + +def test_concatenate_words(): + probs = [ + [0.5, 0.1, 0.1, 0.1, 0.2], + [0.1, 0.5, 0.1, 0.1, 0.2], + [0.1, 0.1, 0.5, 0.1, 0.2], + [0.1, 0.1, 0.1, 0.5, 0.2], + [0.2, 0.1, 0.1, 0.1, 0.5], + ] + + res = create_classification_sequence_message( + ["Quick", "brown", "fox", "jumps", "over"], probs, concatenate_text=True + ) + assert res.classes == ["Quick brown fox jumps over"] + assert np.allclose(res.scores, [0.5]) + + +def test_concatenate_words_ignore_first(): + probs = [ + [0.5, 0.1, 0.1, 0.1, 0.2], + [0.1, 0.5, 0.1, 0.1, 0.2], + [0.1, 0.1, 0.5, 0.1, 0.2], + [0.1, 0.1, 0.1, 0.5, 0.2], + [0.2, 0.1, 0.1, 0.1, 0.5], + ] + + res = create_classification_sequence_message( + ["Slow", "Quick", "brown", "fox", "jumps"], + probs, + concatenate_text=True, + ignored_indexes=[0], + ) + assert res.classes == ["Quick brown fox jumps"] + assert np.allclose(res.scores, [0.5]) + + +def test_concatenate_mixed_words(): + probs = [ + [0.5, 0.1, 0.1, 0.1, 0.2], + [0.1, 0.5, 0.1, 0.1, 0.2], + [0.1, 0.1, 0.5, 0.1, 0.2], + [0.1, 0.1, 0.1, 0.5, 0.2], + [0.2, 0.1, 0.1, 0.1, 0.5], + ] + + res = create_classification_sequence_message( + ["Quick", "b", "fox", "jumps", "o"], probs, concatenate_text=True + ) + assert res.classes == ["Quick b fox jumps o"] + assert np.allclose(res.scores, [0.5]) + + +if __name__ == "__main__": + pytest.main() From 1134c6e2ab43c0f8b7ffab2f351a05665b87869f Mon Sep 17 00:00:00 2001 From: aljazkonec1 Date: Wed, 11 Sep 2024 13:58:18 +0200 Subject: [PATCH 5/8] pre-commit fixs --- depthai_nodes/ml/parsers/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/depthai_nodes/ml/parsers/__init__.py b/depthai_nodes/ml/parsers/__init__.py index 4a25911..92e6544 100644 --- a/depthai_nodes/ml/parsers/__init__.py +++ b/depthai_nodes/ml/parsers/__init__.py @@ -9,8 +9,8 @@ from .mediapipe_hand_landmarker import MPHandLandmarkParser from .mediapipe_palm_detection import MPPalmDetectionParser from .mlsd import MLSDParser -from .ppocr import PaddleOCRParser from .ppdet import PPTextDetectionParser +from .ppocr import PaddleOCRParser from .scrfd import SCRFDParser from .segmentation import SegmentationParser from .superanimal_landmarker import SuperAnimalParser From 245145ae2bf56c3b611a0d6eeafe32ab6de4506e Mon Sep 17 00:00:00 2001 From: aljazkonec1 Date: Wed, 11 Sep 2024 14:31:00 +0200 Subject: [PATCH 6/8] Doc string fix --- .../ml/messages/creators/classification_sequence.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/depthai_nodes/ml/messages/creators/classification_sequence.py b/depthai_nodes/ml/messages/creators/classification_sequence.py index 9c68c8a..9cce539 100644 --- a/depthai_nodes/ml/messages/creators/classification_sequence.py +++ b/depthai_nodes/ml/messages/creators/classification_sequence.py @@ -26,12 +26,8 @@ def create_classification_sequence_message( @type remove_duplicates: bool @param concatenate_text: If True, concatenates consecutive words based on the space character. @type concatenate_text: bool - - Returns - ------- - **Type**: Classifications - A message with attributes `classes` and `scores`, where `classes` is a list of class names and `scores` is a list of corresponding scores. - + @return: A Classification message with attributes `classes` and `scores`, where `classes` is a list of class names and `scores` is a list of corresponding scores. + @rtype: Classifications @raises ValueError: If 'classes' is not a list of strings. @raises ValueError: If 'scores' is not a 2D array of list of shape (sequence_length, n_classes). @raises ValueError: If the number of classes does not match the number of columns in 'scores'. From f432f02ed572a4363f36e1208f1d26e16a9f4065 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 11 Sep 2024 12:32:28 +0000 Subject: [PATCH 7/8] [Automated] Updated coverage badge --- media/coverage_badge.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/media/coverage_badge.svg b/media/coverage_badge.svg index ccbab79..cb3cdc0 100644 --- a/media/coverage_badge.svg +++ b/media/coverage_badge.svg @@ -9,13 +9,13 @@ - + coverage coverage - 39% - 39% + 41% + 41% From 8903d8155b62dd5b8ece1c8c8ca455a4a5940ac1 Mon Sep 17 00:00:00 2001 From: aljazkonec1 Date: Wed, 11 Sep 2024 14:37:52 +0200 Subject: [PATCH 8/8] small fix --- depthai_nodes/ml/messages/creators/classification_sequence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/depthai_nodes/ml/messages/creators/classification_sequence.py b/depthai_nodes/ml/messages/creators/classification_sequence.py index 9cce539..216905f 100644 --- a/depthai_nodes/ml/messages/creators/classification_sequence.py +++ b/depthai_nodes/ml/messages/creators/classification_sequence.py @@ -53,7 +53,7 @@ def create_classification_sequence_message( if np.any(scores < 0) or np.any(scores > 1): raise ValueError("Scores should be in the range [0, 1].") - if np.any(~np.isclose(scores.sum(axis=1), 1.0, atol=1e-3)): + if np.any(~np.isclose(scores.sum(axis=1), 1.0, atol=1e-2)): raise ValueError("Each row of scores should sum to 1.") if ignored_indexes is not None: