Merge pull request #1912 from roboflow/feature/edit_distance_and_fuzzy_match

`edit_distance` and `fuzzy_match_index` that will allow to match faulty class name generated by VLMs with exact class names
This commit is contained in:
Piotr Skalski authored and GitHub committed 2025-07-25 14:52:24 +02:00
commit 9679cd5c4e
6 files changed
+250 -6

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
---
comments: true
status: new
---
# VLMs Utils
<div class="md-typeset">
<h2><a href="#supervision.detection.utils.vlms.edit_distance">edit_distance</a></h2>
</div>
:::supervision.detection.utils.vlms.edit_distance
<div class="md-typeset">
<h2><a href="#supervision.detection.utils.vlms.fuzzy_match_index">fuzzy_match_index</a></h2>
</div>
:::supervision.detection.utils.vlms.fuzzy_match_index
+1
View File
@@ -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
+3
View File
@@ -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",
+105
View File
@@ -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
@@ -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:
+117
View File
@@ -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
)