Skip to content

Commit ebbc9ee

Browse files
Merge pull request #1069 from roboflow/detections-union
Add a Detection union block
2 parents af493d0 + a430764 commit ebbc9ee

5 files changed

Lines changed: 375 additions & 0 deletions

File tree

inference/core/workflows/core_steps/loader.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,9 @@
298298
from inference.core.workflows.core_steps.transformations.detections_filter.v1 import (
299299
DetectionsFilterBlockV1,
300300
)
301+
from inference.core.workflows.core_steps.transformations.detections_merge.v1 import (
302+
DetectionsMergeBlockV1,
303+
)
301304
from inference.core.workflows.core_steps.transformations.detections_transformation.v1 import (
302305
DetectionsTransformationBlockV1,
303306
)
@@ -524,6 +527,7 @@ def load_blocks() -> List[Type[WorkflowBlock]]:
524527
BlurVisualizationBlockV1,
525528
BoundingBoxVisualizationBlockV1,
526529
BoundingRectBlockV1,
530+
DetectionsMergeBlockV1,
527531
ByteTrackerBlockV2,
528532
CacheGetBlockV1,
529533
CacheSetBlockV1,

inference/core/workflows/core_steps/transformations/detections_merge/__init__.py

Whitespace-only changes.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from typing import Any, Dict, List, Literal, Optional, Type
2+
from uuid import uuid4
3+
4+
import numpy as np
5+
import supervision as sv
6+
from pydantic import ConfigDict, Field
7+
8+
from inference.core.workflows.execution_engine.entities.base import OutputDefinition
9+
from inference.core.workflows.execution_engine.entities.types import (
10+
INSTANCE_SEGMENTATION_PREDICTION_KIND,
11+
KEYPOINT_DETECTION_PREDICTION_KIND,
12+
OBJECT_DETECTION_PREDICTION_KIND,
13+
Selector,
14+
)
15+
from inference.core.workflows.prototypes.block import (
16+
BlockResult,
17+
WorkflowBlock,
18+
WorkflowBlockManifest,
19+
)
20+
21+
OUTPUT_KEY: str = "predictions"
22+
23+
SHORT_DESCRIPTION = "Merge multiple detections into a single bounding box."
24+
LONG_DESCRIPTION = """
25+
The `DetectionsMerge` block combines multiple detections into a single bounding box that encompasses all input detections.
26+
This is useful when you want to:
27+
- Merge overlapping or nearby detections of the same object
28+
- Create a single region that contains multiple detected objects
29+
- Simplify multiple detections into one larger detection
30+
31+
The resulting detection will have:
32+
- A bounding box that contains all input detections
33+
- The classname of the merged detection is set to "merged_detection" by default, but can be customized via the `class_name` parameter
34+
- The confidence is set to the lowest confidence among all detections
35+
"""
36+
37+
38+
class DetectionsMergeManifest(WorkflowBlockManifest):
39+
model_config = ConfigDict(
40+
json_schema_extra={
41+
"name": "Detections Merge",
42+
"version": "v1",
43+
"short_description": SHORT_DESCRIPTION,
44+
"long_description": LONG_DESCRIPTION,
45+
"license": "Apache-2.0",
46+
"block_type": "transformation",
47+
"ui_manifest": {
48+
"section": "transformation",
49+
"icon": "fal fa-object-union",
50+
"blockPriority": 5,
51+
},
52+
}
53+
)
54+
type: Literal["roboflow_core/detections_merge@v1"]
55+
predictions: Selector(
56+
kind=[
57+
OBJECT_DETECTION_PREDICTION_KIND,
58+
INSTANCE_SEGMENTATION_PREDICTION_KIND,
59+
KEYPOINT_DETECTION_PREDICTION_KIND,
60+
]
61+
) = Field(
62+
description="Object detection predictions to merge into a single bounding box.",
63+
examples=["$steps.object_detection_model.predictions"],
64+
)
65+
class_name: str = Field(
66+
default="merged_detection",
67+
description="The class name to assign to the merged detection.",
68+
)
69+
70+
@classmethod
71+
def describe_outputs(cls) -> List[OutputDefinition]:
72+
return [
73+
OutputDefinition(name=OUTPUT_KEY, kind=[OBJECT_DETECTION_PREDICTION_KIND]),
74+
]
75+
76+
@classmethod
77+
def get_execution_engine_compatibility(cls) -> Optional[str]:
78+
return ">=1.3.0,<2.0.0"
79+
80+
81+
def calculate_union_bbox(detections: sv.Detections) -> np.ndarray:
82+
"""Calculate a single bounding box that contains all input detections."""
83+
if len(detections) == 0:
84+
return np.array([], dtype=np.float32).reshape(0, 4)
85+
86+
# Get all bounding boxes
87+
xyxy = detections.xyxy
88+
89+
# Calculate the union by taking min/max coordinates
90+
x1 = np.min(xyxy[:, 0])
91+
y1 = np.min(xyxy[:, 1])
92+
x2 = np.max(xyxy[:, 2])
93+
y2 = np.max(xyxy[:, 3])
94+
95+
return np.array([[x1, y1, x2, y2]])
96+
97+
98+
def get_lowest_confidence_index(detections: sv.Detections) -> int:
99+
"""Get the index of the detection with the lowest confidence."""
100+
if detections.confidence is None:
101+
return 0
102+
return int(np.argmin(detections.confidence))
103+
104+
105+
class DetectionsMergeBlockV1(WorkflowBlock):
106+
@classmethod
107+
def get_manifest(cls) -> Type[WorkflowBlockManifest]:
108+
return DetectionsMergeManifest
109+
110+
def run(
111+
self,
112+
predictions: sv.Detections,
113+
class_name: str = "merged_detection",
114+
) -> BlockResult:
115+
if predictions is None or len(predictions) == 0:
116+
return {
117+
OUTPUT_KEY: sv.Detections(
118+
xyxy=np.array([], dtype=np.float32).reshape(0, 4)
119+
)
120+
}
121+
122+
# Calculate the union bounding box
123+
union_bbox = calculate_union_bbox(predictions)
124+
125+
# Get the index of the detection with lowest confidence
126+
lowest_conf_idx = get_lowest_confidence_index(predictions)
127+
128+
# Create a new detection with the union bbox and ensure numpy arrays for all fields
129+
merged_detection = sv.Detections(
130+
xyxy=union_bbox,
131+
confidence=(
132+
np.array([predictions.confidence[lowest_conf_idx]], dtype=np.float32)
133+
if predictions.confidence is not None
134+
else None
135+
),
136+
class_id=np.array(
137+
[0], dtype=np.int32
138+
), # Fixed class_id of 0 for merged detection
139+
data={
140+
"class_name": np.array([class_name]),
141+
"detection_id": np.array([str(uuid4())]),
142+
},
143+
)
144+
145+
return {OUTPUT_KEY: merged_detection}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import numpy as np
2+
import pytest
3+
import supervision as sv
4+
5+
from inference.core.env import WORKFLOWS_MAX_CONCURRENT_STEPS
6+
from inference.core.managers.base import ModelManager
7+
from inference.core.workflows.core_steps.common.entities import StepExecutionMode
8+
from inference.core.workflows.execution_engine.core import ExecutionEngine
9+
from tests.workflows.integration_tests.execution.workflows_gallery_collector.decorators import (
10+
add_to_workflows_gallery,
11+
)
12+
13+
DETECTIONS_MERGE_WORKFLOW = {
14+
"version": "1.0",
15+
"inputs": [
16+
{"type": "WorkflowImage", "name": "image"},
17+
],
18+
"steps": [
19+
{
20+
"type": "ObjectDetectionModel",
21+
"name": "detection",
22+
"image": "$inputs.image",
23+
"model_id": "yolov8n-640",
24+
},
25+
{
26+
"type": "roboflow_core/detections_merge@v1",
27+
"name": "detections_merge",
28+
"predictions": "$steps.detection.predictions",
29+
},
30+
],
31+
"outputs": [
32+
{
33+
"type": "JsonField",
34+
"name": "result",
35+
"selector": "$steps.detections_merge.predictions",
36+
}
37+
],
38+
}
39+
40+
41+
@add_to_workflows_gallery(
42+
category="Basic Workflows",
43+
use_case_title="Workflow with detections merge",
44+
use_case_description="""
45+
This workflow demonstrates how to merge multiple object detections into a single bounding box.
46+
This is useful when you want to:
47+
- Combine overlapping detections of the same object
48+
- Create a single region that contains multiple detected objects
49+
- Simplify multiple detections into one larger detection
50+
""",
51+
workflow_definition=DETECTIONS_MERGE_WORKFLOW,
52+
workflow_name_in_app="merge-detections",
53+
)
54+
def test_detections_merge_workflow(
55+
model_manager: ModelManager,
56+
dogs_image: np.ndarray,
57+
) -> None:
58+
# given
59+
workflow_init_parameters = {
60+
"workflows_core.model_manager": model_manager,
61+
"workflows_core.api_key": None,
62+
"workflows_core.step_execution_mode": StepExecutionMode.LOCAL,
63+
}
64+
execution_engine = ExecutionEngine.init(
65+
workflow_definition=DETECTIONS_MERGE_WORKFLOW,
66+
init_parameters=workflow_init_parameters,
67+
max_concurrent_steps=WORKFLOWS_MAX_CONCURRENT_STEPS,
68+
)
69+
70+
# when
71+
result = execution_engine.run(
72+
runtime_parameters={
73+
"image": [dogs_image],
74+
}
75+
)
76+
77+
# then
78+
assert len(result) == 1, "One set of outputs expected"
79+
assert "result" in result[0], "Output must contain key 'result'"
80+
assert isinstance(
81+
result[0]["result"], sv.Detections
82+
), "Output must be instance of sv.Detections"
83+
84+
# Check that we have exactly one merged detection
85+
assert len(result[0]["result"]) == 1, "Should have exactly one merged detection"
86+
87+
# Check that the merged detection has all required fields
88+
assert "class_name" in result[0]["result"].data, "Should have class_name in data"
89+
assert "detection_id" in result[0]["result"].data, "Should have detection_id in data"
90+
91+
# Check that the bounding box has reasonable dimensions
92+
merged_bbox = result[0]["result"].xyxy[0]
93+
image_height, image_width = dogs_image.shape[:2]
94+
95+
# Check that coordinates are within image bounds
96+
assert 0 <= merged_bbox[0] <= image_width, "x1 should be within image bounds"
97+
assert 0 <= merged_bbox[1] <= image_height, "y1 should be within image bounds"
98+
assert 0 <= merged_bbox[2] <= image_width, "x2 should be within image bounds"
99+
assert 0 <= merged_bbox[3] <= image_height, "y2 should be within image bounds"
100+
101+
# Check that the box has reasonable dimensions
102+
assert merged_bbox[2] > merged_bbox[0], "x2 should be greater than x1"
103+
assert merged_bbox[3] > merged_bbox[1], "y2 should be greater than y1"
104+
105+
# Check that the box is large enough to likely contain the dogs
106+
box_width = merged_bbox[2] - merged_bbox[0]
107+
box_height = merged_bbox[3] - merged_bbox[1]
108+
assert box_width > 100, "Merged box should be reasonably wide"
109+
assert box_height > 100, "Merged box should be reasonably tall"
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import numpy as np
2+
import pytest
3+
import supervision as sv
4+
5+
from inference.core.workflows.core_steps.transformations.detections_merge.v1 import (
6+
DetectionsMergeBlockV1,
7+
DetectionsMergeManifest,
8+
calculate_union_bbox,
9+
)
10+
11+
12+
def test_calculate_union_bbox():
13+
# given
14+
detections = sv.Detections(
15+
xyxy=np.array([[10, 10, 20, 20], [15, 15, 25, 25]]),
16+
)
17+
18+
# when
19+
union_bbox = calculate_union_bbox(detections)
20+
21+
# then
22+
expected_bbox = np.array([[10, 10, 25, 25]])
23+
assert np.allclose(
24+
union_bbox, expected_bbox
25+
), f"Expected bounding box to be {expected_bbox}, but got {union_bbox}"
26+
27+
28+
@pytest.mark.parametrize("type_alias", ["roboflow_core/detections_merge@v1"])
29+
def test_detections_merge_validation_when_valid_manifest_is_given(
30+
type_alias: str,
31+
) -> None:
32+
# given
33+
data = {
34+
"type": type_alias,
35+
"name": "detections_merge",
36+
"predictions": "$steps.od_model.predictions",
37+
"class_name": "custom_merged",
38+
}
39+
40+
# when
41+
result = DetectionsMergeManifest.model_validate(data)
42+
43+
# then
44+
assert result == DetectionsMergeManifest(
45+
type=type_alias,
46+
name="detections_merge",
47+
predictions="$steps.od_model.predictions",
48+
class_name="custom_merged",
49+
)
50+
51+
52+
def test_detections_merge_block() -> None:
53+
# given
54+
block = DetectionsMergeBlockV1()
55+
detections = sv.Detections(
56+
xyxy=np.array([[10, 10, 20, 20], [15, 15, 25, 25]]),
57+
confidence=np.array([0.9, 0.8]),
58+
class_id=np.array([1, 1]),
59+
data={
60+
"class_name": np.array(["person", "person"]),
61+
},
62+
)
63+
64+
# when
65+
output = block.run(predictions=detections)
66+
67+
# then
68+
assert isinstance(output, dict)
69+
assert "predictions" in output
70+
assert len(output["predictions"]) == 1
71+
assert np.allclose(output["predictions"].xyxy, np.array([[10, 10, 25, 25]]))
72+
assert np.allclose(output["predictions"].confidence, np.array([0.8]))
73+
assert np.allclose(output["predictions"].class_id, np.array([0]))
74+
assert output["predictions"].data["class_name"][0] == "merged_detection"
75+
assert isinstance(output["predictions"].data["detection_id"][0], str)
76+
77+
78+
def test_detections_merge_block_with_custom_class() -> None:
79+
# given
80+
block = DetectionsMergeBlockV1()
81+
detections = sv.Detections(
82+
xyxy=np.array([[10, 10, 20, 20], [15, 15, 25, 25]]),
83+
confidence=np.array([0.9, 0.8]),
84+
class_id=np.array([1, 1]),
85+
data={
86+
"class_name": np.array(["person", "person"]),
87+
},
88+
)
89+
90+
# when
91+
output = block.run(predictions=detections, class_name="custom_merged")
92+
93+
# then
94+
assert isinstance(output, dict)
95+
assert "predictions" in output
96+
assert len(output["predictions"]) == 1
97+
assert np.allclose(output["predictions"].xyxy, np.array([[10, 10, 25, 25]]))
98+
assert np.allclose(output["predictions"].confidence, np.array([0.8]))
99+
assert np.allclose(output["predictions"].class_id, np.array([0]))
100+
assert output["predictions"].data["class_name"][0] == "custom_merged"
101+
assert isinstance(output["predictions"].data["detection_id"][0], str)
102+
103+
104+
def test_detections_merge_block_empty_input() -> None:
105+
# given
106+
block = DetectionsMergeBlockV1()
107+
empty_detections = sv.Detections(xyxy=np.array([], dtype=np.float32).reshape(0, 4))
108+
109+
# when
110+
output = block.run(predictions=empty_detections)
111+
112+
# then
113+
assert isinstance(output, dict)
114+
assert "predictions" in output
115+
assert len(output["predictions"]) == 0
116+
assert isinstance(output["predictions"].xyxy, np.ndarray)
117+
assert output["predictions"].xyxy.shape == (0, 4)

0 commit comments

Comments
 (0)