Skip to content
SR-Forge

Entry: The Core Data Container

Every piece of data in SR-Forge flows through Entry objects. Whether you're loading images, time series, point clouds, or any other structured data — if it passes through a dataset, transform, or model, it lives inside an Entry. Understanding Entry is absolutely fundamental to using SR-Forge effectively.

This guide will teach you what Entry is, why it exists, how to use it, and how it flows through your entire pipeline. Already familiar? Jump to the Summary & Quick Reference.


The Core Philosophy: Structured Data Flow

SR-Forge uses Entry to create predictable, type-safe data flow through your pipeline:

Dataset → [Entry] → Transform → [Entry] → Model → [Entry] → Loss

Instead of passing around raw tensors or plain dictionaries, Entry provides:

  • Structure: Named fields with semantic meaning
  • Type safety: Guaranteed tensor types and shapes
  • Device management: Automatic GPU/CPU transfer
  • Composability: Works seamlessly with all SR-Forge components

What is Entry?

Why Not Just Use dict?

A plain dict works fine for holding data — until you need to move it to GPU, batch multiple samples, or pass it through a pipeline. Those are the real pain points:

# With dict, you handle device transfer field by field:
for key in data:
    if isinstance(data[key], torch.Tensor):
        data[key] = data[key].to(device)
    elif isinstance(data[key], dict):  # nested? recurse manually
        for k, v in data[key].items():
            if isinstance(v, torch.Tensor):
                data[key][k] = v.to(device)

# With Entry, one call handles everything — recursively:
entry = entry.to(device)

Entry also provides batching (Entry.collate), per-sample indexing (batch[0]), and integration with transforms and models — all of which you'd have to build yourself with plain dicts.

The Anatomy of an Entry

from srforge.data import Entry
import torch

entry = Entry(
    # Data tensors — natural shapes (no batch dim)
    image=torch.randn(3, 64, 64),
    target=torch.randn(3, 256, 256),
    mask=torch.randint(0, 2, (1, 64, 64)),

    # Metadata (non-tensor)
    meta={
        "filename": "sample_001.png",
        "split": "train",
    },
)

Key points:

  • Holds tensors, nested dicts, lists, and metadata
  • Named fields have semantic meaning
  • Structure reflects your data's natural organization
  • Works with all SR-Forge components
  • Single samples use natural shapes — tensors are [C, H, W] (following PyTorch's convention), names are bare strings, lists are flat. There is no batch dimension — that gets added later when multiple entries are collated into a batch. See Collation for details.

What Entry Provides

  1. Device Transfer

    entry_gpu = entry.to("cuda")  # Recursively moves ALL tensors
    entry_cpu = entry_gpu.to("cpu")
    

  2. Type Preservation

    # Entry preserves types through operations
    entry["meta"]  # Still a dict
    entry["image"]  # Still a tensor
    

  3. Batch Support

    # Collate multiple Entries into a batch — tensors are stacked
    batch = Entry.collate([entry1, entry2, entry3])
    # batch["image"].shape == [3, 3, 64, 64]  — [batch=3, C=3, H=64, W=64]
    

  4. Integration with Transforms

    # Transforms operate on Entry
    transformed = transform(entry)  # Returns modified Entry
    


Quick Reference

Common Operations

Operation Code Description
Create Entry(image=img, target=label) Create new Entry
Access entry["image"] or entry.image Read field
Set entry["target"] = value or entry.target = value Write field
Check "mask" in entry Check if field exists
Remove del entry["mask"] Delete field
Device entry.to(device) Move all tensors to device
NumPy entry.numpy() Convert all tensors to numpy arrays
Keys entry.keys() Get all field names
Items entry.items() Get all (key, value) pairs
Copy entry.copy() Shallow copy
Batch Entry.collate([entry1, entry2, ...]) Stack into batched Entry
Is Batched entry.is_batched Whether Entry has a batch dimension
Length len(entry) Batch size (batched entries only)
Batch Size entry.batch_size Number of samples in batch
Index entry[0] Extract unbatched sample (batched entries only)
Slice entry[0:2] Extract sub-batch (batched entries only)
Unbatch entry.unbatch() Split into list of unbatched Entries

Creating and Modifying Entry

Creating Entry

With keyword arguments:

entry = Entry(
    image=load_image("image.png"),
    target=load_image("target.png"),
)

Starting empty:

entry = Entry()
entry["image"] = load_image("image.png")   # dict-style
entry.target = load_image("target.png")    # attribute-style — both work

From dataset:

class MyDataset(Dataset):
    def __getitem__(self, idx):
        # Datasets return Entry objects — fields use natural shapes
        return Entry(
            name=self.names[idx],          # Bare string
            image=self.load_image(idx),    # [C, H, W]
            target=self.load_target(idx),  # [C, H, W]
        )

The name Field

Every Entry has a name field — a string identifier for the sample. Always set it. The framework uses it throughout the pipeline: error messages include the name so you can identify which sample failed, image-saving hooks use it as the filename when writing predictions to disk, and benchmarking reports per-scene scores keyed by name. When you concatenate datasets or use patching, the name is automatically extended to stay unique (e.g., "dataset_A_scene_042" or "scene_042_003").

entry = Entry(name="scene_042", image=torch.randn(3, 64, 64))

# After collation: batch.name → ["scene_042", "scene_043"]
# After indexing:  batch[0].name → "scene_042"

The name field is protected from deletion (del entry["name"] raises KeyError). It defaults to None, but leaving it unset means you lose all the above benefits.

Accessing and Modifying Fields

Dictionary-style and attribute-style access are fully interchangeable — use whichever you prefer:

entry = Entry(image=some_tensor, target=some_target)

# Read
x = entry["image"]       # dict-style
x = entry.image           # attribute-style — identical

# Write (creates the field if it doesn't exist)
entry["edges"] = compute_edges(entry.image)
entry.gradient = compute_gradient(entry.target)

# Check, delete, iterate
"mask" in entry           # True/False
del entry["mask"]         # remove field
entry.keys()              # all field names
entry.get("mask", None)   # safe access with default

Nested structures work as you'd expect — entry["bands"]["b2"] reads into a nested dict.

Underscore attributes

Names starting with _ (e.g., entry._internal) are stored as regular Python attributes, not as Entry fields. This keeps internal state separate from data.


Device Transfer: The .to() Method

Automatic Recursive Transfer

In PyTorch, tensors live on a specific device — typically CPU (main memory) or GPU (graphics card memory, much faster for neural network computation). Call .to(device) once on an Entry, and every tensor moves — recursively, through nested dicts and lists.

.to() returns a new Entry — the original is not modified. This matches PyTorch's Tensor.to() convention.

entry = Entry(
    image=torch.randn(3, 64, 64),
    target=torch.randn(3, 256, 256),
    channels={
        "rgb": torch.randn(3, 64, 64),
        "depth": torch.randn(1, 64, 64),
    },
)

entry_gpu = entry.to("cuda")   # New Entry on GPU
# entry is still on CPU — unchanged

# entry_gpu["image"] is on cuda
# entry_gpu["channels"]["rgb"] is on cuda
# ... all tensors moved recursively

How .to() Works

Entry's .to() method creates a new Entry where:

  1. Tensors at any depth are moved to the target device
  2. Nested structures (dicts, lists) are preserved
  3. Non-tensors (strings, ints, None) are copied as-is

Common Patterns

Training loop:

for batch in dataloader:
    batch = batch.to(device)

    output = model(batch)
    loss = criterion(output, batch)

Specific GPU:

entry = entry.to("cuda:1")

Back to CPU for visualization:

entry = entry.to("cpu")
visualize(entry["target"])


NumPy Conversion: The .numpy() Method

Like .to(), .numpy() returns a new Entry — the original is not modified. It recursively converts all tensors via .detach().cpu().numpy():

entry = Entry(
    image=torch.randn(3, 64, 64),
    target=torch.randn(3, 256, 256),
    meta={"filename": "sample_001.png"},
)

entry = entry.numpy()

# entry["image"] is now a numpy array
# entry["target"] is now a numpy array
# entry["meta"]["filename"] is still a string

Non-tensor values are left unchanged. This is useful for visualization, saving results, or interoperating with libraries that expect numpy arrays (matplotlib, scikit-image, etc.). GraphEntry also supports .numpy().


Collation: Batching Multiple Entries

SR-Forge uses stack-based collation — each sample has natural shapes, and collation adds a batch dimension via torch.stack:

e1 = Entry(image=torch.randn(3, 64, 64))
e2 = Entry(image=torch.randn(3, 64, 64))

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

This happens automatically inside DataLoader. Different field types are handled differently — tensors are stacked, lists are collected, dicts are recursively collated. Bare scalars (strings, ints, floats, bools) are also handled — see Collation Rules for details.

For the complete collation rules and practical examples, see the dedicated Collation page.


Batch Indexing and Unbatching

After collation, you often need to extract individual samples from a batch — for inspection, per-sample logic, or debugging. Entry supports integer and slice indexing directly.

batch_size Property

batch = Entry.collate([
    Entry(name="s1", image=torch.randn(3, 64, 64)),
    Entry(name="s2", image=torch.randn(3, 64, 64)),
    Entry(name="s3", image=torch.randn(3, 64, 64)),
])
batch.batch_size  # 3

batch_size uses a hybrid approach:

  1. Stored value (O(1)) — when the Entry was created by collate() or extracted via slicing, the batch size is stored as an internal attribute and returned immediately.
  2. Inference fallback — for manually-constructed entries, batch_size scans fields and returns the size of the first tensor (shape[0]) or list (len()).

len(entry) — Batch Size

len(entry) returns the batch size for batched entries. For unbatched entries, it raises TypeError — use entry.is_batched to check first:

batch = Entry.collate([entry1, entry2, entry3])
len(batch)  # 3

sample = batch[0]
# len(sample) → TypeError (unbatched Entry has no len)
sample.is_batched  # False — use this to check

is_batched Property

entry = Entry(image=torch.randn(3, 64, 64))
entry.is_batched  # False — manually constructed

batch = Entry.collate([entry, entry])
batch.is_batched  # True — created by collate

sample = batch[0]
sample.is_batched  # False — integer indexing removes batch dim

sub = batch[0:1]
sub.is_batched  # True — slicing preserves batch dim

The is_batched flag controls how transforms dispatch — see Batched vs Unbatched.

Integer Indexing: entry[i]

Integer indexing extracts a single sample as an unbatched Entry, removing the batch dimension (standard PyTorch semantics):

batch = Entry.collate([
    Entry(name="s1", image=torch.randn(3, 64, 64), scale=4, bands=["b2", "b8"]),
    Entry(name="s2", image=torch.randn(3, 64, 64), scale=8, bands=["b2", "b8"]),
])

sample = batch[0]
# Entry(name="s1", image=tensor([3, 64, 64]), scale=tensor(4),
#       bands=["b2", "b8"])
# sample.is_batched == False

Every field is indexed (tensor[0], list[0]) — so the batch dimension is removed. This follows PyTorch semantics where integer indexing removes a dimension.

Negative indexing works: batch[-1] returns the last sample. Out-of-range raises IndexError.

Field type behavior:

Batched field type Batch (B=2) After entry[0]
Tensor [2, C, H, W] 4D tensor [C, H, W] (batch dim removed)
List ["s1", "s2"] 2-element list "s1" (element extracted)
1D tensor tensor([4, 8]) 2-element tensor tensor(4) (scalar tensor)
Nested list [["b2"], ["b2"]] 2-element list ["b2"] (element extracted)
None list [None, None] 2-element list None (element extracted)
Dict {"a": tensor[2,...]} Dict of batched tensors Dict of unbatched tensors (recursed)

Slice Indexing: entry[start:stop]

Slicing extracts a sub-batch, preserving the batch dimension and is_batched flag:

sub = batch[0:1]   # B=1 sub-batch (batch dim preserved)
sub = batch[1:3]   # B=2 sub-batch
sub = batch[::2]   # Every other sample

Both integer and slice indexing store _batch_size on the result, so subsequent batch_size calls are O(1).

unbatch() — Split Into Individual Samples

samples = batch.unbatch()  # List of unbatched Entries
# len(samples) == batch.batch_size
# Each sample has no batch dimension

Equivalent to [batch[i] for i in range(batch.batch_size)].

Indexing Undoes Collation

Collating entries and then indexing back recovers the original:

e1 = Entry(name="s1", image=tensor_1, scale=4)
e2 = Entry(name="s2", image=tensor_2, scale=8)

batch = Entry.collate([e1, e2])
r1 = batch[0]

# r1.name == "s1"
# torch.equal(r1.image, e1.image)
# r1.scale == 4  (scalar tensor, compares equal to int)

Scalars become tensors after collation

Bare ints and floats are converted to tensors during collation (4tensor([4, 8])). After indexing back, you get a scalar tensor (tensor(4)), not a Python int. This matters if your code uses isinstance(x, int) checks — they'll fail on scalar tensors. Use x.item() to convert back, or rely on the fact that tensor(4) == 4 is True.


GraphEntry: For Graph-Structured Data

If your data is graph-structured (point clouds, molecular graphs, meshes) where each sample has a variable number of nodes and edges, use GraphEntry instead of Entry. It's an Entry subclass that uses PyTorch Geometric's batching (node concatenation + edge index offsets) instead of stack-based collation.

from srforge.data import GraphEntry

graph = GraphEntry(
    x=torch.randn(100, 16),                       # Node features [num_nodes, features]
    edge_index=torch.randint(0, 100, (2, 500)),    # Edges [2, num_edges]
    y=torch.randn(1, 10),                          # Graph-level target
)

For regular grid data (images, videos, spectral bands) — use Entry.


Entry Lifecycle in Pipeline

This section previews how Entry flows through the full pipeline. Don't worry if some concepts (transforms, models, loss) are unfamiliar — they're covered in later pages. The goal here is to show how Entry ties everything together.

This is the default lifecycle

The pipeline below matches the scaffold generated by srforge init. There's nothing stopping you from modifying this flow to fit your needs — skipping steps, reordering them, or adding custom logic. How to wire everything together is covered in the Writing Scripts and Configuration guides.

The Complete Journey

Let's trace an Entry through a complete SR-Forge pipeline:

1. Dataset Creation with Transforms

Transforms are passed directly to the Dataset. It applies them automatically — in order — every time an Entry is loaded:

from srforge.transform.data import ZScore, Downsample

# `MyDataset` is your custom Dataset subclass — see extending.md.
# Assume its `__getitem__` returns Entries with fields ["image", "target", "meta"].
dataset = MyDataset(
    transforms=[
        # Standardise the high-res image in place
        ZScore().set_io({"inputs": {"x": "image"}, "outputs": "image"}),
        # Derive a low-resolution view as a NEW field
        Downsample(scale_factor=2).set_io({"inputs": {"x": "image"}, "outputs": "image_lr"}),
    ]
)

entry = dataset[0]  # Raw Entry with transforms applied

At this point, Entry has: ["image", "target", "meta", "image_lr"]

2. Collation (DataLoader)

batch = Entry.collate([entry1, entry2, entry3, entry4])
# batch["image"].shape: [4, 3, 64, 64]  — four [3,64,64] stacked
# batch.is_batched == True

3. Device Transfer

batch = batch.to(device)  # Move to GPU

4. Model Forward Pass

# The model reads from Entry fields and writes its output back
output = model(batch)
# output["prediction"] now contains the model's result

After model, Entry has: ["image", "target", "meta", "image_lr", "prediction"]

5. Loss Computation

# Loss reads fields from Entry via IO binding, returns MetricScores
scores = loss_fn(output)

6. Backward Pass & Optimization

scores.total_weighted().mean().backward()
optimizer.step()

Visualization of Flow

flowchart TB
    DS[Dataset] --> E1["Entry(image[C,H,W], target[C,H,W], meta)<br/><small>single sample — unbatched</small>"]
    E1 --> TF[Transforms]
    TF --> E2["Entry(…, image_lr[C,H,W])<br/><small>fields added / modified</small>"]
    E2 --> COL["Collation<br/><small>torch.stack along new dim 0</small>"]
    COL --> E3["Entry(image[B,C,H,W], …)<br/><small>batched — is_batched=True</small>"]
    E3 --> DEV[".to(device)"]
    DEV --> M[Model]
    M --> E4["Entry(…, prediction[B,…])<br/><small>output merged in</small>"]
    E4 --> L[Loss]
    L --> MS["MetricScores"]
    MS --> BW["total_weighted().mean().backward()"]

Key Observations

  1. Entry structure evolves — transforms can add, remove, or overwrite fields. The shape of the Entry at the end of the pipeline depends entirely on how you design it.
  2. Entry type can change — an EntryTransform can convert an Entry into a different type (e.g., GraphEntry), so the type at the end doesn't have to match the type at the start.
  3. Automatic handling — device transfer and batching work automatically regardless of what fields the Entry contains.

Best Practices

✅ DO: Use Descriptive Field Names

# Good
entry = Entry(
    image=raw_input,
    target=ground_truth,
    mask=segmentation_mask,
    edge_map=edges,
)

# Bad — unclear names
entry = Entry(x=raw_input, y=ground_truth, m=segmentation_mask, e=edges)

Clear names make code self-documenting and easier to debug.

✅ DO: Prefer Flat Fields Over Nested Dicts

IO binding connects parameters to top-level Entry fields. There's no way to bind to a specific key inside a nested dict — if you bind to "images", the component receives the entire dictionary. Keep fields flat so each one can be individually bound:

# Good — each field individually bindable
entry = Entry(
    rgb=rgb_tensor,
    nir=nir_tensor,
    swir=swir_tensor,
    cloud_mask=cloud_mask,
    shadow_mask=shadow_mask,
)

Nested dicts are useful when a component needs the whole group at once — for example, a DataTransform that resizes all bands to a common resolution. But don't nest by default. If nesting is unavoidable (e.g. data loaded from a nested metadata file), use the FlattenDict EntryTransform to recursively unpack a dict field into top-level fields — keys at every level are joined to form flat names:

from srforge.transform.entry import FlattenDict

# Entry: {"meta": {"sensor": {"name": "S2", "res": 10}, "date": "2024"}}
flatten = FlattenDict(field_key="meta")  # field_key is keyword-only
entry = flatten(entry)
# Entry: {"meta_sensor_name": "S2", "meta_sensor_res": 10, "meta_date": "2024"}

FlattenDict is an EntryTransform, so field selection lives in the constructor (field_key=) — not in set_io(). Pass prefix= to replace the field-name prefix or prefix="" to drop it entirely.

❌ DON'T: Modify Entry During Iteration

# Bad — dict changes size during iteration
for key in entry.keys():
    if key.startswith("temp_"):
        del entry[key]

# Good — collect keys first, then delete
temp_keys = [k for k in entry.keys() if k.startswith("temp_")]
for key in temp_keys:
    del entry[key]

Summary

Entry is SR-Forge's core data container — a dictionary-like object that carries tensors, metadata, and nested structures through the pipeline. Single samples use natural shapes ([C, H, W]); collation adds the batch dimension. Every component (dataset, transform, model, loss) reads from and writes to Entry.

See the Quick Reference table above for all operations.

Entry is the universal data format in SR-Forge. Everything consumes Entry, and everything produces Entry.


Next: Collation — How entries are batched and the stack-based convention