diff --git a/monai/metrics/__init__.py b/monai/metrics/__init__.py index 2265dd3a3f..9f75211cfe 100644 --- a/monai/metrics/__init__.py +++ b/monai/metrics/__init__.py @@ -19,6 +19,7 @@ from .embedding_collapse import EmbeddingCollapseMetric, compute_embedding_collapse from .f_beta_score import FBetaScore from .fid import FIDMetric, compute_frechet_distance +from .frd import FrechetRadiomicsDistance, get_frd_score from .froc import compute_fp_tp_probs, compute_fp_tp_probs_nd, compute_froc_curve_data, compute_froc_score from .generalized_dice import GeneralizedDiceScore, compute_generalized_dice from .hausdorff_distance import HausdorffDistanceMetric, compute_hausdorff_distance diff --git a/monai/metrics/frd.py b/monai/metrics/frd.py new file mode 100644 index 0000000000..3773b79516 --- /dev/null +++ b/monai/metrics/frd.py @@ -0,0 +1,88 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import torch + +from monai.metrics.fid import get_fid_score +from monai.metrics.metric import Metric + +__all__ = ["FrechetRadiomicsDistance", "get_frd_score"] + + +class FrechetRadiomicsDistance(Metric): + """ + Fréchet Radiomics Distance (FRD). Computes the Fréchet distance between two + distributions of radiomic feature vectors, in the same way as the Fréchet + Inception Distance (FID) but applied to radiomics-based features instead of + deep-network embeddings. + + Unlike FID, FRD uses interpretable, clinically relevant radiomic features + (e.g. extracted via PyRadiomics), which makes it directly applicable to both + 2D and 3D images and allows optional conditioning by anatomical masks — + all handled during upstream feature extraction, not by this class. See + Konz et al. "Fréchet Radiomic Distance (FRD): A Versatile Metric for + Comparing Medical Imaging Datasets." https://arxiv.org/abs/2412.01496 + + This class accepts pre-extracted radiomic feature tensors of shape (N, F) + and applies the same Fréchet distance formula as FID to the empirical means + and covariances of those features. + """ + + def __call__(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor: + """Compute FRD between two sets of pre-extracted radiomic feature vectors. + + Args: + y_pred: Radiomic feature vectors for the first distribution (e.g. from + generated or reconstructed images), shape (N, F) with N >= 2. + y: Radiomic feature vectors for the second distribution (e.g. from real + images), shape (N, F) with N >= 2. + + Returns: + Scalar tensor containing the FRD value. + + Raises: + ValueError: When either tensor is not exactly 2-dimensional or has + fewer than 2 samples. + """ + return get_frd_score(y_pred, y) + + +def get_frd_score(y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor: + """Computes the FRD score from two batches of radiomic feature vectors. + + The implementation reuses the same Fréchet distance as FID; only the + semantics (radiomic features vs. deep network features) differ. + + Args: + y_pred: Feature vectors for the first distribution, shape (N, F) with N >= 2. + y: Feature vectors for the second distribution, shape (N, F) with N >= 2. + + Returns: + Scalar tensor containing the Fréchet Radiomics Distance. + + Raises: + ValueError: When either tensor is not exactly 2-dimensional (i.e. not + shape (N, F)), or when either tensor has fewer than 2 samples + (required for covariance estimation). + """ + for name, t in (("y_pred", y_pred), ("y", y)): + if t.ndimension() != 2: + raise ValueError( + f"{name} must be a 2-D tensor of shape (N, F) — got shape {tuple(t.shape)}. " + "Pass pre-extracted radiomic feature vectors, not raw images." + ) + if t.size(0) < 2: + raise ValueError( + f"{name} must contain at least 2 samples for covariance estimation — got {t.size(0)}." + ) + return get_fid_score(y_pred, y) diff --git a/tests/metrics/test_compute_frd_metric.py b/tests/metrics/test_compute_frd_metric.py new file mode 100644 index 0000000000..eb5ae361ef --- /dev/null +++ b/tests/metrics/test_compute_frd_metric.py @@ -0,0 +1,63 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +import torch + +from monai.metrics import FIDMetric, FrechetRadiomicsDistance +from monai.utils import optional_import + +_, has_scipy = optional_import("scipy") + + +@unittest.skipUnless(has_scipy, "Requires scipy") +class TestFrechetRadiomicsDistance(unittest.TestCase): + def test_results(self): + x = torch.Tensor([[1, 2], [1, 2], [1, 2]]) + y = torch.Tensor([[2, 2], [1, 2], [1, 2]]) + results = FrechetRadiomicsDistance()(x, y) + np.testing.assert_allclose(results.cpu().numpy(), 0.4444, atol=1e-4) + + def test_frd_matches_fid_for_same_features(self): + """FRD uses the same Fréchet formula as FID; same inputs give same value.""" + y_pred = torch.Tensor([[1.0, 2.0], [1.0, 2.0], [1.0, 2.0]]) + y = torch.Tensor([[2.0, 2.0], [1.0, 2.0], [1.0, 2.0]]) + frd_score = FrechetRadiomicsDistance()(y_pred, y) + fid_score = FIDMetric()(y_pred, y) + np.testing.assert_allclose(frd_score.cpu().numpy(), fid_score.cpu().numpy(), atol=1e-6) + + def test_rejects_high_dimensional_input(self): + """Raises ValueError when inputs have more than 2 dimensions. """ + high_dim = torch.ones([3, 3, 144, 144]) + with self.assertRaises(ValueError): + FrechetRadiomicsDistance()(high_dim, high_dim) + + def test_rejects_1d_input(self): + """Raises ValueError when inputs are 1-D (single feature vector, not a batch).""" + with self.assertRaises(ValueError): + FrechetRadiomicsDistance()(torch.ones([10]), torch.ones([10])) + + def test_rejects_too_few_samples(self): + """Raises ValueError when either input has fewer than 2 samples.""" + valid = torch.Tensor([[1.0, 2.0], [3.0, 4.0]]) + single = torch.Tensor([[1.0, 2.0]]) + with self.assertRaises(ValueError): + FrechetRadiomicsDistance()(single, valid) + with self.assertRaises(ValueError): + FrechetRadiomicsDistance()(valid, single) + + +if __name__ == "__main__": + unittest.main()