ez_wsi_dicomweb/pixel_spacing.py (125 lines of code) (raw):

# Copyright 2023 Google LLC # # 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. # ============================================================================== """A class that represents a WSI slide's pixel spacing.""" from __future__ import annotations import dataclasses import math from typing import Any, List, Optional import dataclasses_json from ez_wsi_dicomweb import ez_wsi_errors # The tolerance (percentage difference) for difference between pixel spacings. # EZ WSI expects practically square pixels. PIXEL_SPACING_DIFF_TOLERANCE = 0.05 # Magnification & Pixel Spacing are usually linearly related via a constant # scaling factor of 0.01. he imaging scale factor can be directly derived via a # single picture of a microscope calibration slide captured at a known # magnification. _SCALE_FACTOR = 0.01 @dataclasses_json.dataclass_json @dataclasses.dataclass(frozen=True) class PixelSpacing: """Represents the pixel spacing of a DICOM/WSI slide.""" # Do not access _column_spacing_mm directly. # Instead use column_spacing_mm property, property raises if accessing # undefined (None) _column_spacing_mm. _column_spacing_mm: Optional[float] = dataclasses.field( metadata=dataclasses_json.config(field_name='column_spacing_mm') ) # Do not access _row_spacing_mm directly. # Instead use row_spacing_mm property, property raises if accessing # undefined (None) _row_spacing_mm. _row_spacing_mm: Optional[float] = dataclasses.field( metadata=dataclasses_json.config(field_name='row_spacing_mm') ) mag_scaling_factor: float = _SCALE_FACTOR spacing_diff_tolerance: float = PIXEL_SPACING_DIFF_TOLERANCE @property def column_spacing_mm(self) -> float: """Returns column pixel spacing (mm/pixel). Raises: ez_wsi_errors.UndefinedPixelSpacingError: Pixel spacing not defined. """ if self._column_spacing_mm is None: raise ez_wsi_errors.UndefinedPixelSpacingError() return self._column_spacing_mm @property def row_spacing_mm(self) -> float: """Returns row pixel spacing (mm/pixel). Raises: ez_wsi_errors.UndefinedPixelSpacingError: Pixel spacing not defined. """ if self._row_spacing_mm is None: raise ez_wsi_errors.UndefinedPixelSpacingError() return self._row_spacing_mm @property def is_defined(self) -> bool: return ( self._column_spacing_mm is not None and self._row_spacing_mm is not None ) @classmethod def FromDicomPixelSpacingTag( cls, pixel_spacing_tag: List[float] ) -> PixelSpacing: # DICOM Pixel spacing tag is row, column ordered. # https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_10.7.html#sect_10.7.1.3 row_spacing, column_spacing = pixel_spacing_tag return PixelSpacing(column_spacing, row_spacing) @classmethod def FromString( cls, pixel_spacing: str, scaling_factor: float = _SCALE_FACTOR, spacing_diff_tolerance: float = PIXEL_SPACING_DIFF_TOLERANCE, ) -> PixelSpacing: """Returns a PixelSpacing object. Given a string representing a pixel spacing measured in mm/px creates a PixelSpacing object. Args: pixel_spacing: The string value of the pixel spacing, e.g. a string representing a double measured in mm/px. scaling_factor: a number representing the scaling factor between a pixel spacing and a zoom level or magnification. spacing_diff_tolerance: The tolerance (percentage difference) for difference between pixel spacings. Returns: A new PixelSpacing object. """ return PixelSpacing.FromDouble( pixel_spacing=float(pixel_spacing), scaling_factor=scaling_factor, spacing_diff_tolerance=spacing_diff_tolerance, ) @classmethod def FromMagnificationString( cls, magnification: str, scaling_factor: float = _SCALE_FACTOR, spacing_diff_tolerance: float = PIXEL_SPACING_DIFF_TOLERANCE, ) -> PixelSpacing: """Returns a PixelSpacing object. Given a string representing a Magnificiation level creates a PixelSpacing object. Args: magnification: The string value of the magnification, e.g. a string representing a zoom level: 5, 5X, 10, 10X, 25X. scaling_factor: a number representing the scaling factor between a pixel spacing and a zoom level or magnification. spacing_diff_tolerance: The tolerance (percentage difference) for difference between pixel spacings. Returns: A new PixelSpacing object. Raises: InvalidMagnificationStringError if the magnfication is not a float or <=0. """ magnification = magnification.lower().rstrip('x') if not magnification.replace('.', '').isnumeric(): raise ez_wsi_errors.InvalidMagnificationStringError( f'Provided magnification {magnification} is not a float.' ) if float(magnification) <= 0: raise ez_wsi_errors.InvalidMagnificationStringError( f'Provided magnification {magnification} is <= 0. Magnifications must' ' be positive.' ) pixel_spacing = scaling_factor / float(magnification) return PixelSpacing.FromDouble( pixel_spacing=pixel_spacing, scaling_factor=scaling_factor, spacing_diff_tolerance=spacing_diff_tolerance, ) @classmethod def FromDouble( cls, pixel_spacing: float, scaling_factor: float = _SCALE_FACTOR, spacing_diff_tolerance: float = PIXEL_SPACING_DIFF_TOLERANCE, ) -> PixelSpacing: """Returns a PixelSpacing object. Given a pixel spacing float measured in mm/px creates a PixelSpacing object. Args: pixel_spacing: The avg value of the row & column pixel spacing in mm/px. scaling_factor: a number representing the scaling factor between a pixel spacing and a zoom level or magnification. spacing_diff_tolerance: The tolerance (percentage difference) for difference between pixel spacings. Returns: A new PixelSpacing object. """ return PixelSpacing( _column_spacing_mm=pixel_spacing, _row_spacing_mm=pixel_spacing, mag_scaling_factor=scaling_factor, spacing_diff_tolerance=spacing_diff_tolerance, ) def __hash__(self): return hash(dataclasses.astuple(self)) def __eq__(self, other: Any) -> bool: if not isinstance(other, PixelSpacing): return False try: return math.isclose( self.row_spacing_mm, other.row_spacing_mm, rel_tol=self.spacing_diff_tolerance, ) and math.isclose( self.column_spacing_mm, other.column_spacing_mm, rel_tol=self.spacing_diff_tolerance, ) except ez_wsi_errors.UndefinedPixelSpacingError: return self.is_defined == other.is_defined @property def pixel_spacing_mm(self) -> float: """Returns smallest column/row pixel spacing (mm/pixel). Raises: ez_wsi_errors.UndefinedPixelSpacingError: Pixel spacing not defined. """ return min(self.row_spacing_mm, self.column_spacing_mm) @property def as_magnification_string(self) -> str: """Returns a string of the zoom level corresponding to the pixel spacing. Raises: ez_wsi_errors.UndefinedPixelSpacingError: Pixel spacing not defined. """ magnification = self.mag_scaling_factor / self.pixel_spacing_mm if magnification.is_integer(): return f'{int(magnification)}X' else: return f'{magnification}X' def UndefinedPixelSpacing( mag_scaling_factor: float = _SCALE_FACTOR, spacing_diff_tolerance: float = PIXEL_SPACING_DIFF_TOLERANCE, ) -> PixelSpacing: return PixelSpacing( _column_spacing_mm=None, _row_spacing_mm=None, mag_scaling_factor=mag_scaling_factor, spacing_diff_tolerance=spacing_diff_tolerance, )