Ultralytics YOLO27:
Lessonintermediate

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°.

Outcome

Train yolo26n-obb.pt on dota8.yaml, count detections per class from result.obb, and convert every prediction's xywhr into a long-side axis in [0, 180) degrees.

Fast Track
If you already know your way around, here's the short version.
  1. Label row: class, then four corners x1 y1 … x4 y4, normalized, in order around the perimeter.

  2. yolo obb train data=dota8.yaml model=yolo26n-obb.pt epochs=1 imgsz=320.

  3. Read result.obb.xywhr (rotation in radians) or .xyxyxyxy (corners); OBB results do not fill result.boxes.

  4. Normalize orientation to the long side: add 90° when w < h, then take the angle modulo 180°.

Hands-on

When rotation matters#

Ultralytics YOLO oriented bounding boxes fitted tightly around three moored yachts in an aerial photo, each labeled Ship with its confidence

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 boxOriented box
Fit on a ship at 45°about half backgroundtight
Dense, rotated rowsboxes overlap; NMS drops true objectsboxes separate cleanly
Gives an anglenoyes, as an axis (modulo 180°)
Result fieldresult.boxesresult.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.524414

Read 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.

Ultralytics Platform annotation editor on a DOTA8 aerial image, with rotated boxes around tennis courts, small vehicles, and a swimming pool

Train on DOTA8#

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

DOTA8 training mosaic of aerial tiles: rows of moored boats, tennis courts, a parking lot, and airplanes on an apron, each object with an oriented box and class index

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.832

OBB metrics live in metrics.box, but the IoU behind them is computed between rotated rectangles.

DOTA8 is a smoke test

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 deg

Line by line:

  • xywhr: (173, 5) — 173 detections, each center x, center y, width, height in pixels and a rotation in radians. xyxyxyxy holds the same boxes as four corner points, ready for cv2.polylines.
  • The Counter counts from result.obb.cls: 168 ships and 5 harbors. result.boxes is None on OBB models, so counting code written for detection must switch fields.
  • raw versus axis. The first ship is 33 × 108 at −15°: its w is 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:

Highway frame with detections from full-image inference, missing many of the small, distant vehicles

and tiled inference finds them:

The same highway frame with SAHI tiled inference, which detects many more of the small, distant vehicles

Pitfalls#

No front or back

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.

Comparing raw validation curves with detection runs

Symptom: OBB precision-recall curves that look different from your detection runs on similar data. Cause: OBB validation defaults to conf=0.01, while other tasks use 0.001. Fix: compare mAP values, or set conf explicitly when you need matching precision-recall curves.

Try It

Run obb.py, then write a script that computes the long-side axis of every ship in the harbor image, finds the most common axis to the nearest 10°, and counts the ships more than 30° off it. Remember that 175° and 5° are only 10° apart.

Done When
You've finished the lesson when all of these are true.
  • Your script prints the number of ships, the most common axis, and the count of ships more than 30° off it.

  • Your axis comparison wraps at 180°, so 175° and 5° count as 10° apart.

  • You can explain why OBB cannot tell bow from stern and what you would add if you needed a heading.

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 it

Most 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.

What's next

Next, we drop localization altogether and train a classifier for whole images and crops.