# /*##########################################################################
#
# Copyright (c) 2018-2024 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""This module provides Arc ROI item for the :class:`~silx.gui.plot.PlotWidget`.
"""
__authors__ = ["V. Valls"]
__license__ = "MIT"
__date__ = "28/06/2018"
import logging
import numpy
import enum
from typing import Tuple
from ... import utils
from .. import items
from ...colors import rgba
from ....utils.proxy import docstring
from ._roi_base import HandleBasedROI
from ._roi_base import InteractionModeMixIn
from ._roi_base import RoiInteractionMode
logger = logging.getLogger(__name__)
class _ArcGeometry:
"""
Non-mutable object to store the geometry of the arc ROI.
The aim is is to switch between consistent state without dealing with
intermediate values.
"""
def __init__(
self,
center,
startPoint,
endPoint,
radius,
weight,
startAngle,
endAngle,
closed=False,
):
"""Constructor for a consistent arc geometry.
There is also specific class method to create different kind of arc
geometry.
"""
self.center = center
self.startPoint = startPoint
self.endPoint = endPoint
self.radius = radius
self.weight = weight
self.startAngle = startAngle
self.endAngle = endAngle
self._closed = closed
@classmethod
def createEmpty(cls):
"""Create an arc geometry from an empty shape"""
zero = numpy.array([0, 0])
return cls(zero, zero.copy(), zero.copy(), 0, 0, 0, 0)
@classmethod
def createRect(cls, startPoint, endPoint, weight):
"""Create an arc geometry from a definition of a rectangle"""
return cls(None, startPoint, endPoint, None, weight, None, None, False)
@classmethod
def createCircle(
cls, center, startPoint, endPoint, radius, weight, startAngle, endAngle
):
"""Create an arc geometry from a definition of a circle"""
return cls(
center, startPoint, endPoint, radius, weight, startAngle, endAngle, True
)
def withWeight(self, weight):
"""Return a new geometry based on this object, with a specific weight"""
return _ArcGeometry(
self.center,
self.startPoint,
self.endPoint,
self.radius,
weight,
self.startAngle,
self.endAngle,
self._closed,
)
def withRadius(self, radius):
"""Return a new geometry based on this object, with a specific radius.
The weight and the center is conserved.
"""
startPoint = (
self.center + (self.startPoint - self.center) / self.radius * radius
)
endPoint = self.center + (self.endPoint - self.center) / self.radius * radius
return _ArcGeometry(
self.center,
startPoint,
endPoint,
radius,
self.weight,
self.startAngle,
self.endAngle,
self._closed,
)
def withStartAngle(self, startAngle):
"""Return a new geometry based on this object, with a specific start angle"""
vector = numpy.array([numpy.cos(startAngle), numpy.sin(startAngle)])
startPoint = self.center + vector * self.radius
# Never add more than 180 to maintain coherency
deltaAngle = startAngle - self.startAngle
if deltaAngle > numpy.pi:
deltaAngle -= numpy.pi * 2
elif deltaAngle < -numpy.pi:
deltaAngle += numpy.pi * 2
startAngle = self.startAngle + deltaAngle
return _ArcGeometry(
self.center,
startPoint,
self.endPoint,
self.radius,
self.weight,
startAngle,
self.endAngle,
self._closed,
)
def withEndAngle(self, endAngle):
"""Return a new geometry based on this object, with a specific end angle"""
vector = numpy.array([numpy.cos(endAngle), numpy.sin(endAngle)])
endPoint = self.center + vector * self.radius
# Never add more than 180 to maintain coherency
deltaAngle = endAngle - self.endAngle
if deltaAngle > numpy.pi:
deltaAngle -= numpy.pi * 2
elif deltaAngle < -numpy.pi:
deltaAngle += numpy.pi * 2
endAngle = self.endAngle + deltaAngle
return _ArcGeometry(
self.center,
self.startPoint,
endPoint,
self.radius,
self.weight,
self.startAngle,
endAngle,
self._closed,
)
def translated(self, dx, dy):
"""Return the translated geometry by dx, dy"""
delta = numpy.array([dx, dy])
center = None if self.center is None else self.center + delta
startPoint = None if self.startPoint is None else self.startPoint + delta
endPoint = None if self.endPoint is None else self.endPoint + delta
return _ArcGeometry(
center,
startPoint,
endPoint,
self.radius,
self.weight,
self.startAngle,
self.endAngle,
self._closed,
)
def getKind(self):
"""Returns the kind of shape defined"""
if self.center is None:
return "rect"
elif numpy.isnan(self.startAngle):
return "point"
elif self.isClosed():
if self.weight <= 0 or self.weight * 0.5 >= self.radius:
return "circle"
else:
return "donut"
else:
if self.weight * 0.5 < self.radius:
return "arc"
else:
return "camembert"
def isClosed(self):
"""Returns True if the geometry is a circle like"""
if self._closed is not None:
return self._closed
delta = numpy.abs(self.endAngle - self.startAngle)
self._closed = numpy.isclose(delta, numpy.pi * 2)
return self._closed
def __str__(self):
return str(
(
self.center,
self.startPoint,
self.endPoint,
self.radius,
self.weight,
self.startAngle,
self.endAngle,
self._closed,
)
)
[docs]
class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn):
"""A ROI identifying an arc of a circle with a width.
This ROI provides
- 3 handle to control the curvature
- 1 handle to control the weight
- 1 anchor to translate the shape.
"""
ICON = "add-shape-arc"
NAME = "arc ROI"
SHORT_NAME = "arc"
"""Metadata for this kind of ROI"""
_plotShape = "line"
"""Plot shape which is used for the first interaction"""
ThreePointMode = RoiInteractionMode(
"3 points", "Provides 3 points to define the main radius circle"
)
PolarMode = RoiInteractionMode(
"Polar", "Provides anchors to edit the ROI in polar coords"
)
# FIXME: MoveMode was designed cause there is too much anchors
# FIXME: It would be good replace it by a dnd on the shape
MoveMode = RoiInteractionMode(
"Translation", "Provides anchors to only move the ROI"
)
[docs]
class Role(enum.Enum):
"""Identify a set of roles which can be used for now to reach some positions"""
START = 0
"""Location of the anchor at the start of the arc"""
STOP = 1
"""Location of the anchor at the stop of the arc"""
MIDDLE = 2
"""Location of the anchor at the middle of the arc"""
CENTER = 3
"""Location of the center of the circle"""
def __init__(self, parent=None):
HandleBasedROI.__init__(self, parent=parent)
items.LineMixIn.__init__(self)
InteractionModeMixIn.__init__(self)
self._geometry = _ArcGeometry.createEmpty()
self._handleLabel = self.addLabelHandle()
self._handleStart = self.addHandle()
self._handleMid = self.addHandle()
self._handleEnd = self.addHandle()
self._handleWeight = self.addHandle()
self._handleWeight._setConstraint(self._arcCurvatureMarkerConstraint)
self._handleMove = self.addTranslateHandle()
shape = items.Shape("polygon")
shape.setPoints([[0, 0], [0, 0]])
shape.setColor(rgba(self.getColor()))
shape.setFill(False)
shape.setOverlay(True)
shape.setLineStyle(self.getLineStyle())
shape.setLineWidth(self.getLineWidth())
self.__shape = shape
self.addItem(shape)
self._initInteractionMode(self.ThreePointMode)
self._interactiveModeUpdated(self.ThreePointMode)
[docs]
def availableInteractionModes(self):
"""Returns the list of available interaction modes
:rtype: List[RoiInteractionMode]
"""
return [self.ThreePointMode, self.PolarMode, self.MoveMode]
def _interactiveModeUpdated(self, modeId):
"""Set the interaction mode.
:param RoiInteractionMode modeId:
"""
if modeId is self.ThreePointMode:
self._handleStart.setVisible(True)
self._handleEnd.setVisible(True)
self._handleWeight.setVisible(True)
self._handleStart.setSymbol("s")
self._handleMid.setSymbol("s")
self._handleEnd.setSymbol("s")
self._handleWeight.setSymbol("d")
self._handleMove.setSymbol("+")
elif modeId is self.PolarMode:
self._handleStart.setVisible(True)
self._handleEnd.setVisible(True)
self._handleWeight.setVisible(True)
self._handleStart.setSymbol("o")
self._handleMid.setSymbol("o")
self._handleEnd.setSymbol("o")
self._handleWeight.setSymbol("d")
self._handleMove.setSymbol("+")
elif modeId is self.MoveMode:
self._handleStart.setVisible(False)
self._handleEnd.setVisible(False)
self._handleWeight.setVisible(False)
self._handleMid.setSymbol("+")
self._handleMove.setSymbol("+")
else:
assert False
if self._geometry.isClosed():
if modeId != self.MoveMode:
self._handleStart.setSymbol("x")
self._handleEnd.setSymbol("x")
self._updateHandles()
def _updated(self, event=None, checkVisibility=True):
if event == items.ItemChangedType.VISIBLE:
self._updateItemProperty(event, self, self.__shape)
super(ArcROI, self)._updated(event, checkVisibility)
def _updatedStyle(self, event, style):
super(ArcROI, self)._updatedStyle(event, style)
self.__shape.setColor(style.getColor())
self.__shape.setLineStyle(style.getLineStyle())
self.__shape.setLineWidth(style.getLineWidth())
[docs]
def setFirstShapePoints(self, points):
"""Initialize the ROI using the points from the first interaction.
This interaction is constrained by the plot API and only supports few
shapes.
"""
# The first shape is a line
point0 = points[0]
point1 = points[1]
# Compute a non collinear point for the curvature
center = (point1 + point0) * 0.5
normal = point1 - center
normal = numpy.array((normal[1], -normal[0]))
defaultCurvature = numpy.pi / 5.0
weightCoef = 0.20
mid = center - normal * defaultCurvature
distance = numpy.linalg.norm(point0 - point1)
weight = distance * weightCoef
geometry = self._createGeometryFromControlPoints(point0, mid, point1, weight)
self._geometry = geometry
self._updateHandles()
def _updateText(self, text):
self._handleLabel.setText(text)
def _updateMidHandle(self):
"""Keep the same geometry, but update the location of the control
points.
So calling this function do not trigger sigRegionChanged.
"""
geometry = self._geometry
if geometry.isClosed():
start = numpy.array(self._handleStart.getPosition())
midPos = geometry.center + geometry.center - start
else:
if geometry.center is None:
midPos = geometry.startPoint * 0.5 + geometry.endPoint * 0.5
else:
midAngle = geometry.startAngle * 0.5 + geometry.endAngle * 0.5
vector = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
midPos = geometry.center + geometry.radius * vector
with utils.blockSignals(self._handleMid):
self._handleMid.setPosition(*midPos)
def _updateWeightHandle(self):
geometry = self._geometry
if geometry.center is None:
# rectangle
center = (geometry.startPoint + geometry.endPoint) * 0.5
normal = geometry.endPoint - geometry.startPoint
normal = numpy.array((normal[1], -normal[0]))
distance = numpy.linalg.norm(normal)
if distance != 0:
normal = normal / distance
weightPos = center + normal * geometry.weight * 0.5
else:
if geometry.isClosed():
midAngle = geometry.startAngle + numpy.pi * 0.5
elif geometry.center is not None:
midAngle = (geometry.startAngle + geometry.endAngle) * 0.5
vector = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
weightPos = (
geometry.center + (geometry.radius + geometry.weight * 0.5) * vector
)
with utils.blockSignals(self._handleWeight):
self._handleWeight.setPosition(*weightPos)
def _getWeightFromHandle(self, weightPos):
geometry = self._geometry
if geometry.center is None:
# rectangle
center = (geometry.startPoint + geometry.endPoint) * 0.5
return numpy.linalg.norm(center - weightPos) * 2
else:
distance = numpy.linalg.norm(geometry.center - weightPos)
return abs(distance - geometry.radius) * 2
def _updateHandles(self):
geometry = self._geometry
with utils.blockSignals(self._handleStart):
self._handleStart.setPosition(*geometry.startPoint)
with utils.blockSignals(self._handleEnd):
self._handleEnd.setPosition(*geometry.endPoint)
self._updateMidHandle()
self._updateWeightHandle()
self._updateShape()
def _updateCurvature(
self, start, mid, end, updateCurveHandles, checkClosed=False, updateStart=False
):
"""Update the curvature using 3 control points in the curve
:param bool updateCurveHandles: If False curve handles are already at
the right location
"""
if checkClosed:
closed = self._isCloseInPixel(start, end)
else:
closed = self._geometry.isClosed()
if closed:
if updateStart:
start = end
else:
end = start
if updateCurveHandles:
with utils.blockSignals(self._handleStart):
self._handleStart.setPosition(*start)
with utils.blockSignals(self._handleMid):
self._handleMid.setPosition(*mid)
with utils.blockSignals(self._handleEnd):
self._handleEnd.setPosition(*end)
weight = self._geometry.weight
geometry = self._createGeometryFromControlPoints(
start, mid, end, weight, closed=closed
)
self._geometry = geometry
self._updateWeightHandle()
self._updateShape()
def _updateCloseInAngle(self, geometry, updateStart):
azim = numpy.abs(geometry.endAngle - geometry.startAngle)
if numpy.pi < azim < 3 * numpy.pi:
closed = self._isCloseInPixel(geometry.startPoint, geometry.endPoint)
geometry._closed = closed
if closed:
sign = 1 if geometry.startAngle < geometry.endAngle else -1
if updateStart:
geometry.startPoint = geometry.endPoint
geometry.startAngle = geometry.endAngle - sign * 2 * numpy.pi
else:
geometry.endPoint = geometry.startPoint
geometry.endAngle = geometry.startAngle + sign * 2 * numpy.pi
[docs]
def handleDragUpdated(self, handle, origin, previous, current):
modeId = self.getInteractionMode()
if handle is self._handleStart:
if modeId is self.ThreePointMode:
mid = numpy.array(self._handleMid.getPosition())
end = numpy.array(self._handleEnd.getPosition())
self._updateCurvature(
current,
mid,
end,
checkClosed=True,
updateStart=True,
updateCurveHandles=False,
)
elif modeId is self.PolarMode:
v = current - self._geometry.center
startAngle = numpy.angle(complex(v[0], v[1]))
geometry = self._geometry.withStartAngle(startAngle)
self._updateCloseInAngle(geometry, updateStart=True)
self._geometry = geometry
self._updateHandles()
elif handle is self._handleMid:
if modeId is self.ThreePointMode:
if self._geometry.isClosed():
radius = numpy.linalg.norm(self._geometry.center - current)
self._geometry = self._geometry.withRadius(radius)
self._updateHandles()
else:
start = numpy.array(self._handleStart.getPosition())
end = numpy.array(self._handleEnd.getPosition())
self._updateCurvature(start, current, end, updateCurveHandles=False)
elif modeId is self.PolarMode:
radius = numpy.linalg.norm(self._geometry.center - current)
self._geometry = self._geometry.withRadius(radius)
self._updateHandles()
elif modeId is self.MoveMode:
delta = current - previous
self.translate(*delta)
elif handle is self._handleEnd:
if modeId is self.ThreePointMode:
start = numpy.array(self._handleStart.getPosition())
mid = numpy.array(self._handleMid.getPosition())
self._updateCurvature(
start,
mid,
current,
checkClosed=True,
updateStart=False,
updateCurveHandles=False,
)
elif modeId is self.PolarMode:
v = current - self._geometry.center
endAngle = numpy.angle(complex(v[0], v[1]))
geometry = self._geometry.withEndAngle(endAngle)
self._updateCloseInAngle(geometry, updateStart=False)
self._geometry = geometry
self._updateHandles()
elif handle is self._handleWeight:
weight = self._getWeightFromHandle(current)
self._geometry = self._geometry.withWeight(weight)
self._updateShape()
elif handle is self._handleMove:
delta = current - previous
self.translate(*delta)
def _isCloseInPixel(self, point1, point2):
manager = self.parent()
if manager is None:
return False
plot = manager.parent()
if plot is None:
return False
point1 = plot.dataToPixel(*point1)
if point1 is None:
return False
point2 = plot.dataToPixel(*point2)
if point2 is None:
return False
return abs(point1[0] - point2[0]) + abs(point1[1] - point2[1]) < 15
def _normalizeGeometry(self):
"""Keep the same phisical geometry, but with normalized parameters."""
geometry = self._geometry
if geometry.weight * 0.5 >= geometry.radius:
radius = (geometry.weight * 0.5 + geometry.radius) * 0.5
geometry = geometry.withRadius(radius)
geometry = geometry.withWeight(radius * 2)
self._geometry = geometry
return True
return False
[docs]
def handleDragFinished(self, handle, origin, current):
modeId = self.getInteractionMode()
if handle in [self._handleStart, self._handleMid, self._handleEnd]:
if modeId is self.ThreePointMode:
self._normalizeGeometry()
self._updateHandles()
if self._geometry.isClosed():
if modeId is self.MoveMode:
self._handleStart.setSymbol("")
self._handleEnd.setSymbol("")
else:
self._handleStart.setSymbol("x")
self._handleEnd.setSymbol("x")
else:
if modeId is self.ThreePointMode:
self._handleStart.setSymbol("s")
self._handleEnd.setSymbol("s")
elif modeId is self.PolarMode:
self._handleStart.setSymbol("o")
self._handleEnd.setSymbol("o")
if modeId is self.MoveMode:
self._handleStart.setSymbol("")
self._handleEnd.setSymbol("")
def _createGeometryFromControlPoints(self, start, mid, end, weight, closed=None):
"""Returns the geometry of the object"""
if closed or (closed is None and numpy.allclose(start, end)):
# Special arc: It's a closed circle
center = (start + mid) * 0.5
radius = numpy.linalg.norm(start - center)
v = start - center
startAngle = numpy.angle(complex(v[0], v[1]))
endAngle = startAngle + numpy.pi * 2.0
return _ArcGeometry.createCircle(
center, start, end, radius, weight, startAngle, endAngle
)
elif (
numpy.linalg.norm(
numpy.cross(numpy.append(mid - start, 0), numpy.append(end - start, 0))
)
< 1e-5
):
# Degenerated arc, it's a rectangle
return _ArcGeometry.createRect(start, end, weight)
else:
center, radius = self._circleEquation(start, mid, end)
v = start - center
startAngle = numpy.angle(complex(v[0], v[1]))
v = mid - center
midAngle = numpy.angle(complex(v[0], v[1]))
v = end - center
endAngle = numpy.angle(complex(v[0], v[1]))
# Is it clockwise or anticlockwise
relativeMid = (endAngle - midAngle + 2 * numpy.pi) % (2 * numpy.pi)
relativeEnd = (endAngle - startAngle + 2 * numpy.pi) % (2 * numpy.pi)
if relativeMid < relativeEnd:
if endAngle < startAngle:
endAngle += 2 * numpy.pi
else:
if endAngle > startAngle:
endAngle -= 2 * numpy.pi
return _ArcGeometry(
center, start, end, radius, weight, startAngle, endAngle
)
def _createShapeFromGeometry(self, geometry):
kind = geometry.getKind()
if kind == "rect":
# It is not an arc
# but we can display it as an intermediate shape
normal = geometry.endPoint - geometry.startPoint
normal = numpy.array((normal[1], -normal[0]))
distance = numpy.linalg.norm(normal)
if distance != 0:
normal /= distance
points = numpy.array(
[
geometry.startPoint + normal * geometry.weight * 0.5,
geometry.endPoint + normal * geometry.weight * 0.5,
geometry.endPoint - normal * geometry.weight * 0.5,
geometry.startPoint - normal * geometry.weight * 0.5,
]
)
elif kind == "point":
# It is not an arc
# but we can display it as an intermediate shape
# NOTE: At least 2 points are expected
points = numpy.array([geometry.startPoint, geometry.startPoint])
elif kind == "circle":
outerRadius = geometry.radius + geometry.weight * 0.5
angles = numpy.linspace(0, 2 * numpy.pi, num=50)
# It's a circle
points = []
numpy.append(angles, angles[-1])
for angle in angles:
direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
points.append(geometry.center + direction * outerRadius)
points = numpy.array(points)
elif kind == "donut":
innerRadius = geometry.radius - geometry.weight * 0.5
outerRadius = geometry.radius + geometry.weight * 0.5
angles = numpy.linspace(0, 2 * numpy.pi, num=50)
# It's a donut
points = []
# NOTE: NaN value allow to create 2 separated circle shapes
# using a single plot item. It's a kind of cheat
points.append(numpy.array([float("nan"), float("nan")]))
for angle in angles:
direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
points.insert(0, geometry.center + direction * innerRadius)
points.append(geometry.center + direction * outerRadius)
points.append(numpy.array([float("nan"), float("nan")]))
points = numpy.array(points)
else:
innerRadius = geometry.radius - geometry.weight * 0.5
outerRadius = geometry.radius + geometry.weight * 0.5
sign = numpy.sign(geometry.endAngle - geometry.startAngle)
delta = min(0.1, abs(geometry.startAngle - geometry.endAngle) / 100) * sign
if geometry.startAngle == geometry.endAngle:
# Degenerated, it's a line (single radius)
angle = geometry.startAngle
direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
points = []
points.append(geometry.center + direction * innerRadius)
points.append(geometry.center + direction * outerRadius)
return numpy.array(points)
angles = numpy.arange(geometry.startAngle, geometry.endAngle, delta)
if angles[-1] != geometry.endAngle:
angles = numpy.append(angles, geometry.endAngle)
if kind == "camembert":
# It's a part of camembert
points = []
points.append(geometry.center)
points.append(geometry.startPoint)
for angle in angles:
direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
points.append(geometry.center + direction * outerRadius)
points.append(geometry.endPoint)
points.append(geometry.center)
elif kind == "arc":
# It's a part of donut
points = []
points.append(geometry.startPoint)
for angle in angles:
direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
points.insert(0, geometry.center + direction * innerRadius)
points.append(geometry.center + direction * outerRadius)
points.insert(0, geometry.endPoint)
points.append(geometry.endPoint)
else:
assert False
points = numpy.array(points)
return points
def _updateShape(self):
geometry = self._geometry
points = self._createShapeFromGeometry(geometry)
self.__shape.setPoints(points)
index = numpy.nanargmin(points[:, 1])
pos = points[index]
with utils.blockSignals(self._handleLabel):
self._handleLabel.setPosition(pos[0], pos[1])
if geometry.center is None:
movePos = geometry.startPoint * 0.34 + geometry.endPoint * 0.66
else:
movePos = geometry.center
with utils.blockSignals(self._handleMove):
self._handleMove.setPosition(*movePos)
self.sigRegionChanged.emit()
[docs]
def getGeometry(self):
"""Returns a tuple containing the geometry of this ROI
It is a symmetric function of :meth:`setGeometry`.
If `startAngle` is smaller than `endAngle` the rotation is clockwise,
else the rotation is anticlockwise.
:rtype: Tuple[numpy.ndarray,float,float,float,float]
:raise ValueError: In case the ROI can't be represented as section of
a circle
"""
geometry = self._geometry
if geometry.center is None:
raise ValueError("This ROI can't be represented as a section of circle")
return (
geometry.center,
self.getInnerRadius(),
self.getOuterRadius(),
geometry.startAngle,
geometry.endAngle,
)
[docs]
def getPosition(self, role: Role = Role.CENTER) -> Tuple[float, float]:
"""Returns a position by it's role.
By default returns the center of the circle of the arc ROI.
"""
if role == self.Role.START:
return self._handleStart.getPosition()
if role == self.Role.STOP:
return self._handleEnd.getPosition()
if role == self.Role.MIDDLE:
return self._handleMid.getPosition()
if role == self.Role.CENTER:
p = self.getCenter()
return p[0], p[1]
raise ValueError(f"{role} is not supported")
[docs]
def isClosed(self):
"""Returns true if the arc is a closed shape, like a circle or a donut.
:rtype: bool
"""
return self._geometry.isClosed()
[docs]
def getCenter(self):
"""Returns the center of the circle used to draw arcs of this ROI.
This center is usually outside the the shape itself.
:rtype: numpy.ndarray
"""
return self._geometry.center
[docs]
def getStartAngle(self):
"""Returns the angle of the start of the section of this ROI (in radian).
If `startAngle` is smaller than `endAngle` the rotation is clockwise,
else the rotation is anticlockwise.
:rtype: float
"""
return self._geometry.startAngle
[docs]
def getEndAngle(self):
"""Returns the angle of the end of the section of this ROI (in radian).
If `startAngle` is smaller than `endAngle` the rotation is clockwise,
else the rotation is anticlockwise.
:rtype: float
"""
return self._geometry.endAngle
[docs]
def getInnerRadius(self):
"""Returns the radius of the smaller arc used to draw this ROI.
:rtype: float
"""
geometry = self._geometry
radius = geometry.radius - geometry.weight * 0.5
if radius < 0:
radius = 0
return radius
[docs]
def getOuterRadius(self):
"""Returns the radius of the bigger arc used to draw this ROI.
:rtype: float
"""
geometry = self._geometry
radius = geometry.radius + geometry.weight * 0.5
return radius
[docs]
def setGeometry(self, center, innerRadius, outerRadius, startAngle, endAngle):
"""
Set the geometry of this arc.
:param numpy.ndarray center: Center of the circle.
:param float innerRadius: Radius of the smaller arc of the section.
:param float outerRadius: Weight of the bigger arc of the section.
It have to be bigger than `innerRadius`
:param float startAngle: Location of the start of the section (in radian)
:param float endAngle: Location of the end of the section (in radian).
If `startAngle` is smaller than `endAngle` the rotation is clockwise,
else the rotation is anticlockwise.
"""
if innerRadius > outerRadius:
logger.error("inner radius larger than outer radius")
innerRadius, outerRadius = outerRadius, innerRadius
center = numpy.array(center)
radius = (innerRadius + outerRadius) * 0.5
weight = outerRadius - innerRadius
vector = numpy.array([numpy.cos(startAngle), numpy.sin(startAngle)])
startPoint = center + vector * radius
vector = numpy.array([numpy.cos(endAngle), numpy.sin(endAngle)])
endPoint = center + vector * radius
geometry = _ArcGeometry(
center,
startPoint,
endPoint,
radius,
weight,
startAngle,
endAngle,
closed=None,
)
self._geometry = geometry
self._updateHandles()
[docs]
@docstring(HandleBasedROI)
def contains(self, position):
# first check distance, fastest
center = self.getCenter()
distance = numpy.sqrt(
(position[1] - center[1]) ** 2 + ((position[0] - center[0])) ** 2
)
is_in_distance = self.getInnerRadius() <= distance <= self.getOuterRadius()
if not is_in_distance:
return False
rel_pos = position[1] - center[1], position[0] - center[0]
angle = numpy.arctan2(*rel_pos)
# angle is inside [-pi, pi]
# Normalize the start angle between [-pi, pi]
# with a positive angle range
start_angle = self.getStartAngle()
end_angle = self.getEndAngle()
azim_range = end_angle - start_angle
if azim_range < 0:
start_angle = end_angle
azim_range = -azim_range
start_angle = numpy.mod(start_angle + numpy.pi, 2 * numpy.pi) - numpy.pi
if angle < start_angle:
angle += 2 * numpy.pi
return start_angle <= angle <= start_angle + azim_range
def translate(self, x, y):
self._geometry = self._geometry.translated(x, y)
self._updateHandles()
def _arcCurvatureMarkerConstraint(self, x, y):
"""Curvature marker remains on perpendicular bisector"""
geometry = self._geometry
if geometry.center is None:
center = (geometry.startPoint + geometry.endPoint) * 0.5
vector = geometry.startPoint - geometry.endPoint
vector = numpy.array((vector[1], -vector[0]))
vdist = numpy.linalg.norm(vector)
if vdist != 0:
normal = numpy.array((vector[1], -vector[0])) / vdist
else:
normal = numpy.array((0, 0))
else:
if geometry.isClosed():
midAngle = geometry.startAngle + numpy.pi * 0.5
else:
midAngle = (geometry.startAngle + geometry.endAngle) * 0.5
normal = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
center = geometry.center
dist = numpy.dot(normal, (numpy.array((x, y)) - center))
dist = numpy.clip(dist, geometry.radius, geometry.radius * 2)
x, y = center + dist * normal
return x, y
@staticmethod
def _circleEquation(pt1, pt2, pt3):
"""Circle equation from 3 (x, y) points
:return: Position of the center of the circle and the radius
:rtype: Tuple[Tuple[float,float],float]
"""
x, y, z = complex(*pt1), complex(*pt2), complex(*pt3)
w = z - x
w /= y - x
c = (x - y) * (w - abs(w) ** 2) / 2j / w.imag - x
return numpy.array((-c.real, -c.imag)), abs(c + x)
def __str__(self):
try:
center, innerRadius, outerRadius, startAngle, endAngle = self.getGeometry()
params = (
center[0],
center[1],
innerRadius,
outerRadius,
startAngle,
endAngle,
)
params = "center: %f %f; radius: %f %f; angles: %f %f" % params
except ValueError:
params = "invalid"
return "%s(%s)" % (self.__class__.__name__, params)