diff --git a/Builds/labelImg1.0.1.zip b/Builds/labelImg1.0.1.zip
new file mode 100644
index 000000000..aa828d8a9
Binary files /dev/null and b/Builds/labelImg1.0.1.zip differ
diff --git a/Builds/labelImg1.0.2.zip b/Builds/labelImg1.0.2.zip
new file mode 100644
index 000000000..4878d600b
Binary files /dev/null and b/Builds/labelImg1.0.2.zip differ
diff --git a/Builds/labelImg1.0.3.zip b/Builds/labelImg1.0.3.zip
new file mode 100644
index 000000000..862ae1f90
Binary files /dev/null and b/Builds/labelImg1.0.3.zip differ
diff --git a/data/predefined_classes.txt b/data/predefined_classes.txt
index bc2eef133..e69de29bb 100755
--- a/data/predefined_classes.txt
+++ b/data/predefined_classes.txt
@@ -1,15 +0,0 @@
-dog
-person
-cat
-tv
-car
-meatballs
-marinara sauce
-tomato soup
-chicken noodle soup
-french onion soup
-chicken breast
-ribs
-pulled pork
-hamburger
-cavity
\ No newline at end of file
diff --git a/labelImg.py b/labelImg.py
index efd8a2976..78aaf9767 100755
--- a/labelImg.py
+++ b/labelImg.py
@@ -2,6 +2,7 @@
# -*- coding: utf-8 -*-
import argparse
import codecs
+from operator import truediv
import os.path
import platform
import shutil
@@ -188,7 +189,7 @@ def __init__(self, default_filename=None, default_prefdef_class_file=None, defau
self.canvas.zoomRequest.connect(self.zoom_request)
self.canvas.lightRequest.connect(self.light_request)
self.canvas.set_drawing_shape_to_square(settings.get(SETTING_DRAW_SQUARE, False))
-
+ self.canvas.onStartAction.connect(self.saveHistoryBoxes)
scroll = QScrollArea()
scroll.setWidget(self.canvas)
scroll.setWidgetResizable(True)
@@ -242,6 +243,20 @@ def __init__(self, default_filename=None, default_prefdef_class_file=None, defau
save = action(get_str('save'), self.save_file,
'Ctrl+S', 'save', get_str('saveDetail'), enabled=False)
+ actionSelectAll = QAction( "Select all", self)
+ actionSelectAll.setShortcut("Ctrl+A")
+ def selectAll():
+ self.toggle_polygons(True)
+ self.canvas.selectAll()
+ actionSelectAll.triggered.connect(selectAll)
+ self.addAction(actionSelectAll)
+
+ #add action undo
+ # actionUndo =QAction("Undo", self)
+ # actionUndo.setShortcut("Ctrl+Z")
+ # actionUndo.triggered.connect(self.undoActions)
+ # self.addAction(actionUndo)
+
def get_format_meta(format):
"""
returns a tuple containing (title, icon_name) of the selected format
@@ -291,7 +306,7 @@ def get_format_meta(format):
'Ctrl+H', 'hide', get_str('hideAllBoxDetail'),
enabled=False)
show_all = action(get_str('showAllBox'), partial(self.toggle_polygons, True),
- 'Ctrl+A', 'hide', get_str('showAllBoxDetail'),
+ 'Ctrl+I', 'hide', get_str('showAllBoxDetail'),
enabled=False)
help_default = action(get_str('tutorialDefault'), self.show_default_tutorial_dialog, None, 'help', get_str('tutorialDetail'))
@@ -540,13 +555,15 @@ def xbool(x):
self.open_dir_dialog(dir_path=self.file_path, silent=True)
def keyReleaseEvent(self, event):
- if event.key() == Qt.Key_Control:
- self.canvas.set_drawing_shape_to_square(False)
+ print("key release")
+ # if event.key() == Qt.Key_Control:
+ # self.canvas.set_drawing_shape_to_square(False)
def keyPressEvent(self, event):
- if event.key() == Qt.Key_Control:
- # Draw rectangle if Ctrl is pressed
- self.canvas.set_drawing_shape_to_square(True)
+ print("key Pressed")
+ # if event.key() == Qt.Key_Control:
+ # # Draw rectangle if Ctrl is pressed
+ # self.canvas.set_drawing_shape_to_square(True)
# Support Functions #
def set_format(self, save_format):
@@ -1178,6 +1195,8 @@ def counter_str(self):
return '[{} / {}]'.format(self.cur_img_idx + 1, self.img_count)
def show_bounding_box_from_annotation_file(self, file_path):
+ if file_path is None:
+ return
if self.default_save_dir is not None:
basename = os.path.basename(os.path.splitext(file_path)[0])
xml_path = os.path.join(self.default_save_dir, basename + XML_EXT)
@@ -1205,7 +1224,6 @@ def show_bounding_box_from_annotation_file(self, file_path):
self.load_yolo_txt_by_filename(txt_path)
elif os.path.isfile(json_path):
self.load_create_ml_json_by_filename(json_path, file_path)
-
def resizeEvent(self, event):
if self.canvas and not self.image.isNull()\
@@ -1449,7 +1467,17 @@ def open_next_image(self, _value=False):
if filename:
self.load_file(filename)
+ def saveHistoryBoxes(self, firstTime = False):
+ pass
+ # for shape in self.canvas.shapes:
+ # shape.saveHistory()
+ def undoActions(self):
+ print("Undo")
+ for shape in self.canvas.shapes:
+ shape.undoAction()
+ self.canvas.update()
+
def open_file(self, _value=False):
if not self.may_continue():
return
@@ -1478,6 +1506,7 @@ def save_file(self, _value=False):
saved_path = os.path.join(image_file_dir, saved_file_name)
self._save_file(saved_path if self.label_file
else self.save_file_dialog(remove_ext=False))
+ self.saveHistoryBoxes()
def save_file_as(self, _value=False):
assert not self.image.isNull(), "cannot save empty image"
diff --git a/libs/canvas.py b/libs/canvas.py
index ca7986ff3..861d5c8a5 100644
--- a/libs/canvas.py
+++ b/libs/canvas.py
@@ -9,8 +9,12 @@
# from PyQt4.QtOpenGL import *
+from math import fabs
+import math
+from pickle import TRUE
+from turtle import isvisible, width
from libs.shape import Shape
-from libs.utils import distance
+from libs.utils import angleFrom2Vector, distance
CURSOR_DEFAULT = Qt.ArrowCursor
CURSOR_POINT = Qt.PointingHandCursor
@@ -22,6 +26,13 @@
class Canvas(QWidget):
+ posRotate = None
+ imgRotate = QImage(":/rotate_360")
+ lastPos = None
+ isKeyControlPressed = False
+ isKeyShortcutRotate = False
+ isKeyShortcutRotateKeepSize = False
+ isMultySelected = False
zoomRequest = pyqtSignal(int)
lightRequest = pyqtSignal(int)
scrollRequest = pyqtSignal(int, int)
@@ -30,6 +41,9 @@ class Canvas(QWidget):
shapeMoved = pyqtSignal()
drawingPolygon = pyqtSignal(bool)
+ onEndAction = pyqtSignal()
+ onStartAction = pyqtSignal()
+
CREATE, EDIT = list(range(2))
epsilon = 24.0
@@ -38,7 +52,7 @@ def __init__(self, *args, **kwargs):
super(Canvas, self).__init__(*args, **kwargs)
# Initialise local state.
self.mode = self.EDIT
- self.shapes = []
+ self.shapes : list[Shape] = []
self.current = None
self.selected_shape = None # save the selected shape here
self.selected_shape_copy = None
@@ -111,7 +125,25 @@ def selected_vertex(self):
def mouseMoveEvent(self, ev):
"""Update line with last point and current coordinates."""
pos = self.transform_pos(ev.pos())
+ if self.isKeyShortcutRotateKeepSize:
+ angle = angleFrom2Vector(QPoint(0,-1), pos - self.posRotate)
+ canvasSize = QPoint(self.pixmap.width(), self.pixmap.height())
+ for _shape in self.shapes:
+ if _shape.selected:
+ _shape.rotateKeepSize(self.posRotate, angle, canvasSize)
+ self.shapeMoved.emit()
+ self.lastPos = pos
+ return
+ if self.isKeyShortcutRotate:
+ angle = angleFrom2Vector(QPoint(0,-1), pos - self.posRotate)
+ canvasSize = QPoint(self.pixmap.width(), self.pixmap.height())
+ for _shape in self.shapes:
+ if _shape.selected:
+ _shape.rotate(self.posRotate, angle, canvasSize)
+ self.shapeMoved.emit()
+ self.lastPos = pos
+ return
# Update coordinates in status bar if image is opened
window = self.parent().window()
if window.file_path is not None:
@@ -177,6 +209,7 @@ def mouseMoveEvent(self, ev):
# Polygon/Vertex moving.
if Qt.LeftButton & ev.buttons():
+
if self.selected_vertex():
self.bounded_move_vertex(pos)
self.shapeMoved.emit()
@@ -190,11 +223,15 @@ def mouseMoveEvent(self, ev):
self.parent().window().label_coordinates.setText(
'Width: %d, Height: %d / X: %d; Y: %d' % (current_width, current_height, pos.x(), pos.y()))
elif self.selected_shape and self.prev_point:
+ if self.isMultySelected:
+ self.bounded_move_all_shape(pos=pos)
+ self.repaint()
+ return
+
self.override_cursor(CURSOR_MOVE)
self.bounded_move_shape(self.selected_shape, pos)
self.shapeMoved.emit()
self.repaint()
-
# Display annotation width and height while moving shape
point1 = self.selected_shape[1]
point3 = self.selected_shape[3]
@@ -203,6 +240,7 @@ def mouseMoveEvent(self, ev):
self.parent().window().label_coordinates.setText(
'Width: %d, Height: %d / X: %d; Y: %d' % (current_width, current_height, pos.x(), pos.y()))
else:
+
# pan
delta = ev.pos() - self.pan_initial_pos
self.scrollRequest.emit(delta.x(), Qt.Horizontal)
@@ -254,6 +292,25 @@ def mouseMoveEvent(self, ev):
self.update()
self.h_vertex, self.h_shape = None, None
self.override_cursor(CURSOR_DEFAULT)
+ self.lastPos = pos
+
+ def drawIconRotate(self):
+ size = 200
+ angle = angleFrom2Vector(QPointF(0,-1), self.lastPos - self.posRotate)
+ degree = angle * 180 / math.pi
+ self._painter.translate(self.posRotate.x(),self.posRotate.y())
+ self._painter.rotate(degree)
+ self._painter.translate(-self.posRotate.x() - size/2,-self.posRotate.y() - size/2)
+ self._painter.drawImage(QRect(self.posRotate.x(),self.posRotate.y(), size, size),self.imgRotate)
+ self._painter.resetTransform()
+
+ # secondColor = QColor(0,250,0)
+ # self._painter.setPen(secondColor)
+ # fontsize = 25
+ # font= QFont()
+ # font.setPointSize(fontsize)
+ # self._painter.setFont(font)
+ # self._painter.drawText(QPoint(self.posRotate.x() - fontsize, self.posRotate.y()+size/2 + fontsize),str(round(degree))+"°")
def mousePressEvent(self, ev):
pos = self.transform_pos(ev.pos())
@@ -262,7 +319,18 @@ def mousePressEvent(self, ev):
if self.drawing():
self.handle_drawing(pos)
else:
- selection = self.select_shape_point(pos)
+ selection = self.get_shape_point(pos)
+ if self.isKeyControlPressed:
+ self.select_shape_point_multy_select(pos)
+ elif selection != None:
+ if selection.selected == True:
+ self.select_shape_point_multy_select(pos)
+ else:
+ selection = self.select_shape_point(pos)
+ else:
+ self.de_select_shape()
+ pass
+
self.prev_point = pos
if selection is None:
@@ -271,11 +339,22 @@ def mousePressEvent(self, ev):
self.pan_initial_pos = ev.pos()
elif ev.button() == Qt.RightButton and self.editing():
+ print("Qt.RightButton")
self.select_shape_point(pos)
self.prev_point = pos
self.update()
+ self.onStartAction.emit()
+
+ def selectAll(self):
+ self.isMultySelected = True
+ for shape in self.shapes:
+ shape.selected = True
+ if self.isVisible(shape) != True:
+ self.set_shape_visible(shape, True)
+
def mouseReleaseEvent(self, ev):
+
if ev.button() == Qt.RightButton:
menu = self.menus[bool(self.selected_shape_copy)]
self.restore_cursor()
@@ -302,6 +381,7 @@ def end_move(self, copy=False):
shape = self.selected_shape_copy
# del shape.fill_color
# del shape.line_color
+ print("end move")
if copy:
self.shapes.append(shape)
self.selected_shape.selected = False
@@ -375,6 +455,32 @@ def select_shape_point(self, point):
return self.selected_shape
return None
+ def get_shape_point(self, point):
+ for shape in reversed(self.shapes):
+ if self.isVisible(shape) and shape.contains_point(point):
+ return shape
+ return None
+ def select_shape_point_multy_select(self, point):
+ """Select the first shape created which contains this point."""
+ containShape = False
+ for shape in reversed(self.shapes):
+ if self.isVisible(shape) and shape.contains_point(point):
+ if self.isKeyControlPressed == True:
+ if shape.selected:
+ shape.selected = False
+ else:
+ shape.selected = True
+ self.selected_shape = shape
+ else:
+ self.selected_shape = shape
+ shape.selected = True
+ containShape = True
+ break
+ if containShape == False:
+ self.de_select_shape()
+ self.isMultySelected = True
+ return self.selected_shape
+ #return None
def calculate_offsets(self, shape, point):
rect = shape.bounding_rect()
x1 = rect.x() - point.x()
@@ -432,6 +538,33 @@ def bounded_move_vertex(self, pos):
right_shift = QPointF(0, shift_pos.y())
shape.move_vertex_by(right_index, right_shift)
shape.move_vertex_by(left_index, left_shift)
+ def bounded_move_all_shape(self, pos):
+ if self.out_of_pixmap(pos):
+ return False
+ originPos = pos
+ # The next line tracks the new position of the cursor
+ # relative to the shape, but also results in making it
+ # a bit "shaky" when nearing the border and allows it to
+ # go outside of the shape's area for some reason. XXX
+ # self.calculateOffsets(self.selectedShape, pos)
+ self.offsets = QPointF(self.pixmap.width(), self.pixmap.height()), QPointF(0,0)
+ for _shape in self.shapes:
+ if _shape.selected == False:
+ continue
+ pos = originPos
+ #self.calculate_offsets(_shape,pos )
+ o1 = pos + self.offsets[0]
+ if self.out_of_pixmap(o1):
+ pos -= QPointF(min(0, o1.x()), min(0, o1.y()))
+ o2 = pos + self.offsets[1]
+ if self.out_of_pixmap(o2):
+ pos += QPointF(min(0, self.pixmap.width() - o2.x()),
+ min(0, self.pixmap.height() - o2.y()))
+ dp = pos - self.prev_point
+ if dp:
+ _shape.move_by(dp)
+ self.prev_point = pos
+ return True
def bounded_move_shape(self, shape, pos):
if self.out_of_pixmap(pos):
@@ -456,6 +589,9 @@ def bounded_move_shape(self, shape, pos):
return False
def de_select_shape(self):
+ self.isMultySelected = False
+ for _shape in self.shapes:
+ _shape.selected = False
if self.selected_shape:
self.selected_shape.selected = False
self.selected_shape = None
@@ -477,6 +613,7 @@ def copy_selected_shape(self):
shape = self.selected_shape.copy()
self.de_select_shape()
self.shapes.append(shape)
+ print("copy_selected_shape")
shape.selected = True
self.selected_shape = shape
self.bounded_shift_shape(shape)
@@ -491,6 +628,7 @@ def bounded_shift_shape(self, shape):
self.prev_point = point
if not self.bounded_move_shape(shape, point - offset):
self.bounded_move_shape(shape, point + offset)
+ self.onEndAction.emit()
def paintEvent(self, event):
if not self.pixmap:
@@ -551,7 +689,8 @@ def paintEvent(self, event):
pal = self.palette()
pal.setColor(self.backgroundRole(), QColor(232, 232, 232, 255))
self.setPalette(pal)
-
+ if self.isKeyShortcutRotate or self.isKeyShortcutRotateKeepSize:
+ self.drawIconRotate()
p.end()
def transform_pos(self, point):
@@ -578,7 +717,7 @@ def finalise(self):
self.drawingPolygon.emit(False)
self.update()
return
-
+ print("finalise")
self.current.close()
self.shapes.append(self.current)
self.current = None
@@ -625,9 +764,50 @@ def wheelEvent(self, ev):
v_delta and self.scrollRequest.emit(v_delta, Qt.Vertical)
h_delta and self.scrollRequest.emit(h_delta, Qt.Horizontal)
ev.accept()
+
+ def focusOutEvent(self, a0):
+ self.isKeyControlPressed = False
+
+ def keyReleaseEvent(self, ev):
+ key = ev.key()
+ if key == Qt.Key.Key_Control and not ev.isAutoRepeat():
+ self.isKeyControlPressed = False
+
+ if key == Qt.Key.Key_R and not ev.isAutoRepeat():
+ self.isKeyShortcutRotate = False
+ self.onEndAction.emit()
+ self.update()
+
+ if key == Qt.Key.Key_T and not ev.isAutoRepeat():
+ self.isKeyShortcutRotateKeepSize = False
+ self.onEndAction.emit()
+ self.update()
+
+
def keyPressEvent(self, ev):
key = ev.key()
+ if key == Qt.Key.Key_Control:
+ self.isKeyControlPressed = True
+
+ if key == Qt.Key.Key_R:
+ if self.isKeyShortcutRotate is False:
+ self.posRotate = QPointF(self.lastPos.x(), self.lastPos.y())
+ for _shape in self.shapes:
+ _shape.pointsBeforRotate = _shape.points
+ self.isKeyShortcutRotate = True
+ self.onStartAction.emit()
+ self.update()
+
+ if key == Qt.Key.Key_T:
+ if self.isKeyShortcutRotateKeepSize is False:
+ self.posRotate = QPointF(self.lastPos.x(), self.lastPos.y())
+ for _shape in self.shapes:
+ _shape.pointsBeforRotate = _shape.points
+ self.isKeyShortcutRotateKeepSize = True
+ self.onStartAction.emit()
+ self.update()
+
if key == Qt.Key_Escape and self.current:
print('ESC press')
self.current = None
diff --git a/libs/shape.py b/libs/shape.py
index 65b5bac12..d4a57eb01 100644
--- a/libs/shape.py
+++ b/libs/shape.py
@@ -9,7 +9,9 @@
from PyQt4.QtGui import *
from PyQt4.QtCore import *
-from libs.utils import distance
+import math
+from turtle import width
+from libs.utils import distance, rotateVector
import sys
DEFAULT_LINE_COLOR = QColor(0, 255, 0, 128)
@@ -24,7 +26,7 @@ class Shape(object):
P_SQUARE, P_ROUND = range(2)
MOVE_VERTEX, NEAR_VERTEX = range(2)
-
+ pointsBeforRotate : list[QPointF] = []
# The following class variables influence the drawing
# of _all_ shape objects.
line_color = DEFAULT_LINE_COLOR
@@ -37,9 +39,13 @@ class Shape(object):
point_size = 16
scale = 1.0
label_font_size = 8
+ historyActions : list[list[QPointF]] = []
+ lastAction : list[QPointF] = []
def __init__(self, label=None, line_color=None, difficult=False, paint_label=False):
self.label = label
+ self.historyActions = []
+ self.lastAction = []
self.points = []
self.fill = False
self.selected = False
@@ -52,7 +58,6 @@ def __init__(self, label=None, line_color=None, difficult=False, paint_label=Fal
self.NEAR_VERTEX: (4, self.P_ROUND),
self.MOVE_VERTEX: (1.5, self.P_SQUARE),
}
-
self._closed = False
if line_color is not None:
@@ -61,6 +66,21 @@ def __init__(self, label=None, line_color=None, difficult=False, paint_label=Fal
# is used for drawing the pending line a different color.
self.line_color = line_color
+ def saveHistory(self):
+ if self.reach_max_points() and self.isCurrentActionSaved() == False:
+ print("saved")
+ self.historyActions.append(self.points)
+ self.lastAction = self.points.copy()
+
+ def isCurrentActionSaved(self):
+ return self.lastAction == self.points
+
+ def undoAction(self):
+ if len(self.historyActions) > 0:
+ points = self.historyActions.pop()
+ self.points = points
+ #self.lastAction = self.points.copy()
+
def close(self):
self._closed = True
@@ -207,3 +227,84 @@ def __getitem__(self, key):
def __setitem__(self, key, value):
self.points[key] = value
+
+ def rotate(self, origin: QPoint, angleRadian: float, canvasSize: QPoint):
+
+ # point0 = self.pointsBeforRotate[0] - origin
+ # point1 = self.pointsBeforRotate[1] - origin
+ # point2 = self.pointsBeforRotate[2] - origin
+ # point3 = self.pointsBeforRotate[3] - origin
+ point0 = (self.pointsBeforRotate[0] + self.pointsBeforRotate[1])/2 - origin
+ point1 = (self.pointsBeforRotate[1] + self.pointsBeforRotate[2])/2 - origin
+ point2 = (self.pointsBeforRotate[2] + self.pointsBeforRotate[3])/2 - origin
+ point3 = (self.pointsBeforRotate[3] + self.pointsBeforRotate[0])/2 - origin
+
+ newPoint0 = origin + rotateVector(point0, angleRadian)
+ newPoint1 = origin + rotateVector(point1, angleRadian)
+ newPoint2 = origin + rotateVector(point2, angleRadian)
+ newPoint3 = origin + rotateVector(point3, angleRadian)
+ points = [newPoint0, newPoint1, newPoint2, newPoint3]
+
+ self.points = self.reCacularPoints(points, canvasSize)
+
+ def rotateKeepSize(self, origin: QPoint, angleRadian: float, canvasSize: QPoint):
+ minPoint, maxPoint =self.getMinMaxPoint([self.pointsBeforRotate[0],self.pointsBeforRotate[1],self.pointsBeforRotate[2],self.pointsBeforRotate[3]], canvasSize)
+ haftSize = (maxPoint - minPoint) / 2
+ center = ((minPoint + maxPoint) / 2) - origin
+ newCenter = origin + rotateVector(center, angleRadian)
+ newMinPoint = newCenter - haftSize
+ newMaxPoint = newCenter + haftSize
+ points = [newMinPoint, newMinPoint, newMaxPoint, newMaxPoint]
+ self.points = self.reCacularPoints(points, canvasSize)
+
+ def snap_point_to_canvas(self, point : QPointF, canvasSize: QPoint):
+ """
+ Moves a point x,y to within the boundaries of the canvas.
+ :return: (x,y,snapped) where snapped is True if x or y were changed, False if not.
+ """
+ x = point.x()
+ y = point.y()
+ if x < 0 or x > canvasSize.x() or y < 0 or y > canvasSize.y():
+ x = max(x, 0)
+ y = max(y, 0)
+ x = min(x, canvasSize.x())
+ y = min(y, canvasSize.y())
+ return QPointF(x,y)
+ return point
+
+ def reCacularPoints(self, points: list[QPointF], canvasSize: QPoint):
+ minPoint = QPointF(sys.maxsize, sys.maxsize)
+ maxPoint = QPointF(0,0)
+ for i in range(0,4):
+ point = points[i]
+ if point.x() < minPoint.x():
+ minPoint.setX(point.x())
+ if point.y() < minPoint.y():
+ minPoint.setY(point.y())
+ if point.x() > maxPoint.x():
+ maxPoint.setX(point.x())
+ if point.y() > maxPoint.y():
+ maxPoint.setY(point.y())
+ point0 = self.snap_point_to_canvas(QPointF(minPoint.x(), maxPoint.y()), canvasSize)
+ point1 = self.snap_point_to_canvas(QPointF(maxPoint.x(), maxPoint.y()), canvasSize)
+ point2 = self.snap_point_to_canvas(QPointF(maxPoint.x(), minPoint.y()), canvasSize)
+ point3 = self.snap_point_to_canvas(QPointF(minPoint.x(), minPoint.y()), canvasSize)
+ return [point0, point1, point2, point3]
+
+ def getMinMaxPoint(self, points: list[QPointF], canvasSize: QPoint):
+ minPoint = QPointF(sys.maxsize, sys.maxsize)
+ maxPoint = QPointF(0,0)
+ for i in range(0,4):
+ point = points[i]
+ if point.x() < minPoint.x():
+ minPoint.setX(point.x())
+ if point.y() < minPoint.y():
+ minPoint.setY(point.y())
+ if point.x() > maxPoint.x():
+ maxPoint.setX(point.x())
+ if point.y() > maxPoint.y():
+ maxPoint.setY(point.y())
+ minPoint = self.snap_point_to_canvas(minPoint, canvasSize)
+ maxPoint = self.snap_point_to_canvas(maxPoint, canvasSize)
+ return minPoint, maxPoint
+
diff --git a/libs/utils.py b/libs/utils.py
index d511c151b..81fe2b6ba 100644
--- a/libs/utils.py
+++ b/libs/utils.py
@@ -1,4 +1,5 @@
from math import sqrt
+import math
from libs.ustr import ustr
import hashlib
import re
@@ -107,6 +108,15 @@ def get_alphanum_key_func(key):
sort_key = get_alphanum_key_func(key)
list.sort(key=sort_key)
+def rotateVector(vector: QPointF, angleRadian: QPointF):
+ newX = vector.x() * math.cos(angleRadian) - vector.y() * math.sin(angleRadian)
+ newY = vector.x() * math.sin(angleRadian) + vector.y() * math.cos(angleRadian)
+ return QPointF(newX, newY)
+
+def angleFrom2Vector(vectorA: QPointF, vectorB: QPointF):
+ dot = vectorA.x() * vectorB.x() + vectorA.y() * vectorB.y()
+ det = vectorA.x()* vectorB.y() - vectorA.y()*vectorB.x() # determinant
+ return math.atan2(det, dot) # atan2(y, x) or atan2(sin, cos)
# QT4 has a trimmed method, in QT5 this is called strip
if QT5:
diff --git a/resources.qrc b/resources.qrc
index 7479b9e30..c2e03e4eb 100644
--- a/resources.qrc
+++ b/resources.qrc
@@ -39,5 +39,6 @@
resources/strings/strings-zh-TW.properties
resources/strings/strings-zh-CN.properties
resources/strings/strings-ja-JP.properties
+resources/icons/rotate_360.png
diff --git a/resources/icons/rotate_360.png b/resources/icons/rotate_360.png
new file mode 100644
index 000000000..4eb2ec017
Binary files /dev/null and b/resources/icons/rotate_360.png differ