diff --git a/README.rst b/README.rst index d9c66bc..05a8457 100644 --- a/README.rst +++ b/README.rst @@ -7,6 +7,15 @@ roLabelImg .. image:: https://img.shields.io/travis/tzutalin/labelImg.svg :target: https://travis-ci.org/tzutalin/labelImg +Newly Update In 2023.4.5 +------------------ +- The angle of box is converted to the range of [-pi, pi], positive in counterclockwise direction. +- Add a red arrow to each box to indicate the direction for better visualization. +- Newly add 'Track' button to track the boxes and labels in previous image to next image, then we just need to fine tuning the box rather than redraw the boxes. The function is useful when you label the images which are consecutively shooted. +.. image:: ./demo/track.png + +Introduction +------------------ roLabelImg is a graphical image annotation tool can label ROTATED rectangle regions, which is rewrite from 'labelImg'. The original version 'labelImg''s link is here. diff --git a/data/predefined_classes.txt b/data/predefined_classes.txt index e123877..442a5ae 100644 --- a/data/predefined_classes.txt +++ b/data/predefined_classes.txt @@ -1,7 +1,7 @@ -ship -plane -dog -person -cat -tv -car +postive_face +negative_face +open_eye +closed_eye +open_mouth +closed_mouth +phone_and_hand \ No newline at end of file diff --git a/demo/track.png b/demo/track.png new file mode 100644 index 0000000..a89dbb7 Binary files /dev/null and b/demo/track.png differ diff --git a/libs/canvas.py b/libs/canvas.py index 5e712d3..64ea73a 100644 --- a/libs/canvas.py +++ b/libs/canvas.py @@ -845,9 +845,11 @@ def resetAllLines(self): self.drawingPolygon.emit(False) self.update() - def loadPixmap(self, pixmap): + def loadPixmap(self, pixmap, trackCuerentLabels=False): self.pixmap = pixmap - self.shapes = [] + if not trackCuerentLabels: + self.shapes = [] + self.repaint() def loadShapes(self, shapes): diff --git a/libs/colors.py b/libs/colors.py new file mode 100644 index 0000000..fb1a1ab --- /dev/null +++ b/libs/colors.py @@ -0,0 +1,26 @@ +try: + from PyQt5.QtGui import * + from PyQt5.QtCore import * +except ImportError: + from PyQt4.QtGui import * + from PyQt4.QtCore import * + +class Colors: + # Ultralytics color palette https://ultralytics.com/ + def __init__(self): + # hex = matplotlib.colors.TABLEAU_COLORS.values() + hexs = ('00E600', 'FF0000', '0000FF', 'FF1AC6', 'FF9900', '9900FF', 'FF6600', '3DDB86', '1A9334', '00D4BB', + '2C99A8', '00C2FF', '344593', '6473FF', '0018EC', '8438FF', '520085', 'CB38FF', 'FF95C8', 'FF37C7') + self.palette = [self.hex2rgb(f'#{c}') for c in hexs] + self.n = len(self.palette) + + def __call__(self, i): + c = self.palette[int(i) % self.n] + return QColor(c[0], c[1], c[2]) + + @staticmethod + def hex2rgb(h): # rgb order (PIL) + return tuple(int(h[1 + i:1 + i + 2], 16) for i in (0, 2, 4)) + + +colors = Colors() # create instance for 'from utils.plots import colors' \ No newline at end of file diff --git a/libs/labelFile.py b/libs/labelFile.py index aed17d7..e299ca6 100644 --- a/libs/labelFile.py +++ b/libs/labelFile.py @@ -114,6 +114,6 @@ def convertPoints2RotatedBndBox(shape): h = math.sqrt((points[2][0]-points[1][0]) ** 2 + (points[2][1]-points[1][1]) ** 2) - angle = direction % math.pi + angle = math.pi - direction # % math.pi return (round(cx,4),round(cy,4),round(w,4),round(h,4),round(angle,6)) diff --git a/libs/pascal_voc_io.py b/libs/pascal_voc_io.py index 9a926be..e65e54c 100644 --- a/libs/pascal_voc_io.py +++ b/libs/pascal_voc_io.py @@ -178,7 +178,7 @@ def appendObjects(self, top): w.text = str(each_object['w']) h = SubElement(robndbox, 'h') h.text = str(each_object['h']) - angle = SubElement(robndbox, 'angle') + angle = SubElement(robndbox, 'angle') angle.text = str(each_object['angle']) def save(self, targetFile=None): @@ -224,7 +224,7 @@ def addRotatedShape(self, label, robndbox, difficult): cy = float(robndbox.find('cy').text) w = float(robndbox.find('w').text) h = float(robndbox.find('h').text) - angle = float(robndbox.find('angle').text) + angle = math.pi - float(robndbox.find('angle').text) p0x,p0y = self.rotatePoint(cx,cy, cx - w/2, cy - h/2, -angle) p1x,p1y = self.rotatePoint(cx,cy, cx + w/2, cy - h/2, -angle) @@ -235,8 +235,8 @@ def addRotatedShape(self, label, robndbox, difficult): self.shapes.append((label, points, angle, True, None, None, difficult)) def rotatePoint(self, xc,yc, xp,yp, theta): - xoff = xp-xc; - yoff = yp-yc; + xoff = xp-xc + yoff = yp-yc cosTheta = math.cos(theta) sinTheta = math.sin(theta) diff --git a/libs/shape.py b/libs/shape.py index f82d658..1d0b5f5 100644 --- a/libs/shape.py +++ b/libs/shape.py @@ -11,14 +11,19 @@ from lib import distance import math +from colors import * +import os +import codecs DEFAULT_LINE_COLOR = QColor(0, 255, 0, 128) DEFAULT_FILL_COLOR = QColor(255, 0, 0, 128) DEFAULT_SELECT_LINE_COLOR = QColor(255, 255, 255) +DIRECTION_ARROW_COLOR = QColor(255, 0, 0) DEFAULT_SELECT_FILL_COLOR = QColor(0, 128, 255, 155) DEFAULT_VERTEX_FILL_COLOR = QColor(0, 255, 0, 255) DEFAULT_HVERTEX_FILL_COLOR = QColor(255, 0, 0) - +ARROW_LENGTH = 10 +ARROW_ANGLE = 0.5 class Shape(object): P_SQUARE, P_ROUND = range(2) @@ -62,7 +67,18 @@ def __init__(self, label=None, line_color=None,difficult = False): # with an object attribute. Currently this # is used for drawing the pending line a different color. self.line_color = line_color - + self.labels = None + self.loadPredefinedClasses(os.path.join('data', 'predefined_classes.txt')) + + def loadPredefinedClasses(self, predefClassesFile): + if os.path.exists(predefClassesFile) is True: + with codecs.open(predefClassesFile, 'r', 'utf8') as f: + for line in f: + line = line.strip() + if self.labels is None: + self.labels = [line] + else: + self.labels.append(line) def rotate(self, theta): for i, p in enumerate(self.points): @@ -71,7 +87,7 @@ def rotate(self, theta): self.direction = self.direction % (2 * math.pi) def rotatePoint(self, p, theta): - order = p-self.center; + order = p-self.center cosTheta = math.cos(theta) sinTheta = math.sin(theta) pResx = cosTheta * order.x() + sinTheta * order.y() @@ -108,10 +124,16 @@ def setOpen(self): def paint(self, painter): if self.points: - color = self.select_line_color if self.selected else self.line_color + label_index = 7 + for i in range(len(self.labels)): + if self.label == self.labels[i]: + label_index = i + break + color = colors(label_index)#self.select_line_color if self.selected else self.line_color + #print(self.label, label_index, color) pen = QPen(color) # Try using integer sizes for smoother drawing(?) - pen.setWidth(max(1, int(round(2.0 / self.scale)))) + pen.setWidth(max(1, int(round(1.0 / self.scale)))) painter.setPen(pen) line_path = QPainterPath() @@ -126,7 +148,15 @@ def paint(self, painter): for i, p in enumerate(self.points): line_path.lineTo(p) # print('shape paint points (%d, %d)' % (p.x(), p.y())) + # if(i == len(self.points) - 1): + # pen.setColor(DIRECTION_ARROW_COLOR) + # painter.setPen(pen) + # cur_line = QLineF() + # cur_line.setP1(self.points[i]) + # cur_line.setP2(self.points[(i + 1) % (len(self.points))]) + # painter.drawLine(cur_line) self.drawVertex(vrtx_path, i) + if self.isClosed(): line_path.lineTo(self.points[0]) @@ -140,9 +170,38 @@ def paint(self, painter): painter.drawPath(line_path) painter.drawPath(vrtx_path) painter.fillPath(vrtx_path, self.vertex_fill_color) - if self.fill: - color = self.select_fill_color if self.selected else self.fill_color - painter.fillPath(line_path, color) + + + if len(self.points) == 4 and self.isRotated: + pen.setColor(DIRECTION_ARROW_COLOR) + painter.setPen(pen) + center = (self.points[0]+self.points[2]) / 2 # the center of rectangle + toward_point = (self.points[3] + self.points[0]) / 2 # the end point in the direction + + arrow_center = QLineF(toward_point, center) + painter.drawLine(arrow_center) + + arrow_center.setLength(ARROW_LENGTH) + arrow_center_x = arrow_center.x2() - arrow_center.x1() + arrow_center_y = arrow_center.y2() - arrow_center.y1() + + arrow_left_p2_x = arrow_center_x * math.cos(ARROW_ANGLE) - arrow_center_y * math.sin(ARROW_ANGLE) + arrow_left_p2_y = arrow_center_x * math.sin(ARROW_ANGLE) + arrow_center_y * math.cos(ARROW_ANGLE) + arrow_center.setP2(QPointF(arrow_center.x1() + arrow_left_p2_x, arrow_center.y1() + arrow_left_p2_y)) + painter.drawLine(arrow_center) + + arrow_right_p2_x = arrow_center_x * math.cos(-ARROW_ANGLE) - arrow_center_y * math.sin(-ARROW_ANGLE) + arrow_right_p2_y = arrow_center_x * math.sin(-ARROW_ANGLE) + arrow_center_y * math.cos(-ARROW_ANGLE) + arrow_center.setP2(QPointF(arrow_center.x1() + arrow_right_p2_x, arrow_center.y1() + arrow_right_p2_y)) + painter.drawLine(arrow_center) + + pen.setColor(color) + painter.setPen(pen) + + + #if self.fill: + # color = self.select_fill_color if self.selected else self.fill_color + # painter.fillPath(line_path, color) if self.center is not None: center_path = QPainterPath() @@ -156,7 +215,7 @@ def paint(self, painter): def paintNormalCenter(self, painter): if self.center is not None: - center_path = QPainterPath(); + center_path = QPainterPath() d = self.point_size / self.scale center_path.addRect(self.center.x() - d / 2, self.center.y() - d / 2, d, d) painter.drawPath(center_path) diff --git a/roLabelImg.py b/roLabelImg.py index 08aa427..0473d2c 100755 --- a/roLabelImg.py +++ b/roLabelImg.py @@ -200,8 +200,7 @@ def __init__(self, defaultFilename=None, defaultPrefdefClassFile=None): self.addDockWidget(Qt.RightDockWidgetArea, self.dock) # Tzutalin 20160906 : Add file list and dock to move faster self.addDockWidget(Qt.RightDockWidgetArea, self.filedock) - self.dockFeatures = QDockWidget.DockWidgetClosable\ - | QDockWidget.DockWidgetFloatable + self.dockFeatures = QDockWidget.DockWidgetClosable | QDockWidget.DockWidgetFloatable self.dock.setFeatures(self.dock.features() ^ self.dockFeatures) self.filedock.setFeatures(self.filedock.features() ^ self.dockFeatures) @@ -225,6 +224,9 @@ def __init__(self, defaultFilename=None, defaultPrefdefClassFile=None): openNextImg = action('&Next Image', self.openNextImg, 'd', 'next', u'Open Next') + trackCurrentLabels = action('&Track', self.trackCurrentLabels, + 's', 'next', u'Track') + openPrevImg = action('&Prev Image', self.openPrevImg, 'a', 'prev', u'Open Prev') @@ -376,7 +378,7 @@ def __init__(self, defaultFilename=None, defaultPrefdefClassFile=None): self.tools = self.toolbar('Tools') self.actions.beginner = ( - open, opendir, openNextImg, openPrevImg, verify, save, None, create, createRo, copy, delete, None, + open, opendir, openNextImg, trackCurrentLabels, openPrevImg, verify, save, None, create, createRo, copy, delete, None, zoomIn, zoom, zoomOut, fitWindow, fitWidth) self.actions.advanced = ( @@ -522,6 +524,8 @@ def setDirty(self): self.dirty = True self.canvas.verified = False self.actions.save.setEnabled(True) + self.actions.create.setEnabled(True) + self.actions.createRo.setEnabled(True) def setClean(self): self.dirty = False @@ -534,6 +538,7 @@ def enableCreate(self,b): self.actions.create.setEnabled(self.isEnableCreate) def enableCreateRo(self,b): + print("hallow enableCreateRo") self.isEnableCreateRo = not b self.actions.createRo.setEnabled(self.isEnableCreateRo) @@ -752,10 +757,8 @@ def saveLabels(self, annotationFilePath): def format_shape(s): return dict(label=s.label, - line_color=s.line_color.getRgb() - if s.line_color != self.lineColor else None, - fill_color=s.fill_color.getRgb() - if s.fill_color != self.fillColor else None, + line_color=s.line_color.getRgb() if s.line_color != self.lineColor else None, + fill_color=s.fill_color.getRgb() if s.fill_color != self.fillColor else None, points=[(p.x(), p.y()) for p in s.points], # add chris difficult = s.difficult, @@ -873,9 +876,10 @@ def togglePolygons(self, value): for item, shape in self.itemsToShapes.items(): item.setCheckState(Qt.Checked if value else Qt.Unchecked) - def loadFile(self, filePath=None): + def loadFile(self, filePath=None, trackCuerentLabels=False): """Load the specified file, or the last opened file if None.""" - self.resetState() + if not trackCuerentLabels: + self.resetState() self.canvas.setEnabled(False) if filePath is None: filePath = self.settings.get('filename') @@ -902,6 +906,7 @@ def loadFile(self, filePath=None): self.imageData = self.labelFile.imageData self.lineColor = QColor(*self.labelFile.lineColor) self.fillColor = QColor(*self.labelFile.fillColor) + print(len(self.labelFile.shapes)) else: # Load image: # read data first and store for saving into label file. @@ -916,10 +921,13 @@ def loadFile(self, filePath=None): self.status("Loaded %s" % os.path.basename(unicodeFilePath)) self.image = image self.filePath = unicodeFilePath - self.canvas.loadPixmap(QPixmap.fromImage(image)) - if self.labelFile: - self.loadLabels(self.labelFile.shapes) - self.setClean() + self.canvas.loadPixmap(QPixmap.fromImage(image), trackCuerentLabels) #whether track current labels + if not trackCuerentLabels: + if self.labelFile: + self.loadLabels(self.labelFile.shapes) + self.setClean() + else: + self.setDirty() self.canvas.setEnabled(True) self.adjustScale(initial=True) self.paintCanvas() @@ -927,7 +935,7 @@ def loadFile(self, filePath=None): self.toggleActions(True) # Label xml file and show bound box according to its filename - if self.usingPascalVocFormat is True: + if self.usingPascalVocFormat is True and not trackCuerentLabels: # and not trackCuerentLabels if self.defaultSaveDir is not None: basename = os.path.basename( os.path.splitext(self.filePath)[0]) + XML_EXT @@ -1142,6 +1150,32 @@ def openNextImg(self, _value=False): if filename: self.loadFile(filename) + + def trackCurrentLabels(self): + # Proceding next image without dialog if having any label + if self.autoSaving is True and self.defaultSaveDir is not None: + if self.dirty is True: + self.dirty = False + self.canvas.verified = True + self.saveFile() + + if not self.mayContinue(): + return + + if len(self.mImgList) <= 0: + return + + filename = None + if self.filePath is None: + filename = self.mImgList[0] + else: + currIndex = self.mImgList.index(self.filePath) + if currIndex + 1 < len(self.mImgList): + filename = self.mImgList[currIndex + 1] + + if filename: + self.loadFile(filename, trackCuerentLabels=True) + def openFile(self, _value=False): @@ -1168,8 +1202,7 @@ def saveFile(self, _value=False): imgFileName = os.path.basename(self.filePath) savedFileName = os.path.splitext(imgFileName)[0] + XML_EXT savedPath = os.path.join(imgFileDir, savedFileName) - self._saveFile(savedPath if self.labelFile - else self.saveFileDialog()) + self._saveFile(savedPath) #if self.labelFile else self.saveFileDialog()) def saveFileAs(self, _value=False): assert not self.image.isNull(), "cannot save empty image" @@ -1276,7 +1309,7 @@ def loadPredefinedClasses(self, predefClassesFile): for line in f: line = line.strip() if self.labelHist is None: - self.lablHist = [line] + self.labelHist = [line] else: self.labelHist.append(line)