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