from __future__ import annotations
import sys
import warnings
from math import floor
from typing import Any
from typing import Callable
from typing import Optional
from typing import Tuple
from typing import Type
from typing import TypeVar
from pydantic import NonNegativeFloat
from pydantic import PositiveFloat
from pydantic import StrictInt
from pydantic import conint
from pydantic.dataclasses import dataclass
from shapely.affinity import scale as shapely_scale
from shapely.geometry.base import BaseGeometry
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
__all__ = [
"Point",
"Size",
"IntPoint",
"IntSize",
"MPP",
"Bounds",
"IntBounds",
"Geometry",
"ensure_type",
"match_mpp",
]
def __getattr__(name):
if name == "FuzzyMPP":
warnings.warn(
"MPP now supports setting atol and rtol for fuzzy matching. "
"Please import `MPP` instead of `FuzzyMPP`",
DeprecationWarning,
)
return MPP
else:
raise AttributeError(name)
# NOTE:
# maybe we should use decimals instead of floats here, to be really correct.
# Why you ask? There's some strange edge cases that we should further investigate,
# where downsample levels in svs files are slightly off due to integer sizes
# of the pyramidal layers.
# Anyways, this is just a reminder in case we run into problems in the future.
[docs]@dataclass(frozen=True)
class MPP:
"""micrometer per pixel scaling common in pathological images"""
x: PositiveFloat
y: PositiveFloat
# support approximate matching
rtol: NonNegativeFloat = 0.0 # relative tolerance
atol: NonNegativeFloat = 0.0 # absolute tolerance
def scale(self, downsample: float) -> MPP:
return MPP(x=self.x * downsample, y=self.y * downsample)
@classmethod
def from_float(cls, xy: float) -> MPP:
return MPP(x=xy, y=xy)
@classmethod
def from_tuple(cls, xy: Tuple[float, float]) -> MPP:
x, y = xy
return MPP(x=x, y=y)
def as_tuple(self) -> Tuple[float, float]:
return self.x, self.y
def with_tolerance(self, rtol: float, atol: float) -> MPP:
return MPP(self.x, self.y, rtol=rtol, atol=atol)
@property
def tolerance(self) -> Tuple[float, float]:
tol_x = self.atol + self.rtol * abs(self.x)
tol_y = self.atol + self.rtol * abs(self.y)
return tol_x, tol_y
@property
def is_exact(self) -> bool:
return self.rtol == 0 and self.atol == 0
def _get_xy_tolerance(self, other: Any) -> Tuple[float, float, float, float]:
if isinstance(other, MPP):
atol = max(self.atol, other.atol)
rtol_x = max(self.rtol, other.rtol * other.x / self.x)
rtol_y = max(self.rtol, other.rtol * other.y / self.y)
tol_x = atol + rtol_x * abs(self.x)
tol_y = atol + rtol_y * abs(self.y)
return other.x, other.y, tol_x, tol_y
elif (
isinstance(other, (tuple, list))
and len(other) == 2
and isinstance(other[0], (float, int))
and isinstance(other[1], (float, int))
):
tol_x, tol_y = self.tolerance
return other[0], other[1], tol_x, tol_y
else:
raise TypeError(f"unsupported object of type: {type(other).__name__!r}")
def __eq__(self, other: Any) -> bool:
try:
x, y, tol_x, tol_y = self._get_xy_tolerance(other)
except TypeError:
return False
dx = abs(self.x - x)
dy = abs(self.y - y)
return dx <= tol_x and dy <= tol_y
def __lt__(self, other: Any) -> bool:
x, y, tol_x, tol_y = self._get_xy_tolerance(other)
return self.x < (x - tol_x) and self.y < (y - tol_y)
def __gt__(self, other: Any) -> bool:
x, y, tol_x, tol_y = self._get_xy_tolerance(other)
return self.x > (x + tol_x) and self.y > (y + tol_y)
def __ne__(self, other):
return not self.__eq__(other)
def __le__(self, other):
return self.__lt__(other) or self.__eq__(other)
def __ge__(self, other):
return self.__gt__(other) or self.__eq__(other)
[docs]def match_mpp(
origin: MPP,
*targets: MPP,
remove_tolerance: bool = True,
raise_no_match: bool = False,
) -> MPP:
"""returns an MPP from potential matches or the original"""
_targets = sorted(
targets,
key=lambda target: (origin.x - target.x) ** 2 + (origin.y - target.y) ** 2,
)
for t in _targets:
if origin == t:
break
else:
if raise_no_match:
raise ValueError("could not match to targets")
t = origin
if remove_tolerance:
return t.with_tolerance(rtol=0, atol=0)
else:
return t
[docs]@dataclass(frozen=True)
class Point:
"""a 2D point that can optionally come with a MPP for scaling"""
x: float
y: float
mpp: Optional[MPP] = None
def round(self, method: Callable[[float], int] = round) -> IntPoint:
return IntPoint(method(self.x), method(self.y), self.mpp)
[docs] def scale(self, mpp: MPP) -> Point:
"""scale a point to a new mpp"""
current = self.mpp
if current is None:
raise ValueError(f"Can't scale: {self!r} has no mpp")
return Point(
x=self.x * current.x / mpp.x,
y=self.y * current.y / mpp.y,
mpp=mpp,
)
@classmethod
def from_tuple(cls: Type[Self], xy: Tuple[float, float], *, mpp: MPP) -> Self:
x, y = xy
return cls(x=x, y=y, mpp=mpp)
def as_tuple(self) -> Tuple[float, float]:
return self.x, self.y
# noinspection PyDataclass
[docs]@dataclass(frozen=True)
class IntPoint(Point):
"""an integer 2D point"""
x: StrictInt
y: StrictInt
def round(self, method=None) -> IntPoint:
return self
def as_tuple(self) -> Tuple[int, int]:
return self.x, self.y
[docs]@dataclass(frozen=True)
class Size:
"""a general 2D size that can optionally come with a MPP for scaling"""
x: PositiveFloat
y: PositiveFloat
mpp: Optional[MPP] = None
def round(self) -> IntSize:
return IntSize(round(self.x), round(self.y), self.mpp)
[docs] def scale(self, mpp: MPP) -> Size:
"""scale a size to a new mpp"""
current = self.mpp
if current is None:
raise ValueError(f"Can't scale: {self!r} has no mpp")
return Size(
x=self.x * current.x / mpp.x,
y=self.y * current.y / mpp.y,
mpp=mpp,
)
@property
def width(self):
return self.x
@property
def height(self):
return self.y
@classmethod
def from_tuple(
cls: Type[Self], xy: Tuple[float, float], *, mpp: Optional[MPP]
) -> Self:
x, y = xy
return cls(x=x, y=y, mpp=mpp)
def as_tuple(self) -> Tuple[float, float]:
return self.x, self.y
# noinspection PyDataclass
[docs]@dataclass(frozen=True)
class IntSize(Size):
"""an integer 2D size"""
x: conint(gt=0, strict=True) # type: ignore
y: conint(gt=0, strict=True) # type: ignore
def round(self) -> IntSize:
return self
def as_tuple(self) -> Tuple[int, int]:
return int(self.x), int(self.y)
[docs]@dataclass(frozen=True)
class Bounds:
"""a class for rectangular bounds
Notes
-----
p0 ---- +
| |
| |
+ ---- p1
p0: Point(x0, y0)
p1: Point(x1, y1)
with: p0.x < p1.x and p0.y < p1.y
"""
x0: NonNegativeFloat
y0: NonNegativeFloat
x1: NonNegativeFloat
y1: NonNegativeFloat
mpp: Optional[MPP] = None
def __post_init_post_parse__(self):
if not (self.x0 < self.x1 and self.y0 < self.y1):
raise ValueError(
f"Invalid bounds, must: {self.x0} < {self.x1} and {self.y0} < {self.y1}"
)
@property
def x0y0(self) -> Point:
return Point(self.x0, self.y0, mpp=self.mpp)
@property
def x1y1(self) -> Point:
return Point(self.x0, self.y0, mpp=self.mpp)
@property
def width(self) -> float:
return self.x1 - self.x0
@property
def height(self) -> float:
return self.y1 - self.y0
@property
def size(self) -> Size:
return Size(x=self.width, y=self.height, mpp=self.mpp)
def round(self) -> IntBounds:
return IntBounds(
round(self.x0),
round(self.y0),
round(self.x1),
round(self.y1),
self.mpp,
)
def floor(self) -> IntBounds:
return IntBounds(
floor(self.x0),
floor(self.y0),
floor(self.x1),
floor(self.y1),
self.mpp,
)
[docs] def scale(self, mpp: MPP) -> Bounds:
"""scale bounds to a new mpp"""
current = self.mpp
if current is None:
raise ValueError(f"Can't scale: {self!r} has no mpp")
return Bounds(
x0=self.x0 * current.x / mpp.x,
y0=self.y0 * current.y / mpp.y,
x1=self.x1 * current.x / mpp.x,
y1=self.y1 * current.y / mpp.y,
mpp=mpp,
)
@classmethod
def from_tuple(
cls: Type[Self], x0y0x1y1: Tuple[float, float, float, float], *, mpp: MPP
) -> Self:
return cls(*x0y0x1y1, mpp=mpp)
def as_tuple(self) -> Tuple[float, float, float, float]:
return self.x0, self.y0, self.x1, self.y1
def as_record(self) -> dict[str, float]:
if self.mpp is None:
raise ValueError("won't serialize without MPP")
return {
"x0": self.x0,
"y0": self.y0,
"x1": self.x1,
"y1": self.y1,
"mpp_x": self.mpp.x,
"mpp_y": self.mpp.y,
}
@classmethod
def from_record(cls, record) -> Bounds:
return Bounds(
record["x0"],
record["y0"],
record["x1"],
record["y1"],
mpp=MPP(
record["mpp_x"],
record["mpp_y"],
),
)
# noinspection PyDataclass
[docs]@dataclass(frozen=True)
class IntBounds(Bounds):
x0: conint(ge=0, strict=True) # type: ignore
y0: conint(ge=0, strict=True) # type: ignore
x1: conint(ge=0, strict=True) # type: ignore
y1: conint(ge=0, strict=True) # type: ignore
@property
def x0y0(self) -> IntPoint:
return IntPoint(self.x0, self.y0, mpp=self.mpp)
@property
def x1y1(self) -> IntPoint:
return IntPoint(self.x0, self.y0, mpp=self.mpp)
@property
def width(self) -> int:
return self.x1 - self.x0
@property
def height(self) -> int:
return self.y1 - self.y0
@property
def size(self) -> IntSize:
return IntSize(x=self.width, y=self.height, mpp=self.mpp)
def round(self) -> IntBounds:
return self
def floor(self) -> IntBounds:
return self
def as_tuple(self) -> Tuple[int, int, int, int]:
return self.x0, self.y0, self.x1, self.y1
[docs]@dataclass(config=type("", (), {"arbitrary_types_allowed": True}))
class Geometry:
"""
A general class for dealing with BaseGeometries at various MPPs.
"""
geometry: BaseGeometry
mpp: Optional[MPP] = None
[docs] def scale(self: Self, mpp: MPP) -> Self:
"""scale geometry to a new mpp"""
current = self.mpp
if current is None:
raise ValueError(f"Can't scale: {self!r} has no mpp")
factor_x = current.x / mpp.x
factor_y = current.y / mpp.y
return self.from_geometry(
geometry=shapely_scale(
self.geometry, xfact=factor_x, yfact=factor_y, origin=(0, 0)
),
mpp=mpp,
)
@property
def is_valid(self):
return self.geometry.is_valid
def fix_geometry(self, buffer_size: Optional[Tuple[int, int]] = None):
if buffer_size is None:
buffer_size = (0, 0)
if not self.is_valid:
self.geometry = self.geometry.buffer(buffer_size[0])
if not self.is_valid:
self.geometry = self.geometry.buffer(buffer_size[0])
@classmethod
def from_geometry(cls: Type[Self], geometry: BaseGeometry, *, mpp: MPP) -> Self:
return cls(geometry=geometry, mpp=mpp)
T = TypeVar("T")
def ensure_type(obj: Any, cls: type[T]) -> T:
if isinstance(obj, cls):
return obj
elif isinstance(obj, dict):
return cls(**obj)
elif isinstance(obj, tuple):
return cls(*obj)
else:
raise ValueError(f"could not cast {obj!r} to {cls.__name__}")