Extending SR-Forge¶
Every component in SR-Forge follows the same pattern: subclass, implement method(s), use from Python or YAML. This page shows how to create custom components — models, transforms, metrics (and the losses built from them), datasets, and hooks — that integrate seamlessly with the framework. Your custom classes work exactly like built-in ones: they can be wired in YAML, composed in SequentialModel, and used in any pipeline.
Model¶
Subclass Model, implement forward(). Parameter names are used for IO binding automatically.
from srforge.models import Model
import torch
class MyUpscaler(Model):
def __init__(self, scale: int = 2):
super().__init__()
self.net = torch.nn.Sequential(
torch.nn.Conv2d(3, 64, 3, padding=1),
torch.nn.ReLU(),
torch.nn.Conv2d(64, 3 * scale ** 2, 3, padding=1),
torch.nn.PixelShuffle(scale),
)
def forward(self, image):
return self.net(image)
Use in YAML:
See Model for details on multiple outputs and standalone use.
DataTransform¶
Subclass DataTransform, implement transform() (batched) or transform_unbatched() (per-sample). Parameter names and type annotations define the interface.
from srforge.transform import DataTransform
import torch
class GammaCorrection(DataTransform):
def __init__(self, gamma: float = 2.2):
super().__init__()
self.gamma = gamma
def transform(self, image: torch.Tensor) -> torch.Tensor:
return image.clamp(min=0).pow(1.0 / self.gamma)
Use in YAML:
preprocessing:
training:
- _target: my_project.transforms.GammaCorrection
params:
gamma: 2.2
io:
inputs:
image: lr
Or in a SequentialModel flow:
modules:
gamma:
_target: my_project.transforms.GammaCorrection
params:
gamma: 2.2
flow:
- "lr -> gamma -> lr"
See DataTransform for annotation-driven recursion, multi-input transforms, and per-sample processing.
EntryTransform¶
Subclass EntryTransform, implement transform_unbatched() (or transform() for batched). Use _key suffix parameters for field names.
from srforge.transform import EntryTransform
class DropSmallFields(EntryTransform):
def __init__(self, *, min_size: int, field_key: str, output_key: str = None):
self.min_size = min_size
self.field_key = field_key
self.output_key = output_key or field_key
super().__init__()
def transform_unbatched(self, entry):
tensor = entry[self.field_key]
if tensor.shape[-1] >= self.min_size and tensor.shape[-2] >= self.min_size:
entry[self.output_key] = tensor
return entry
Use in YAML — field keys go in params:, not io::
See EntryTransform for the _key convention, optional keys, and SequentialModel integration.
Metric¶
Subclass Metric and provide the best_min property, then implement whichever
of the three stages apply. A loss is a metric you train on, so this is also
how you write a loss. The full guide is Writing a Metric:
three questions that tell you which methods to write, and one metric written
every possible way. This section is the short version. Most metrics need
exactly one of the first two:
Level 2 — override pointwise() only (preferred for new metrics)¶
Return a per-element score map; the framework's default calculate_score composes reduce(pointwise(...), mask=y_mask) for you. The metric's inputs are pointwise's parameters — any number, any names — plus y_mask, which the framework adds. Masking, batch reduction, and per-sample normalisation are all handled automatically. Your metric also becomes wrappable by UncertaintyLoss(base_metric=...) and CorrectedLoss(base_metric=...).
from srforge.metrics import Metric
import torch
class HuberLoss(Metric):
def __init__(self, delta: float = 1.0, **kwargs):
super().__init__(**kwargs) # forward framework kwargs!
self.delta = delta
@property
def best_min(self) -> bool:
return True
def pointwise(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
diff = (x - y).abs()
quadratic = torch.clamp(diff, max=self.delta)
linear = diff - quadratic
return 0.5 * quadratic ** 2 + self.delta * linear # [B, C, H, W]
That's it. The framework reduces and masks automatically.
Level 1 — override calculate_score() (structural metrics)¶
Use this when the metric has no per-pixel value (SSIM uses windowed convolutions, MGE and TotalVariation compare neighbouring pixels), or when something happens after the average (PSNR takes a logarithm). Return a [B] tensor, and apply or refuse y_mask yourself.
class StructuralLoss(Metric):
def __init__(self, **kwargs):
super().__init__(**kwargs)
@property
def best_min(self) -> bool:
return True
def calculate_score(self, x: torch.Tensor, y: torch.Tensor,
y_mask: torch.Tensor = None) -> torch.Tensor:
# Custom windowed/structural math; must return shape [B].
# Every parameter needs a type annotation.
...
return per_sample_scalar
For windowed metrics, compute the score map on the real inputs and average it over self.erode_mask(mask, window_size), which keeps only positions whose whole window is valid. Don't zero the masked inputs before the window runs: the zeros end up inside neighbouring windows (see Using Metrics → Masking).
The epoch stage — override finalize() (optional, either level)¶
Independent of the two above. By default the epoch value is the average of the
per-image scores; override finalize when it isn't — a ratio, a logarithm, a
total, a worst case. Declare what each part is so the framework combines it
correctly over the epoch and across processes:
class MicroF1(Metric):
@property
def best_min(self) -> bool:
return False
@property
def reductions(self):
return {"tp": "sum", "fp": "sum", "fn": "sum"}
def calculate_score(self, x: torch.Tensor, y: torch.Tensor) -> dict:
...
return {"tp": tp, "fp": fp, "fn": fn} # ingredients, per image
def finalize(self, raw):
return 2 * raw["tp"] / (2 * raw["tp"] + raw["fp"] + raw["fn"])
Valid reductions are sum, mean, min, max, cat. A Level-2 metric that
overrides finalize receives {"sum", "count"} and needs no pixel
arithmetic of its own — see
Writing a Metric → Epoch rules.
Overriding finalize like this makes a metric custom: one fixed epoch rule,
no aggregate option, not part of the training total.
Offering micro and macro — declare aggregations¶
A Level-2 metric gets aggregate="micro" for free. A Level-1 metric has to say
how it pools — here is PSNR's version, slightly condensed:
class PSNR(Metric):
aggregations = ("macro", "micro") # first one is the default
def calculate_score(self, x, y, y_mask=None):
squared_error = torch.square(y - x)
if self.aggregate == "micro":
return self._sum_and_count(squared_error, y_mask) # ingredients
return self._psnr(self.reduce(squared_error, mask=y_mask))
def finalize(self, raw): # only called under micro
return self._psnr(raw["sum"] / raw["count"])
Three rules:
- Under micro, return
{"sum", "count"}(from_sum_and_count) and the framework declares their reductions for you. Other ingredients need areductionsproperty, as inMicroF1above. finalizeruns over one batch during training and over the epoch for reporting, so write it for totals, not for a particular scale.- Declaring
"micro"without a way to produce it — a Level-1 metric with nofinalize— fails when the class is defined, not when it is first used.
Set aggregation_note = "..." to explain, in the refusal message, why your
class does not support something.
For any per-pixel yes/no metric — segmentation, change detection, anomaly
detection — subclass ConfusionRatio rather than writing this by hand: it
supplies the counts, the masking and both averagings. See
Classification Metrics.
Framework kwargs — accept ALL of them, and forward
Metric.__init__ is decorated with @audit_subclasses, which enforces
two rules at class definition (both raise TypeError):
- Coverage — your subclass must ACCEPT every framework kwarg
(
weight,name,reduction,aggregate), either via**kwargs(recommended) or by declaring each explicitly. YAML users can't configure params your signature doesn't take. - Forwarding — whatever you accept must be passed to super. Either
super().__init__(**kwargs)orsuper().__init__(weight=weight, name=name, reduction=reduction, aggregate=aggregate).
Simplest compliant pattern: def __init__(self, my_param, **kwargs): super().__init__(**kwargs).
Use in YAML:
loss:
_target: srforge.metrics.LossCombiner
params:
losses:
- _target: my_project.metrics.HuberLoss
params:
delta: 0.5
weight: 1.0
io:
inputs: {x: sr, y: hr}
Need a custom regularizer? Use Regularizer, don't subclass¶
If your metric is a single-tensor magnitude penalty (penalise the
mean / L1 / L2 / entropy of one Entry field), don't write a custom
Metric subclass — use the built-in :class:Regularizer directly:
import torch
from srforge.metrics.regularization import Regularizer
# Custom robust soft-L1 penalty on attention weights
reg = Regularizer(penalty=lambda x: torch.log1p(x.abs()))
reg.set_io({"inputs": {"x": "attention_map"}})
Regularizer accepts any callable Tensor → Tensor and handles
masking + reduction for you. Only subclass Metric for a regularizer
if you need multi-tensor inputs or stateful logic. See
Using Metrics → Regularizer for the built-in
penalty options.
See Using Metrics for masking, multi-band inputs, MetricScores, and LossCombiner.
Dataset¶
Subclass Dataset from srforge.dataset and implement __getitem__ and
__len__. Each __getitem__ call returns one Entry. The framework
auto-wraps your __getitem__ (via Dataset.__init_subclass__) to apply
the constructor transforms= list and to handle disk caching when
cache_dir= is set.
Framework kwargs — accept ALL of them
Same rule as for Metric: Dataset is decorated with @audit_subclasses,
so your subclass must accept every framework kwarg
(name, transforms, cache_dir, recache) — either via **kwargs
or by declaring each explicitly. Forgetting raises TypeError at
class definition. Simplest compliant pattern:
from srforge.dataset import Dataset
from srforge.data import Entry
from pathlib import Path
import torch
class TiffDataset(Dataset):
def __init__(self, root: str, **kwargs):
super().__init__(**kwargs) # accepts name=, transforms=, cache_dir=, recache=
self._paths = sorted(Path(root).glob("*.tif"))
def __getitem__(self, index: int) -> Entry:
path = self._paths[index]
image = load_tiff(path) # your loading function
return Entry(
name=path.stem,
image=torch.from_numpy(image).float(),
)
def __len__(self) -> int:
return len(self._paths)
Use in YAML:
dataset:
training:
_target: my_project.datasets.TiffDataset
params:
root: /data/train
cache_dir: /tmp/tiff-cache # optional — caches transformed entries
transforms: # applied automatically per __getitem__
- _target: srforge.transform.data.ZScore
io: {inputs: {x: image}, outputs: image}
Datasets support transforms at load time and on-disk caching — see
Datasets. Useful operators (take, filter, shuffle,
+ for concat) are inherited from the base class.
Hook¶
Subclass Hook and decorate handler methods with @hooks_into("on_<point>") — the decorator says which HookPoint on the runner/trainer the method fires at:
from srforge.training.hooks import Hook, hooks_into
class EpochTimer(Hook):
@hooks_into("on_epoch_end")
def print_time(self, ctx):
import time
print(f"Epoch {ctx.epoch} finished at {time.strftime('%H:%M:%S')}")
Use in YAML — put it in the hooks: list of the component whose lifecycle it follows (here: the training runner):
training_runner:
_target: srforge.training.runners.TrainingEpochRunner
params:
# ... optimizer, postprocessor, ...
hooks:
- _target: my_project.hooks.EpochTimer
The ctx argument is a mutable Context — hooks can read entry data, scores, and epoch state, and can also modify training (add auxiliary losses, clip gradients, skip steps). See Hooks for the full HookPoint list, the Context API, and stateful hooks.
Custom observers were removed in 0.16.0
Code written for 0.15.x and earlier may subclass Observer with an
EVENTS list. srforge.observers no longer exists; turn each one
into a hook with the
upgrading guide — the
conversion is mechanical.
Making Classes Available in YAML¶
All custom classes are used in YAML via their full module path:
The ConfigResolver imports the module and instantiates the class automatically — no registration step needed. As long as the module is importable (i.e., on sys.path or installed as a package), it works.
For a typical project layout:
my-experiment/
├── train.py
├── configs/
│ └── train-cfg.yaml
└── my_project/
├── __init__.py
├── models.py # _target: my_project.models.MyUpscaler
├── transforms.py # _target: my_project.transforms.GammaCorrection
├── metrics.py # _target: my_project.metrics.HuberLoss
├── datasets.py # _target: my_project.datasets.TiffDataset
└── hooks.py # _target: my_project.hooks.EpochTimer
SR-Forge built-in classes use the srforge.* prefix:
_target: srforge.metrics.regression.L1
_target: srforge.transform.data.Multiply
_target: srforge.training.trainers.PyTorchTrainer
Next: Return to Getting Started to scaffold a project, or browse the API Reference for detailed class documentation.