Training Vision Models

Building an Albumentations pipeline

Albumentations applies a chain of image transformations and moves the masks, boxes and labels along with the pixels, which is the part that hand-written augmentation code always gets wrong.

On this page 5
  1. Why a library exists for this
  2. How a pipeline reads
  3. What to know before you install it
  4. Remember this
  5. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

Albumentations is a library that runs a list of image edits, and keeps every label attached to the right place.

Picture a photograph with a small sticky note on it, pointing at a crack in a wall. Now cut a piece off the left of the photo. If the note stays where it was, it points at nothing.

Anything you do to the picture has to be done to the note. Crop it, the note moves. Flip it, the note moves to the other side. Rotate it, the note rotates.

That is the whole job. It sounds easy and it is where hand-written augmentation code goes wrong.

Why a library exists for this

For plain classification you do not need any of this. The label is "cat", and cropping a picture of a cat leaves it a picture of a cat.

The moment your label is a position, everything changes:

  • Object detection labels are boxes. Boxes have coordinates.
  • Segmentation labels are masks, a full picture of which pixel is what.
  • Keypoints are dots, for joints or facial landmarks.

Flip the image and every box, mask and dot must flip identically. Get the arithmetic wrong by a mirror and you train a detector on nonsense that looks fine in the logs.

How a pipeline reads

   image  +  mask  +  boxes  +  labels
      |       |        |         |
      +-------+---+----+---------+
                  |
       [ horizontal flip ]    every one of the four is updated
       [ brightness      ]    at every step, together
       [ small rotation  ]
       [ resize          ]
       [ normalise       ]
       [ to tensor       ]
                  |
   image tensor + mask tensor + moved boxes + surviving labels

Read the word surviving. Crop away part of the picture and some objects leave the frame entirely. Their boxes must be deleted. Their labels must go with them, or the two lists fall out of step. Albumentations does that for you and it is a genuine source of bugs otherwise.

What to know before you install it

The MIT-licensed package named albumentations has been at version 2.0.8 since May 2025. Active development moved to a package called albumentationsx. It uses the same code style, but is licensed AGPL-3.0. A separate commercial licence is sold to teams needing other terms.

AGPL has real consequences for closed-source products. Check with whoever handles licensing at your organisation before you put albumentationsx into a shipping product. The MIT albumentations 2.0.8 remains available and is what this lesson uses.

Remember this

  • Albumentations keeps masks, boxes and keypoints in step with the pixels.
  • Objects cropped out of frame get their boxes and their labels removed together.
  • The plain albumentations package is MIT and frozen at 2.0.8; the actively developed albumentationsx is AGPL, so check the licence.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install "albumentations==2.0.8"

Written and run against albumentations 2.0.8, numpy 2.2.6, opencv 5.0.0, torch 2.13.0 (CPU). Installing albumentations pulls in opencv-python-headless and albucore. ToTensorV2 also needs torch.

Version 2.0 changed things. always_apply was removed, and Compose gained a seed argument. Code written for 1.x can raise or warn. Pin your version.

One pipeline, four kinds of label

pipeline.py
import numpy as np, albumentations as A
from albumentations.pytorch import ToTensorV2

print("albumentations", A.__version__)
rng = np.random.default_rng(0)
image = rng.integers(0, 256, (60, 80, 3), dtype=np.uint8)      # HWC uint8: what Albumentations wants
mask  = np.zeros((60, 80), dtype=np.uint8); mask[10:30, 20:50] = 1
bboxes = [[20, 10, 50, 30]]                                     # pascal_voc: x_min y_min x_max y_max
labels = ["cat"]

tf = A.Compose(
    [
        A.HorizontalFlip(p=1.0),
        A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=1.0),
        A.Affine(rotate=(15, 15), p=1.0),
        A.Resize(48, 64),
        A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
        ToTensorV2(),
    ],
    bbox_params=A.BboxParams(format="pascal_voc", label_fields=["labels"], min_visibility=0.3),
    seed=0,
)

out = tf(image=image, mask=mask, bboxes=bboxes, labels=labels)
print("keys        :", sorted(out.keys()))
print("image       :", type(out["image"]).__name__, tuple(out["image"].shape), out["image"].dtype)
print("mask        :", type(out["mask"]).__name__, tuple(out["mask"].shape), out["mask"].dtype)
print("bboxes      :", [[round(float(v), 1) for v in b] for b in out["bboxes"]])
print("labels      :", out["labels"])
print("image mean  :", round(float(out["image"].mean()), 3))
Output
albumentations 2.0.8
keys        : ['bboxes', 'image', 'labels', 'mask']
image       : Tensor (3, 48, 64) torch.float32
mask        : Tensor (48, 64) torch.uint8
bboxes      : [[20.1, 4.4, 47.5, 26.1]]
labels      : ['cat']
image mean  : 0.567

Reproduces exactly, because Compose(seed=0) was set.

Seven details in that output

The transform is called with keyword arguments and returns a dict. tf(image=..., mask=..., bboxes=..., labels=...). Passing the image positionally raises. This is the single biggest difference from torchvision.

The image went in as HWC and came out as CHW. (60, 80, 3) numpy became (3, 48, 64) torch. ToTensorV2 does the transpose. Every other transform in the chain sees HWC numpy.

The mask did not become float and did not get normalised. It stayed uint8 with shape (48, 64). A.Normalize and ToTensorV2 know a mask is a label, not a picture. Class indices survive intact, which is what a segmentation loss needs.

The box moved through four transforms and came out sensible. It started at [20, 10, 50, 30] on an 80x60 canvas. It ended at [20.1, 4.4, 47.5, 26.1] on a 64x48 canvas, after a mirror, a rotation and a resize. Doing that by hand is the part people get wrong.

labels came back beside the boxes because it was named in label_fields. Anything listed there is filtered in lockstep with the boxes.

min_visibility=0.3 sets the survival rule. A box whose visible area drops below 30% of its original area is dropped.

image mean 0.567 is after normalisation with ImageNet statistics, on random noise. It is a sanity check that Normalize ran, not a meaningful quantity.

The behaviour that justifies the library

boxes_disappear.py
import numpy as np, albumentations as A

rng = np.random.default_rng(0)
image = rng.integers(0, 256, (60, 80, 3), dtype=np.uint8)
bboxes = [[20, 10, 50, 30], [60, 40, 78, 58]]      # second box lives in the bottom-right
labels = ["cat", "dog"]

crop = A.Compose([A.Crop(x_min=0, y_min=0, x_max=55, y_max=35)],
                 bbox_params=A.BboxParams(format="pascal_voc",
                                          label_fields=["labels"],
                                          min_visibility=0.3))
out = crop(image=image, bboxes=bboxes, labels=labels)
print("boxes kept :", [[round(float(v)) for v in b] for b in out["bboxes"]])
print("labels kept:", out["labels"])
print("image now  :", out["image"].shape)
Output
boxes kept : [[20, 10, 50, 30]]
labels kept: ['cat']
image now  : (35, 55, 3)

The crop kept the top-left 55x35 region. The dog sat at x from 60 to 78, entirely outside it. Its box went, and "dog" went with it.

Write this by hand and the usual bug is filtering the boxes while forgetting the labels. Then box zero is the cat and label zero is still "dog". Training proceeds, loss decreases, and the detector learns to call cats dogs.

Choosing a bbox format

formatMeaningCoordinates
pascal_voccorners, pixelsx_min, y_min, x_max, y_max
albumentationscorners, normalised 0-1x_min, y_min, x_max, y_max
cococorner plus size, pixelsx_min, y_min, width, height
yolocentre plus size, normalisedx_centre, y_centre, width, height

Declare the format your annotations are already in. Converting by hand before the pipeline is a step you do not need and a bug you do not want.

Common mistakes

Feeding BGR images from cv2.imread and normalising with RGB statistics. OpenCV loads BGR. Fix: cv2.cvtColor(img, cv2.COLOR_BGR2RGB) right after reading, once, in your Dataset. See OpenCV.

Passing float images in the 0-255 range. Several transforms branch on dtype. Fix: keep uint8 until A.Normalize.

Copying 1.x code with always_apply=True. In 2.0.8 this does not raise. It emits UserWarning: Argument(s) 'always_apply' are not valid for transform BasicTransform, then ignores it. The transform falls back to its default p=0.5 and fires half the time. Your "always applied" resize now runs on half your images. Fix: p=1.0, and treat warnings from this library as errors in CI.

Forgetting label_fields. Boxes get filtered, your separate label list does not, and the two silently misalign. Fix: always pass every per-box array through label_fields.

Augmenting the validation set. Fix: build two Composes. The evaluation one holds resize, normalise and ToTensorV2 only.

Assuming reproducibility without asking for it. Fix: A.Compose([...], seed=0) for tests and debugging. Leave it unset in real training, or every epoch sees identical augmentations. See random seeds and reproducibility.

Try it yourself

Change min_visibility=0.3 to 0.05 in the second script and re-run. Then set the crop to x_max=65 so the dog is half in frame. Find the visibility threshold at which the dog's box survives. Decide which value you would want on a real detector.

What to learn next

Researcher — Mathematics and papers.

What the library is for, technically

Buslaev et al. (2020) published it in Information 11(2):125. It wraps OpenCV and other backends behind one interface, covering image, mask, box and keypoint targets. The stated design goals are speed and breadth of transforms; the durable contribution in practice is the target-consistency contract.

That contract is worth stating precisely. A transform declares one application method per target type: apply, apply_to_mask, apply_to_bboxes, apply_to_keypoints. The framework guarantees all of them receive the same sampled parameters. Hand-written pipelines break this by re-sampling the random angle between the image call and the mask call. The mask then rotates differently from its image. The failure is silent and the loss still decreases.

Interpolation differs by target as well. Images use bilinear or bicubic. Masks must use nearest-neighbour, or class indices become fractional and meaningless. The library enforces that split.

Comparison with the alternatives

LibraryMulti-targetDeviceNotes
torchvision.transforms.v2Yes, via tv_tensorsCPU and GPU tensorsIn the framework; no extra dependency
albumentations 2.0.8YesCPU (numpy / OpenCV)Widest transform catalogue; MIT
albumentationsxYesCPUActively developed fork; AGPL-3.0 or commercial
korniaYesGPU, differentiableBatched, autograd-compatible

torchvision v2 closed most of the functional gap by introducing tv_tensors. These carry the box format and canvas size, so transforms can dispatch on them. It is the same contract, expressed through types rather than a dict of named targets. Choose torchvision to avoid a dependency. Choose Albumentations for catalogue breadth and detection transforms. Choose kornia when augmentation must run on GPU inside the graph.

Throughput matters at scale but the bottleneck is usually elsewhere: decoding JPEGs and moving bytes generally dominates transform cost. Profile before optimising the transform chain.

The licensing situation, stated plainly

As of this writing, PyPI shows albumentations at 2.0.8 (May 2025, MIT). It shows albumentationsx at 2.4.3 (August 2026, AGPL-3.0-only). Albumentations, LLC offers commercial licences separately. The import name is unchanged. A swap is one line in requirements.txt, and an invisible change to your legal position.

AGPL-3.0 extends copyleft to software offered over a network. If augmentation runs in a training pipeline behind a service you provide, get an opinion rather than a guess. This is exactly the kind of change that a pinned dependency file prevents and a floating one does not.

Papers

What to learn next