def make_lines(self): line_seg1 = ImageLineSegment(ImagePoint(100, 200), ImagePoint(250, 200)) line_seg2 = ImageLineSegment(ImagePoint(200, 150), ImagePoint(200, 300)) line_seg1a = ImageLineSegment(ImagePoint(100, 225), ImagePoint(250, 225)) line_seg2a = ImageLineSegment(ImagePoint(175, 150), ImagePoint(175, 300)) self._lines = [] self._lines.append(Line("test00")) self._lines.append(Line("test01")) self._lines.append(Line("test02")) self._lines[0].add_line(67, line_seg1) self._lines[1].add_line(67, line_seg1) self._lines[1].add_line(122, line_seg1a) self._lines[2].add_line(254, line_seg2) self._lines[2].add_line(123, line_seg2a) self._lines[2].add_line(345, line_seg2a)
def make_crystal1(): """ factory function to produce a test crystals """ line1 = ImageLineSegment(ImagePoint(50, 150), ImagePoint(150, 50), "01") line2 = ImageLineSegment(ImagePoint(50, 50), ImagePoint(150, 150), "02") tmp_crystal = Crystal() tmp_crystal.add_faces([line1, line2], 250) return tmp_crystal
def test_vertical(self): """ test the is_vertical function """ start = ImagePoint(100, 200) end0 = ImagePoint(200, 300) end1 = ImagePoint(100, 300) non_vert_line = ImageLineSegment(start, end0) vert_line = ImageLineSegment(start, end1) self.assertFalse(non_vert_line.is_vertical, "non-vertical line reports vertical") self.assertTrue(vert_line.is_vertical, "vertical line reports non-vertical")
def store_lines(store, lines, segments): """ combine the lines and segments and store the Args: store (VideoAnalysisResultsStore) the results object to be filled lines ([[string]]) the data rows of the lines csv file segments ([[string]]) the data rows of the lines_segments csv file """ for row in lines: note = row["Note"] region_index = int(row["Region Index"]) line = Line(note) store.add_line(region_index, line) for row in segments: frame = int(row["Frame"]) start_x = int(row["Start x"]) start_y = int(row["Start y"]) end_x = int(row["End x"]) end_y = int(row["End y"]) line_segment = ImageLineSegment(ImagePoint(start_x, start_y), ImagePoint(end_x, end_y)) line_index = int(row["Line Index"]) store.lines[line_index].add_line_segment(frame, line_segment)
def make_crystal2(): """ factory function to produce a test crystal """ line1 = ImageLineSegment(ImagePoint(100, 200), ImagePoint(250, 200), "01") line2 = ImageLineSegment(ImagePoint(200, 150), ImagePoint(200, 300), "02") line1a = ImageLineSegment(ImagePoint(100, 225), ImagePoint(250, 225), "01") line2a = ImageLineSegment(ImagePoint(175, 150), ImagePoint(175, 300), "02") tmp_crystal = Crystal(notes="very blured") tmp_crystal.add_faces([line1, line2], 250) tmp_crystal.add_faces([line1a, line2a], 500) return tmp_crystal
def make_line(self): """ make the current line allowing for the label's zoom factor, the line will be in coordinates of the original pixmap Returns: None """ zoom = self._current_zoom start_x = np.float64(self._start.x()) / zoom start_x = np.uint32(np.round(start_x)) start_y = np.float64(self._start.y()) / zoom start_y = np.uint32(np.round(start_y)) end_x = np.float64(self._end.x()) / zoom end_x = np.uint32(np.round(end_x)) end_y = np.float64(self._end.y()) / zoom end_y = np.uint32(np.round(end_y)) self._current_line = ImageLineSegment(ImagePoint(start_x, start_y), ImagePoint(end_x, end_y))
def test_dist_to_line(self): """ test the is_vertical function """ start = ImagePoint(100, 200) end0 = ImagePoint(200, 300) end1 = ImagePoint(100, 300) line = ImageLineSegment(start, end0) v_line = ImageLineSegment(start, end1) test_point = ImagePoint(100, 100) flag_l, closest_l = line.is_closest_point_on_segment(test_point) flag_vl, closest_vl = v_line.is_closest_point_on_segment(test_point) d_l = closest_l.distance_from(test_point) d_vl = closest_vl.distance_from(test_point) self.assertFalse(flag_l, "claimed test point in line segment") self.assertFalse(flag_vl, "claimed test point in vertical line segment") self.assertAlmostEqual(d_l, 70.7107, msg="distance to line failed on non-vertical line", delta=0.0001) self.assertAlmostEqual(d_vl, 0.0, msg="distance to line failed on vertical line", delta=0.0001)
class DrawingLabel(qw.QLabel): """ subclass of label providing functions for drawing using a mouse. `DrawingLabel` is a subclass of QLabel its basic function is to store the original image as a constant pixmap, which is redisplayed with or without lines as the user requires. The mouse down, up and moved callback functions have been overridden, which allows for handling user input. Internally newly created lines are stored in a list called `_linedBase`, when they are moved the modified copies are stored in a list `_linesNew`. `DrawingLabel's` behaviour is governed by state variables, defined using Python `enum.IntEnum`. These relate to how the user's inputs are handled and how the lines are stored. In creating mode, the user is allowed to draw lines; in adjusting the user can select existing lines and move them (whole line or just end points); in copying mode existing lines can be adjusted and are then copied to new set. """ ## signal to indicate saving line_saved = qc.pyqtSignal() def __init__(self, parent=None): """ Set up the objeect Args: parent (QObject) the parent Object """ super().__init__(parent) ## the class name as in translation, used in dialogs self._translated_name = self.tr("DrawingLabel") ## store the Drawing/Adjusting state self._state = WidgetState.DRAWING ## store the Creating/Copying state self._storage_state = StorageState.CREATING_LINES ## start point of the current line self._start = None ## end point of the current line self._end = None ## the initial value of the magnification self._current_zoom = 1.0 ## records if the left mouse button is currently held down ## Qt does not allow interrogation of the current state of ## the mouse buttons, so a state variable must be ## provided to record the state for 'drag' operations self._mouse_left_down = False ## if true then the label must redraw on the next paint event self._redraw = True ## the following two variables govern the adjustmen of an existing line ## if true the whole line is shifted else an end is shifted self._adjust_line = True ## the _lines_base array index of the line being adjusted self._adjust_index = 0 ## if true display the line labesl on the pixmap self._show_labels = False ## array for the created lines or the base from which the lines are being adjusted self._lines_base = [] ## array for the new lines resulting from adjustment self._lines_new = [] ## the line being worked on, is created by mouse move or mouse release events self._current_line = None ## the original line segment when moving self._moving_line_segment = None ## the pixmap on which we are to draw self._background_pixmap = None ## the frame number of the pixmap self._current_frame = 0 ## the line in the results to be displayed self._display_line = None self.setAlignment(qc.Qt.AlignTop | qc.Qt.AlignLeft) self.setSizePolicy(qw.QSizePolicy.Ignored, qw.QSizePolicy.Fixed) self.setSizePolicy(qw.QSizePolicy.Minimum, qw.QSizePolicy.Minimum) def set_backgroud_pixmap(self, pix, frame): """ set the pixmap to be displayed Args: pix (QPixmap) the pixmap to be displayed Returns: None """ self._background_pixmap = pix self._current_frame = frame @property def state(self): """ getter for the Drawing/Adjusting state Returns: None """ return self._state @property def storage_state(self): """ getter for the Creating/Copying state Returns: current storage state """ return self._storage_state @property def size(self): """ getter for the number of line segments Returns a tuple consisting of (number line segments in base, number in new list) """ return (len(self._lines_base), len(self._lines_new)) @property def lines_base(self): """ getter for the base lines Returns: the array of lines """ return self._lines_base @property def lines_new(self): """ getter for the new lines Returns: the array of lines """ return self._lines_new def set_drawing(self): """ set the Drawing/Adjusting state to Drawing Returns: None """ if self._state == WidgetState.MOVING: self._current_line = None self._moving_line_segment = None self._state = WidgetState.DRAWING self.redisplay() def set_moving(self): """ set the Drawing/Adjusting state to Adjusting Returns: None """ self._state = WidgetState.MOVING self._current_line = None self._moving_line_segment = None self.redisplay() def set_adjusting(self): """ set the Drawing/Adjusting state to Adjusting Returns: None """ if self._state == WidgetState.MOVING: self._current_line = None self._moving_line_segment = None self._state = WidgetState.ADJUSTING self.redisplay() def set_creating(self): """ set the Creating/Copying state to Creating Returns: None """ self._storage_state = StorageState.CREATING_LINES def set_copying(self): """ set the Creating/Copying state to Creating Returns: None """ self._storage_state = StorageState.COPYING_LINES def set_zoom(self, value): """ change the magnification and redisplay Args: value (float) the new value of the magnification Returns: None """ self._current_zoom = value self.redisplay() def show_labels(self, flag): """ make the label display, or not, the labels by the lines Args: flag (boolean) the new values of the display labels flag Returns: None """ self._show_labels = flag self.redisplay() @qc.pyqtSlot() def mousePressEvent(self, event): """ detect the start of a mouse movement Args: event (QEvent) the mouse event, which stores details Returns: None """ if self._background_pixmap is None: return pix_rect = self.pixmap().rect() point = event.pos() # only make point end if within pixmap if not pix_rect.contains(point): return if self._state == WidgetState.DRAWING: if event.button() == qc.Qt.LeftButton: self._start = event.pos() self._mouse_left_down = True else: pass self.redisplay() elif self._state == WidgetState.ADJUSTING: if event.button() == qc.Qt.LeftButton: pick = self.pick_artifact(event.pos()) if pick is not None: self._adjust_index = pick[0] self._mouse_left_down = True if pick[1] is None: self._start = event.pos() self._adjust_line = AdjustingState.LINE elif pick[1] == "start": self._start = None self._adjust_line = AdjustingState.START else: self._start = None self._adjust_line = AdjustingState.END elif self._state == WidgetState.MOVING: if event.button( ) == qc.Qt.LeftButton and self._display_line is not None: pick = self.pick_line_segment(event.pos()) if pick is not None: self._current_line = pick[0] self._moving_line_segment = pick[0] self._start = event.pos() self._mouse_left_down = True else: self._start = None self.redisplay() def pick_line_segment(self, position, radius=5): """ find if the event is picking a line segment from the displayed line Args: position (QPosition) the event position radius (int) the distance from the line for a pick to apply Returns: pointer to the line or None """ line = self.test_lines_moving(position, radius) if line is not None: return line return None def pick_artifact(self, position, radius=5): """ respond to a user mouse click by picking a line or the end point of a line. 1. define radius in pixels around click event location 2. Test all end point to see if they lie in radius if one or more found return the closest end point (index, start/end) 3. Test for any lines passing within radius of event if one or more found return the closest line index Args: position the location of the mouse click radius the size of the region around the event that is significant Args: position (QPoint) the screen coordinates of the pixel the user has selected radius (int) the distance in pixels around the selected pixel that is significant Returns: (<line array index>, <endpoint = None>) if no line detected the return is None, else it is a size two tuple consisting of the array index of the line and, if a line end was selected, "start" or "end", else the second item is None """ points = self.test_points(position, radius) if points is not None: return points lines = self.test_lines(position, radius) if lines is not None: return lines return None def test_points(self, position, radius): """ test if a position is within radius of any line end points Args: position (QPoint) the target point radius (int) the distance in pixels around the selected pixel that is significant Returns if end point found a tuple (<line array index>, <start/end>) else None """ def in_r(im_pt, target): # find if pixel is within square (2r + 1) about target # return True and seperation if in, else (False, 0) del_x = abs(im_pt.x - target.x()) del_y = abs(im_pt.y - target.y()) if del_x > radius: return (False, 0) if del_y > radius: return (False, 0) return (True, np.round(np.sqrt(del_x * del_x + del_y * del_y))) points = [] distances = [] for i in range(len(self._lines_base)): line = self._lines_base[i] tmp = in_r(line.start.scale(self._current_zoom), position) if tmp[0]: points.append((i, "start")) distances.append(tmp[1]) else: tmp = in_r(line.end.scale(self._current_zoom), position) if tmp[0]: points.append((i, "end")) distances.append(tmp[1]) if len(points) == 1: return points[0] if len(points) > 1: return points[np.argmin(distances)] return None def test_lines(self, position, radius): """ find if a line segment lies within radius of the a given point Args: position (QPoint) the target point radius (int) the distance in pixels around the selected pixel that is significant Returns if line found a tuple (<line array index>, None) else None """ lines = [] distances = [] for i in range(len(self._lines_base)): line = self._lines_base[i].scale(self._current_zoom) dist_to_line = line.distance_point_to_line(position) if dist_to_line < radius: point = ImagePoint(position.x(), position.y()) close_points = line.is_closest_point_on_segment(point) if close_points[0]: lines.append(i) distances.append(dist_to_line) if len(lines) == 1: return (lines[0], None) if len(lines) > 1: return (lines[np.argmin(distances)], None) return None def test_lines_moving(self, position, radius): """ find if a line segment lies within radius of the a given point when the moving existing lines selecte Args: position (QPoint) the target point radius (int) the distance in pixels around the selected pixel that is significant Returns if line found a tuple (LineSegment, None) else None """ frames = [] distances = [] for key in self._display_line.keys(): line_seg = self._display_line[key].scale(self._current_zoom) dist_to_line = line_seg.distance_point_to_line(position) if dist_to_line < radius: point = ImagePoint(position.x(), position.y()) close_points = line_seg.is_closest_point_on_segment(point) if close_points[0]: frames.append(key) distances.append(dist_to_line) if len(frames) == 1: return (self._display_line[frames[0]], None) if len(frames) > 1: key = frames[np.argmin(distances)] return (self._display_line[key], None) return None @qc.pyqtSlot() def mouseMoveEvent(self, event): """ responde to the morment of the mouse, if drawing redraw the line else to the alter chosen line function Args: event (QEvent) the event holding the button/location data Returns: None """ if self._background_pixmap is None or not self._mouse_left_down: return pix_rect = self.pixmap().rect() point = event.pos() # only make point end if within pixmap if not pix_rect.contains(point): return if self._state == WidgetState.DRAWING: self._end = event.pos() self.make_line() self.redisplay() elif self._state == WidgetState.ADJUSTING: self.alter_chosen_line(event) elif self._state == WidgetState.MOVING: self.shift_line_segment(event) def shift_line_segment(self, event): """ move the the currently chosen line Args: event (QEvent) the event holding coordinates and source data Returns: None """ shift_qt = event.pos() - self._start shift_vec = ImagePoint(shift_qt.x(), shift_qt.y()).scale(1.0 / self._current_zoom) self._current_line = self._moving_line_segment.shift(shift_vec) self.redisplay() def alter_chosen_line(self, event): """ the function for altering a line, if adjusting shift the whole line else shift the currently selected end. Args: event (QEvent) the event holding coordinates and source data Returns: None """ if self._adjust_line == AdjustingState.LINE: self.shift_chosen_line(event) else: self.move_chosen_line_end(event) def move_chosen_line_end(self, event): """ move the end of the currently chosen line Args: event (QEvent) the event holding coordinates and source data Returns: None """ point = ImagePoint(event.x(), event.y()).scale(1.0 / self._current_zoom) if self._adjust_line == AdjustingState.START: self._current_line = self._lines_base[ self._adjust_index].new_start(point) else: self._current_line = self._lines_base[self._adjust_index].new_end( point) self.redisplay() def shift_chosen_line(self, event): """ move the the currently chosen line Args: event (QEvent) the event holding coordinates and source data Returns: None """ shift_qt = event.pos() - self._start shift_vec = ImagePoint(shift_qt.x(), shift_qt.y()).scale(1.0 / self._current_zoom) self._current_line = self._lines_base[self._adjust_index].shift( shift_vec) self.redisplay() @qc.pyqtSlot() def mouseReleaseEvent(self, event): """ the mouse button release callback function Args: event (QEvent) the event holding coordinates and source data Returns: None """ if self._background_pixmap is None: return # TODO replace with self._left_mouse_button_down # ignore anything other than the left mouse button if not event.button() == qc.Qt.LeftButton: return # if mode drawing ask the user if the line is wanted if self._state == WidgetState.DRAWING: pix_rect = self.pixmap().rect() point = event.pos() if pix_rect.contains(point): self._end = point self.make_line() self.redisplay() reply = qw.QMessageBox.question( self, self.tr("Create Line"), self.tr("Do you wish to store the line?")) if reply == qw.QMessageBox.Yes: self.add_line() self.clear_current() self.redisplay() # else pass the call to the adjusting release function elif self._state == WidgetState.ADJUSTING and self._current_line is not None: self.adjusting_release() elif self._state == WidgetState.MOVING and self._current_line is not None: self.moving_release() self._mouse_left_down = False def moving_release(self): """ respond to the release of a mouse button. Returns: None Emits: line_saved if the user saves a line """ reply = qw.QMessageBox.question(self, self.tr("Move Line"), self.tr("Add moved line to results?")) if reply == qw.QMessageBox.Yes: keys = self._display_line.keys() if self._current_frame in keys: qw.QMessageBox.critical( self, self.tr("Change Frame"), self.tr("The line is already defined for this frame.")) self.clear_current() self.redisplay() return self._display_line.add_line_segment(self._current_frame, self._current_line) self.line_saved.emit() self.clear_current() self.redisplay() def adjusting_release(self): """ the mouse button release function for use in the adjusting mode, if creating lines current is added to the _lines_base, else if copying mode the current line is added to lines_new Returns: None """ reply = qw.QMessageBox.question( self, self.tr("Adjust Line"), self.tr("Do you wish to store the adjusted line?")) if reply == qw.QMessageBox.Yes: if self._storage_state == StorageState.CREATING_LINES: self._lines_base[self._adjust_index] = self._current_line else: self._lines_new.append(self._current_line) self.clear_current() self._adjust_line = True # if true the whole line is shifted self._adjust_index = 0 self.redisplay() def make_line(self): """ make the current line allowing for the label's zoom factor, the line will be in coordinates of the original pixmap Returns: None """ zoom = self._current_zoom start_x = np.float64(self._start.x()) / zoom start_x = np.uint32(np.round(start_x)) start_y = np.float64(self._start.y()) / zoom start_y = np.uint32(np.round(start_y)) end_x = np.float64(self._end.x()) / zoom end_x = np.uint32(np.round(end_x)) end_y = np.float64(self._end.y()) / zoom end_y = np.uint32(np.round(end_y)) self._current_line = ImageLineSegment(ImagePoint(start_x, start_y), ImagePoint(end_x, end_y)) def clear_current(self): """ delete the current line and the current start and end points Returns: None """ self._start = None self._end = None self._current_line = None self._moving_line_segment = None def add_line(self): """ copy the current line to the _lines_base with its index as its label Returns: None """ if self._current_line is None: return self._lines_base.append(self._current_line) def set_lines_base(self, lines): """ set the set of base lines, existing are deleted Return: None """ self._lines_base = lines self.redisplay() def redisplay(self): """ force the label to redisplay the current contents Returns: None """ self._redraw = True self.repaint() @qc.pyqtSlot() def paintEvent(self, event): """ if the redraw flag is set then redraw the lines, else nothing Args: event (QEvent) data relating to cause of paint event Returns: None """ qw.QLabel.paintEvent(self, event) if self._background_pixmap is not None and self._redraw: self.draw_lines() self._redraw = False def draw_lines(self): """ Draw the lines: iterates the _lines_base, and if COPYING_LINES, the iterate the_lines_new, finally draw the current line Returns: None """ new_pen = qg.QPen(qg.QColor(qc.Qt.red), 3, qc.Qt.SolidLine) adj_pen = qg.QPen(qg.QColor(qc.Qt.red), 3, qc.Qt.DashLine) old_pen = qg.QPen(qg.QColor.fromRgb(0, 230, 255), 3, qc.Qt.SolidLine) painter = qg.QPainter() height = self._background_pixmap.height() * self._current_zoom width = self._background_pixmap.width() * self._current_zoom pix = self._background_pixmap.scaled(width, height) painter.begin(pix) # make copy font = painter.font() font.setPointSize(font.pointSize() * 2) painter.setFont(font) # TODO make self._current_line the source for adjustments if self._display_line is not None: painter.setPen(old_pen) frames = self._display_line.frame_numbers for frame in frames: line_segment = self._display_line[frame] self.draw_single_line(line_segment, painter, str(frame)) if self._storage_state == StorageState.COPYING_LINES: painter.setPen(old_pen) for line in self._lines_base: self.draw_single_line(line, painter) painter.setPen(new_pen) for line in self._lines_new: self.draw_single_line(line, painter, str(self._current_frame)) else: painter.setPen(new_pen) for line in self._lines_base: self.draw_single_line(line, painter, str(self._current_frame)) if self._current_line is not None: painter.setPen(adj_pen) zoomed = self._current_line.scale(self._current_zoom) qt_line = qc.QLine( qc.QPoint(int(zoomed.start.x), int(zoomed.start.y)), qc.QPoint(int(zoomed.end.x), int(zoomed.end.y))) painter.drawLine(qt_line) painter.end() self.setPixmap(pix) def draw_single_line(self, line, painter, label=None): """ draw a single line segment Args: line (int) the array index of the line painter (QPainter) the painter to be used for the drawing, with pen set label (string) a label for the line, default is None Returns: None """ zoomed = line.scale(self._current_zoom) qt_line = qc.QLine(qc.QPoint(int(zoomed.start.x), int(zoomed.start.y)), qc.QPoint(int(zoomed.end.x), int(zoomed.end.y))) painter.drawLine(qt_line) if self._show_labels and label is not None: self.draw_line_label(painter, zoomed, label) def draw_line_label(self, painter, zoomed, label): """ add the label to a single line segment Args: painter (QPainter) the painter to be used for the drawing, with pen set zoomed (ImageLineSegment) the line that needs labelling, with current zoom applied label (string) a label for the line Returns: None """ # find the bounding box for the text bounding_box = qc.QRect(1, 1, 1, 1) bounding_box = painter.boundingRect(bounding_box, qc.Qt.AlignCenter, label) point = zoomed.start location = qc.QPoint(point.x, point.y) bounding_box = qc.QRect(location, bounding_box.size()) painter.drawText(bounding_box, qc.Qt.AlignHorizontal_Mask | qc.Qt.AlignVertical_Mask, label) def clear_all(self): """ clear all lines Returns: None """ self._lines_base.clear() self._lines_new.clear() self.redisplay() def set_display_line(self, line): """ display the chosen lines, if None no display Args: line (Line) the line to be displayed """ self._display_line = line def save(self, file): """ save the current image as a PNG file Args: file (PyQt5.QtCore.QFile) the file for writing. Returns None. """ file.open(qc.QIODevice.WriteOnly) self.pixmap().save(file, "PNG", quality=100)