import json
import math
from collections.abc import MutableMapping
from functools import partial
from typing import Callable
from typing import Iterator
from typing import Optional
from typing import Type
from typing import TypeVar
from typing import Union
from shapely.geometry import shape
from shapely.geometry.base import BaseGeometry
from shapely.wkb import dumps as shapely_wkb_dumps
from shapely.wkb import loads as shapely_wkb_loads
from paquo._utils import cached_property
from paquo.classes import QuPathPathClass
from paquo.java import ROI
from paquo.java import GeometryTools
from paquo.java import GsonTools
from paquo.java import PathAnnotationObject
from paquo.java import PathDetectionObject
from paquo.java import PathObjects
from paquo.java import PathROIObject
from paquo.java import PathTileObject
from paquo.java import String
from paquo.java import WKBReader
from paquo.java import WKBWriter
__all__ = [
"fix_geojson_geometry",
"BaseGeometry",
"PathROIObjectType",
"QuPathPathAnnotationObject",
"QuPathPathDetectionObject",
"QuPathPathTileObject",
]
def _shapely_geometry_to_qupath_roi(geometry: BaseGeometry, image_plane=None) -> ROI:
"""convert a shapely geometry into a qupath ROI
uses well known binary WKB as intermediate representation
todo: expose image plane settings and provide pythonic interface to it
"""
wkb_bytes = shapely_wkb_dumps(geometry)
jts_geometry = WKBReader().read(wkb_bytes)
return GeometryTools.geometryToROI(jts_geometry, image_plane)
def _qupath_roi_to_shapely_geometry(roi) -> BaseGeometry:
"""convert a qupath ROI to a shapely geometry
uses well known binary WKB as intermediate representation
note: this loses the image plane information
"""
jts_geometry = GeometryTools.roiToGeometry(roi)
wkb_bytearray = WKBWriter(2).write(jts_geometry)
return shapely_wkb_loads(bytes(wkb_bytearray))
def fix_geojson_geometry(geometry: dict) -> dict:
"""try to fix a provided geojson geometry via buffering"""
s = shape(geometry)
if not s.is_valid:
# attempt to fix
s = s.buffer(0, 1)
if not s.is_valid:
s = s.buffer(0, 1)
if not s.is_valid:
raise ValueError("invalid geometry")
return s.__geo_interface__ # type: ignore
class _MeasurementList(MutableMapping):
def __init__(
self,
measurement_list,
*,
update_callback: Optional[Callable[[], None]] = None
):
self._measurement_list = measurement_list
self._update_callback = update_callback
def __setitem__(self, k: str, v: float) -> None:
if not isinstance(v, float):
raise TypeError(f"value must be float, got: {type(v).__name__}")
self._measurement_list.putMeasurement(k, v)
if self._update_callback:
self._update_callback()
def __delitem__(self, v: str) -> None:
if v not in self:
raise KeyError(v)
self._measurement_list.removeMeasurements(v)
if self._update_callback:
self._update_callback()
def __getitem__(self, k: Union[str, int]) -> float:
if not isinstance(k, (int, str)):
raise KeyError(f"unsupported key of type {type(k)}")
value = float(self._measurement_list.getMeasurementValue(k))
if math.isnan(value) and k not in self:
raise KeyError(k)
return value
def __contains__(self, item: object) -> bool:
if not isinstance(item, str):
return False
return bool(self._measurement_list.containsNamedMeasurement(item))
def __len__(self) -> int:
return int(self._measurement_list.size())
def __iter__(self) -> Iterator[str]:
return iter(map(str, self._measurement_list.getMeasurementNames()))
def clear(self) -> None:
self._measurement_list.clear()
if self._update_callback:
self._update_callback()
def __repr__(self):
return f"Measurements({repr(dict(self))})"
def __str__(self):
return str(dict(self))
def to_records(self):
return [{'name': name, 'value': value} for name, value in self.items()]
# noinspection PyTypeChecker
PathROIObjectType = TypeVar('PathROIObjectType', bound='_PathROIObject')
[docs]class _PathROIObject:
"""internal base class for PathObjects"""
# must be provided in subclass
java_class: Type[PathROIObject]
java_class_factory: Callable[..., PathROIObject]
def __init__(
self,
java_object: PathROIObject,
*,
update_callback: Optional[Callable[[PathROIObjectType], None]] = None
) -> None:
"""instantiate using classmethods: `from_shapely`, `from_geojson`"""
self.java_object = java_object
self._update_callback = update_callback
[docs] @classmethod
def from_shapely(cls: Type[PathROIObjectType],
roi: BaseGeometry,
path_class: Optional[QuPathPathClass] = None,
measurements: Optional[dict] = None,
*,
path_class_probability: float = math.nan) -> PathROIObjectType:
"""create a Path Object from a shapely shape
Parameters
----------
roi:
a shapely shape as the region of interest of the annotation
path_class:
a paquo QuPathPathClass to mark the annotation type
measurements:
dict holding static measurements for annotation object
path_class_probability:
keyword only argument defining the probability of the class
(default NaN)
"""
if not isinstance(roi, BaseGeometry):
raise TypeError("roi needs to be an instance of shapely.geometry.base.BaseGeometry")
qupath_roi = _shapely_geometry_to_qupath_roi(roi)
qupath_path_class = path_class.java_object if path_class is not None else None
# fixme: should create measurements here and pass instead of None
java_obj = cls.java_class_factory(qupath_roi, qupath_path_class, None)
if not math.isnan(path_class_probability):
java_obj.setPathClass(java_obj.getPathClass(), path_class_probability)
obj = cls(java_obj)
if measurements is not None:
obj.measurements.update(measurements)
return obj
[docs] @classmethod
def from_geojson(cls: Type[PathROIObjectType], geojson) -> PathROIObjectType:
"""create a new Path Object from geojson"""
gson = GsonTools.getInstance()
java_obj = gson.fromJson(String(json.dumps(geojson)), cls.java_class)
return cls(java_obj)
[docs] def to_geojson(self) -> dict:
"""convert the annotation object to geojson"""
gson = GsonTools.getInstance()
geojson = gson.toJson(self.java_object)
return dict(json.loads(str(geojson)))
@property
def path_class(self) -> Optional[QuPathPathClass]:
"""the annotation path class"""
pc = self.java_object.getPathClass()
if not pc:
return None
return QuPathPathClass.from_java(pc)
@property
def path_class_probability(self) -> float:
"""the annotation path class probability"""
return float(self.java_object.getClassProbability())
[docs] def update_path_class(self: PathROIObjectType, pc: Optional[QuPathPathClass], probability: float = math.nan) -> None:
"""updating the class or probability has to be done via this method"""
if not (pc is None or isinstance(pc, QuPathPathClass)):
raise TypeError("requires QuPathPathClass")
else:
pc = pc if pc is None else pc.java_object
self.java_object.setPathClass(pc, probability)
if self._update_callback:
self._update_callback(self)
@property
def locked(self) -> bool:
"""lock state of the annotation"""
return bool(self.java_object.isLocked())
@locked.setter
def locked(self: PathROIObjectType, value: bool) -> None:
self.java_object.setLocked(value)
if self._update_callback:
self._update_callback(self)
@property
def is_editable(self) -> bool:
"""can the annotation be edited in the qupath UI"""
return bool(self.java_object.isEditable())
@property
def level(self) -> int:
"""the annotation's level"""
return int(self.java_object.getLevel())
@property
def name(self) -> Optional[str]:
"""an optional name for the annotation"""
name = self.java_object.getName()
if name is None:
return None
return str(name)
@name.setter
def name(self: PathROIObjectType, name: Union[str, None]) -> None:
if name is not None:
name = String(name)
self.java_object.setName(name)
if self._update_callback:
self._update_callback(self)
@property
def parent(self: PathROIObjectType) -> Optional[PathROIObjectType]:
"""the annotation object's parent annotation object"""
parent = self.java_object.getParent()
if not parent:
return None
# fixme: Is this true? Or do we need to dynamically cast to the right subclass
return self.__class__(parent)
@property
def roi(self) -> BaseGeometry:
"""the roi as a shapely shape"""
roi = self.java_object.getROI()
return _qupath_roi_to_shapely_geometry(roi)
[docs] def update_roi(self: PathROIObjectType, geometry: BaseGeometry) -> None:
"""update the roi of the annotation"""
roi = _shapely_geometry_to_qupath_roi(geometry)
self.java_object.setROI(roi)
if self._update_callback:
self._update_callback(self)
@cached_property
def measurements(self):
if self._update_callback:
cb = partial(self._update_callback, self)
else:
cb = None
return _MeasurementList(
self.java_object.getMeasurementList(),
update_callback=cb,
)
def __repr__(self):
name = self.name
path_class = self.path_class
roi = self.roi
out = []
if name:
out.append(f'name="{name}"')
if path_class:
out.append(f'class="{path_class.name}"')
if roi:
out.append(f'roi={roi.geom_type}')
return f"{type(self).__name__}({' '.join(out)})"
def _repr_html_(self):
from paquo._repr import br
from paquo._repr import div
from paquo._repr import h4
from paquo._repr import p
from paquo._repr import rawhtml
from paquo._repr import repr_svg
from paquo._repr import span
obj_class_name = self.__class__.__name__
if obj_class_name.startswith('QuPath'):
obj_class_name = obj_class_name[6:]
name = self.name or "N/A"
path_class = self.path_class
path_class_name = path_class.name if path_class else "N/A"
roi = self.roi
if hasattr(roi, '_repr_svg_'):
roi_tag = span(rawhtml(repr_svg(roi)), style={"vertical-align": "text-top"})
else:
roi_tag = span(text=roi.wkt) # pragma: no cover
return div(
h4(text=f"{obj_class_name}:", style={"margin-top": "0"}),
p(
span(text="name: ", style={"font-weight": "bold"}),
span(text=name),
br(),
span(text="path_class: ", style={"font-weight": "bold"}),
span(text=path_class_name),
br(),
span(text="roi_type: ", style={"font-weight": "bold"}),
span(text=roi.geom_type),
br(),
span(text="roi: ", style={"font-weight": "bold"}),
roi_tag,
style={"margin": "0.5em"},
),
)
[docs]class QuPathPathAnnotationObject(_PathROIObject):
java_class = PathAnnotationObject
java_class_factory = PathObjects.createAnnotationObject
@property
def description(self) -> Optional[str]:
"""an optional description for the annotation"""
desc = self.java_object.getDescription()
return str(desc) if desc is not None else None
@description.setter
def description(self, value: str):
if not isinstance(value, str):
raise TypeError("requires a str")
self.java_object.setDescription(String(value))
[docs]class QuPathPathDetectionObject(_PathROIObject):
java_class = PathDetectionObject
java_class_factory = PathObjects.createDetectionObject
[docs]class QuPathPathTileObject(QuPathPathDetectionObject):
java_class = PathTileObject
java_class_factory = PathObjects.createTileObject