From 72fe0bf47f7330e07932f0541b99b49a6fcdd8fd Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Thu, 24 Jul 2025 15:05:19 +0200 Subject: [PATCH 1/5] 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 From 8262b6e64ba44e4312bc275ed04ce8cf63d8c873 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 24 Jul 2025 13:06:42 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto=20?= =?UTF-8?q?format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supervision/__init__.py | 2 +- supervision/detection/utils/vlms.py | 4 +-- test/detection/utils/test_vlms.py | 43 ++++++++++++++--------------- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/supervision/__init__.py b/supervision/__init__.py index 0a80113d..cc54fe70 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -51,7 +51,6 @@ 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, @@ -93,6 +92,7 @@ from supervision.detection.utils.polygons import ( approximate_polygon, filter_polygons_by_area, ) +from supervision.detection.utils.vlms import edit_distance, fuzzy_match_index from supervision.detection.vlm import LMM, VLM from supervision.draw.color import Color, ColorPalette from supervision.draw.utils import ( diff --git a/supervision/detection/utils/vlms.py b/supervision/detection/utils/vlms.py index 94af7476..9022b061 100644 --- a/supervision/detection/utils/vlms.py +++ b/supervision/detection/utils/vlms.py @@ -62,7 +62,7 @@ def edit_distance(string_1: str, string_2: str, case_sensitive: bool = True) -> 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 + distance_matrix[i - 1][j - 1] + substitution_cost, ) return distance_matrix[length_1][length_2] @@ -103,4 +103,4 @@ def fuzzy_match_index( 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 + return None diff --git a/test/detection/utils/test_vlms.py b/test/detection/utils/test_vlms.py index 6365cf8a..76310a22 100644 --- a/test/detection/utils/test_vlms.py +++ b/test/detection/utils/test_vlms.py @@ -2,77 +2,69 @@ 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 + 1, ), ( "The quick brown fox jumps over the lazy dog", "The quick brown fox jumps over the lazy cog", False, - 1 + 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 + assert ( + edit_distance(string_1, string_2, case_sensitive=case_sensitive) + == expected_result + ) @pytest.mark.parametrize( @@ -109,12 +101,17 @@ def test_edit_distance(string_1, string_2, case_sensitive, expected_result): (["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 +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 + ) From 58bdce57076fe2c2c72d661aac46a42849a52a2f Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Thu, 24 Jul 2025 15:13:46 +0200 Subject: [PATCH 3/5] tests fixed --- test/detection/utils/test_vlms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/detection/utils/test_vlms.py b/test/detection/utils/test_vlms.py index 76310a22..a6fe649b 100644 --- a/test/detection/utils/test_vlms.py +++ b/test/detection/utils/test_vlms.py @@ -73,7 +73,7 @@ def test_edit_distance(string_1, string_2, 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), + (["cat", "dog", "rat"], "dat", 1, True, 0), # no match due to high threshold (["cat", "dog", "rat"], "bat", 0, True, None), # multiple possible matches, returns first @@ -87,7 +87,7 @@ def test_edit_distance(string_1, string_2, case_sensitive, expected_result): # no match (all distances too high) (["one", "two", "three"], "ten", 1, True, None), # unicode/emoji match - (["😊", "😢", "😁"], "😄", 1, True, None), + (["😊", "😢", "😁"], "😄", 1, True, 0), (["😊", "😢", "😁"], "😊", 0, True, 0), # empty candidates ([], "any", 2, True, None), From ec189bf38895a83c92f2ea6ddc9fb665a3f26531 Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Fri, 25 Jul 2025 13:41:03 +0200 Subject: [PATCH 4/5] new, more memory efficient `edit_distance` implementation --- supervision/detection/utils/vlms.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/supervision/detection/utils/vlms.py b/supervision/detection/utils/vlms.py index 9022b061..c284ac8d 100644 --- a/supervision/detection/utils/vlms.py +++ b/supervision/detection/utils/vlms.py @@ -44,28 +44,27 @@ def edit_distance(string_1: str, string_2: str, case_sensitive: bool = True) -> 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)] + if len(string_1) < len(string_2): + string_1, string_2 = string_2, string_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 + prev_row = list(range(len(string_2) + 1)) + curr_row = [0] * (len(string_2) + 1) - for i in range(1, length_1 + 1): - for j in range(1, length_2 + 1): + for i in range(1, len(string_1) + 1): + curr_row[0] = i + for j in range(1, len(string_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, + curr_row[j] = min( + prev_row[j] + 1, + curr_row[j - 1] + 1, + prev_row[j - 1] + substitution_cost ) + prev_row, curr_row = curr_row, prev_row - return distance_matrix[length_1][length_2] + return prev_row[len(string_2)] def fuzzy_match_index( From 00817df9e23a12ca5a9f8462864c4f916654feab Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:41:24 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto=20?= =?UTF-8?q?format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supervision/detection/utils/vlms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supervision/detection/utils/vlms.py b/supervision/detection/utils/vlms.py index c284ac8d..8c4aca74 100644 --- a/supervision/detection/utils/vlms.py +++ b/supervision/detection/utils/vlms.py @@ -60,7 +60,7 @@ def edit_distance(string_1: str, string_2: str, case_sensitive: bool = True) -> curr_row[j] = min( prev_row[j] + 1, curr_row[j - 1] + 1, - prev_row[j - 1] + substitution_cost + prev_row[j - 1] + substitution_cost, ) prev_row, curr_row = curr_row, prev_row