Skip to content
SR-Forge

Adapters

Use metrics from other libraries as SR-Forge metrics.

A metric from torchmetrics (and, later, other libraries) can be named directly in a config::

_target: torchmetrics.image.PeakSignalNoiseRatio
params:
    data_range: 1.0

and it arrives in the training loop as an ordinary :class:~srforge.metrics.Metric. Nothing in the YAML mentions an adapter; :func:adapt is applied by the config resolver to whatever comes out of _target, and returns anything it does not recognise untouched.

The reasoning, the measurements behind the reduction policy, and the rejected alternatives are in docs/design/external-metric-adapters-rfc.md.

ExternalMetric

Bases: Metric

A :class:Metric that delegates to a metric object from another library.

The foreign metric is held, never inherited from. Metric is already nn.Module + IOModule + ABC and torchmetrics' Metric is itself an nn.Module with its own state machinery; inheriting from both is how you get an object whose .to(device) half-works. Assigning it to self.inner still registers it as a submodule, so device movement and state_dict propagate — composition keeps the two lifecycles separate without losing that.

Subclasses provide :meth:detects, :meth:_direction, :meth:_arity and :meth:_call. Weight, name, IO binding and MetricScores packaging all come from Metric unchanged.

Parameters:

Name Type Description Default
inner Any

The foreign metric object.

required
weight float

Defaults to 1, as for every native metric. The weight decides how much the metric counts in total_weighted — the number the optimiser follows in training and the one that picks the best checkpoint in validation. Use 0 to only log it.

1.0

arity: str property

"FR" or "NR" — what the wrapped metric declared.

detects(obj: Any) -> bool staticmethod

Whether this adapter handles obj. Imports inside the body.

available() -> bool classmethod

Whether this adapter's library is loaded.

Checks sys.modules rather than importing: if the library has not been imported, no object from it can exist, so it cannot be the thing being adapted. This keeps :func:adapt free of imports on the common path and costs nothing when the library is absent.

calculate_score(x: torch.Tensor, y: torch.Tensor = None) -> torch.Tensor

Score a batch, returning [B]: one value per image.

Libraries differ in what they return for a batch. pyiqa, and a torchmetrics metric built with reduction='none', return one value per image: used as is, from one call. Most torchmetrics metrics pool the batch into a single number, which is no image's score, so each image is scored in a call of its own. Which kind a metric is shows on its first batch of more than one image and is remembered — that batch is the only one scored twice.

Nothing here is configurable, because nothing is a choice: a score per image is the Metric contract, and the pooled number is what aggregate="micro" gives.

TorchMetricAdapter

Bases: ExternalMetric

Adapts a torchmetrics.Metric to the SR-Forge Metric interface.

torchmetrics usually pools a whole batch into one number. aggregate decides what the epoch value means:

========================== ============================================= aggregate="macro" per image, averaged — every image gets its (default) own score, exactly as a native SR-Forge metric reports. A metric that pools is called once per image for it; one built with reduction='none' already returns a value per image and is called once per batch. aggregate="micro" pooled — the metric's own counts are totalled (over the batch for training, over every batch and process for the epoch), then its own compute() runs once. The number torchmetrics would give had it seen the whole epoch at once. One call per batch. ========================== =============================================

Parameters:

Name Type Description Default
**kwargs Any

Passed to :class:Metric — weight, name and aggregate.

{}

How micro works, and why not the obvious way. The obvious way is to let torchmetrics keep its state across batches and call compute() at the end. That fails three ways: nothing tells a metric that an epoch started, so epoch 2 would include epoch 1; under DDP the counts would be summed across ranks by SR-Forge and then synced again by torchmetrics; and until now the option did exactly that — kept state nobody read.

Instead each batch resets the metric, updates it, and hands its state (tp/fp/fn/tn, a confusion matrix, a sum of squared errors) to SR-Forge as the parts of this metric, declared with the reductions torchmetrics itself registered for them. SR-Forge totals them the usual way — correctly across ranks — and :meth:finalize loads the totals back and calls the metric's own compute. The wrapped metric is stateless between calls.

PyIQAAdapter

Bases: ExternalMetric

Adapts a pyiqa metric to the SR-Forge Metric interface.

pyiqa is where SR-Forge's no-reference metrics come from — it ships NIQE, PIQE and around sixty others, and torchmetrics has none at all. Reach one through its factory::

_target: pyiqa.create_metric
params:
    metric_name: niqe

Unlike torchmetrics, pyiqa declares whether each metric needs a reference (metric_mode), so a no-reference metric binds only x and is never asked for a reference image that does not exist. It also returns one value per image, so every batch is scored in a single call.

A metric's prior is not SR-Forge's judgement. NIQE scores an image by its distance from statistics fitted to roughly 125 natural photographs, and pyiqa's pipeline converts RGB to luma before measuring. Neither assumption holds for multispectral, hyperspectral, SAR or Bayer data — on SAR especially, speckle reads as maximally unnatural, so the score behaves as a speckle meter and a denoiser that destroys real texture will appear to improve it. Adapting rather than reimplementing is what keeps that judgement with the user who picks the metric. See §7 of docs/design/external-metric-adapters-rfc.md.

register_adapter(*, library: str, module: str, install: str)

Register an adapter class for a foreign metric library.

One registry drives both dispatch and the "you need to install X" message, so the set of libraries SR-Forge claims to support and the set it can actually adapt cannot drift apart.

Parameters:

Name Type Description Default
library str

Human-readable name used in messages.

required
module str

Top-level importable module name, used for availability.

required
install str

The command that installs it.

required

known_adapters() -> Iterable[type['ExternalMetric']]

Every registered adapter, in registration order.

library_for(obj: Any) -> str | None

Name the library whose adapter would handle obj, or None.

Asks the same detectors :func:adapt dispatches on, without building anything. Used to turn "that is not a Metric" into "that is a torchmetrics metric, here is the one call that fixes it".

adapt(obj: Any, **options: Any) -> Any

Return obj as a :class:Metric, wrapping it if it comes from a library SR-Forge knows how to adapt.

Anything that is already a Metric — and anything no adapter claims — is returned unchanged and identical, so this is safe to call on every object the resolver builds.

Parameters:

Name Type Description Default
obj Any

The object to adapt.

required
**options Any

Forwarded to the adapter's constructor (weight, name, aggregate). Passing options for an object that needs no adapting is an error, because they would be silently dropped.

{}

missing_library_report(module: str, target: str) -> str | None

Explain a failed _target import when it names an adaptable library.

Returns None when module is nothing SR-Forge knows how to adapt, so the caller can fall back to its ordinary error. Built from the same registry that dispatches, so the list of supported libraries cannot claim something the code does not actually do.