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:
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¶
-
Device Transfer
-
Type Preservation
-
Batch Support
-
Integration with Transforms
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:
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:
- Tensors at any depth are moved to the target device
- Nested structures (dicts, lists) are preserved
- 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:
Back to CPU for visualization:
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:
- 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. - Inference fallback — for manually-constructed entries,
batch_sizescans 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 (4 → tensor([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¶
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¶
6. Backward Pass & Optimization¶
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¶
- 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.
- 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.
- 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