"""Region of interest creation"""
import abc
import six
import math
import warnings
from functools import wraps
from contextlib import contextmanager
import numpy as np
from ginga.canvas.types import basic
[docs]@six.add_metaclass(abc.ABCMeta)
class ROIBase(basic.Polygon):
"""Base class for all ROI shapes"""
def __init__(self, image_set, view_canvas, color='red',
linewidth=1, linestyle='solid', showcap=False,
fill=True, fillcolor=None, alpha=1.0,
drawdims=False, font='Sans Serif', fillalpha=1.0,
**kwargs):
self.image_set = image_set
self.view_canvas = view_canvas
self.color = color
self.linewidth = linewidth
self.linestyle = linestyle
self.showcap = showcap
self.fill = True
self.fillcolor = fillcolor
self.alpha = alpha
self.drawdims = drawdims
self.font = font
self.fillalpha = fillalpha
self.kwargs = kwargs
self._has_temp_point = False
self._current_path = None
[docs] @staticmethod
def draw_after(func):
"""Wrapper to redraw canvas after function"""
@wraps(func)
def wrapper(self, *args, **kwargs):
run_func = func(self, *args, **kwargs)
self.view_canvas.redraw()
return run_func
return wrapper
@property
def top(self):
""":obj:`float` : The top edge of the image
The top edge is 1.5 units past the edge of the image due to how ginga
renders the image. I feel like this is a bug but I haven't had time to
try to expose it in a simple example so working around it will have
to do for now.
"""
return self.image_set.current_image.shape[0] + 1.5
@property
def right(self):
""":obj:`float` : The right edge of the image
The right edge is 0.5 units before the right edge of the image.
"""
return self.image_set.current_image.shape[1] - 0.5
def _get_default_point_value(self, point, high_edge):
"""Get a default value for a single edge case
Either the top or bottom edge case or the left or right edge case
Parameters
----------
point : :obj:`float`
The given x or y coordinate
high_edge : :obj:`float`
Either the right or top edge
Returns
-------
default_point : :obj:`float` or None
Returns the default value if an edge case, None otherwise
"""
default_point = None
# Since the center coordinate of the left and bottom pixels is zero,
# the outer edge coordinate of the left and bottom pixels is -0.5
low_edge = -0.5
if point <= 0:
default_point = low_edge
elif point >= high_edge + 0.5:
default_point = high_edge
return default_point
def _get_default_data_values(self, data_x, data_y):
"""Get default values for edge cases
Parameters
----------
data_x : :obj:`float`
The given x coordinate
data_y : :obj:`float`
The given y coordinate
Returns
-------
default_x : :obj:`float` or None
The x coordinate for the edge case. If not an edge case then None
default_y : :obj:`float` or None
The y coordinate for the edge case. If not an edge case then None
"""
default_x = self._get_default_point_value(data_x, self.right)
default_y = self._get_default_point_value(data_y, self.top)
return default_x, default_y
def _lock_coordinate_to_pixel(self, coordinate):
"""Lock x or y coordinate to bottom or left edge of the pixel
See :meth:`lock_coords_to_pixel` for explanation on logic
Parameters
----------
coordinate : :obj:`float`
x or y coordinate to lock
Returns
-------
locked_point : :obj:`float`
The left or bottom edge of the pixel
"""
coordinate_floor = math.floor(coordinate)
coordinate_ceil = math.ceil(coordinate)
if coordinate_ceil - coordinate <= 0.5:
locked_point = coordinate_floor + 0.5
else:
locked_point = coordinate_floor - 0.5
return locked_point
[docs] def lock_coords_to_pixel(self, data_x, data_y):
"""Lock the coordinates to the bottom-left corner of the pixel
The center of the pixel has integer coordinates and the edges of the
pixel are 0.5 units away. We choose to lock to the bottom left corner
or each pixel. If the decimal value of the coordinate is less than or
equal to 0.5 then the coordinate is to the left/below the center of the
pixel. To lock we round the coordinate down and add 0.5. If the
decimal value is greater than 0.5 then the coordinate is to the
right/above the center. To lock we round the coordinate down and
subtract 0.5. For example, if the coordinate is (2.3, 4.7) the pixel
coordinate is (2, 4) and the corresponding locked coordinate is
(2.5, 4.5).
Parameters
----------
data_x : :obj:`float`
The given x coordinate
data_y : :obj:`float`
The given y coordinate
Returns
-------
point_x : :obj:`float`
The corresponding x pixel coordinate
point_y : :obj:`float`
The corresponding y pixel coordinate
"""
point_x, point_y = self._get_default_data_values(data_x, data_y)
if None not in (point_x, point_y):
return point_x, point_y
if point_x is None:
point_x = self._lock_coordinate_to_pixel(data_x)
if point_y is None:
point_y = self._lock_coordinate_to_pixel(data_y)
return point_x, point_y
[docs] @staticmethod
def lock_coords_to_pixel_wrapper(func):
"""Wrapper to lock data coordinates to the corresponding pixels"""
@wraps(func)
def wrapper(self, data_x, data_y):
point_x, point_y = self.lock_coords_to_pixel(data_x, data_y)
return func(self, point_x, point_y)
return wrapper
[docs] @abc.abstractmethod
def start_ROI(self, data_x, data_y):
"""Abstract method to start the ROI process"""
pass
[docs] @abc.abstractmethod
def continue_ROI(self, data_x, data_y):
"""Abstract method to continue the ROI process"""
pass
[docs] @abc.abstractmethod
def extend_ROI(self, data_x, data_y):
"""Abstract method to extend the ROI process"""
pass
[docs] @abc.abstractmethod
def stop_ROI(self, data_x, data_y):
"""Abstract method to stop the ROI process"""
pass
[docs] def create_ROI(self, points=None):
"""Create a Region of interest
Parameters
----------
points : :obj:`list` of :obj:`tuple` of two :obj:`int`
Points that make up the vertices of the ROI
Returns
-------
coordinates : :class:`numpy.ndarray`
``m x 2`` array of coordinates.
"""
points = self._current_path.get_points() if points is None else points
super(ROIBase, self).__init__(
points, color=self.color,
linewidth=self.linewidth, linestyle=self.linestyle,
showcap=self.showcap, fill=self.fill, fillcolor=self.color,
alpha=self.alpha, drawdims=self.drawdims, font=self.font,
fillalpha=self.fillalpha, **self.kwargs)
self.view_canvas.add(self)
coords = self._get_roi_coords()
self.view_canvas.deleteObject(self)
coordinates = np.stack(coords, axis=-1)
return coordinates
[docs] def contains_arr(self, x_arr, y_arr):
"""Determine whether the points in the ROI are in arrays
The arrays must be the same shape. The arrays should be result of
``np.mgrid[y1:y2:1, x1:x2:1]``
Parameters
----------
x_arr : :class:`numpy.ndarray`
Array of x coodinates
y_arr : :class:`numpy.ndarray`
Array of y coordinates
Returns
-------
result : :class:`numpy.ndarray`
Boolean array where coordinates that are in ROI are True
"""
# NOTE: we use a version of the ray casting algorithm
# See: http://alienryderflex.com/polygon/
xa, ya = x_arr, y_arr
# Result 1 and 2 are used to inclusively select pixels on left and
# right side of the ROI. Result is the combination of the two
result = np.zeros(y_arr.shape, dtype=np.bool)
result1 = np.zeros(y_arr.shape, dtype=np.bool)
result2 = np.zeros(y_arr.shape, dtype=np.bool)
points = self.get_data_points()
xj, yj = points[-1]
for point in points:
xi, yi = point
tf = np.logical_and(
np.logical_or(np.logical_and(yi < ya, yj >= ya),
np.logical_and(yj < ya, yi >= ya)),
np.logical_or(xi <= xa, xj <= xa)
)
rs, cs = np.where(tf)
cross1 = np.zeros(ya.shape, dtype=bool)
cross2 = np.zeros(ya.shape, dtype=bool)
mask1 = (
(xi + (ya[rs, cs] - yi) / (yj - yi) * (xj - xi)) < xa[rs, cs]
)
mask2 = (
(xi + (ya[rs, cs] - yi) / (yj - yi) * (xj - xi)) <= xa[rs, cs]
)
cross1[rs, cs] = mask1
cross2[rs, cs] = mask2
result1[tf] ^= cross1[tf]
result2[tf] ^= cross2[tf]
xj, yj = xi, yi
result = np.logical_or(result1, result2)
return result
def _get_mask_from_roi(self, roi, mask=None):
"""Get mask array from ROI
Parameters
----------
roi : :class:`ROIBase`
The region of interest
mask : :class:`numpy.ndarray`
Boolean array of the image
Returns
-------
mask : :class:`numpy.ndarray`
Boolean array of the image with ROI coordinates as ``True``
"""
if mask is None:
mask = np.zeros(self.image_set.current_image.shape, dtype=np.bool)
x1, y1, x2, y2 = roi.get_llur()
x1, y1, = int(math.floor(x1)), int(math.floor(y1))
x2, y2 = int(math.ceil(x2)), int(math.ceil(y2))
# Fix top edge case. Due to display reasons, the top edge case must be
# dealt with differently than right edge case.
ends_above_top = y2 == self.top - .5
starts_and_ends_above_top = y1 == self.top - 2.5 and ends_above_top
if ends_above_top:
if starts_and_ends_above_top:
y1 = int(self.top - 3.5)
# Must move roi so the points are inside the region
self.move_delta(0, -1)
y2 = int(self.top - 1.5)
X, Y = np.mgrid[x1:x2, y1:y2]
rows, cols = Y, X
coords = roi.contains_arr(X, Y)
mask[rows, cols] = coords
return mask
@contextmanager
def _temporary_move_by_delta(self, delta):
"""Context manager to move the ROI by delta temporarily
Parameters
----------
delta : :obj:`tuple` of two :obj:`float`
Change the roi position by x and y
Example
-------
>>> with _temporary_move_by_delta((10, 15)) as moved_roi:
... moved_roi.get_points()
"""
delta_x, delta_y = delta
self.move_delta(delta_x, delta_y)
yield self
self.move_delta(-delta_x, -delta_y)
def _get_roi_coords(self):
"""Get the coordinates in the region of interest"""
delta = self.image_set.map_zoom_to_full_view()
with self._temporary_move_by_delta(delta) as moved_roi:
mask = self._get_mask_from_roi(moved_roi)
roi_coords = np.where(mask)
return roi_coords
[docs]class Polygon(ROIBase):
"""Polygon Region of Interest"""
[docs] @ROIBase.draw_after
@ROIBase.lock_coords_to_pixel_wrapper
def start_ROI(self, data_x, data_y):
"""Start the ROI process
The ROI will be a :class:`ginga.canvas.types.basic.Path` object
Parameters
----------
data_x : :obj:`float`
The x coordinate
data_y : :obj:`float`
The y coordinate
"""
self._current_path = basic.Path(
[(data_x, data_y)], color=self.color
)
self.view_canvas.add(self._current_path)
[docs] @ROIBase.draw_after
@ROIBase.lock_coords_to_pixel_wrapper
def continue_ROI(self, data_x, data_y):
"""Create new vertex on the polygon on left click
Parameters
----------
data_x : :obj:`float`
The x coordinate
data_y : :obj:`float`
The y coordinate
"""
self._current_path.insert_pt(0, (data_x, data_y))
self._has_temp_point = False
[docs] @ROIBase.draw_after
@ROIBase.lock_coords_to_pixel_wrapper
def extend_ROI(self, data_x, data_y):
"""Extend the current edge of the polygon on mouse motion
Parameters
----------
data_x : :obj:`float`
The x coordinate
data_y : :obj:`float`
The y coordinate
"""
self._current_path.insert_pt(0, (data_x, data_y))
if self._current_path.get_num_points() > 2 and self._has_temp_point:
self._current_path.delete_pt(1)
self._has_temp_point = True
[docs] @ROIBase.draw_after
@ROIBase.lock_coords_to_pixel_wrapper
def stop_ROI(self, data_x, data_y):
"""Close the polygon on right click
The polygon will close based on last left click and not on the right
click. There must be more than 2 points to formulate a polygon
Parameters
----------
data_x : :obj:`float`
The x coordinate
data_y : :obj:`float`
The y coordinate
"""
if self._has_temp_point:
self._current_path.delete_pt(0)
if self._current_path.get_num_points() <= 2:
warnings.warn("Must have more than 2 points for a polygon")
coords = []
else:
coords = self.create_ROI(self._current_path.get_points())
self.view_canvas.deleteObject(self._current_path)
return coords
[docs]class Rectangle(ROIBase):
"""Rectangle Region of interest"""
# anchor point is pixel coordinate of the pixel first selected
# This pixel will always be selected as a result
_anchor_point = (0, 0)
[docs] @ROIBase.draw_after
@ROIBase.lock_coords_to_pixel_wrapper
def start_ROI(self, data_x, data_y):
"""Start the region of interest on left click
Parameters
----------
data_x : :obj:`float`
The x coordinate
data_y : :obj:`float`
The y coordinate
"""
data_x = data_x - 1 if data_x == self.right else data_x
data_y = data_y - 1 if data_y == self.top else data_y
self._current_path = basic.Rectangle(
data_x, data_y, data_x + 1, data_y + 1, color=self.color)
self.view_canvas.add(self._current_path)
self._anchor_point = (self._current_path.x1, self._current_path.y1)
def continue_ROI(self, data_x, data_y):
pass
def _extend_point(self, point, anchor_point, edge):
"""Determine the x1, x2 or y1, y2 coordinates when extending
Parameters
----------
point : :obj:`float`
The x or y coordinate in extending the rectangle
anchor_point : :obj:`float`
The x or y coordinate anchor point for the rectangle
edge : :obj:`float`
The top or right edge
Returns
-------
p1 : :obj:`float`
x1 or y1 coordinate
p2 : :obj:`float`
x2 or y2 coordinate
"""
if point >= anchor_point:
p1 = anchor_point
p2 = point + 1 if point != edge else point
else:
p1 = point
p2 = anchor_point + 1
return p1, p2
[docs] @ROIBase.draw_after
@ROIBase.lock_coords_to_pixel_wrapper
def extend_ROI(self, data_x, data_y):
"""Exend the rectangle on region of interest on mouse motion
Parameters
----------
data_x : :obj:`float`
The x coordinate
data_y : :obj:`float`
The y coordinate
"""
self._current_path.x1, self._current_path.x2 = self._extend_point(
point=data_x,
anchor_point=self._anchor_point[0],
edge=self.right
)
self._current_path.y1, self._current_path.y2 = self._extend_point(
point=data_y,
anchor_point=self._anchor_point[1],
edge=self.top
)
[docs] @ROIBase.draw_after
@ROIBase.lock_coords_to_pixel_wrapper
def stop_ROI(self, data_x, data_y):
"""Stop the region of interest on right click
Parameters
----------
data_x : :obj:`float`
The x coordinate
data_y : :obj:`float`
The y coordinate
"""
coords = self.create_ROI(self._current_path.get_points())
self.view_canvas.deleteObject(self._current_path)
return coords
[docs]class Pencil(ROIBase):
"""Select individual pixels"""
point_radius = center_shift = 0.5
def __init__(self, *args, **kwargs):
super(Pencil, self).__init__(*args, **kwargs)
self._current_path = []
[docs] @ROIBase.draw_after
def start_ROI(self, data_x, data_y):
"""Start choosing pixels on left click
Parameters
----------
data_x : :obj:`float`
The x coordinate
data_y : :obj:`float`
The y coordinate
"""
self._add_point(data_x, data_y)
@ROIBase.lock_coords_to_pixel_wrapper
def _add_point(self, data_x, data_y):
"""Add a point to the current path list
Parameters
----------
data_x : :obj:`float`
The x coordinate
data_y : :obj:`float`
The y coordinate
"""
next_point = basic.Point(
data_x + self.center_shift,
data_y + self.center_shift,
self.point_radius,
color=self.color)
self.view_canvas.add(next_point)
self._current_path.append(next_point)
[docs] @ROIBase.draw_after
def continue_ROI(self, data_x, data_y):
"""Add another pixel on left click
Parameters
----------
data_x : :obj:`float`
The x coordinate
data_y : :obj:`float`
The y coordinate
"""
self._add_point(data_x, data_y)
# @ROIBase.draw_after
# def extend_ROI(self, data_x, data_y):
# self._add_point(data_x, data_y)
def extend_ROI(self, data_x, data_y):
pass
[docs] def move_delta(self, delta_x, delta_y):
"""Override the move_delta function to move all the points
Parameters
----------
delta_x : :obj:`float`
Change in the x direction
delta_y : :obj:`float`
Change in the y direction
"""
for point in self._current_path:
point.move_delta(delta_x, delta_y)
def _fix_coordinate(self, coordinate):
"""Fix the coordinate after it is moved from its original position
The coordinate may be changed to a float when the point is moved from
its original position when the pan is zoomed. As a result, we must
relock the coordinate to its correct integer location.
Parameters
----------
coordinate : :obj:`float`
Either the x or y coordinate of the moved point
Returns
-------
fixed_coordinate : :obj:`int`
The fixed coordinate locked onto its integer location
"""
if coordinate.is_integer():
fixed_coordinate = int(coordinate)
else:
fixed_coordinate = int(
self._lock_coordinate_to_pixel(coordinate) + self.center_shift
)
return fixed_coordinate
[docs] @ROIBase.draw_after
def stop_ROI(self, data_x, data_y):
"""Set all pixels as roi coordinates on right click
Parameters
----------
data_x : :obj:`float`
The x coordinate
data_y : :obj:`float`
The y coordinate
Returns
-------
coordinates : :class:`numpy.ndarray`
Coordinates of points selected
"""
delta = self.image_set.map_zoom_to_full_view()
with self._temporary_move_by_delta(delta) as moved:
pixels = list(set([(p.x, p.y) for p in moved._current_path]))
self.view_canvas.delete_objects(self._current_path)
coords = []
for x, y in pixels:
row = self._fix_coordinate(y)
column = self._fix_coordinate(x)
coords.append((row, column))
coordinates = np.array(coords)
return coordinates