Spaces:
Running on Zero
Running on Zero
Improve detection label placement
Browse files- detection_utils.py +63 -5
- tests/test_detection_utils.py +6 -0
detection_utils.py
CHANGED
|
@@ -48,6 +48,48 @@ def detection_color(class_id: int) -> tuple[int, int, int]:
|
|
| 48 |
)
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
def render_detection(
|
| 52 |
image: Image.Image,
|
| 53 |
detections: Iterable[dict[str, object]],
|
|
@@ -58,8 +100,13 @@ def render_detection(
|
|
| 58 |
short_side = min(rendered.size)
|
| 59 |
line_width = max(2, round(short_side / 320))
|
| 60 |
font = _load_font(max(13, min(24, round(short_side / 55))))
|
|
|
|
| 61 |
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
class_id = int(detection["class_id"])
|
| 64 |
color = detection_color(class_id)
|
| 65 |
box = [
|
|
@@ -73,13 +120,24 @@ def render_detection(
|
|
| 73 |
f"{float(detection['confidence']):.2f}"
|
| 74 |
)
|
| 75 |
draw.rectangle(box, outline=color, width=line_width)
|
| 76 |
-
text_box = draw.textbbox((
|
| 77 |
text_height = text_box[3] - text_box[1]
|
| 78 |
text_width = text_box[2] - text_box[0]
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
draw.rectangle(background, fill=color)
|
| 82 |
-
draw.text(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
return rendered
|
| 85 |
|
|
|
|
| 48 |
)
|
| 49 |
|
| 50 |
|
| 51 |
+
def _boxes_overlap(
|
| 52 |
+
first: tuple[float, float, float, float],
|
| 53 |
+
second: tuple[float, float, float, float],
|
| 54 |
+
) -> bool:
|
| 55 |
+
return not (
|
| 56 |
+
first[2] <= second[0]
|
| 57 |
+
or second[2] <= first[0]
|
| 58 |
+
or first[3] <= second[1]
|
| 59 |
+
or second[3] <= first[1]
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _label_box(
|
| 64 |
+
object_box: list[float],
|
| 65 |
+
label_width: float,
|
| 66 |
+
label_height: float,
|
| 67 |
+
image_size: tuple[int, int],
|
| 68 |
+
occupied: list[tuple[float, float, float, float]],
|
| 69 |
+
) -> tuple[float, float, float, float]:
|
| 70 |
+
"""Place a label near its object while avoiding earlier labels."""
|
| 71 |
+
image_width, image_height = image_size
|
| 72 |
+
center_x = (object_box[0] + object_box[2]) / 2
|
| 73 |
+
x = max(0.0, min(center_x - label_width / 2, image_width - label_width))
|
| 74 |
+
|
| 75 |
+
candidates: list[tuple[float, float, float, float]] = []
|
| 76 |
+
for slot in range(7):
|
| 77 |
+
bottom = object_box[1] - 4 - slot * (label_height + 4)
|
| 78 |
+
top = bottom - label_height
|
| 79 |
+
if top >= 0:
|
| 80 |
+
candidates.append((x, top, x + label_width, bottom))
|
| 81 |
+
for slot in range(4):
|
| 82 |
+
top = object_box[3] + 4 + slot * (label_height + 4)
|
| 83 |
+
bottom = top + label_height
|
| 84 |
+
if bottom <= image_height:
|
| 85 |
+
candidates.append((x, top, x + label_width, bottom))
|
| 86 |
+
|
| 87 |
+
for candidate in candidates:
|
| 88 |
+
if not any(_boxes_overlap(candidate, used) for used in occupied):
|
| 89 |
+
return candidate
|
| 90 |
+
return candidates[0] if candidates else (x, 0.0, x + label_width, label_height)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
def render_detection(
|
| 94 |
image: Image.Image,
|
| 95 |
detections: Iterable[dict[str, object]],
|
|
|
|
| 100 |
short_side = min(rendered.size)
|
| 101 |
line_width = max(2, round(short_side / 320))
|
| 102 |
font = _load_font(max(13, min(24, round(short_side / 55))))
|
| 103 |
+
occupied_labels: list[tuple[float, float, float, float]] = []
|
| 104 |
|
| 105 |
+
ordered_detections = sorted(
|
| 106 |
+
detections,
|
| 107 |
+
key=lambda item: (float(item["x1"]), float(item["y1"])),
|
| 108 |
+
)
|
| 109 |
+
for detection in ordered_detections:
|
| 110 |
class_id = int(detection["class_id"])
|
| 111 |
color = detection_color(class_id)
|
| 112 |
box = [
|
|
|
|
| 120 |
f"{float(detection['confidence']):.2f}"
|
| 121 |
)
|
| 122 |
draw.rectangle(box, outline=color, width=line_width)
|
| 123 |
+
text_box = draw.textbbox((0, 0), label, font=font)
|
| 124 |
text_height = text_box[3] - text_box[1]
|
| 125 |
text_width = text_box[2] - text_box[0]
|
| 126 |
+
background = _label_box(
|
| 127 |
+
box,
|
| 128 |
+
text_width + 8,
|
| 129 |
+
text_height + 8,
|
| 130 |
+
rendered.size,
|
| 131 |
+
occupied_labels,
|
| 132 |
+
)
|
| 133 |
+
occupied_labels.append(background)
|
| 134 |
draw.rectangle(background, fill=color)
|
| 135 |
+
draw.text(
|
| 136 |
+
(background[0] + 4, background[1] + 4),
|
| 137 |
+
label,
|
| 138 |
+
fill="white",
|
| 139 |
+
font=font,
|
| 140 |
+
)
|
| 141 |
|
| 142 |
return rendered
|
| 143 |
|
tests/test_detection_utils.py
CHANGED
|
@@ -55,3 +55,9 @@ def test_detection_overlay_matches_input_size():
|
|
| 55 |
rendered = utils.render_detection(image, DETECTIONS)
|
| 56 |
assert rendered.size == image.size
|
| 57 |
assert rendered.getpixel((1, 2)) != (255, 255, 255)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
rendered = utils.render_detection(image, DETECTIONS)
|
| 56 |
assert rendered.size == image.size
|
| 57 |
assert rendered.getpixel((1, 2)) != (255, 255, 255)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_label_boxes_avoid_existing_labels():
|
| 61 |
+
first = utils._label_box([20, 30, 30, 40], 20, 10, (100, 100), [])
|
| 62 |
+
second = utils._label_box([22, 30, 32, 40], 20, 10, (100, 100), [first])
|
| 63 |
+
assert not utils._boxes_overlap(first, second)
|