Skip to content
SR-Forge

EntryTransform

Where a DataTransform changes the content of fields, an EntryTransform changes the structure of the Entry itself. It can add new fields, remove fields, read metadata, make decisions based on multiple values, and even change the Entry type entirely — for example, converting a plain Entry into a GraphEntry or any other custom Entry subclass.

Entry --> [your transform] --> Entry (modified, possibly different type)

Use EntryTransform when you need to reshape the data container, not just process the data inside it.

An EntryTransform can do everything a DataTransform can (processing field values, applying functions to tensors), but at a cost: you write more boilerplate, there is no automatic recursion into nested dicts (you must loop manually if needed), and the transform only works on Entry objects — you cannot call it as a standalone function on raw tensors. Prefer DataTransform for pure field-level operations and reserve EntryTransform for cases where you genuinely need to modify the Entry structure.

Why no IO binding?

EntryTransforms don't use set_io() because their operations cannot be described as a fixed mapping of input fields to output fields. An EntryTransform can add or delete multiple fields dynamically based on the Entry's content, perform conditional logic, or change the Entry type entirely. The number of added, removed, or modified fields may even differ between entries in the same dataset.

For example, consider a quality gate that drops fields with too many NaN values:

class DropLowQualityFields(EntryTransform):
    def __init__(self, threshold: float = 0.5, *, fields_key: str = "tensor_fields"):
        self.threshold = threshold
        self.fields_key = fields_key
        super().__init__()

    def transform_unbatched(self, entry):
        for name in list(entry[self.fields_key]):
            tensor = entry[name]
            if tensor.isnan().float().mean() > self.threshold:
                del entry[name]
        return entry

One entry might keep all its fields, while another loses two of them. There is no way to express "delete a variable number of fields depending on the data" as an IO mapping. Instead, field names are passed as constructor parameters — this is also why EntryTransforms are opaque steps (-> transform ->) in the SequentialModel flow DSL.


Your First EntryTransform

Here's an EntryTransform that unpacks a nested dict field into top-level Entry fields — something that requires creating new fields dynamically and removing the original:

from srforge.transform import EntryTransform

class FlattenField(EntryTransform):

    def __init__(self, *, field_key: str):
        self.field_key = field_key
        super().__init__()

    def transform_unbatched(self, entry):
        data = entry[self.field_key]
        for key, value in data.items():
            entry[key] = value
        del entry[self.field_key]
        return entry

sr-forge ships an equivalent class

The example above is for teaching. sr-forge already includes a similar built-in called FlattenDict (srforge.transform.entry.FlattenDict) which adds prefixing and recursive flattening. Prefer the built-in for production code; use this example to understand the pattern.

Two things are different compared to DataTransform:

  1. Constructor _key params — field names are passed as constructor parameters with a _key suffix. The parameter field_key tells the transform which Entry field to read. This means the same transform class works with "metadata", "properties", or any other dict field — just pass a different field_key value. There is no magic behind the _key suffix — it's a naming convention used by all built-in transforms for clarity. You could name the parameter anything (field, source, etc.) and it would work the same way.
  2. transform_unbatched(entry) — both DataTransform and EntryTransform have this method, but they receive different things. A DataTransform's transform_unbatched receives individual field values (tensors, scalars, etc.). An EntryTransform's receives the full Entry object, so you can create new fields (entry[key] = value), remove fields (del entry[...]), and inspect the entry's structure.

Use it with an Entry:

from srforge.data import Entry
import torch

t = FlattenField(field_key="metadata")

entry = Entry(
    image=torch.randn(3, 64, 64),
    metadata={"width": 256, "height": 256, "source": "camera_1"},
)
entry = t(entry)
# entry has: image, width, height, source — metadata dict was unpacked and removed

The field key "metadata" is passed at construction time. The same transform class can be reused with different field names by creating a new instance:

t2 = FlattenField(field_key="properties")  # Different field

This couldn't be a DataTransform. Here's what each type can and can't do for this operation:

Operation DataTransform EntryTransform
Read a dict field from Entry Yes Yes
Iterate over dict keys Yes Yes
Create new Entry fields dynamically No Yes
Remove the original dict field No Yes

A DataTransform can write to new fields, but the number of outputs is fixed by what transform() returns and must be mapped to field names upfront. An EntryTransform can create fields dynamically at runtime — FlattenField produces as many new fields as there are keys in the dict, whether that's 0 or 100.


Field Keys Convention

Every EntryTransform accepts Entry field names as constructor parameters with a _key suffix:

class MyTransform(EntryTransform):
    def __init__(self, *, field_key: str, output_key: str = None):
        self.field_key = field_key
        self.output_key = output_key or field_key  # Default: in-place
        super().__init__()

The _key suffix makes it clear that the parameter holds a field name (a string like "lrs"), not the data itself:

# CLEAR: self.field_key holds a string key like "lrs"
entry[self.field_key]

# AMBIGUOUS: self.field could be data or a key
entry[self.field]

Optional Field Keys

Use Optional[str] = None for optional fields and check before accessing:

from typing import Optional

class ConditionalNormalize(EntryTransform):
    def __init__(self, *, image_key: str, reference_key: Optional[str] = None,
                 output_key: str):
        self.image_key = image_key
        self.reference_key = reference_key
        self.output_key = output_key
        super().__init__()

    def transform_unbatched(self, entry):
        image = entry[self.image_key]
        if self.reference_key and self.reference_key in entry:
            ref = entry[self.reference_key]
            mean, std = ref.mean(), ref.std()
        else:
            mean, std = image.mean(), image.std()
        entry[self.output_key] = (image - mean) / std
        return entry

List-Valued Keys

A _key parameter doesn't have to be a single string. Nothing stops you from accepting a list of field names to operate on multiple fields in one call:

from typing import Union, List

class RemoveFields(EntryTransform):
    def __init__(self, *, field_key: Union[str, List[str]]):
        self.field_keys = [field_key] if isinstance(field_key, str) else field_key
        super().__init__()

    def transform_unbatched(self, entry):
        for key in self.field_keys:
            if key in entry:
                del entry[key]
        return entry

# Remove one field:
RemoveFields(field_key="mask")

# Remove several at once:
RemoveFields(field_key=["mask", "metadata", "debug_info"])

YAML Configuration

Field keys go in params: — no io: key:

normalize:
  _target: ConditionalNormalize
  params:
    image_key: raw_ms
    reference_key: hr
    output_key: normalized

No io: key for EntryTransforms

Using io: with an EntryTransform in a config file raises TypeError. Move field keys into params:.


In SequentialModel

Inside a SequentialModel, EntryTransforms are opaque steps — the flow DSL just marks when to run the transform. Field mapping is handled by the constructor:

from srforge.transform.entry import CopyFields

seq = SequentialModel(
    modules={"copy": CopyFields(field_key="y", output_key="z")},
    flow=[" -> copy -> "],
)

For different field mappings, use separate instances:

copy1 = CopyFields(field_key="y", output_key="z")
copy2 = CopyFields(field_key="z", output_key="w")
seq = SequentialModel(
    modules={"copy1": copy1, "copy2": copy2},
    flow=[" -> copy1 -> ", " -> copy2 -> "],
)

This is the same reason EntryTransforms don't use IO binding — the flow DSL's input -> module -> output syntax is just another form of IO mapping. See SequentialModel: EntryTransforms in Flow for details.


Batched vs Unbatched: Pick One

Like DataTransform, EntryTransform lets you pick one method and the framework handles the other case (see the overview). The choice works the same way: transform(entry) for batched logic, transform_unbatched(entry) for per-sample logic. If both are implemented, transform() always wins.

One thing to keep in mind: EntryTransform methods receive the Entry itself, which has a concrete batched state (is_batched, tensor shapes, list-vs-bare-string fields). If your code depends on shapes (e.g., indexing into tensors, using .shape), make sure it matches the Entry's batched state. Shape-agnostic code (e.g., renaming or deleting fields) works regardless.


Using DataTransforms Inside EntryTransform

Since DataTransforms operate on raw values, you can call them inside transform_unbatched(). This lets you combine Entry-level logic with reusable value-processing components:

from srforge.transform.data import Standardize

class StandardizeAllTensorFields(EntryTransform):

    def __init__(self, mean: float = 0.5, std: float = 0.5):
        super().__init__()
        # Standardize is a DataTransform — see transforms/data-transform.md.
        # Its __call__ falls through to .transform() when given a raw tensor,
        # so we can use it directly here without binding IO.
        self.standardize = Standardize(mean=mean, std=std)

    def transform_unbatched(self, entry):
        for key in list(entry.keys()):
            if isinstance(entry[key], torch.Tensor):
                entry[key] = self.standardize(entry[key])
        return entry

The EntryTransform iterates over the Entry structure (inspecting field names and types), while the DataTransform handles the actual computation on each value. However, you cannot call an EntryTransform inside a DataTransform — a DataTransform only receives values, so it has no Entry to pass.


Examples

Band Splitting (One to Many Fields)

class SplitMultispectralBands(EntryTransform):

    def __init__(self, *, multispectral_key: str,
                 rgb_key: str, nir_key: str, swir_key: str):
        self.multispectral_key = multispectral_key
        self.rgb_key = rgb_key
        self.nir_key = nir_key
        self.swir_key = swir_key
        super().__init__()

    def transform_unbatched(self, entry: Entry) -> Entry:
        ms = entry[self.multispectral_key]  # [C, H, W] — no batch dim
        entry[self.rgb_key] = ms[0:3]
        entry[self.nir_key] = ms[3:5]
        entry[self.swir_key] = ms[5:7]
        return entry

Field Merging (Many to One)

class MergeBands(EntryTransform):

    def __init__(self, *, rgb_key: str, nir_key: str, output_key: str):
        self.rgb_key = rgb_key
        self.nir_key = nir_key
        self.output_key = output_key
        super().__init__()

    def transform_unbatched(self, entry: Entry) -> Entry:
        rgb = entry[self.rgb_key]
        nir = entry[self.nir_key]
        entry[self.output_key] = torch.cat([rgb, nir], dim=0)
        return entry

Common Mistakes

Using EntryTransform for Simple Value Operations

# BAD: Overkill for a pure function on values
class Normalize(EntryTransform):
    def __init__(self, *, image_key: str, output_key: str):
        self.image_key = image_key
        self.output_key = output_key
        super().__init__()
    def transform_unbatched(self, entry):
        entry[self.output_key] = (entry[self.image_key] - 0.5) / 0.5
        return entry

# GOOD: Simple and reusable
class Normalize(DataTransform):
    def transform(self, image: torch.Tensor) -> torch.Tensor:
        return (image - 0.5) / 0.5

DataTransform is simpler, more reusable, and supports automatic dict/list recursion. Use EntryTransform only when you need access to Entry structure — removing fields, inspecting field names, or changing the Entry type.


Next: IO Binding — The complete reference for connecting components to Entry fields