diff --git a/.gitignore b/.gitignore index 63754ecf1..efee683b0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ labelImg.egg-info* build/ dist/ +venv/ tags cscope* diff --git a/README.rst b/README.rst index ef061f46f..fa3a6df6c 100644 --- a/README.rst +++ b/README.rst @@ -32,7 +32,7 @@ LabelImg is a graphical image annotation tool. It is written in Python and uses Qt for its graphical interface. Annotations are saved as XML files in PASCAL VOC format, the format used -by `ImageNet `__. Besides, it also supports YOLO and CreateML formats. +by `ImageNet `__. Besides, it also supports YOLO, RotatedYOLO and CreateML formats. .. image:: https://raw.githubusercontent.com/tzutalin/labelImg/master/demo/demo3.jpg :alt: Demo Image @@ -40,6 +40,9 @@ by `ImageNet `__. Besides, it also supports YOLO and .. image:: https://raw.githubusercontent.com/tzutalin/labelImg/master/demo/demo.jpg :alt: Demo Image +.. image:: /demo/demo6.png + :alt: Demo Image + `Watch a demo video `__ Installation @@ -236,33 +239,41 @@ Annotation visualization Hotkeys ~~~~~~~ -+--------------------+--------------------------------------------+ -| Ctrl + u | Load all of the images from a directory | -+--------------------+--------------------------------------------+ -| Ctrl + r | Change the default annotation target dir | -+--------------------+--------------------------------------------+ -| Ctrl + s | Save | -+--------------------+--------------------------------------------+ -| Ctrl + d | Copy the current label and rect box | -+--------------------+--------------------------------------------+ -| Ctrl + Shift + d | Delete the current image | -+--------------------+--------------------------------------------+ -| Space | Flag the current image as verified | -+--------------------+--------------------------------------------+ -| w | Create a rect box | -+--------------------+--------------------------------------------+ -| d | Next image | -+--------------------+--------------------------------------------+ -| a | Previous image | -+--------------------+--------------------------------------------+ -| del | Delete the selected rect box | -+--------------------+--------------------------------------------+ -| Ctrl++ | Zoom in | -+--------------------+--------------------------------------------+ -| Ctrl-- | Zoom out | -+--------------------+--------------------------------------------+ -| ↑→↓← | Keyboard arrows to move selected rect box | -+--------------------+--------------------------------------------+ ++--------------------+------------------------------------------------------------------------------------------+ +| Ctrl + u | Load all of the images from a directory | ++--------------------+------------------------------------------------------------------------------------------+ +| Ctrl + r | Change the default annotation target dir | ++--------------------+------------------------------------------------------------------------------------------+ +| Ctrl + s | Save | ++--------------------+------------------------------------------------------------------------------------------+ +| Ctrl + d | Copy the current label and rect box | ++--------------------+------------------------------------------------------------------------------------------+ +| Ctrl + Shift + d | Delete the current image | ++--------------------+------------------------------------------------------------------------------------------+ +| Space | Flag the current image as verified | ++--------------------+------------------------------------------------------------------------------------------+ +| w | Create a rect box | ++--------------------+------------------------------------------------------------------------------------------+ +| d | Next image | ++--------------------+------------------------------------------------------------------------------------------+ +| a | Previous image | ++--------------------+------------------------------------------------------------------------------------------+ +| del | Delete the selected rect box | ++--------------------+------------------------------------------------------------------------------------------+ +| Ctrl++ | Zoom in | ++--------------------+------------------------------------------------------------------------------------------+ +| Ctrl-- | Zoom out | ++--------------------+------------------------------------------------------------------------------------------+ +| ↑→↓← | Keyboard arrows to move selected rect box | ++--------------------+------------------------------------------------------------------------------------------+ +| z | Counter-clockwise rotation of the selected rect box at a large angle. (RotatedYOLO Only) | ++--------------------+------------------------------------------------------------------------------------------+ +| x | Counter-clockwise rotation of the selected rect box at a small angle. (RotatedYOLO Only) | ++--------------------+------------------------------------------------------------------------------------------+ +| c | Clockwise rotation of the selected rect box at a small angle. (RotatedYOLO Only) | ++--------------------+------------------------------------------------------------------------------------------+ +| v | Clockwise rotation of the selected rect box at a large angle. (RotatedYOLO Only) | ++--------------------+------------------------------------------------------------------------------------------+ **Verify Image:** diff --git a/demo/demo6.png b/demo/demo6.png new file mode 100644 index 000000000..457855088 Binary files /dev/null and b/demo/demo6.png differ diff --git a/labelImg.py b/labelImg.py index efd8a2976..d5097abfd 100755 --- a/labelImg.py +++ b/labelImg.py @@ -45,6 +45,7 @@ from libs.yolo_io import TXT_EXT from libs.create_ml_io import CreateMLReader from libs.create_ml_io import JSON_EXT +from libs.rotated_yolo_io import RotatedYOLOReader from libs.ustr import ustr from libs.hashableQListWidgetItem import HashableQListWidgetItem @@ -252,6 +253,8 @@ def get_format_meta(format): return '&YOLO', 'format_yolo' elif format == LabelFileFormat.CREATE_ML: return '&CreateML', 'format_createml' + elif format == LabelFileFormat.ROTATED_YOLO: + return '&RotatedYOLO', 'format_rotated_yolo' save_format = action(get_format_meta(self.label_file_format)[0], self.change_format, 'Ctrl+Y', @@ -409,6 +412,10 @@ def get_format_meta(format): recentFiles=QMenu(get_str('menu_openRecent')), labelList=label_menu) + # Save label to image folder + self.save_label_to_image_folder = QAction(get_str('saveLabelToImageFolder'), self) + self.save_label_to_image_folder.setCheckable(True) + self.save_label_to_image_folder.setChecked(settings.get(SETTING_SAVE_LABEL_TO_IMAGE_FOLDER, False)) # Auto saving : Enable auto saving if pressing next self.auto_saving = QAction(get_str('autoSaveMode'), self) self.auto_saving.setCheckable(True) @@ -431,6 +438,7 @@ def get_format_meta(format): add_actions(self.menus.help, (help_default, show_info, show_shortcut)) add_actions(self.menus.view, ( self.auto_saving, + self.save_label_to_image_folder, self.single_class_mode, self.display_label_option, labels, advanced_mode, None, @@ -550,6 +558,7 @@ def keyPressEvent(self, event): # Support Functions # def set_format(self, save_format): + self.canvas.canDrawRotatedRect = False if save_format == FORMAT_PASCALVOC: self.actions.save_format.setText(FORMAT_PASCALVOC) self.actions.save_format.setIcon(new_icon("format_voc")) @@ -567,6 +576,13 @@ def set_format(self, save_format): self.actions.save_format.setIcon(new_icon("format_createml")) self.label_file_format = LabelFileFormat.CREATE_ML LabelFile.suffix = JSON_EXT + + elif save_format == FORMAT_ROTATED_YOLO: + self.canvas.canDrawRotatedRect = True + self.actions.save_format.setText(FORMAT_ROTATED_YOLO) + self.actions.save_format.setIcon(new_icon("format_yolo")) + self.label_file_format = LabelFileFormat.ROTATED_YOLO + LabelFile.suffix = TXT_EXT def change_format(self): if self.label_file_format == LabelFileFormat.PASCAL_VOC: @@ -574,6 +590,8 @@ def change_format(self): elif self.label_file_format == LabelFileFormat.YOLO: self.set_format(FORMAT_CREATEML) elif self.label_file_format == LabelFileFormat.CREATE_ML: + self.set_format(FORMAT_ROTATED_YOLO) + elif self.label_file_format == LabelFileFormat.ROTATED_YOLO: self.set_format(FORMAT_PASCALVOC) else: raise ValueError('Unknown label file format.') @@ -724,7 +742,7 @@ def toggle_draw_mode(self, edit=True): def set_create_mode(self): assert self.advanced() self.toggle_draw_mode(False) - + def set_edit_mode(self): assert self.advanced() self.toggle_draw_mode(True) @@ -837,7 +855,7 @@ def remove_label(self, shape): def load_labels(self, shapes): s = [] - for label, points, line_color, fill_color, difficult in shapes: + for label, points, line_color, fill_color, difficult, direction in shapes: shape = Shape(label=label) for x, y in points: @@ -848,6 +866,7 @@ def load_labels(self, shapes): shape.add_point(QPointF(x, y)) shape.difficult = difficult + shape.direction = direction shape.close() s.append(shape) @@ -888,7 +907,10 @@ def format_shape(s): fill_color=s.fill_color.getRgb(), points=[(p.x(), p.y()) for p in s.points], # add chris - difficult=s.difficult) + difficult=s.difficult, + direction=s.direction, + center=s.center, + ) shapes = [format_shape(shape) for shape in self.canvas.shapes] # Can add different annotation formats here @@ -908,6 +930,11 @@ def format_shape(s): annotation_file_path += JSON_EXT self.label_file.save_create_ml_format(annotation_file_path, shapes, self.file_path, self.image_data, self.label_hist, self.line_color.getRgb(), self.fill_color.getRgb()) + elif self.label_file_format == LabelFileFormat.ROTATED_YOLO: + if annotation_file_path[-4:].lower() != ".txt": + annotation_file_path += TXT_EXT + self.label_file.save_rotated_yolo_format(annotation_file_path, shapes, self.file_path, self.image_data, self.label_hist, + self.line_color.getRgb(), self.fill_color.getRgb()) else: self.label_file.save(annotation_file_path, shapes, self.file_path, self.image_data, self.line_color.getRgb(), self.fill_color.getRgb()) @@ -1099,6 +1126,9 @@ def load_file(self, file_path=None): # Make sure that filePath is a regular python string, rather than QString file_path = ustr(file_path) + if self.save_label_to_image_folder.isChecked(): + self.default_save_dir = os.path.dirname(file_path) + # Fix bug: An index error after select a directory when open a new file. unicode_file_path = ustr(file_path) unicode_file_path = os.path.abspath(unicode_file_path) @@ -1178,33 +1208,42 @@ 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 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) - txt_path = os.path.join(self.default_save_dir, basename + TXT_EXT) - json_path = os.path.join(self.default_save_dir, basename + JSON_EXT) - - """Annotation file priority: - PascalXML > YOLO - """ - if os.path.isfile(xml_path): - self.load_pascal_xml_by_filename(xml_path) - elif os.path.isfile(txt_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) + if file_path is not None: + 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) + txt_path = os.path.join(self.default_save_dir, basename + TXT_EXT) + json_path = os.path.join(self.default_save_dir, basename + JSON_EXT) + + """Annotation file priority: + PascalXML > YOLO + """ + if os.path.isfile(xml_path): + self.load_pascal_xml_by_filename(xml_path) + elif os.path.isfile(txt_path): + self.check_txt_label_type(txt_path) + if self.label_file_format == LabelFileFormat.ROTATED_YOLO: + self.load_rotated_yolo_txt_by_filename(txt_path) + else: + 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) - else: - xml_path = os.path.splitext(file_path)[0] + XML_EXT - txt_path = os.path.splitext(file_path)[0] + TXT_EXT - json_path = os.path.splitext(file_path)[0] + JSON_EXT - - if os.path.isfile(xml_path): - self.load_pascal_xml_by_filename(xml_path) - elif os.path.isfile(txt_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) + else: + xml_path = os.path.splitext(file_path)[0] + XML_EXT + txt_path = os.path.splitext(file_path)[0] + TXT_EXT + json_path = os.path.splitext(file_path)[0] + JSON_EXT + + if os.path.isfile(xml_path): + self.load_pascal_xml_by_filename(xml_path) + elif os.path.isfile(txt_path): + self.check_txt_label_type(txt_path) + if self.label_file_format == LabelFileFormat.ROTATED_YOLO: + self.load_rotated_yolo_txt_by_filename(txt_path) + else: + 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): @@ -1270,6 +1309,7 @@ def closeEvent(self, event): settings[SETTING_LAST_OPEN_DIR] = '' settings[SETTING_AUTO_SAVE] = self.auto_saving.isChecked() + settings[SETTING_SAVE_LABEL_TO_IMAGE_FOLDER] = self.save_label_to_image_folder.isChecked() settings[SETTING_SINGLE_CLASS] = self.single_class_mode.isChecked() settings[SETTING_PAINT_LABEL] = self.display_label_option.isChecked() settings[SETTING_DRAW_SQUARE] = self.draw_squares_option.isChecked() @@ -1638,10 +1678,29 @@ def load_yolo_txt_by_filename(self, txt_path): self.set_format(FORMAT_YOLO) t_yolo_parse_reader = YoloReader(txt_path, self.image) shapes = t_yolo_parse_reader.get_shapes() - print(shapes) self.load_labels(shapes) self.canvas.verified = t_yolo_parse_reader.verified + def load_rotated_yolo_txt_by_filename(self, txt_path): + if self.file_path is None: + return + if os.path.isfile(txt_path) is False: + return + + self.set_format(FORMAT_ROTATED_YOLO) + t_yolo_parse_reader = RotatedYOLOReader(txt_path) + shapes = t_yolo_parse_reader.get_shapes() + self.load_labels(shapes) + self.canvas.verified = t_yolo_parse_reader.verified + + def check_txt_label_type(self, txt_path): + with open(txt_path, 'r') as f: + line = f.readline() + if len(line.strip().split()) == 5: + self.set_format(FORMAT_YOLO) + else: + self.set_format(FORMAT_ROTATED_YOLO) + def load_create_ml_json_by_filename(self, json_path, file_path): if self.file_path is None: return diff --git a/libs/canvas.py b/libs/canvas.py index ca7986ff3..a3c112725 100644 --- a/libs/canvas.py +++ b/libs/canvas.py @@ -1,3 +1,4 @@ +import math try: from PyQt5.QtGui import * @@ -66,6 +67,9 @@ def __init__(self, *args, **kwargs): self.verified = False self.draw_square = False + # judge can draw rotate rect + self.canDrawRotatedRect = True + # initialisation for panning self.pan_initial_pos = QPoint() @@ -166,7 +170,11 @@ def mouseMoveEvent(self, ev): # Polygon copy moving. if Qt.RightButton & ev.buttons(): - if self.selected_shape_copy and self.prev_point: + if self.selected_vertex() and self.canDrawRotatedRect: + self.boundedRotateShape(pos) + self.shapeMoved.emit() + self.repaint() + elif self.selected_shape_copy and self.prev_point: self.override_cursor(CURSOR_MOVE) self.bounded_move_shape(self.selected_shape_copy, pos) self.repaint() @@ -396,42 +404,123 @@ def snap_point_to_canvas(self, x, y): return x, y, True return x, y, False - + def bounded_move_vertex(self, pos): index, shape = self.h_vertex, self.h_shape point = shape[index] - if self.out_of_pixmap(pos): - size = self.pixmap.size() - clipped_x = min(max(0, pos.x()), size.width()) - clipped_y = min(max(0, pos.y()), size.height()) - pos = QPointF(clipped_x, clipped_y) - - if self.draw_square: - opposite_point_index = (index + 2) % 4 - opposite_point = shape[opposite_point_index] - - min_size = min(abs(pos.x() - opposite_point.x()), abs(pos.y() - opposite_point.y())) - direction_x = -1 if pos.x() - opposite_point.x() < 0 else 1 - direction_y = -1 if pos.y() - opposite_point.y() < 0 else 1 - shift_pos = QPointF(opposite_point.x() + direction_x * min_size - point.x(), - opposite_point.y() + direction_y * min_size - point.y()) + if self.canDrawRotatedRect: + if self.out_of_pixmap(pos): + return + + sindex = (index + 2) % 4 + p2,p3,p4 = self.getAdjointPoints(shape.direction, shape[sindex], pos, index) + + pcenter = (pos+p3)/2 + # if one pixal out of map , do nothing + if self.out_of_pixmap(pcenter) or self.out_of_pixmap(p2) or \ + self.out_of_pixmap(p3) or self.out_of_pixmap(p4): + return + + # move 4 pixal one by one + shape.move_vertex_by(index, pos - point) + lindex = (index + 1) % 4 + + rindex = (index + 3) % 4 + shape[lindex] = p2 + shape[rindex] = p4 + shape.close() else: - shift_pos = pos - point + if self.out_of_pixmap(pos): + size = self.pixmap.size() + clipped_x = min(max(0, pos.x()), size.width()) + clipped_y = min(max(0, pos.y()), size.height()) + pos = QPointF(clipped_x, clipped_y) + + if self.draw_square: + opposite_point_index = (index + 2) % 4 + opposite_point = shape[opposite_point_index] + + min_size = min(abs(pos.x() - opposite_point.x()), abs(pos.y() - opposite_point.y())) + direction_x = -1 if pos.x() - opposite_point.x() < 0 else 1 + direction_y = -1 if pos.y() - opposite_point.y() < 0 else 1 + shift_pos = QPointF(opposite_point.x() + direction_x * min_size - point.x(), + opposite_point.y() + direction_y * min_size - point.y()) + else: + shift_pos = pos - point + + shape.move_vertex_by(index, shift_pos) + + left_index = (index + 1) % 4 + right_index = (index + 3) % 4 + left_shift = None + right_shift = None + if index % 2 == 0: + right_shift = QPointF(shift_pos.x(), 0) + left_shift = QPointF(0, shift_pos.y()) + else: + left_shift = QPointF(shift_pos.x(), 0) + right_shift = QPointF(0, shift_pos.y()) + shape.move_vertex_by(right_index, right_shift) + shape.move_vertex_by(left_index, left_shift) + + def getAdjointPoints(self, theta, p3, p1, index): + a1 = math.tan(theta) + if (a1 == 0): + if index % 2 == 0: + p2 = QPointF(p3.x(), p1.y()) + p4 = QPointF(p1.x(), p3.y()) + else: + p4 = QPointF(p3.x(), p1.y()) + p2 = QPointF(p1.x(), p3.y()) + else: + a3 = a1 + a2 = - 1/a1 + a4 = - 1/a1 + b1 = p1.y() - a1 * p1.x() + b2 = p1.y() - a2 * p1.x() + b3 = p3.y() - a1 * p3.x() + b4 = p3.y() - a2 * p3.x() + + if index % 2 == 0: + p2 = self.getCrossPoint(a1,b1,a4,b4) + p4 = self.getCrossPoint(a2,b2,a3,b3) + else: + p4 = self.getCrossPoint(a1,b1,a4,b4) + p2 = self.getCrossPoint(a2,b2,a3,b3) + + return p2,p3,p4 + + def getCrossPoint(self,a1,b1,a2,b2): + x = (b2-b1)/(a1-a2) + y = (a1*b2 - a2*b1)/(a1-a2) + return QPointF(x,y) + + def boundedRotateShape(self, pos): + index, shape = self.h_vertex, self.h_shape + point = shape[index] + + angle = self.getAngle(shape.center, pos, point) + if not self.rotateOutOfBound(angle): + shape.rotate(angle) + self.prev_point = pos + + def getAngle(self, center, p1, p2): + dx1 = p1.x() - center.x() + dy1 = p1.y() - center.y() - shape.move_vertex_by(index, shift_pos) + dx2 = p2.x() - center.x() + dy2 = p2.y() - center.y() - left_index = (index + 1) % 4 - right_index = (index + 3) % 4 - left_shift = None - right_shift = None - if index % 2 == 0: - right_shift = QPointF(shift_pos.x(), 0) - left_shift = QPointF(0, shift_pos.y()) + c = math.sqrt(dx1*dx1 + dy1*dy1) * math.sqrt(dx2*dx2 + dy2*dy2) + if c == 0: return 0 + y = (dx1*dx2+dy1*dy2)/c + if y>1: return 0 + angle = math.acos(y) + + if (dx1*dy2-dx2*dy1)>0: + return angle else: - left_shift = QPointF(shift_pos.x(), 0) - right_shift = QPointF(0, shift_pos.y()) - shape.move_vertex_by(right_index, right_shift) - shape.move_vertex_by(left_index, left_shift) + return -angle def bounded_move_shape(self, shape, pos): if self.out_of_pixmap(pos): @@ -643,6 +732,32 @@ def keyPressEvent(self, ev): self.move_one_pixel('Up') elif key == Qt.Key_Down and self.selected_shape: self.move_one_pixel('Down') + elif key == Qt.Key_Z and self.selected_shape and\ + self.canDrawRotatedRect and not self.rotateOutOfBound(0.1): + self.selected_shape.rotate(0.1) + self.shapeMoved.emit() + self.update() + elif key == Qt.Key_X and self.selected_shape and\ + self.canDrawRotatedRect and not self.rotateOutOfBound(0.01): + self.selected_shape.rotate(0.01) + self.shapeMoved.emit() + self.update() + elif key == Qt.Key_C and self.selected_shape and\ + self.canDrawRotatedRect and not self.rotateOutOfBound(-0.01): + self.selected_shape.rotate(-0.01) + self.shapeMoved.emit() + self.update() + elif key == Qt.Key_V and self.selected_shape and\ + self.canDrawRotatedRect and not self.rotateOutOfBound(-0.1): + self.selected_shape.rotate(-0.1) + self.shapeMoved.emit() + self.update() + + def rotateOutOfBound(self, angle): + for i, p in enumerate(self.selected_shape.points): + if self.out_of_pixmap(self.selected_shape.rotatePoint(p,angle)): + return True + return False def move_one_pixel(self, direction): # print(self.selectedShape.points) @@ -652,24 +767,28 @@ def move_one_pixel(self, direction): self.selected_shape.points[1] += QPointF(-1.0, 0) self.selected_shape.points[2] += QPointF(-1.0, 0) self.selected_shape.points[3] += QPointF(-1.0, 0) + self.selected_shape.center += QPointF(-1.0, 0) elif direction == 'Right' and not self.move_out_of_bound(QPointF(1.0, 0)): # print("move Right one pixel") self.selected_shape.points[0] += QPointF(1.0, 0) self.selected_shape.points[1] += QPointF(1.0, 0) self.selected_shape.points[2] += QPointF(1.0, 0) self.selected_shape.points[3] += QPointF(1.0, 0) + self.selected_shape.center += QPointF(1.0, 0) elif direction == 'Up' and not self.move_out_of_bound(QPointF(0, -1.0)): # print("move Up one pixel") self.selected_shape.points[0] += QPointF(0, -1.0) self.selected_shape.points[1] += QPointF(0, -1.0) self.selected_shape.points[2] += QPointF(0, -1.0) self.selected_shape.points[3] += QPointF(0, -1.0) + self.selected_shape.center += QPointF(0.0, -1.0) elif direction == 'Down' and not self.move_out_of_bound(QPointF(0, 1.0)): # print("move Down one pixel") self.selected_shape.points[0] += QPointF(0, 1.0) self.selected_shape.points[1] += QPointF(0, 1.0) self.selected_shape.points[2] += QPointF(0, 1.0) self.selected_shape.points[3] += QPointF(0, 1.0) + self.selected_shape.center += QPointF(0.0, 1.0) self.shapeMoved.emit() self.repaint() diff --git a/libs/constants.py b/libs/constants.py index 1efda037c..2c1b649e8 100644 --- a/libs/constants.py +++ b/libs/constants.py @@ -11,9 +11,11 @@ SETTING_PAINT_LABEL = 'paintlabel' SETTING_LAST_OPEN_DIR = 'lastOpenDir' SETTING_AUTO_SAVE = 'autosave' +SETTING_SAVE_LABEL_TO_IMAGE_FOLDER = 'saveLabelToImageFolder' SETTING_SINGLE_CLASS = 'singleclass' FORMAT_PASCALVOC='PascalVOC' FORMAT_YOLO='YOLO' +FORMAT_ROTATED_YOLO='RotatedYOLO' FORMAT_CREATEML='CreateML' SETTING_DRAW_SQUARE = 'draw/square' SETTING_LABEL_FILE_FORMAT= 'labelFileFormat' diff --git a/libs/create_ml_io.py b/libs/create_ml_io.py index 3aca8d676..69a43bf4f 100644 --- a/libs/create_ml_io.py +++ b/libs/create_ml_io.py @@ -129,7 +129,7 @@ def add_shape(self, label, bnd_box): y_max = bnd_box["y"] + (bnd_box["height"] / 2) points = [(x_min, y_min), (x_max, y_min), (x_max, y_max), (x_min, y_max)] - self.shapes.append((label, points, None, None, True)) + self.shapes.append((label, points, None, None, True, 0)) def get_shapes(self): return self.shapes diff --git a/libs/labelFile.py b/libs/labelFile.py index 185570bcb..7e62a838a 100644 --- a/libs/labelFile.py +++ b/libs/labelFile.py @@ -1,5 +1,6 @@ # Copyright (c) 2016 Tzutalin # Create by TzuTaLin +import math try: from PyQt5.QtGui import QImage @@ -13,12 +14,14 @@ from libs.pascal_voc_io import PascalVocWriter from libs.pascal_voc_io import XML_EXT from libs.yolo_io import YOLOWriter +from libs.rotated_yolo_io import RotatedYOLOWriter class LabelFileFormat(Enum): PASCAL_VOC = 1 YOLO = 2 CREATE_ML = 3 + ROTATED_YOLO = 4 class LabelFileError(Exception): @@ -111,6 +114,32 @@ def save_yolo_format(self, filename, shapes, image_path, image_data, class_list, writer.save(target_file=filename, class_list=class_list) return + def save_rotated_yolo_format(self, filename, shapes, image_path, image_data, + line_color=None, fill_color=None, database_src=None): + img_folder_path = os.path.dirname(image_path) + img_folder_name = os.path.split(img_folder_path)[-1] + img_file_name = os.path.basename(image_path) + if isinstance(image_data, QImage): + image = image_data + else: + image = QImage() + image.load(image_path) + image_shape = [image.height(), image.width(), + 1 if image.isGrayscale() else 3] + writer = RotatedYOLOWriter(img_folder_name, img_file_name, + image_shape, local_img_path=image_path) + writer.verified = self.verified + + for shape in shapes: + label = shape['label'] + # Add Chris + difficult = int(shape['difficult']) + points = shape['points'] + writer.add_bnd_box(points, label, difficult) + + writer.save(target_file=filename) + return + def toggle_verify(self): self.verified = not self.verified @@ -172,3 +201,4 @@ def convert_points_to_bnd_box(points): y_min = 1 return int(x_min), int(y_min), int(x_max), int(y_max) + \ No newline at end of file diff --git a/libs/pascal_voc_io.py b/libs/pascal_voc_io.py index d8f7d690b..ed7006bc8 100644 --- a/libs/pascal_voc_io.py +++ b/libs/pascal_voc_io.py @@ -1,6 +1,5 @@ #!/usr/bin/env python # -*- coding: utf8 -*- -import sys from xml.etree import ElementTree from xml.etree.ElementTree import Element, SubElement from lxml import etree @@ -82,7 +81,7 @@ def add_bnd_box(self, x_min, y_min, x_max, y_max, name, difficult): bnd_box['name'] = name bnd_box['difficult'] = difficult self.box_list.append(bnd_box) - + def append_objects(self, top): for each_object in self.box_list: object_item = SubElement(top, 'object') @@ -146,8 +145,8 @@ def add_shape(self, label, bnd_box, difficult): x_max = int(float(bnd_box.find('xmax').text)) y_max = int(float(bnd_box.find('ymax').text)) points = [(x_min, y_min), (x_max, y_min), (x_max, y_max), (x_min, y_max)] - self.shapes.append((label, points, None, None, difficult)) - + self.shapes.append((label, points, None, None, difficult, 0)) + def parse_xml(self): assert self.file_path.endswith(XML_EXT), "Unsupported file format" parser = etree.XMLParser(encoding=ENCODE_METHOD) diff --git a/libs/rotated_yolo_io.py b/libs/rotated_yolo_io.py new file mode 100644 index 000000000..cf036f22d --- /dev/null +++ b/libs/rotated_yolo_io.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# -*- coding: utf8 -*- +import math +import codecs +from libs.constants import DEFAULT_ENCODING + + +TXT_EXT = '.txt' +ENCODE_METHOD = DEFAULT_ENCODING + + +class RotatedYOLOWriter: + + def __init__(self, folder_name, filename, img_size, database_src='Unknown', local_img_path=None): + self.folder_name = folder_name + self.filename = filename + self.database_src = database_src + self.img_size = img_size + self.box_list = [] + self.local_img_path = local_img_path + self.verified = False + + def add_bnd_box(self, points, name, difficult): + flat_points = [] + for x, y in points: + flat_points.extend([x, y]) + bndbox = {'points': flat_points, 'name': name, 'difficult': difficult} + self.box_list.append(bndbox) + + def save(self, target_file=None): + out_file = None + if target_file is None: + out_file = codecs.open( + self.filename + TXT_EXT, 'w', encoding=ENCODE_METHOD) + else: + out_file = codecs.open(target_file, 'w', encoding=ENCODE_METHOD) + + for box in self.box_list: + x1, y1, x2, y2, x3, y3, x4, y4 = box['points'] + out_file.write("%.1f %.1f %.1f %.1f %.1f %.1f %.1f %.1f %s %s\n" % + (x1, y1, x2, y2, x3, y3, x4, y4, box['name'], box['difficult'])) + out_file.close() + + +class RotatedYOLOReader: + + def __init__(self, file_path): + self.shapes = [] + self.file_path = file_path + self.verified = False + + self.parse_rotated_yolo_format() + + def get_shapes(self): + return self.shapes + + def get_angle(self, x1, y1, x2, y2): + return math.atan2(y2 - y1, x2 - x1) + + def add_shape(self, x1, y1, x2, y2, x3, y3, x4, y4, label, difficult): + angle = self.get_angle(x1, y1, x2, y2) + points = [(x1, y1), (x2, y2), (x3, y3), (x4, y4)] + self.shapes.append((label, points, None, None, difficult, angle)) + + def parse_rotated_yolo_format(self): + bnd_box_file = open(self.file_path, 'r') + for bndBox in bnd_box_file: + x1, y1, x2, y2, x3, y3, x4, y4, label, difficult = bndBox.strip().split(' ') + self.add_shape(float(x1), float(y1), float(x2), float(y2), float(x3), float(y3), + float(x4), float(y4), label, int(difficult)) diff --git a/libs/shape.py b/libs/shape.py index 65b5bac12..b30dca680 100644 --- a/libs/shape.py +++ b/libs/shape.py @@ -1,6 +1,6 @@ #!/usr/bin/python # -*- coding: utf-8 -*- - +import math try: from PyQt5.QtGui import * @@ -53,6 +53,9 @@ def __init__(self, label=None, line_color=None, difficult=False, paint_label=Fal self.MOVE_VERTEX: (1.5, self.P_SQUARE), } + self.direction = 0 + self.center = None + self._closed = False if line_color is not None: @@ -62,6 +65,7 @@ def __init__(self, label=None, line_color=None, difficult=False, paint_label=Fal self.line_color = line_color def close(self): + self.center = QPointF((self.points[0].x()+self.points[2].x()) / 2, (self.points[0].y()+self.points[2].y()) / 2) self._closed = True def reach_max_points(self): @@ -84,6 +88,21 @@ def is_closed(self): def set_open(self): self._closed = False + def rotate(self, theta): + for i, p in enumerate(self.points): + self.points[i] = self.rotatePoint(p, theta) + self.direction -= theta + self.direction = self.direction % (2 * math.pi) + + def rotatePoint(self, p, theta): + order = p-self.center + cosTheta = math.cos(theta) + sinTheta = math.sin(theta) + pResx = cosTheta * order.x() + sinTheta * order.y() + pResy = - sinTheta * order.x() + cosTheta * order.y() + pRes = QPointF(self.center.x() + pResx, self.center.y() + pResy) + return pRes + def paint(self, painter): if self.points: color = self.select_line_color if self.selected else self.line_color @@ -189,6 +208,8 @@ def highlight_clear(self): def copy(self): shape = Shape("%s" % self.label) shape.points = [p for p in self.points] + shape.center = self.center + shape.direction = self.direction shape.fill = self.fill shape.selected = self.selected shape._closed = self._closed diff --git a/libs/yolo_io.py b/libs/yolo_io.py index 192e2c785..c6a04a7be 100644 --- a/libs/yolo_io.py +++ b/libs/yolo_io.py @@ -116,7 +116,7 @@ def get_shapes(self): def add_shape(self, label, x_min, y_min, x_max, y_max, difficult): points = [(x_min, y_min), (x_max, y_min), (x_max, y_max), (x_min, y_max)] - self.shapes.append((label, points, None, None, difficult)) + self.shapes.append((label, points, None, None, difficult, 0)) def yolo_line_to_shape(self, class_index, x_center, y_center, w, h): label = self.classes[int(class_index)] diff --git a/readme/README.jp.rst b/readme/README.jp.rst index 00a550ec7..e55ed1c2d 100644 --- a/readme/README.jp.rst +++ b/readme/README.jp.rst @@ -22,7 +22,7 @@ labelImg LabelImgは、PythonとQtを使うアノテーション補助ツールです。 -このツールはPascalVOCフォーマットとYOLOとCreateMLをサポートしています。 +このツールはPascalVOC形式、YOLO、RotatedYOLO、そしてCreateMLをサポートしています。 .. image:: https://raw.githubusercontent.com/tzutalin/labelImg/master/demo/demo3.jpg :alt: Demo Image @@ -30,6 +30,9 @@ LabelImgは、PythonとQtを使うアノテーション補助ツールです。 .. image:: https://raw.githubusercontent.com/tzutalin/labelImg/master/demo/demo.jpg :alt: Demo Image +.. image:: /demo/demo6.png + :alt: Demo Image + `サンプル動画は にあります。`__ インストール方法 @@ -190,6 +193,14 @@ Dockerの場合 +--------------------+--------------------------------------------+ | ↑→↓← | 十字キーで矩形を移動する | +--------------------+--------------------------------------------+ +| z | 逆時針大角度に矩形を回転する (RotatedYOLOのみ対応)| ++--------------------+--------------------------------------------+ +| x | 逆時針に矩形を少しずつ回転する (RotatedYOLOのみ対応)| ++--------------------+--------------------------------------------+ +| c | 時計回りに矩形を少しずつ回転する (RotatedYOLOのみ対応)| ++--------------------+--------------------------------------------+ +| v | 時計回りに矩形を大きく回転する (RotatedYOLOのみ対応)| ++--------------------+--------------------------------------------+ 開発に参加するには? ~~~~~~~~~~~~~~~~~~~~~ diff --git a/readme/README.zh.rst b/readme/README.zh.rst index 4ea22e0e8..0dbf2e53d 100644 --- a/readme/README.zh.rst +++ b/readme/README.zh.rst @@ -21,7 +21,7 @@ LabelImg LabelImg 是影像標註工具,它是用python 和 QT 寫成的. -支持的儲存格式包括PASCAL VOC format, YOLO, createML. +支持的儲存格式包括PASCAL VOC format, YOLO, RotatedYOLO, createML. .. image:: https://raw.githubusercontent.com/tzutalin/labelImg/master/demo/demo3.jpg :alt: Demo Image @@ -29,6 +29,9 @@ LabelImg 是影像標註工具,它是用python 和 QT 寫成的. .. image:: https://raw.githubusercontent.com/tzutalin/labelImg/master/demo/demo.jpg :alt: Demo Image +.. image:: /demo/demo6.png + :alt: Demo Image + `展示影片 `__ 安裝 @@ -188,6 +191,14 @@ Use Docker +--------------------+--------------------------------------------+ | ↑→↓← | 移動所選的物件區塊 | +--------------------+--------------------------------------------+ +| z | 逆時針大角度選轉所選的物件區塊 (只支援 RotatedYOLO) | ++--------------------+--------------------------------------------+ +| x | 逆時針小角度選轉所選的物件區塊 (只支援 RotatedYOLO) | ++--------------------+--------------------------------------------+ +| c | 順時針小角度選轉所選的物件區塊 (只支援 RotatedYOLO) | ++--------------------+--------------------------------------------+ +| v | 順時針大角度選轉所選的物件區塊 (只支援 RotatedYOLO) | ++--------------------+--------------------------------------------+ 如何貢獻 ~~~~~~~~~~~~~~~~~ diff --git a/resources/strings/strings-ja-JP.properties b/resources/strings/strings-ja-JP.properties index 239c52b37..d06be693b 100644 --- a/resources/strings/strings-ja-JP.properties +++ b/resources/strings/strings-ja-JP.properties @@ -75,3 +75,4 @@ fileList=ファイル一覧 files=ファイル boxLabelText=矩形ラベル copyPrevBounding=前の画像の矩形ラベルをこの画像にコピー +saveLabelToImageFolder=画像フォルダにラベルを保存する \ No newline at end of file diff --git a/resources/strings/strings-zh-CN.properties b/resources/strings/strings-zh-CN.properties index e944509a0..6d28cd286 100644 --- a/resources/strings/strings-zh-CN.properties +++ b/resources/strings/strings-zh-CN.properties @@ -87,3 +87,4 @@ menu_openRecent=最近打开(&R) chooseLineColor=选择线条颜色 chooseFillColor=选择填充颜色 drawSquares=绘制正方形 +saveLabelToImageFolder=保存标签到图像文件夹 \ No newline at end of file diff --git a/resources/strings/strings-zh-TW.properties b/resources/strings/strings-zh-TW.properties index 162ab2151..f04279f25 100644 --- a/resources/strings/strings-zh-TW.properties +++ b/resources/strings/strings-zh-TW.properties @@ -81,3 +81,4 @@ menu_edit=編輯(&E) menu_view=檢視(&V) menu_help=說明(&H) menu_openRecent=最近開啟(&R) +saveLabelToImageFolder=儲存標籤到圖像目錄 \ No newline at end of file diff --git a/resources/strings/strings.properties b/resources/strings/strings.properties index 8c5d14ce8..1be51fdd4 100644 --- a/resources/strings/strings.properties +++ b/resources/strings/strings.properties @@ -86,4 +86,5 @@ menu_help=&Help menu_openRecent=Open &Recent chooseLineColor=Choose Line Color chooseFillColor=Choose Fill Color -drawSquares=Draw Squares \ No newline at end of file +drawSquares=Draw Squares +saveLabelToImageFolder=Save label to image folder \ No newline at end of file