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..cc54fe70 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -92,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 ( @@ -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..8c4aca74 --- /dev/null +++ b/supervision/detection/utils/vlms.py @@ -0,0 +1,105 @@ +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() + + if len(string_1) < len(string_2): + string_1, string_2 = string_2, string_1 + + prev_row = list(range(len(string_2) + 1)) + curr_row = [0] * (len(string_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 + 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 prev_row[len(string_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 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..a6fe649b --- /dev/null +++ b/test/detection/utils/test_vlms.py @@ -0,0 +1,117 @@ +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, 0), + # 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, 0), + (["😊", "😢", "😁"], "😊", 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 + )