Skip to content

Commit 3429050

Browse files
Feature/python rotation (#30)
* add equivalent python image rotation * attempt to integrate rotation with instance segmentation
1 parent 9c6e844 commit 3429050

5 files changed

Lines changed: 148 additions & 34 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Class for performing inference on a rotated image."""
2+
3+
import enum
4+
5+
import numpy as np
6+
7+
8+
class RotationType(enum.Enum):
9+
"""Type of rotation to apply to image."""
10+
11+
ROTATE_90_CLOCKWISE = "ROTATE_90_CLOCKWISE"
12+
ROTATE_180 = "ROTATE_180"
13+
ROTATE_90_COUNTERCLOCKWISE = "ROTATE_90_COUNTERCLOCKWISE"
14+
NONE = "none"
15+
16+
17+
class ImageRotator:
18+
"""Class to apply and unapply image rotations."""
19+
20+
def __init__(self, mode):
21+
"""Construct an image rotator with the transformation to apply for inference."""
22+
self._mode = mode
23+
match self._mode:
24+
case RotationType.ROTATE_90_CLOCKWISE:
25+
self._forward_iters = 3
26+
self._reverse_iters = 1
27+
case RotationType.ROTATE_180:
28+
self._forward_iters = 2
29+
self._reverse_iters = 2
30+
case RotationType.ROTATE_90_COUNTERCLOCKWISE:
31+
self._forward_iters = 1
32+
self._reverse_iters = 3
33+
case _:
34+
pass
35+
36+
@property
37+
def mode(self):
38+
"""Get the rotation mode."""
39+
return self._mode
40+
41+
def rotate(self, img):
42+
"""Rotate an image to the correct orientation to run inference."""
43+
if self._mode == RotationType.NONE:
44+
return img
45+
46+
return np.rot90(img, k=self._forward_iters)
47+
48+
def derotate(self, img):
49+
"""Unapply rotation to an image after running inference."""
50+
if self._mode == RotationType.NONE:
51+
return img
52+
53+
return np.rot90(img, k=self._reverse_iters)

semantic_inference/python/semantic_inference/models/instance_segmenter.py

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
from spark_config import Config, config_field
3939
from torch import nn
4040

41+
from semantic_inference.image_rotator import ImageRotator, RotationType
42+
4143

4244
def _map_opt(values, f):
4345
return {k: v if v is None else f(v) for k, v in values.items()}
@@ -52,30 +54,7 @@ class Results:
5254
boxes: torch.Tensor # (n, 4) xyxy format, torch.float32
5355
categories: torch.Tensor # (n,), torch.float32/int64 (doesn't matter)
5456
confidences: torch.Tensor # (n,), torch.float32
55-
img_shape: tuple[int, int]
56-
57-
@property
58-
def instance_seg_img(self):
59-
"""
60-
Convert segmentation results to instance segmentation image.
61-
Each pixel value encodes both category id and instance id.
62-
First 16 bits are category id, last 16 bits are instance id.
63-
"""
64-
if self.masks is None:
65-
return np.zeros(self.img_shape)
66-
67-
masks = self.masks.cpu().numpy()
68-
category_ids = self.categories.cpu().numpy()
69-
img = np.zeros(masks[0].shape, dtype=np.uint32)
70-
for i in range(masks.shape[0]):
71-
category_id = int(category_ids[i]) # category id are 0-indexed
72-
instance_id = i + 1 # instance ids are 1-indexed
73-
combined_id = (
74-
category_id << 16
75-
) | instance_id # combine into single uint32
76-
img[masks[i, ...] > 0] = combined_id
77-
78-
return img
57+
instances: np.ndarray # (H, W, c) np.uint32 (result image)
7958

8059
def cpu(self):
8160
"""Move results to CPU."""
@@ -94,6 +73,7 @@ class InstanceSegmenterConfig(Config):
9473

9574
# relevant configs (model path, model weights) for the model
9675
instance_model: Any = config_field("instance_model", default="yolo-seg")
76+
rotation_type: str = "none"
9777

9878

9979
class InstanceSegmenter(nn.Module):
@@ -106,6 +86,7 @@ def __init__(self, config):
10686
self._canary_param = nn.Parameter(torch.empty(0))
10787

10888
self.config = config
89+
self._rotator = ImageRotator(RotationType(config.rotation_type))
10990
self.segmenter = self.config.instance_model.create()
11091

11192
def eval(self):
@@ -134,7 +115,7 @@ def segment(self, rgb_img, is_rgb_order=True):
134115
Encoded image
135116
"""
136117
img = rgb_img if is_rgb_order else rgb_img[:, :, ::-1].copy()
137-
return self(img)
118+
return self._rotator.derotate(img)
138119

139120
@property
140121
def device(self):
@@ -156,12 +137,28 @@ def forward(self, rgb_img):
156137
Returns:
157138
Encoded image
158139
"""
159-
categories, masks, boxes, confidences, img_shape = self.segmenter(rgb_img)
140+
rotated = self._rotator.rotate(rgb_img)
141+
categories, masks, boxes, confidences = self.segmenter(rotated)
142+
143+
if self.masks is None:
144+
instances = np.zeros(rgb_img.shape[:2])
145+
else:
146+
instances = np.zeros(masks[0].shape, dtype=np.uint32)
147+
masks = self.masks.cpu().numpy()
148+
category_ids = self.categories.cpu().numpy()
149+
for i in range(masks.shape[0]):
150+
category_id = int(category_ids[i]) # category id are 0-indexed
151+
instance_id = i + 1 # instance ids are 1-indexed
152+
# combine into single uint32
153+
combined_id = (category_id << 16) | instance_id
154+
instances[masks[i, ...] > 0] = combined_id
155+
156+
instances = self._rotator.derotate(instances)
160157

161158
return Results(
162159
masks=masks,
163160
boxes=boxes,
164161
categories=categories,
165162
confidences=confidences,
166-
img_shape=img_shape,
163+
instances=instances,
167164
)

semantic_inference/python/semantic_inference/models/wrappers.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ def forward(self, img):
381381
iou=self.config.overlap_merge_iou,
382382
)[0] # assume batch size 1
383383
if result.masks is None:
384-
return None, None, None, None, (img.shape[0], img.shape[1])
384+
return None, None, None, None
385385

386386
categories = result.boxes.cls.to(torch.int).cpu() # int8
387387
masks = result.masks.data.to(torch.bool).cpu()
@@ -397,7 +397,7 @@ def forward(self, img):
397397
boxes = boxes[size_indices]
398398
confidences = confidences[size_indices]
399399

400-
return categories, masks, boxes, confidences, (masks.shape[1], masks.shape[2])
400+
return categories, masks, boxes, confidences
401401

402402

403403
@dataclasses.dataclass
@@ -569,7 +569,7 @@ def forward(self, img):
569569

570570
# if nothing detected
571571
if boxes.shape[0] == 0:
572-
return None, None, None, None, (img.shape[0], img.shape[1])
572+
return None, None, None, None
573573

574574
# process the box prompt for SAM 2
575575
h, w, _ = img.shape
@@ -633,7 +633,7 @@ def forward(self, img):
633633
# use xyxy boxes
634634
boxes = torch.tensor(input_boxes)
635635

636-
return categories, masks, boxes, confidences, (masks.shape[1], masks.shape[2])
636+
return categories, masks, boxes, confidences
637637

638638

639639
@register_config(
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Unit tests for image rotator."""
2+
3+
import numpy as np
4+
5+
from semantic_inference.image_rotator import ImageRotator, RotationType
6+
7+
8+
def test_no_rotation():
9+
"""Test that no rotation produces the same matrix."""
10+
A = np.arange(24).reshape((3, 4, 2))
11+
rotator = ImageRotator(RotationType.NONE)
12+
13+
assert (rotator.rotate(A) == A).all()
14+
assert (rotator.derotate(A) == A).all()
15+
16+
17+
def test_90cw_rotation():
18+
"""Test that no rotation produces the same matrix."""
19+
A = np.arange(24).reshape((3, 4, 2))
20+
21+
rotator = ImageRotator(RotationType.ROTATE_90_CLOCKWISE)
22+
rotated = rotator.rotate(A)
23+
expected = np.array(
24+
[
25+
[[16, 17], [8, 9], [0, 1]],
26+
[[18, 19], [10, 11], [2, 3]],
27+
[[20, 21], [12, 13], [4, 5]],
28+
[[22, 23], [14, 15], [6, 7]],
29+
]
30+
)
31+
32+
assert rotated.shape == (4, 3, 2)
33+
assert (rotated == expected).all()
34+
assert (rotator.derotate(rotated) == A).all()
35+
36+
37+
def test_180_rotation():
38+
"""Test that no rotation produces the same matrix."""
39+
A = np.arange(24).reshape((3, 4, 2))
40+
41+
rotator = ImageRotator(RotationType.ROTATE_180)
42+
rotated = rotator.rotate(A)
43+
44+
assert rotated.shape == A.shape
45+
assert (rotated != A).any()
46+
assert (rotator.derotate(rotated) == A).all()
47+
48+
49+
def test_90ccw_rotation():
50+
"""Test that no rotation produces the same matrix."""
51+
A = np.arange(24).reshape((3, 4, 2))
52+
53+
rotator = ImageRotator(RotationType.ROTATE_90_COUNTERCLOCKWISE)
54+
rotated = rotator.rotate(A)
55+
expected = np.array(
56+
[
57+
[[6, 7], [14, 15], [22, 23]],
58+
[[4, 5], [12, 13], [20, 21]],
59+
[[2, 3], [10, 11], [18, 19]],
60+
[[0, 1], [8, 9], [16, 17]],
61+
]
62+
)
63+
64+
assert rotated.shape == (4, 3, 2)
65+
assert (expected == rotated).all()
66+
assert (rotator.derotate(rotated) == A).all()

semantic_inference_ros/app/instance_segmentation_node

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,8 @@ class InstanceSegmentationNode(Node):
8585
self.get_logger().debug("get image size: " + str(ret.img_shape))
8686
self.get_logger().debug(f"result category type: {ret.categories.dtype}")
8787

88-
# get 32 bit instance + cateogry img, all 0 if no box detected
89-
instance_seg_img = ret.instance_seg_img
9088
# Convert to int32 to match 32SC1 encoding expected by cv_bridge
91-
instance_seg_img = instance_seg_img.astype(np.int32)
89+
instance_seg_img = ret.instances.astype(np.int32)
9290
msg = Conversions.to_image_msg(header, instance_seg_img, encoding="32SC1")
9391
self._pub.publish(msg)
9492
self._last_inference_time = self.get_clock().now().nanoseconds * 1e-9

0 commit comments

Comments
 (0)