Skip to content

Commit 958414c

Browse files
fix(server): map GroundingDINO detections back onto the caller's labels (#277)
* fix(server): map GroundingDINO detections back onto the caller's labels The post-processor returns lowercased phrase fragments, so a caller matching detections against the labels it sent found none. Each decoded phrase is now mapped onto the caller's label it overlaps most; a free-text instruction prompt keeps the phrase. * fix(server): count each distinct label word once when canonicalising
1 parent 1b3479d commit 958414c

2 files changed

Lines changed: 87 additions & 6 deletions

File tree

packages/sie_server/src/sie_server/adapters/grounding_dino/adapter.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from __future__ import annotations
2929

3030
import logging
31+
import re
3132
from pathlib import Path
3233
from typing import TYPE_CHECKING, Any, ClassVar
3334

@@ -53,6 +54,34 @@
5354
_ERR_ENCODE_NOT_SUPPORTED = "GroundingDINOAdapter does not support encode(). Use extract() instead."
5455

5556

57+
_WORD_RE = re.compile(r"\w+")
58+
59+
60+
def _canonical_label(phrase: str, labels: list[str] | None) -> str:
61+
"""The caller's label a grounded phrase came from, or the phrase itself.
62+
63+
Scores each label by the share of its words the phrase contains, then by
64+
how many of the phrase's words it explains ("red handbag" beats "handbag"
65+
for the phrase "red handbag"); the earlier label breaks a full tie. The
66+
phrase is kept verbatim only when no label shares a word with it, which
67+
is the ``instruction`` prompt case (no labels) or a decode the caller
68+
never asked for.
69+
"""
70+
if not labels:
71+
return phrase
72+
phrase_words = set(_WORD_RE.findall(phrase.lower()))
73+
best_label, best_key = phrase, (0.0, 0)
74+
for label in labels:
75+
words = set(_WORD_RE.findall(label.lower()))
76+
if not words:
77+
continue
78+
matched = len(words & phrase_words)
79+
key = (matched / len(words), matched)
80+
if key > best_key:
81+
best_label, best_key = label, key
82+
return best_label
83+
84+
5685
class GroundingDINOAdapter(BaseAdapter):
5786
"""Adapter for GroundingDINO open-vocabulary object detection.
5887
@@ -225,6 +254,7 @@ def extract(
225254
pixel_values=pixel_values,
226255
pixel_mask=pixel_mask,
227256
original_sizes=original_sizes,
257+
labels=labels,
228258
)
229259
else:
230260
# Fallback: decode images inline
@@ -246,6 +276,7 @@ def extract(
246276
box_threshold=box_threshold,
247277
text_threshold=text_threshold,
248278
images=images,
279+
labels=labels,
249280
)
250281

251282
# Map results back to original item positions
@@ -265,6 +296,7 @@ def _detect_batch(
265296
pixel_mask: torch.Tensor | None = None,
266297
original_sizes: list[tuple[int, int]] | None = None,
267298
images: list[Image] | None = None,
299+
labels: list[str] | None = None,
268300
) -> list[list[DetectedObject]]:
269301
"""Run batched detection on multiple images.
270302
@@ -279,6 +311,8 @@ def _detect_batch(
279311
pixel_values: Preprocessed image tensor [B, C, H, W] (optional).
280312
original_sizes: List of (width, height) tuples for bbox denormalization.
281313
images: List of PIL Images (fallback if pixel_values not provided).
314+
labels: The caller's labels, verbatim, when the prompt was built from
315+
them; every detection's label is mapped back onto one of these.
282316
283317
Returns:
284318
List of detected object lists, one per input image.
@@ -345,10 +379,19 @@ def _detect_batch(
345379
)
346380

347381
# Convert results to DetectedObject format
348-
return [self._results_to_objects(result) for result in results]
349-
350-
def _results_to_objects(self, result: dict[str, Any]) -> list[DetectedObject]:
351-
"""Convert post-processed detection result to DetectedObject list."""
382+
return [self._results_to_objects(result, labels) for result in results]
383+
384+
def _results_to_objects(self, result: dict[str, Any], labels: list[str] | None = None) -> list[DetectedObject]:
385+
"""Convert post-processed detection result to DetectedObject list.
386+
387+
The post-processor decodes, per box, the prompt tokens above
388+
``text_threshold``, so a caller's label comes back lowercased and
389+
possibly as a fragment or a span merged across two labels ("leather
390+
handbag" for "Red Leather Handbag"). Callers match detections against
391+
the labels they sent, so each phrase is mapped back onto the caller's
392+
label it overlaps most; a free-text ``instruction`` prompt has no
393+
labels and keeps the phrase.
394+
"""
352395
boxes = result["boxes"]
353396
scores = result["scores"]
354397
result_labels = result.get("text_labels", result.get("labels", []))
@@ -364,7 +407,7 @@ def _results_to_objects(self, result: dict[str, Any]) -> list[DetectedObject]:
364407
for i in range(n_detections):
365408
x1, y1, x2, y2 = boxes_list[i]
366409
score = scores_list[i]
367-
label_text = result_labels[i]
410+
label_text = _canonical_label(result_labels[i], labels)
368411

369412
objects.append(
370413
DetectedObject(

packages/sie_server/tests/adapters/test_grounding_dino.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010
import torch
1111
from PIL import Image
12-
from sie_server.adapters.grounding_dino.adapter import GroundingDINOAdapter
12+
from sie_server.adapters.grounding_dino.adapter import GroundingDINOAdapter, _canonical_label
1313
from sie_server.core.inference_output import ExtractOutput
1414
from sie_server.types.inputs import ImageInput, Item
1515

@@ -319,6 +319,44 @@ def test_results_to_objects_bulk_converts_tensors_once(self) -> None:
319319
scores.detach.return_value.cpu.assert_called_once_with()
320320
scores.detach.return_value.cpu.return_value.tolist.assert_called_once_with()
321321

322+
def test_results_to_objects_maps_decoded_phrases_back_onto_the_callers_labels(self) -> None:
323+
"""The post-processor lowercases and fragments multi-word labels; callers get their own back."""
324+
adapter = GroundingDINOAdapter("IDEA-Research/grounding-dino-tiny")
325+
boxes = MagicMock()
326+
boxes.__len__.return_value = 3
327+
boxes.detach.return_value.cpu.return_value.tolist.return_value = [
328+
[10.0, 20.0, 110.0, 220.0],
329+
[0.0, 0.0, 50.0, 50.0],
330+
[5.0, 5.0, 15.0, 15.0],
331+
]
332+
scores = MagicMock()
333+
scores.detach.return_value.cpu.return_value.tolist.return_value = [0.9, 0.5, 0.4]
334+
335+
objects = adapter._results_to_objects(
336+
{
337+
"boxes": boxes,
338+
"scores": scores,
339+
"text_labels": ["leather handbag", "handbag backpack", "camera"],
340+
},
341+
["Red Leather Handbag", "backpack", "camera — acceptance 1959"],
342+
)
343+
344+
# A span merged across two labels ("handbag backpack") goes to the
345+
# label it covers completely, not to the one it merely touches.
346+
assert [obj["label"] for obj in objects] == ["Red Leather Handbag", "backpack", "camera — acceptance 1959"]
347+
348+
def test_canonical_label_prefers_the_label_the_phrase_covers_most(self) -> None:
349+
labels = ["handbag", "red handbag", "backpack"]
350+
assert _canonical_label("handbag", labels) == "handbag"
351+
assert _canonical_label("red handbag", labels) == "red handbag"
352+
assert _canonical_label("backpack.", labels) == "backpack"
353+
# A repeated word counts once, so it cannot outscore the earlier label.
354+
assert _canonical_label("red car", ["red car", "red red car"]) == "red car"
355+
# No labels (an instruction prompt) or no overlap: the phrase stands.
356+
assert _canonical_label("dog", None) == "dog"
357+
assert _canonical_label("dog", labels) == "dog"
358+
assert _canonical_label("dog", ["", " "]) == "dog"
359+
322360
def test_results_to_objects_empty_does_not_transfer_scores(self) -> None:
323361
adapter = GroundingDINOAdapter("IDEA-Research/grounding-dino-tiny")
324362
scores = MagicMock()

0 commit comments

Comments
 (0)