From 72fe0bf47f7330e07932f0541b99b49a6fcdd8fd Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Thu, 24 Jul 2025 15:05:19 +0200 Subject: [PATCH] initial version of `edit_distance` and `fuzzy_match_index` that will allow to match faulty class name generated by VLMs with exact class names --- docs/detection/utils/vlms.md | 18 +++ mkdocs.yml | 1 + supervision/__init__.py | 3 + supervision/detection/utils/vlms.py | 106 ++++++++++++++++ supervision/metrics/mean_average_precision.py | 12 +- test/detection/utils/test_vlms.py | 120 ++++++++++++++++++ 6 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 docs/detection/utils/vlms.md create mode 100644 supervision/detection/utils/vlms.py create mode 100644 test/detection/utils/test_vlms.py diff --git a/docs/detection/utils/vlms.md b/docs/detection/utils/vlms.md new file mode 100644 index 00000000..2706d2a8 --- /dev/null +++ b/docs/detection/utils/vlms.md @@ -0,0 +1,18 @@ +--- +comments: true +status: new +--- + +# VLMs Utils + +
+

edit_distance

+
+ +:::supervision.detection.utils.vlms.edit_distance + +
+

fuzzy_match_index

+
+ +:::supervision.detection.utils.vlms.fuzzy_match_index diff --git a/mkdocs.yml b/mkdocs.yml index f2501534..394d5ddd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -47,6 +47,7 @@ nav: - Boxes: detection/utils/boxes.md - Masks: detection/utils/masks.md - Polygons: detection/utils/polygons.md + - VLMs: detection/utils/vlms.md - Keypoint Detection: - Core: keypoint/core.md - Annotators: keypoint/annotators.md diff --git a/supervision/__init__.py b/supervision/__init__.py index 925628be..0a80113d 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -51,6 +51,7 @@ from supervision.detection.tools.inference_slicer import InferenceSlicer from supervision.detection.tools.json_sink import JSONSink from supervision.detection.tools.polygon_zone import PolygonZone, PolygonZoneAnnotator from supervision.detection.tools.smoother import DetectionsSmoother +from supervision.detection.utils.vlms import edit_distance, fuzzy_match_index from supervision.detection.utils.boxes import ( clip_boxes, denormalize_boxes, @@ -215,7 +216,9 @@ __all__ = [ "draw_polygon", "draw_rectangle", "draw_text", + "edit_distance", "filter_polygons_by_area", + "fuzzy_match_index", "get_coco_class_index_mapping", "get_polygon_center", "get_video_frames_generator", diff --git a/supervision/detection/utils/vlms.py b/supervision/detection/utils/vlms.py new file mode 100644 index 00000000..94af7476 --- /dev/null +++ b/supervision/detection/utils/vlms.py @@ -0,0 +1,106 @@ +from __future__ import annotations + + +def edit_distance(string_1: str, string_2: str, case_sensitive: bool = True) -> int: + """ + Calculates the minimum number of single-character edits required + to transform one string into another. Allowed operations are insertion, + deletion, and substitution. + + Args: + string_1 (str): The source string to be transformed. + string_2 (str): The target string to transform into. + case_sensitive (bool, optional): Whether comparison should be case-sensitive. + Defaults to True. + + Returns: + int: The minimum number of edits required to convert `string_1` + into `string_2`. + + Examples: + ```python + import supervision as sv + + sv.edit_distance("hello", "hello") + # 0 + + sv.edit_distance("Test", "test", case_sensitive=True) + # 1 + + sv.edit_distance("abc", "xyz") + # 3 + + sv.edit_distance("hello", "") + # 5 + + sv.edit_distance("", "") + # 0 + + sv.edit_distance("hello world", "helloworld") + # 1 + ``` + """ + if not case_sensitive: + string_1 = string_1.lower() + string_2 = string_2.lower() + + length_1 = len(string_1) + length_2 = len(string_2) + distance_matrix = [[0] * (length_2 + 1) for _ in range(length_1 + 1)] + + for i in range(length_1 + 1): + distance_matrix[i][0] = i + for j in range(length_2 + 1): + distance_matrix[0][j] = j + + for i in range(1, length_1 + 1): + for j in range(1, length_2 + 1): + if string_1[i - 1] == string_2[j - 1]: + substitution_cost = 0 + else: + substitution_cost = 1 + distance_matrix[i][j] = min( + distance_matrix[i - 1][j] + 1, + distance_matrix[i][j - 1] + 1, + distance_matrix[i - 1][j - 1] + substitution_cost + ) + + return distance_matrix[length_1][length_2] + + +def fuzzy_match_index( + candidates: list[str], + query: str, + threshold: int, + case_sensitive: bool = True, +) -> int | None: + """ + Searches for the first string in `candidates` whose edit distance + to `query` is less than or equal to `threshold`. + + Args: + candidates (list[str]): List of strings to search. + query (str): String to compare against the candidates. + threshold (int): Maximum allowed edit distance for a match. + case_sensitive (bool, optional): Whether matching should be case-sensitive. + + Returns: + Optional[int]: Index of the first matching string in candidates, + or None if no match is found. + + Examples: + ```python + fuzzy_match_index(["cat", "dog", "rat"], "dat", threshold=1) + # 0 + + fuzzy_match_index(["alpha", "beta", "gamma"], "bata", threshold=1) + # 1 + + fuzzy_match_index(["one", "two", "three"], "ten", threshold=2) + # None + ``` + """ + for idx, candidate in enumerate(candidates): + if edit_distance(candidate, query, case_sensitive=case_sensitive) <= threshold: + return idx + return None \ No newline at end of file diff --git a/supervision/metrics/mean_average_precision.py b/supervision/metrics/mean_average_precision.py index 43a3116a..69673617 100644 --- a/supervision/metrics/mean_average_precision.py +++ b/supervision/metrics/mean_average_precision.py @@ -102,12 +102,12 @@ class MeanAveragePrecisionResult: f"maxDets=100 ] = {self.map50:.3f}\n" f"Average Precision (AP) @[ IoU=0.75 | area= all | " f"maxDets=100 ] = {self.map75:.3f}\n" - f"Average Precision (AP) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] " - f"= {self.small_objects.map50_95:.3f}\n" - f"Average Precision (AP) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] \ - = {self.medium_objects.map50_95:.3f}\n" - f"Average Precision (AP) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] \ - = {self.large_objects.map50_95:.3f}" + f"Average Precision (AP) @[ IoU=0.50:0.95 | area= small | " + f"maxDets=100 ] = {self.small_objects.map50_95:.3f}\n" + f"Average Precision (AP) @[ IoU=0.50:0.95 | area=medium | " + f"maxDets=100 ] = {self.medium_objects.map50_95:.3f}\n" + f"Average Precision (AP) @[ IoU=0.50:0.95 | area= large | " + f"maxDets=100 ] = {self.large_objects.map50_95:.3f}" ) def to_pandas(self) -> pd.DataFrame: diff --git a/test/detection/utils/test_vlms.py b/test/detection/utils/test_vlms.py new file mode 100644 index 00000000..6365cf8a --- /dev/null +++ b/test/detection/utils/test_vlms.py @@ -0,0 +1,120 @@ +import pytest + +from supervision.detection.utils.vlms import edit_distance, fuzzy_match_index + +@pytest.mark.parametrize( + "string_1, string_2, case_sensitive, expected_result", + [ + # identical strings, various cases + ("hello", "hello", True, 0), + ("hello", "hello", False, 0), + + # case sensitive vs insensitive + ("Test", "test", True, 1), + ("Test", "test", False, 0), + ("CASE", "case", True, 4), + ("CASE", "case", False, 0), + + # completely different + ("abc", "xyz", True, 3), + ("abc", "xyz", False, 3), + + # one string empty + ("hello", "", True, 5), + ("", "world", True, 5), + + # single character cases + ("a", "b", True, 1), + ("A", "a", True, 1), + ("A", "a", False, 0), + + # whitespaces + ("hello world", "helloworld", True, 1), + ("test", " test", True, 1), + + # unicode and emoji + ("😊", "😊", True, 0), + ("😊", "😢", True, 1), + + # long string vs empty + ("a" * 100, "", True, 100), + ("", "b" * 100, True, 100), + + # prefix/suffix + ("prefix", "prefixes", True, 2), + ("suffix", "asuffix", True, 1), + + # leading/trailing whitespace + (" hello", "hello", True, 1), + ("hello", "hello ", True, 1), + + # long almost-equal string + ( + "The quick brown fox jumps over the lazy dog", + "The quick brown fox jumps over the lazy cog", + True, + 1 + ), + ( + "The quick brown fox jumps over the lazy dog", + "The quick brown fox jumps over the lazy cog", + False, + 1 + ), + + # both empty + ("", "", True, 0), + ("", "", False, 0), + + # mixed case with symbols + ("123ABC!", "123abc!", True, 3), + ("123ABC!", "123abc!", False, 0), + ], +) +def test_edit_distance(string_1, string_2, case_sensitive, expected_result): + assert edit_distance(string_1, string_2, case_sensitive=case_sensitive) == expected_result + + +@pytest.mark.parametrize( + "candidates, query, threshold, case_sensitive, expected_result", + [ + # exact match at index 0 + (["cat", "dog", "rat"], "cat", 0, True, 0), + # match at index 2 within threshold + (["cat", "dog", "rat"], "dat", 1, True, 2), + # no match due to high threshold + (["cat", "dog", "rat"], "bat", 0, True, None), + # multiple possible matches, returns first + (["apple", "apply", "appla"], "apple", 1, True, 0), + # case-insensitive match + (["Alpha", "beta", "Gamma"], "alpha", 0, False, 0), + # case-sensitive: no match + (["Alpha", "beta", "Gamma"], "alpha", 0, True, None), + # threshold boundary + (["alpha", "beta", "gamma"], "bata", 1, True, 1), + # no match (all distances too high) + (["one", "two", "three"], "ten", 1, True, None), + # unicode/emoji match + (["😊", "😢", "😁"], "😄", 1, True, None), + (["😊", "😢", "😁"], "😊", 0, True, 0), + # empty candidates + ([], "any", 2, True, None), + # empty query, non-empty candidates + (["", "abc"], "", 0, True, 0), + (["", "abc"], "", 1, True, 0), + (["a", "b", "c"], "", 1, True, 0), + # non-empty query, empty candidate + (["", ""], "a", 1, True, 0), + # all candidates require higher edit than threshold + (["short", "words", "only"], "longerword", 2, True, None), + # repeated candidates + (["a", "a", "a"], "b", 1, True, 0), + ] +) +def test_fuzzy_match_index(candidates, query, threshold, case_sensitive, expected_result): + assert fuzzy_match_index( + candidates=candidates, + query=query, + threshold=threshold, + case_sensitive=case_sensitive + ) == expected_result