Oriented Bounding Boxes for Rotated Objects
Label four-corner OBBs, train yolo26n-obb.pt on DOTA8, and turn xywhr predictions into a stable orientation axis without falling into the angle conventions.
Ships in a harbor, parcels on a belt, and cars in a parking lot seen from above share one problem: axis-aligned boxes around rotated, tightly packed objects overlap each other and swallow background, so NMS deletes real objects and counts come out low. Oriented bounding boxes fit each object with a rotated rectangle, which fixes counting in dense scenes and gives you an angle for free. The angle comes with conventions that trip up nearly every first integration — on the harbor image in this lesson, the first ship reports a rotation of −15°, but its hull lies along 75°.
Hands-on
When rotation matters#

Pose Estimation and OBB covers when to pick OBB over detection. The practical test: if axis-aligned bounding boxes around neighboring objects overlap more than the objects themselves do, NMS will suppress true detections and counts will drift low. Aerial imagery is the classic case, which is why the pretrained yolo26n-obb.pt is trained on DOTA at imgsz=1024.
| Axis-aligned box | Oriented box | |
|---|---|---|
| Fit on a ship at 45° | about half background | tight |
| Dense, rotated rows | boxes overlap; NMS drops true objects | boxes separate cleanly |
| Gives an angle | no | yes, as an axis (modulo 180°) |
| Result field | result.boxes | result.obb |
The xyxyxyxy label format#
An OBB label row is the class index followed by the four corners, normalized, in order around the perimeter. A real DOTA8 row (class 1 is ship):
1 0.813477 0.523437 0.814453 0.504882 0.87207 0.504882 0.87207 0.524414Read it as four points: (0.813, 0.523) → (0.814, 0.505) → (0.872, 0.505) → (0.872, 0.524). This ship happens to lie almost horizontally, about 0.059 of the image wide and 0.019 tall. Internally, Ultralytics converts each corner set to center, width, height, and rotation (xywhr) by fitting the minimum-area rectangle, so a slightly non-rectangular quadrilateral from an annotation tool still trains. The OBB dataset guide documents the format, and convert_dota_to_yolo_obb in ultralytics.data.converter converts original DOTA annotations.

Train on DOTA8#
DOTA8 is a small oriented-box dataset built for testing pipelines:

from ultralytics import YOLO
model = YOLO("yolo26n-obb.pt")
model.train(data="dota8.yaml", epochs=1, imgsz=320, device="cpu")
metrics = model.val()
print(f"OBB mAP50: {metrics.box.map50:.3f} mAP50-95: {metrics.box.map:.3f}")OBB mAP50: 0.995 mAP50-95: 0.832OBB metrics live in metrics.box, but the IoU behind them is computed between rotated rectangles.
The score is high because DOTA8 is a slice of the pretraining data. It proves the pipeline runs; it says nothing about your imagery.
Read predictions and normalize the angle#
import math
from collections import Counter
from ultralytics import YOLO
def long_side_axis(w, h, r):
"""Angle of the box's long side in degrees, in [0, 180), whichever side the model called width."""
deg = math.degrees(r) + (90 if w < h else 0)
return deg % 180
model = YOLO("yolo26n-obb.pt") # trained on DOTAv1, 15 aerial classes
result = model("https://ultralytics.com/images/boats.jpg", verbose=False)[0]
obb = result.obb.cpu()
print("xywhr:", tuple(obb.xywhr.shape), " xyxyxyxy:", tuple(obb.xyxyxyxy.shape))
print(Counter(result.names[int(c)] for c in obb.cls.tolist()))
for (cx, cy, w, h, r), conf in list(zip(obb.xywhr.tolist(), obb.conf.tolist()))[:5]:
print(f"{conf:.2f} center=({cx:.0f},{cy:.0f}) size={w:.0f}x{h:.0f} raw={math.degrees(r):4.0f} deg axis={long_side_axis(w, h, r):4.0f} deg")xywhr: (173, 5) xyxyxyxy: (173, 4, 2)
Counter({'ship': 168, 'harbor': 5})
0.87 center=(619,850) size=33x108 raw= -15 deg axis= 75 deg
0.86 center=(1650,937) size=116x33 raw= 21 deg axis= 21 deg
0.86 center=(1105,571) size=124x35 raw= 21 deg axis= 21 deg
0.86 center=(1596,1037) size=129x35 raw= 25 deg axis= 25 deg
0.86 center=(825,940) size=34x99 raw= -15 deg axis= 75 degLine by line:
xywhr: (173, 5)— 173 detections, each center x, center y, width, height in pixels and a rotation in radians.xyxyxyxyholds the same boxes as four corner points, ready forcv2.polylines.- The Counter counts from
result.obb.cls: 168 ships and 5 harbors.result.boxesisNoneon OBB models, so counting code written for detection must switch fields. rawversusaxis. The first ship is 33 × 108 at −15°: itswis the short side, so the hull runs along −15° + 90° = 75°. The second is 116 × 33 at 21°, already long side first. Read raw angles and the first ship's axis is 90° wrong; after normalizing, every ship's angle means the same thing and can be compared across ships and frames.
The rule comes from the installed source: predicted rotation lies in [−π/4, 3π/4) — roughly −45° to 135° — and the same rectangle can be (w, h, θ) or (h, w, θ + 90°). Never assume w is the long side.
Small objects need tiles, not shrinking#
Aerial objects are small, and validating or deploying below the training imgsz costs recall quickly. Split satellite tiles larger than the model's input into overlapping tiles rather than shrinking them, or a 30-pixel car becomes a 5-pixel smudge. The SAHI tiled inference guide shows the technique; on a highway frame with a detector, full-image inference misses many distant vehicles:

and tiled inference finds them:

Pitfalls#
Symptom: a "heading" that flips by 180° between frames, or ships that seem to sail backward. Cause: a rectangle rotated 180° is the same rectangle, so OBB gives an axis, not a heading. Fix: if you need which way a ship or forklift points, add a pose keypoint on the bow or fork tips, or a classifier on the crop.
Show solution
import math
from collections import Counter
from ultralytics import YOLO
result = YOLO("yolo26n-obb.pt")("https://ultralytics.com/images/boats.jpg", verbose=False)[0]
axes = []
for (cx, cy, w, h, r), cls in zip(result.obb.xywhr.cpu().tolist(), result.obb.cls.tolist()):
if result.names[int(cls)] == "ship":
axes.append((math.degrees(r) + (90 if w < h else 0)) % 180) # long-side axis in [0, 180)
def axis_gap(a, b):
"""Smallest difference between two axes; 175 and 5 degrees are only 10 apart."""
d = abs(a - b) % 180
return min(d, 180 - d)
common = Counter(round(a / 10) * 10 % 180 for a in axes).most_common(1)[0][0]
off = [a for a in axes if axis_gap(a, common) > 30]
print(f"{len(axes)} ships, most common axis ~{common} deg, {len(off)} more than 30 deg off it")168 ships, most common axis ~20 deg, 32 more than 30 deg off itMost boats lie parallel at about 20°; the 32 off-axis ships are the outliers a harbor-monitoring rule would flag for review. OBB cannot distinguish bow from stern because a rectangle is symmetric under a 180° rotation; a pose keypoint on the bow would give a true heading.