Source code for RodTracker.ui.rodimagewidget

# Copyright (c) 2023-24 Adrian Niemann, Dmitry Puzyrev, and others
#
# This file is part of RodTracker.
# RodTracker is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# RodTracker is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with RodTracker. If not, see <http://www.gnu.org/licenses/>.

"""**TBD**"""

import logging
import math
from typing import List, Union

import numpy as np
import pandas as pd
from PyQt5 import QtCore, QtGui
from PyQt5.QtGui import QKeyEvent
from PyQt5.QtWidgets import QInputDialog, QLabel

import RodTracker.backend.logger as lg
import RodTracker.ui.rodnumberwidget as rn
from RodTracker.ui import dialogs

_logger = logging.getLogger(__name__)


[docs] class RodImageWidget(QLabel): """A custom ``QLabel`` that displays an image and can overlay rods. Parameters ---------- *args : iterable Positional arguments for the ``QLabel`` superclass. **kwargs : dict Keyword arguments for the ``QLabel`` superclass. .. admonition:: Signals - :attr:`loaded_rods` - :attr:`normal_frame_change` - :attr:`notify_undone` - :attr:`number_switches` - :attr:`request_color_change` - :attr:`request_frame_change` .. admonition:: Slots - :meth:`adjust_rod_length` - :meth:`delete_rod` - :meth:`extract_rods` - :meth:`undo_action` - :meth:`update_settings` Attributes ---------- base_pixmap : QPixmap A *clean* image in the correct scaled size. rod_pixmap : QPixmap Image that is temporarily painted on when rod corrections are put in by the user. startPos : QtCore.QPoint Start position for new rod position. """ request_color_change = QtCore.pyqtSignal(str, name="request_color_change") """pyqtSignal(str): Request to change the displayed colors. Currently this is used to revert actions performed on a color other than the displayed one. """ request_frame_change = QtCore.pyqtSignal(int, name="request_frame_change") """pyqtSignal(int) : Request to change the displayed frames. Currently this is used to revert actions performed on a frame other than the displayed one. """ notify_undone = QtCore.pyqtSignal(lg.Action, name="notify_undone") """pyqtSignal(Action) : Notifies objects, that the :class:`.Action` in the payload has been reverted. """ normal_frame_change = QtCore.pyqtSignal(int, name="normal_frame_change") """pyqtSignal(int) : Requests a normal change of frame. The payload is the index of the desired frame, relative to the current one, e.g. ``-1`` to request the previous image. """ number_switches = QtCore.pyqtSignal( [lg.NumberChangeActions, int, int, str], [lg.NumberChangeActions, int, int, str, str, int], name="number_switches", ) """pyqtSignal : Indicates switches of numbers between rods. - **[NumberChangeActions, int, int, str]**:\n Notifies data maintainance objects, that the user attempts to change rod IDs in more than just the frame displayed by this :class:`RodImageWidget`.\n Payload: type of the attempted change, previous rod ID, new rod ID, and camera ID - **[NumberChangeActions, int, int, str, str, int]**:\n The second version of this signal will be obsolete.\n Payload: type of the attempted change, previous rod ID, new rod ID, camera ID, rod color, and frame """ loaded_rods = QtCore.pyqtSignal(int, name="loaded_rods") """pyqtSignal(int) : Notifies objects, how many rods have just been loaded for display. """ autoselect: bool = True rods: List[rn.RodNumberWidget] _logger: lg.ActionLogger = None _current_color: str = "unknown" # Settings _rod_thickness = 3 _number_offset = 15 _position_scaling = 10.0 _rod_incr = 1.0 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Custom properties self.startPos = None self._image = None # Image that is temporarily painted on when rod corrections are input self.rod_pixmap = None # "clean" image in correct scaling self.base_pixmap = None self._rods = None self._scale_factor = 1.0 self._offset = [0, 0] self._cam_id = "unknown" # Access to properties ==================================================== @property def rods(self) -> List[rn.RodNumberWidget]: """ Property that hold :class:`.RodNumberWidget` representing rods that are displayable on the Widget. Returns ------- List[RodNumberWidget] """ return self._rods @rods.setter def rods(self, new_edits: List[rn.RodNumberWidget]): # Delete previous rods del self.rods # Save and connect new rods self._rods = new_edits for rod in self._rods: self._connect_rod(rod) self._scale_image() @rods.deleter def rods(self): if self._rods is None: return for rod in self._rods: rod.deleteLater() self._rods = None @property def scale_factor(self) -> float: """ Property that holds the scaling factor by which the original image is scaled when displayed. Returns ------- float """ return self._scale_factor @scale_factor.setter def scale_factor(self, factor: float): if factor <= 0: raise ValueError("factor must be >0") self._scale_factor = factor self._scale_image()
[docs] @QtCore.pyqtSlot(QtGui.QImage) def image(self, new_image: QtGui.QImage): """Show a new image in this widget. Parameters ---------- new_image : QImage Raises ------ ValueError If the image is Null. """ if new_image.isNull(): raise ValueError("Assigned image cannot be 'Null'.") self._image = new_image self.base_pixmap = QtGui.QPixmap.fromImage(new_image) self._scale_image()
@property def logger(self) -> lg.ActionLogger: """ Property that holds a logger object keeping track of users' actions performed on this widget and its contents. Returns ------- ActionLogger """ return self._logger @logger.setter def logger(self, new_logger: lg.ActionLogger): if self._logger: self._logger.undo_action.disconnect() self._logger = new_logger self._logger.undo_action.connect(self.undo_action) @property def cam_id(self) -> str: """ Property that holds a string used as and ID for logging and data selection. It must be human readable as it is used for labelling the performed actions displayed in the GUI. Returns ------- str """ return self._cam_id @cam_id.setter def cam_id(self, cam_id: str): self._cam_id = cam_id try: self._logger.parent_id = cam_id except AttributeError: raise AttributeError( "There is no ActionLogger set for this " "Widget yet." ) @property def active_rod(self): """Property that returns the currently activated rod, if applicable. Returns ------- int | None """ if not self._rods: return None for rod in self._rods: if rod.rod_state == rn.RodState.SELECTED: return rod.rod_id return None
[docs] def frame(self, frame: int): """Set the frame number information about the displayed image. Parameters ---------- frame : int Frame number that is associated with the currently displayed image. """ if self._logger is not None: self._logger.frame = frame
[docs] def set_autoselect(self, state: bool): """En-/Disable autoselection of rods based on the mouse distance. Parameters ---------- state : bool New state of the autoselection. """ self.autoselect = state
# Display manipulation ==================================================== def _scale_image(self) -> None: if self._image is None: return old_pixmap = QtGui.QPixmap.fromImage(self._image) new_pixmap = old_pixmap.scaledToHeight( int(old_pixmap.height() * self._scale_factor), QtCore.Qt.SmoothTransformation, ) self.setPixmap(new_pixmap) self.base_pixmap = new_pixmap # Handle the pixmap's shift to the center of the widget, in cases # the surrounding scrollArea is larger than the pixmap x_off = (self.width() - self.base_pixmap.width()) // 2 y_off = (self.height() - self.base_pixmap.height()) // 2 self._offset = [x_off if x_off > 0 else 0, y_off if y_off > 0 else 0] # Update rod and number display self.draw_rods()
[docs] def draw_rods(self) -> Union[QtGui.QPixmap, None]: """Updates the visual display of overlayed rods in the widget. Updates the visual appearance of all rods that are overlaying the original image. It specifically handles the different visual states a rod can be assigned. Returns ------- Union[QPixmap, None] """ if self._rods is None: # No rods available that might need redrawing return rod_pixmap = QtGui.QPixmap(self.base_pixmap) painter = QtGui.QPainter(rod_pixmap) for rod in self._rods: rod_pos = self.adjust_rod_position(rod) # Gets the display style from the rod number widget. try: pen = rod.pen except rn.RodStateError: dialogs.show_warning( "A rod with unknown state was " "encountered!" ) pen = None if pen is None: continue painter.setPen(pen) painter.drawLine(*rod_pos) painter.end() self.setPixmap(rod_pixmap) return rod_pixmap
[docs] def clear_screen(self) -> None: """Removes the displayed rods and deletes them. Returns ------- None """ del self.rods self._scale_image()
[docs] def scale_to_size(self, new_size: QtCore.QSize): """Scales the image to a specified size. Scales the image to a specified size, while retaining the image's aspect ratio. Parameters ---------- new_size : QSize Returns ------- None """ if self._image is None: return old_pixmap = QtGui.QPixmap.fromImage(self._image) height_ratio = new_size.height() / old_pixmap.height() width_ratio = new_size.width() / old_pixmap.width() if height_ratio > width_ratio: # Use width self.scale_factor = width_ratio return else: # Use height self.scale_factor = height_ratio return
# Interaction callbacks ===================================================
[docs] def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: """Reimplements ``QLabel.mousePressEvent(event)``. Handles the beginning and ending actions for rod corrections by the user. Parameters ---------- event : QMouseEvent Returns ------- None """ if self._rods is not None: if self.startPos is None: # Check rod states for number editing mode for rod in self._rods: if not rod.isReadOnly(): # Rod is in number editing mode if event.button() == QtCore.Qt.LeftButton: # Confirm changes and check for conflicts last_id = rod.rod_id rod.rod_id = int(rod.text()) self.check_rod_conflicts(rod, last_id) elif event.button() == QtCore.Qt.RightButton: # Abort editing and restore previous number rod.setText(str(rod.rod_id)) rod.deactivate_rod() self.draw_rods() self.rod_pixmap = None return if ( event.button() == QtCore.Qt.RightButton and self._rods is not None ): # Deactivate any active rods self.rod_activated(-1) elif event.button() == QtCore.Qt.LeftButton: # Start rod correction self.startPos = self.subtract_offset( event.pos(), self._offset ) for rod in self._rods: if rod.rod_state == rn.RodState.SELECTED: rod.rod_state = rn.RodState.EDITING self.rod_pixmap = self.draw_rods() else: if event.button() == QtCore.Qt.RightButton: # Abort current line drawing self.startPos = None for rod in self._rods: if rod.rod_state == rn.RodState.EDITING: rod.rod_state = rn.RodState.SELECTED self.draw_rods() self.rod_pixmap = None else: # Finish line and save it self.save_line( self.startPos, self.subtract_offset(event.pos(), self._offset), ) self.startPos = None self.draw_rods() self.rod_pixmap = None
[docs] def mouseMoveEvent(self, mouse_event: QtGui.QMouseEvent) -> None: """Reimplements ``QLabel.mouseMoveEvent(event)``. Handles the drawing and updating of a *draft* rod during start and end point selection. Parameters ---------- mouse_event : QMouseEvent Returns ------- None """ # Draw intermediate rod position between clicks if self.startPos is not None: end = self.subtract_offset(mouse_event.pos(), self._offset) pixmap = QtGui.QPixmap(self.rod_pixmap) qp = QtGui.QPainter(pixmap) pen = QtGui.QPen(QtCore.Qt.white, self._rod_thickness) qp.setPen(pen) qp.drawLine(self.startPos, end) qp.end() self.setPixmap(pixmap) elif self.rods is not None and self.autoselect: # activate rod that is closes to the cursor mouse_pos = np.array( [mouse_event.pos().x(), mouse_event.pos().y()] ) closest_rod = None min_dist = np.inf for rod in self.rods: # distance 0: distance from center # distance = np.linalg.norm(rod.rod_center - mouse_pos) # distance 1: use closest endpoint points = np.asarray( [ self._position_scaling * self._scale_factor * coord for coord in rod.rod_points ] ) dist_p1 = np.linalg.norm( points[0:2] + np.array(self._offset) - mouse_pos ) dist_p2 = np.linalg.norm( points[2:] + np.array(self._offset) - mouse_pos ) distance = np.min([dist_p1, dist_p2]) # distance 2: min distance to the whole line segment # points = np.asarray([ # self._position_scaling * self._scale_factor * coord # for coord in rod.rod_points]) # p1 = points[0:2] + np.array(self._offset) # p2 = points[2:] + np.array(self._offset) # distance = _line_segment_distance(p1, p2, mouse_pos) if distance < min_dist: min_dist = distance closest_rod = rod self.rod_activated(closest_rod.rod_id)
[docs] def save_line(self, start: QtCore.QPoint, end: QtCore.QPoint): """Saves a line selected by the user to be a rod with a rod number. The user's selected start and end point are saved in a :class:`.RodNumberWidget`. Either in one that was activated prior to the point selection, or that is selected by the user post point selection as part of this function. Parameters ---------- start : QPoint end : QPoint Returns ------- None """ send_rod = None for rod in self._rods: if rod.rod_state == rn.RodState.EDITING: new_position = [start.x(), start.y(), end.x(), end.y()] new_position = [ coord / self._position_scaling / self._scale_factor for coord in new_position ] rod.seen = True this_action = lg.ChangeRodPositionAction( rod.copy(), new_position ) self._logger.add_action(this_action) rod.rod_points = new_position rod.rod_state = rn.RodState.SELECTED send_rod = rod break if send_rod is None: # Find out which rods are unseen rods_unseen = [] for rod in self._rods: if not rod.seen: rods_unseen.append(rod.rod_id) rods_unseen.sort() # Get intended rod number from user if rods_unseen: dialog_rodnum = ( f"Unseen rods: {rods_unseen}\n" f"Enter rod number:" ) selected_rod, ok = QInputDialog.getInt( self, "Choose a rod to replace", dialog_rodnum, value=rods_unseen[0], min=0, max=99, ) else: dialog_rodnum = "No unseen rods. Enter rod number: " selected_rod, ok = QInputDialog.getInt( self, "Choose a rod to replace", dialog_rodnum, min=0, max=99, ) if not ok: return # Check whether the rod already exists rod_exists = False for rod in self._rods: if rod.rod_id == selected_rod: # Overwrite previous position rod_exists = True rod.rod_id = selected_rod new_position = [start.x(), start.y(), end.x(), end.y()] new_position = [ coord / self._position_scaling / self._scale_factor for coord in new_position ] # Mark rod as "seen", before logging! rod.seen = True this_action = lg.ChangeRodPositionAction( rod.copy(), new_position ) self._logger.add_action(this_action) rod.rod_points = new_position rod.rod_state = rn.RodState.SELECTED break if not rod_exists: # Rod didn't exists -> create new RodNumber corrected_pos = [ start.x() / self._position_scaling / self._scale_factor, start.y() / self._position_scaling / self._scale_factor, end.x() / self._position_scaling / self._scale_factor, end.y() / self._position_scaling / self._scale_factor, ] self.create_rod(selected_rod, corrected_pos)
# Rod Handling ============================================================
[docs] def rod_activated(self, rod_id: int) -> None: """Changes the rod state of the one given to active. The rod state of the rod, which ID is given to active and deactivates all other rods maintained by this widget. Parameters ---------- rod_id : int ID of the rod that shall be activated. Returns ------- None """ # A new rod was activated for position editing. Deactivate all others. for rod in self._rods: if rod.rod_id != rod_id: rod.deactivate_rod() if rod.rod_id == rod_id: rod.rod_state = rn.RodState.SELECTED rod.setFocus(QtCore.Qt.OtherFocusReason) self.draw_rods()
[docs] def check_rod_conflicts( self, set_rod: rn.RodNumberWidget, last_id: int ) -> None: """Checks whether a new/changed rod has a number conflict with others. Checks whether a new/changed rod has an ID that conflicts with is already occupied by one/multiple other rods in this widget. The user is displayed multiple options for resolving these conflicts. Parameters ---------- set_rod : RodNumberWidget The rod in its new (changed) state. last_id : int The rod's previous ID, i.e. directly prior to the change. Returns ------- None .. hint:: **Emits**: - :attr:`number_switches` [NumberChangeActions, int, int, str] - :attr:`number_switches` [NumberChangeActions, int, int, str, int, str] """ # Marks any rods that have the same number in RodStyle.CONFLICT conflicting = [] for rod in self._rods: if rod.rod_id == set_rod.rod_id: conflicting.append(rod) if len(conflicting) > 1: for rod in conflicting: rod.rod_state = rn.RodState.CONFLICT self.draw_rods() if len(conflicting) > 1: msg = dialogs.ConflictDialog(last_id, set_rod.rod_id) msg.exec() if msg.clickedButton() == msg.btn_switch_all: self.number_switches.emit( lg.NumberChangeActions.ALL, last_id, set_rod.rod_id, self.cam_id, ) self._logger.add_action( lg.NumberExchange( lg.NumberChangeActions.ALL, last_id, set_rod.rod_id, set_rod.color, self._logger.frame, self.cam_id, ) ) elif msg.clickedButton() == msg.btn_one_cam: self.number_switches.emit( lg.NumberChangeActions.ALL_ONE_CAM, last_id, set_rod.rod_id, self.cam_id, ) self._logger.add_action( lg.NumberExchange( lg.NumberChangeActions.ALL_ONE_CAM, last_id, set_rod.rod_id, set_rod.color, self._logger.frame, self.cam_id, ) ) elif msg.clickedButton() == msg.btn_both_cams: self.number_switches.emit( lg.NumberChangeActions.ONE_BOTH_CAMS, last_id, set_rod.rod_id, self.cam_id, ) self._logger.add_action( lg.NumberExchange( lg.NumberChangeActions.ONE_BOTH_CAMS, last_id, set_rod.rod_id, set_rod.color, self._logger.frame, self.cam_id, ) ) elif msg.clickedButton() == msg.btn_only_this: # Switch the rod numbers first_change = None second_change = None for rod in conflicting: rod.rod_state = rn.RodState.CHANGED if rod is not set_rod: id_to_log = rod.rod_id rod.setText(str(last_id)) rod.rod_id = last_id first_change = self.catch_rodnumber_change( rod, id_to_log ) else: second_change = self.catch_rodnumber_change( rod, last_id ) first_change.coupled_action = second_change second_change.coupled_action = first_change elif msg.clickedButton() == msg.btn_cancel: # Return to previous state if last_id == -1: set_rod.deleteLater() self._rods.remove(set_rod) else: set_rod.setText(str(last_id)) set_rod.rod_id = last_id for rod in conflicting: rod.rod_state = rn.RodState.CHANGED self.draw_rods() else: if set_rod.rod_id == last_id: return # No conflicts, inform logger if self._logger is None: raise Exception("Logger not set.") new_id = set_rod.rod_id set_rod.rod_id = last_id self.create_rod(new_id, set_rod.rod_points) self.delete_rod(set_rod)
[docs] def catch_rodnumber_change( self, new_rod: rn.RodNumberWidget, last_id: int ) -> lg.ChangedRodNumberAction: """Handles the number/ID change of rods for logging. Constructs an Action for a number/ID change of a rod that can be used for logging with an ActionLogger. Parameters ---------- new_rod : RodNumberWidget The rod in its new (changed) state. last_id : int The rod's previous ID, i.e. directly prior to the change. Returns ------- ChangedRodNumberAction """ old_rod = new_rod.copy() old_rod.setEnabled(False) old_rod.setVisible(False) old_rod.rod_id = last_id new_id = new_rod.rod_id action = lg.ChangedRodNumberAction(old_rod, new_id) self._logger.add_action(action) return action
[docs] def check_exchange(self, drop_position): """Evaluates, whether a position is on top of a :class:`.RodNumberWidget`. Evaluates, whether a position is on top of a :class:`.RodNumberWidget` maintained by this object.""" # TODO: check where rod number was dropped and whether an exchange # is needed. pass
[docs] @QtCore.pyqtSlot(lg.Action) def undo_action( self, action: Union[ lg.Action, lg.ChangeRodPositionAction, lg.ChangedRodNumberAction, lg.DeleteRodAction, ], ): """Reverts an :class:`.Action` performed on a rod. Reverts the :class:`.Action` given this function, if it was constructed by the object. It can handle actions performed on a rod. This includes position changes, number changes and deletions. It returns without further actions, if the :class:`.Action` was not originally performed on this object or if it has is of an unknown type. Parameters ---------- action : Union[Action, ChangeRodPositionAction, ChangedRodNumberAction, DeleteRodAction] An :class:`.Action` that was logged previously. It will only be reverted, if it associated with this object. Returns ------- None .. hint:: **Emits**: - :attr:`request_frame_change` - :attr:`request_color_change` - :attr:`number_switches` [NumberChangeActions, int, int, str, str, int] """ if action.parent_id != self._cam_id: return if action.frame != self.logger.frame: self.request_frame_change.emit(action.frame) try: action_color = action.rod.color if action_color != self._rods[0].color: self.request_color_change.emit(action_color) except AttributeError: # Given action does not require a color to be handled pass if isinstance(action, lg.ChangeRodPositionAction) or isinstance( action, lg.PruneLength ): new_rods = action.undo(rods=self._rods) self._rods = new_rods self.draw_rods() elif isinstance(action, lg.DeleteRodAction): if action.coupled_action is not None: self._logger.register_undone(action.coupled_action) current_rods = self._rods new_rods = [] for rod in current_rods: if rod.rod_id != action.rod.rod_id: new_rods.append(rod) else: rod.deleteLater() if action.coupled_action: new_rods = action.coupled_action.undo(rods=new_rods) deleted_rod = action.undo(None) self._connect_rod(deleted_rod) new_rods.append(deleted_rod) self._rods = new_rods self.draw_rods() elif isinstance(action, lg.ChangedRodNumberAction): if action.coupled_action is not None: self._logger.register_undone(action.coupled_action) new_rods = action.undo(rods=self._rods) self._rods = new_rods self.draw_rods() elif isinstance(action, lg.CreateRodAction): new_rods = action.undo(rods=self._rods) if action.coupled_action is not None: # This should only get triggered when a # RodNumberChangeAction that incorporates a RodDeletion gets # redone, so a CreateRodAction+RodPositionChange. # If this shall be extended to more combinations the line # below must be uncommented and then the redo mechanism will # break for the above mentioned occasion! So more work is # required then. # self._logger.register_undone(action.coupled_action) new_rods = action.coupled_action.undo(rods=new_rods) self._rods = new_rods self.draw_rods() elif isinstance(action, lg.NumberExchange): self.number_switches[ lg.NumberChangeActions, int, int, str, str, int ].emit( action.mode, action.new_id, action.previous_id, action.cam_id, action.color, action.frame, ) else: # Cannot handle this action return
[docs] def resizeEvent(self, a0: QtGui.QResizeEvent) -> None: """ Adjust rod positions after resizing of the widget happened, e.g. the slider was actuated or the image was scaled. Parameters ---------- a0 : QResizeEvent Returns ------- None """ super().resizeEvent(a0) # Adjust rod positions after resizing of the widget happened, # e.g. the slider was actuated or the image was scaled if self._rods is not None: # Calculate offset x_off = (a0.size().width() - self.base_pixmap.width()) // 2 y_off = (a0.size().height() - self.base_pixmap.height()) // 2 x_off = x_off if x_off > 0 else 0 y_off = y_off if y_off > 0 else 0 self._offset = [x_off, y_off] # Complete version for rod in self._rods: self.adjust_rod_position(rod)
[docs] @QtCore.pyqtSlot(float, bool) def adjust_rod_length( self, amount: float = 1.0, only_selected: bool = True ): """Adjusts rod length(s) by a given amount in px. Adds the length (in px) given in ``amount`` to the active rod or all rods. Negative values shorten the rod(s). Parameters ---------- amount : float, optional Amount in px by which the lenght will be adjusted. By default ``1``. only_selected : bool, optional Whether to only adjust the currently active rod's length. By default ``True``. """ rods = [] new_pos = [] previously_selected = None for rod in self._rods: if rod.rod_state == rn.RodState.SELECTED: previously_selected = rod.rod_id elif only_selected: continue n_p = np.asarray(rod.rod_points) rod_direction = np.array([n_p[0:2] - n_p[2:]]) rod_direction = rod_direction / np.sqrt(np.sum(rod_direction**2)) rod_direction = amount / 2 * rod_direction if np.isnan(rod_direction).any(): rod_direction = np.array([0, 0]) n_p += np.concatenate([rod_direction, -rod_direction]).flatten() n_p = list(n_p) rod.rod_points = n_p rods.append(rod.copy()) new_pos.append(n_p) self._logger.add_action(lg.PruneLength(rods, new_pos, amount)) self.draw_rods() self.rod_activated(previously_selected) return
[docs] @staticmethod def subtract_offset( point: QtCore.QPoint, offset: List[int] ) -> QtCore.QPoint: """Subtracts a given offset from a point and returns the new point. Parameters ---------- point : QPoint offset : List[int] Returns ------- QPoint """ new_x = point.x() - offset[0] new_y = point.y() - offset[1] return QtCore.QPoint(new_x, new_y)
[docs] def adjust_rod_position(self, rod: rn.RodNumberWidget) -> List[int]: """Adjusts a rod number position to be on the right side of its rod. The position of the :class:`.RodNumberWidget` is adjusted, such that it is displayed to the right side and in the middle of its corresponding rod. This adjustment is mainly due to scaling of the image. It also returns the rods position in the image associated with the moved :class:`.RodNumberWidget`. Parameters ---------- rod : RodNumberWidget Returns ------- List[int] """ rod_pos = rod.rod_points rod_pos = [ int(self._position_scaling * self._scale_factor * coord) for coord in rod_pos ] rod.rod_center = (np.array(rod_pos[0:2]) + np.array(rod_pos[2:])) / 2 rod.rod_center += np.array(self._offset) # Update rod number positions x = rod_pos[2] - rod_pos[0] y = rod_pos[3] - rod_pos[1] x_orthogonal = -y y_orthogonal = x if x_orthogonal < 0: # Change vector to always point to the right x_orthogonal = -x_orthogonal y_orthogonal = -y_orthogonal len_vec = math.sqrt(x_orthogonal**2 + y_orthogonal**2) try: pos_x = ( rod_pos[0] + int(x_orthogonal / len_vec * self._number_offset) + int(x / 2) ) pos_y = ( rod_pos[1] + int(y_orthogonal / len_vec * self._number_offset) + int(y / 2) ) # Account for the widget's dimensions pos_x -= rod.size().width() / 2 pos_y -= rod.size().height() / 2 except ZeroDivisionError: # Rod has length of 0 pos_x = rod_pos[0] + self._number_offset pos_y = rod_pos[1] + self._number_offset pos_x += self._offset[0] pos_y += self._offset[1] rod.move(QtCore.QPoint(int(pos_x), int(pos_y))) return rod_pos
[docs] @QtCore.pyqtSlot(rn.RodNumberWidget) def delete_rod(self, rod: rn.RodNumberWidget) -> None: """Deletes the given rod, thus sets its position to ``(-1, -1)``. Parameters ---------- rod : RodNumberWidget Returns ------- None """ rod.setText(str(rod.rod_id)) delete_action = lg.DeleteRodAction(rod.copy()) rod.seen = False rod.rod_points = 4 * [-1] rod.rod_state = rn.RodState.CHANGED self._logger.add_action(delete_action) self.draw_rods()
def _connect_rod(self, rod: rn.RodNumberWidget) -> None: """Connects all signals from the given rod with the widget's slots. Parameters ---------- rod : RodNumberWidget Returns ------- None """ rod.activated.connect(self.rod_activated) rod.id_changed.connect(self.check_rod_conflicts) rod.request_delete.connect(self.delete_rod) rod.installEventFilter(self) rod.show()
[docs] def eventFilter( self, source: QtCore.QObject, event: QtCore.QEvent ) -> bool: """Intercepts events, here ``QKeyEvents`` for frame switching and edit aborting. Parameters ---------- source : QObject event : QEvent Returns ------- bool ``True``, if the event shall not be propagated further. ``False``, if the event shall be passed to the next object to be handled. .. hint:: **Emits:** - :attr:`normal_frame_change` """ if event.type() != QtCore.QEvent.KeyPress: return False event = QKeyEvent(event) if not isinstance(source, rn.RodNumberWidget): return False if source.isReadOnly(): if event.key() == QtCore.Qt.Key_Escape: # Abort any editing (not rod number editing) if self._rods is not None: # Deactivate any active rods self.rod_activated(-1) return True elif event.key() == QtCore.Qt.Key_Right: self.normal_frame_change.emit(1) return False elif event.key() == QtCore.Qt.Key_Left: self.normal_frame_change.emit(-1) return False elif event.key() == QtCore.Qt.Key_A: # Lengthen rod amount = self._rod_incr elif event.key() == QtCore.Qt.Key_S: # Shorten rod amount = -self._rod_incr elif event.key() == QtCore.Qt.Key.Key_Delete: # Delete the currently selected rod self.delete_rod(source) return True else: # RodNumberWidget is in the process of rod number changing, # let the widget handle that itself return False if "amount" in locals(): # Adjust rod length self.adjust_rod_length(amount) return True return False
[docs] @QtCore.pyqtSlot(dict) def update_settings(self, settings: dict) -> None: """Catches updates of the settings from a :class:`.Settings` class. Checks for the keys relevant to itself and updates the corresponding attributes. Redraws itself with the new settings in place. Parameters ---------- settings : dict Returns ------- None """ settings_changed = False if "rod_thickness" in settings: settings_changed = True self._rod_thickness = settings["rod_thickness"] if "number_offset" in settings: settings_changed = True self._number_offset = settings["number_offset"] if "position_scaling" in settings: settings_changed = True self._position_scaling = settings["position_scaling"] if "rod_increment" in settings: settings_changed = True self._rod_incr = settings["rod_increment"] if settings_changed: self.draw_rods()
[docs] @QtCore.pyqtSlot(pd.DataFrame, str) def extract_rods(self, data: pd.DataFrame, color: str) -> None: """Extract rod positions for a color and create the :class:`.RodNumberWidget` (s). Extracts the rod position data one color in one frame from ``data``. It creates the :class:`.RodNumberWidget` that is associated with each rod. If a rod has been activated previously, it is attempted to activate a rod with the same number again. Parameters ---------- data : DataFrame Data from which to extract the 2D rod positions relevant to this camera view. Required columns: ``"x1_{self.cam_id}"``, ``"x2_{self.cam_id}"``, ``"y1_{self.cam_id}"``, ``"y2_{self.cam_id}"``, ``"seen_{self.cam_id}"``, ``"particle"``, ``"frame"`` color : str Color of the rods given in ``data``. Returns ------- None .. hint:: **Emits:** - :attr:`loaded_rods` """ active_rod = self.active_rod col_list = [ "particle", "frame", f"x1_{self.cam_id}", f"x2_{self.cam_id}", f"y1_{self.cam_id}", f"y2_{self.cam_id}", f"seen_{self.cam_id}", ] try: data = data[col_list] except KeyError: _logger.info( f"Couldn't extract rods. Didn't find columns: " f"{col_list}" ) del self.rods return self._current_color = color new_rods = [] cleaned = data.fillna(-1) for _, rod in cleaned.iterrows(): x1 = rod[f"x1_{self.cam_id}"] x2 = rod[f"x2_{self.cam_id}"] y1 = rod[f"y1_{self.cam_id}"] y2 = rod[f"y2_{self.cam_id}"] seen = bool(rod[f"seen_{self.cam_id}"]) no = int(rod["particle"]) # Add rods ident = rn.RodNumberWidget( color, self, str(no), QtCore.QPoint(0, 0) ) ident.rod_id = no ident.rod_points = [x1, y1, x2, y2] ident.setObjectName(f"rn_{no}") ident.seen = seen new_rods.append(ident) self.rods = new_rods if active_rod is not None: self.rod_activated(active_rod) self.loaded_rods.emit(len(new_rods))
[docs] def create_rod(self, number: int, new_position: list): """Create a new rod, display it and log this action. Parameters ---------- number : int new_position : list Positon coordinates: [x1, y1, x2, y2] """ new_rod = rn.RodNumberWidget(self._current_color, self, str(number)) new_rod.rod_id = number new_rod.setObjectName(f"rn_{number}") new_rod.rod_points = new_position new_rod.rod_state = rn.RodState.SELECTED # Newly created rods are always "seen" new_rod.seen = True self._rods.append(new_rod) self._connect_rod(new_rod) self._scale_image() last_action = lg.CreateRodAction(new_rod.copy()) self._logger.add_action(last_action)
[docs] def line_segment_distance( p1: np.ndarray, p2: np.ndarray, p: np.ndarray ) -> float: l2 = np.sum((p2 - p1) ** 2) # |p2-p1|, without the root if l2 == 0.0: # line segment of length 0 return np.linalg.norm(p1 - p) m = np.max([0.0, np.min([1.0, np.dot(p - p1, p2 - p1) / l2])]) min_d_point = p1 + m * (p2 - p1) return float(np.linalg.norm(min_d_point - p))