""" This module contains an image viewer and drawing routines based on OpenCV. """ import numpy as np import cv2 import time def is_in_bounds(mat, roi): """Check if ROI is fully contained in the image. Parameters ---------- mat : ndarray An ndarray of ndim>=2. roi : (int, int, int, int) Region of interest (x, y, width, height) where (x, y) is the top-left corner. Returns ------- bool Returns true if the ROI is contain in mat. """ if roi[0] < 0 or roi[0] + roi[2] >= mat.shape[1]: return False if roi[1] < 0 or roi[1] + roi[3] >= mat.shape[0]: return False return True def view_roi(mat, roi): """Get sub-array. The ROI must be valid, i.e., fully contained in the image. Parameters ---------- mat : ndarray An ndarray of ndim=2 or ndim=3. roi : (int, int, int, int) Region of interest (x, y, width, height) where (x, y) is the top-left corner. Returns ------- ndarray A view of the roi. """ sx, ex = roi[0], roi[0] + roi[2] sy, ey = roi[1], roi[1] + roi[3] if mat.ndim == 2: return mat[sy:ey, sx:ex] else: return mat[sy:ey, sx:ex, :] class ImageViewer(object): """An image viewer with drawing routines and video capture capabilities. Key Bindings: * 'SPACE' : pause * 'ESC' : quit Parameters ---------- update_ms : int Number of milliseconds between frames (1000 / frames per second). window_shape : (int, int) Shape of the window (width, height). caption : Optional[str] Title of the window. Attributes ---------- image : ndarray Color image of shape (height, width, 3). You may directly manipulate this image to change the view. Otherwise, you may call any of the drawing routines of this class. Internally, the image is treated as beeing in BGR color space. Note that the image is resized to the the image viewers window_shape just prior to visualization. Therefore, you may pass differently sized images and call drawing routines with the appropriate, original point coordinates. color : (int, int, int) Current BGR color code that applies to all drawing routines. Values are in range [0-255]. text_color : (int, int, int) Current BGR text color code that applies to all text rendering routines. Values are in range [0-255]. thickness : int Stroke width in pixels that applies to all drawing routines. """ def __init__(self, update_ms, window_shape=(640, 480), caption="Figure 1"): self._window_shape = window_shape self._caption = caption self._update_ms = update_ms self._video_writer = None self._user_fun = lambda: None self._terminate = False self.image = np.zeros(self._window_shape + (3, ), dtype=np.uint8) self._color = (0, 0, 0) self.text_color = (255, 255, 255) self.thickness = 1 @property def color(self): return self._color @color.setter def color(self, value): if len(value) != 3: raise ValueError("color must be tuple of 3") self._color = tuple(int(c) for c in value) def rectangle(self, x, y, w, h, label=None): """Draw a rectangle. Parameters ---------- x : float | int Top left corner of the rectangle (x-axis). y : float | int Top let corner of the rectangle (y-axis). w : float | int Width of the rectangle. h : float | int Height of the rectangle. label : Optional[str] A text label that is placed at the top left corner of the rectangle. """ pt1 = int(x), int(y) pt2 = int(x + w), int(y + h) cv2.rectangle(self.image, pt1, pt2, self._color, self.thickness) if label is not None: text_size = cv2.getTextSize( label, cv2.FONT_HERSHEY_PLAIN, 1, self.thickness) center = pt1[0] + 5, pt1[1] + 5 + text_size[0][1] pt2 = pt1[0] + 10 + text_size[0][0], pt1[1] + 10 + \ text_size[0][1] cv2.rectangle(self.image, pt1, pt2, self._color, -1) cv2.putText(self.image, label, center, cv2.FONT_HERSHEY_PLAIN, 1, (255, 255, 255), self.thickness) def circle(self, x, y, radius, label=None): """Draw a circle. Parameters ---------- x : float | int Center of the circle (x-axis). y : float | int Center of the circle (y-axis). radius : float | int Radius of the circle in pixels. label : Optional[str] A text label that is placed at the center of the circle. """ image_size = int(radius + self.thickness + 1.5) # actually half size roi = int(x - image_size), int(y - image_size), \ int(2 * image_size), int(2 * image_size) if not is_in_bounds(self.image, roi): return image = view_roi(self.image, roi) center = image.shape[1] // 2, image.shape[0] // 2 cv2.circle( image, center, int(radius + .5), self._color, self.thickness) if label is not None: cv2.putText( self.image, label, center, cv2.FONT_HERSHEY_PLAIN, 2, self.text_color, 2) def gaussian(self, mean, covariance, label=None): """Draw 95% confidence ellipse of a 2-D Gaussian distribution. Parameters ---------- mean : array_like The mean vector of the Gaussian distribution (ndim=1). covariance : array_like The 2x2 covariance matrix of the Gaussian distribution. label : Optional[str] A text label that is placed at the center of the ellipse. """ # chi2inv(0.95, 2) = 5.9915 vals, vecs = np.linalg.eigh(5.9915 * covariance) indices = vals.argsort()[::-1] vals, vecs = np.sqrt(vals[indices]), vecs[:, indices] center = int(mean[0] + .5), int(mean[1] + .5) axes = int(vals[0] + .5), int(vals[1] + .5) angle = int(180. * np.arctan2(vecs[1, 0], vecs[0, 0]) / np.pi) cv2.ellipse( self.image, center, axes, angle, 0, 360, self._color, 2) if label is not None: cv2.putText(self.image, label, center, cv2.FONT_HERSHEY_PLAIN, 2, self.text_color, 2) def annotate(self, x, y, text): """Draws a text string at a given location. Parameters ---------- x : int | float Bottom-left corner of the text in the image (x-axis). y : int | float Bottom-left corner of the text in the image (y-axis). text : str The text to be drawn. """ cv2.putText(self.image, text, (int(x), int(y)), cv2.FONT_HERSHEY_PLAIN, 2, self.text_color, 2) def colored_points(self, points, colors=None, skip_index_check=False): """Draw a collection of points. The point size is fixed to 1. Parameters ---------- points : ndarray The Nx2 array of image locations, where the first dimension is the x-coordinate and the second dimension is the y-coordinate. colors : Optional[ndarray] The Nx3 array of colors (dtype=np.uint8). If None, the current color attribute is used. skip_index_check : Optional[bool] If True, index range checks are skipped. This is faster, but requires all points to lie within the image dimensions. """ if not skip_index_check: cond1, cond2 = points[:, 0] >= 0, points[:, 0] < 480 cond3, cond4 = points[:, 1] >= 0, points[:, 1] < 640 indices = np.logical_and.reduce((cond1, cond2, cond3, cond4)) points = points[indices, :] if colors is None: colors = np.repeat( self._color, len(points)).reshape(3, len(points)).T indices = (points + .5).astype(np.int) self.image[indices[:, 1], indices[:, 0], :] = colors def enable_videowriter(self, output_filename, fourcc_string="MJPG", fps=None): """ Write images to video file. Parameters ---------- output_filename : str Output filename. fourcc_string : str The OpenCV FOURCC code that defines the video codec (check OpenCV documentation for more information). fps : Optional[float] Frames per second. If None, configured according to current parameters. """ fourcc = cv2.VideoWriter_fourcc(*fourcc_string) if fps is None: fps = int(1000. / self._update_ms) self._video_writer = cv2.VideoWriter( output_filename, fourcc, fps, self._window_shape) def disable_videowriter(self): """ Disable writing videos. """ self._video_writer = None def run(self, update_fun=None): """Start the image viewer. This method blocks until the user requests to close the window. Parameters ---------- update_fun : Optional[Callable[] -> None] An optional callable that is invoked at each frame. May be used to play an animation/a video sequence. """ if update_fun is not None: self._user_fun = update_fun self._terminate, is_paused = False, False # print("ImageViewer is paused, press space to start.") while not self._terminate: t0 = time.time() if not is_paused: self._terminate = not self._user_fun() if self._video_writer is not None: self._video_writer.write( cv2.resize(self.image, self._window_shape)) t1 = time.time() remaining_time = max(1, int(self._update_ms - 1e3*(t1-t0))) cv2.imshow( self._caption, cv2.resize(self.image, self._window_shape[:2])) key = cv2.waitKey(remaining_time) if key & 255 == 27: # ESC print("terminating") self._terminate = True elif key & 255 == 32: # ' ' print("toggeling pause: " + str(not is_paused)) is_paused = not is_paused elif key & 255 == 115: # 's' print("stepping") self._terminate = not self._user_fun() is_paused = True # Due to a bug in OpenCV we must call imshow after destroying the # window. This will make the window appear again as soon as waitKey # is called. # # see https://github.com/Itseez/opencv/issues/4535 self.image[:] = 0 cv2.destroyWindow(self._caption) cv2.waitKey(1) cv2.imshow(self._caption, self.image) def stop(self): """Stop the control loop. After calling this method, the viewer will stop execution before the next frame and hand over control flow to the user. Parameters ---------- """ self._terminate = True