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
+
+
+
+:::supervision.detection.utils.vlms.edit_distance
+
+
+
+:::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