Skip to content
SR-Forge

Collation and the Batch Convention

What is Collation?

Collation is the process of combining multiple individual samples into a single batched structure that can be processed efficiently by a neural network. SR-Forge uses stack-based collation: each sample stores values in their natural shape (e.g. a single image is [C, H, W]), and collation adds a new batch dimension via torch.stack:

e1 = Entry(image=torch.randn(3, 64, 64))  # [C, H, W] — natural shape
e2 = Entry(image=torch.randn(3, 64, 64))

batch = Entry.collate([e1, e2])
# batch.image.shape == [2, 3, 64, 64]  — batch dim added by stacking

An Entry is a flexible container that holds tensors, lists, dicts, and nested structures. Collation handles all of these types, turning a list of Entries into one batched Entry where every field is combined in a type-appropriate way — tensors are stacked, lists are collected, and nested structures are recursed into.


The Stack-Based Convention

SR-Forge follows a convention where single samples use natural shapes — no batch dimension. A single image is [C, H, W], a name is "scene_042" (a bare string), a band list is ["b2", "b8"]. Collation adds the batch dimension — see the full rules table below for every type.

When a Dataset returns an Entry, fields should be in their natural form:

class MyDataset(Dataset):
    def __getitem__(self, idx) -> Entry:
        image = load_image(path)           # Returns [C, H, W] — natural shape
        target = load_image(target_path)
        return Entry(
            name=self.names[idx],          # Bare string
            image=image,                   # [C, H, W]
            target=target,
            bands=["b2", "b8"],            # Flat list
            scale=4,                       # Bare int
        )

The is_batched Flag

Collation marks the result with _is_batched = True. This flag controls how transforms dispatch:

  • Batched entry (is_batched == True): Data has a batch dimension. Tensors are [B, C, H, W].
  • Unbatched entry (is_batched == False): Data is in natural form. Tensors are [C, H, W].

You don't need to manage this flag manually — collate() sets it, and integer indexing (batch[i]) clears it.


How Collation Works

Collation combines a list of Entries into a single batched Entry. It happens automatically inside DataLoader via SR-Forge's built-in GeneralCollation collate function:

from srforge.data.loader import DataLoaderFactory

loader = DataLoaderFactory(dataset, batch_size=4).get_loader()

for batch in loader:
    # batch is a single Entry with stacked tensors
    print(batch.image.shape)  # [4, 3, 64, 64]

The key operation is stacking: each sample has shape [C, H, W], so stacking four of them produces [4, C, H, W]:

Sample 1:  [3, 64, 64]  --+
Sample 2:  [3, 64, 64]    +--  torch.stack(dim=0)  -->  [4, 3, 64, 64]
Sample 3:  [3, 64, 64]    |
Sample 4:  [3, 64, 64]  --+

Entry.collate() also stores the batch size and sets is_batched = True on the result. See Batch Indexing for how to extract individual samples back out.


Collation Rules by Type

Field type Single sample After collation (B=4)
Tensor (same shape) [C, H, W] [4, C, H, W] (stacked)
Tensor (different shapes) varies kept as list (not stackable)
str "scene_042" ["scene_042", "scene_043", ...] (collected)
int / float 4 tensor([4, 4, ...])
bool True tensor([True, False, ...], dtype=torch.bool)
list ["b2", "b8"] [["b2", "b8"], ["b2", "b8"], ...] (collected)
tuple (t1, t2) [(t1, t2), (t3, t4)] (collected)
dict {"a": t1, "b": t2} {"a": stack(t1s), "b": stack(t2s)} (recurse)
None (all) None [None, None, ...] (collected)
None (mixed with values) None or value kept as list of raw values

Tensors

Tensors of the same shape are stacked along a new dim 0. If shapes differ across the batch (e.g. variable-size images), collation falls back to returning a plain list — you'll need to handle padding or grouping yourself.

Lists and Tuples

Lists and tuples are collected — each sample's value becomes an element in the batch list:

e1 = Entry(bands=["b2", "b8"])
e2 = Entry(bands=["b2", "b8"])

batch = Entry.collate([e1, e2])
# batch.bands == [["b2", "b8"], ["b2", "b8"]]

For multi-temporal data where each sample has a sequence of images:

e1 = Entry(lrs=[t1, t2])      # 1 sample, 2 timesteps
e2 = Entry(lrs=[t3, t4, t5])  # 1 sample, 3 timesteps

batch = Entry.collate([e1, e2])
# batch.lrs == [[t1, t2], [t3, t4, t5]]  — a list of 2 inner lists

Dicts

Dict fields are collated recursively — each key is collated independently:

e1 = Entry(bands={"rgb": torch.randn(3, 64, 64), "nir": torch.randn(2, 64, 64)})
e2 = Entry(bands={"rgb": torch.randn(3, 64, 64), "nir": torch.randn(2, 64, 64)})

batch = Entry.collate([e1, e2])
# batch.bands["rgb"].shape == [2, 3, 64, 64]
# batch.bands["nir"].shape == [2, 2, 64, 64]

None

None fields are always collected into a list — both when all entries are None and when some are mixed with actual values. This preserves batch size information and keeps indexing consistent (list[i] works the same way as for strings or other non-stackable types):

e1 = Entry(mask=None)
e2 = Entry(mask=torch.randn(1, 64, 64))

batch = Entry.collate([e1, e2])
# batch.mask == [None, tensor([1, 64, 64])]  — a list of raw values

Warning

All non-None values in a field must be the same type. Mixing different types (e.g. a string in one sample and an int in another) raises a TypeError — only None gets special treatment as "missing data".

Bare Scalars

Bare strings, ints, floats, and bools are handled automatically:

# Strings are collected into a list
e1 = Entry(name="scene_042")
e2 = Entry(name="scene_043")
batch = Entry.collate([e1, e2])
# batch.name == ["scene_042", "scene_043"]

# Ints and floats become tensors
e1 = Entry(scale=2)
e2 = Entry(scale=4)
batch = Entry.collate([e1, e2])
# batch.scale == tensor([2, 4])

# Bools become bool tensors
e1 = Entry(flag=True)
e2 = Entry(flag=False)
batch = Entry.collate([e1, e2])
# batch.flag == tensor([True, False])

Writing Transforms for Batched Data

Batched transforms (transform()) should assume tensors have a batch dimension. Use ... (ellipsis) indexing when you want to operate on spatial dimensions regardless of how many leading dimensions exist:

class CropBorder(DataTransform):
    def __init__(self, border: int):
        super().__init__()
        self.border = border

    def transform(self, image: torch.Tensor) -> torch.Tensor:
        b = self.border
        return image[..., b:-b, b:-b]  # Works for any number of leading dims

For operations that need explicit batch/channel dimensions, unpack them directly:

class PerChannelNormalize(DataTransform):
    def transform(self, image: torch.Tensor) -> torch.Tensor:
        B, C, H, W = image.shape
        mean = image.mean(dim=(2, 3), keepdim=True)
        std = image.std(dim=(2, 3), keepdim=True)
        return (image - mean) / (std + 1e-8)

Transforms can also be written to work on single samples — implement transform_unbatched() instead, and the framework handles batching and unbatching for you (more on this in Transforms):

class PerSampleResize(DataTransform):
    def transform_unbatched(self, image: torch.Tensor) -> torch.Tensor:
        # image is [C, H, W] — no batch dim
        return F.interpolate(image.unsqueeze(0), size=self.size).squeeze(0)

Reversing Collation: Batch Indexing

Collation is reversible. Given a batched Entry, you can extract individual samples using integer indexing:

batch = Entry.collate([e1, e2, e3])
sample = batch[0]         # Unbatched Entry — batch dim removed
samples = batch.unbatch() # [batch[0], batch[1], batch[2]]

Integer indexing uses true indexing (tensor[i]) — the batch dimension is removed (PyTorch semantics). Slice indexing (batch[0:1]) preserves the batch dimension and the is_batched flag:

# Integer index: removes batch dim
sample = batch[0]
# sample.image.shape == [3, 64, 64]  — no batch dim
# sample.name == "scene_042"         — bare string
# sample.is_batched == False

# Slice: preserves batch dim
sub = batch[0:1]
# sub.image.shape == [1, 3, 64, 64]  — batch dim preserved
# sub.name == ["scene_042"]           — list
# sub.is_batched == True

Round-Trip Invariant

# collate → index recovers original values
e1 = Entry(name="s1", image=tensor_1)
e2 = Entry(name="s2", image=tensor_2)
batch = Entry.collate([e1, e2])

r1 = batch[0]
# r1.name == "s1"
# torch.equal(r1.image, e1.image)

For the full API and round-trip invariants, see Entry: Batch Indexing.


GraphEntry

GraphEntry uses PyTorch Geometric's own collation via torch_geometric.loader.DataLoader. This means graph data is batched by concatenating node features and adjusting edge indices — the standard PyG approach. SR-Forge's DataLoaderFactory automatically selects the right loader based on the Entry type.


Next: Datasets — How to load data and create custom datasets