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:
6 files changed
+250
-6
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Reference in new issue
Block a user