from abc import ABC, abstractmethod from typing import Callable, Sequence, Union import numpy as np def flatten_non_category_dims( xs: Union[np.ndarray, Sequence[np.ndarray]], category_dim: int = None ): """Flattens all non-category dimensions into a single dimension. Args: xs (ndarrays): Sequence of ndarrays with the same category dimension. category_dim: The dimension/axis corresponding to different categories. i.e. `C`. If `None`, behaves like `np.flatten(x)`. Returns: ndarray: Shape (C, -1) if `category_dim` specified else shape (-1,) """ single_item = isinstance(xs, np.ndarray) if single_item: xs = [xs] if category_dim is not None: dims = (xs[0].shape[category_dim], -1) xs = (np.moveaxis(x, category_dim, 0).reshape(dims) for x in xs) else: xs = (x.flatten() for x in xs) if single_item: return list(xs)[0] else: return xs class Metric(Callable, ABC): """Interface for new metrics. A metric should be implemented as a callable with explicitly defined arguments. In other words, metrics should not have `**kwargs` or `**args` options in the `__call__` method. While not explicitly constrained to the return type, metrics typically return float value(s). The number of values returned corresponds to the number of categories. * metrics should have different name() for different functionality. * `category_dim` duck type if metric can process multiple categories at once. To compute metrics: .. code-block:: python metric = Metric() results = metric(...) """ def __init__(self, units: str = ""): self.units = units def name(self): return type(self).__name__ def display_name(self): """Name to use for pretty printing and display purposes.""" name = self.name() return "{} {}".format(name, self.units) if self.units else name @abstractmethod def __call__(self, *args, **kwargs): pass class HounsfieldUnits(Metric): FULL_NAME = "Hounsfield Unit" def __init__(self, units="hu"): super().__init__(units) def __call__(self, mask, x, category_dim: int = None): mask = mask.astype(np.bool) if category_dim is None: return np.mean(x[mask]) assert category_dim == -1 num_classes = mask.shape[-1] return np.array([np.mean(x[mask[..., c]]) for c in range(num_classes)]) def name(self): return self.FULL_NAME class CrossSectionalArea(Metric): def __call__(self, mask, spacing=None, category_dim: int = None): pixel_area = np.prod(spacing) if spacing else 1 mask = mask.astype(np.bool) mask = flatten_non_category_dims(mask, category_dim) return pixel_area * np.count_nonzero(mask, -1) / 100.0 def name(self): if self.units: return "Cross-sectional Area ({})".format(self.units) else: return "Cross-sectional Area" def manifest_to_map(manifest, model_type): """Converts a manifest to a map of metric name to metric instance. Args: manifest (dict): A dictionary of metric name to metric instance. Returns: dict: A dictionary of metric name to metric instance. """ # TODO: hacky. Update this figure_text_key = {} for manifest_dict in manifest: try: key = manifest_dict["Level"] except BaseException: key = ".".join((manifest_dict["File"].split("/")[-1]).split(".")[:-1]) muscle_hu = f"{manifest_dict['Hounsfield Unit (muscle)']:.2f}" muscle_area = f"{manifest_dict['Cross-sectional Area (cm^2) (muscle)']:.2f}" vat_hu = f"{manifest_dict['Hounsfield Unit (vat)']:.2f}" vat_area = f"{manifest_dict['Cross-sectional Area (cm^2) (vat)']:.2f}" sat_hu = f"{manifest_dict['Hounsfield Unit (sat)']:.2f}" sat_area = f"{manifest_dict['Cross-sectional Area (cm^2) (sat)']:.2f}" imat_hu = f"{manifest_dict['Hounsfield Unit (imat)']:.2f}" imat_area = f"{manifest_dict['Cross-sectional Area (cm^2) (imat)']:.2f}" if model_type.model_name == "abCT_v0.0.1": figure_text_key[key] = [ muscle_hu, muscle_area, imat_hu, imat_area, vat_hu, vat_area, sat_hu, sat_area, ] else: figure_text_key[key] = [ muscle_hu, muscle_area, vat_hu, vat_area, sat_hu, sat_area, imat_hu, imat_area, ] return figure_text_key