diff --git a/README.rst b/README.rst index 637b64bf9..c1f940987 100644 --- a/README.rst +++ b/README.rst @@ -219,6 +219,8 @@ Hotkeys +--------------------+--------------------------------------------+ | a | Previous image | +--------------------+--------------------------------------------+ +| c | Copy Previous label | ++--------------------+--------------------------------------------+ | del | Delete the selected rect box | +--------------------+--------------------------------------------+ | Ctrl++ | Zoom in | diff --git a/labelImg.py b/labelImg.py index 739c7f092..4cfbdd3f8 100755 --- a/labelImg.py +++ b/labelImg.py @@ -128,16 +128,44 @@ def __init__(self, defaultFilename=None, defaultPrefdefClassFile=None, defaultSa useDefaultLabelContainer = QWidget() useDefaultLabelContainer.setLayout(useDefaultLabelQHBoxLayout) - # Create a widget for edit and diffc button - self.diffcButton = QCheckBox(getStr('useDifficult')) - self.diffcButton.setChecked(False) - self.diffcButton.stateChanged.connect(self.btnstate) self.editButton = QToolButton() self.editButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + # Create Flag checkboxes list + self.flagGroupBox = QGroupBox(self) + self.flagGroupBox.setTitle('Flags') + + vbox = QVBoxLayout(self) + self.flagslayout = QGridLayout(self) + vbox.addLayout(self.flagslayout) + + addflagLayout = QHBoxLayout(self) + self.flaglineedit = QLineEdit() + self.flaglineedit.setPlaceholderText('Flag name') + self.flaglineedit.returnPressed.connect(self.addflag_button_pushed) + addflagLayout.addWidget(self.flaglineedit, 9) + self.addflag_button = QPushButton('↑') + self.addflag_button.clicked.connect(self.addflag_button_pushed) + addflagLayout.addWidget(self.addflag_button, 1) + vbox.addLayout(addflagLayout) + + self.discardUnnecessaryFlagsCheckbox = QCheckBox(getStr('discardFlags')) + self.discardUnnecessaryFlagsCheckbox.setChecked(settings.get(SETTING_DISCARD_FLAGS, False)) + self.discardUnnecessaryFlagsCheckbox.clicked.connect(self.btnstate) + vbox.addWidget(self.discardUnnecessaryFlagsCheckbox) + + self.flagGroupBox.setLayout(vbox) + # list of tuple(checkbox, x button) + self.flagWidgets = [] + + # add difficult and truncated flag by default + flaglist = settings.get(SETTING_FLAGS_INFO, [getStr('useDifficult'), getStr('useTruncated')]) + for flagname in flaglist: + self.addFlags(flagname) + # Add some of widgets to listLayout + listLayout.addWidget(self.flagGroupBox) listLayout.addWidget(self.editButton) - listLayout.addWidget(self.diffcButton) listLayout.addWidget(useDefaultLabelContainer) # Create and add combobox for showing unique labels in group @@ -214,7 +242,7 @@ def __init__(self, defaultFilename=None, defaultPrefdefClassFile=None, defaultSa 'Ctrl+u', 'open', getStr('openDir')) copyPrevBounding = action(getStr('copyPrevBounding'), self.copyPreviousBoundingBoxes, - 'Ctrl+v', 'paste', getStr('copyPrevBounding')) + 'c', 'paste', getStr('copyPrevBounding')) changeSavedir = action(getStr('changeSaveDir'), self.changeSavedirDialog, 'Ctrl+r', 'open', getStr('changeSavedAnnotationDir')) @@ -438,8 +466,6 @@ def getFormatMeta(format): self.fillColor = None self.zoom_level = 100 self.fit_window = False - # Add Chris - self.difficult = False ## Fix the compatible issue for qt4 and qt5. Convert the QStringList to python list if settings.get(SETTING_RECENT_FILES): @@ -471,8 +497,7 @@ def getFormatMeta(format): Shape.line_color = self.lineColor = QColor(settings.get(SETTING_LINE_COLOR, DEFAULT_LINE_COLOR)) Shape.fill_color = self.fillColor = QColor(settings.get(SETTING_FILL_COLOR, DEFAULT_FILL_COLOR)) self.canvas.setDrawingColor(self.lineColor) - # Add chris - Shape.difficult = self.difficult + Shape.flags = self.flags def xbool(x): if isinstance(x, QVariant): @@ -722,6 +747,87 @@ def fileitemDoubleClicked(self, item=None): if filename: self.loadFile(filename) + @property + def flags(self): + """ + Returns + ------- + dict + key is a flag name + value is a Bool + """ + return {flagbtn.text(): flagbtn.isChecked() for flagbtn in self.flagButtons} + + def addflag_button_pushed(self): + # check whether the name is valid or not + flagname = self.flaglineedit.text() + if flagname == '': + self.errorMessage('Invalid flag name', 'Input any texts!') + return + + if flagname in [c.text() for c in self.flagButtons]: + self.errorMessage('Invalid flag name', 'Already exist!') + return + + self.addFlags(flagname) + + def addFlags(self, name): + index = len(self.flagWidgets) + + newbtn = QCheckBox(name) + newbtn.setChecked(False) + # calling stateChanged is inappropriate! + newbtn.clicked.connect(self.btnstate) + newbtn.setProperty('colpos', index) + self.flagslayout.addWidget(newbtn, *(index, 0)) + self.flagslayout.setColumnStretch(0, 9) + + removebtn = QPushButton('X') + widgets = (newbtn, removebtn) + removebtn.setProperty('colpos', index) + removebtn.clicked.connect(lambda : self.removeFlags(removebtn.property('colpos'))) + self.flagslayout.addWidget(removebtn, *(index, 1)) + self.flagslayout.setColumnStretch(1, 1) + self.flagWidgets.append(widgets) + + + def removeFlags(self, index): + # remove widget by index totally + flaglineedit, removebtn = self.flagWidgets[index] + # remove checkbox + self.flagslayout.removeWidget(flaglineedit) + flaglineedit.setParent(None) + + # remove x button + self.flagslayout.removeWidget(removebtn) + removebtn.setParent(None) + + flaglineedit.deleteLater() + removebtn.deleteLater() + del self.flagWidgets[index] + + # update position + for i, (flaglineedit, removebtn) in enumerate(self.flagWidgets): + # update property + flaglineedit.setProperty('colpos', i) + removebtn.setProperty('colpos', i) + + # update flaglayout position + self.flagslayout.addWidget(flaglineedit, *(i, 0)) + self.flagslayout.addWidget(removebtn, *(i, 1)) + + self.setDirty() + + @property + def flagButtons(self): + for widgets in self.flagWidgets: + yield widgets[0] + + @property + def flagRemoveButtons(self): + for widgets in self.flagWidgets: + yield widgets[1] + # Add chris def btnstate(self, item= None): """ Function to handle difficult examples @@ -733,7 +839,7 @@ def btnstate(self, item= None): if not item: # If not selected Item, take the first one item = self.labelList.item(self.labelList.count()-1) - difficult = self.diffcButton.isChecked() + flags = self.flags try: shape = self.itemsToShapes[item] @@ -741,8 +847,7 @@ def btnstate(self, item= None): pass # Checked and Update try: - if difficult != shape.difficult: - shape.difficult = difficult + if shape.setChangedFlags(flags): self.setDirty() else: # User probably changed item visibility self.canvas.setShapeVisible(shape, item.checkState() == Qt.Checked) @@ -790,7 +895,8 @@ def remLabel(self, shape): def loadLabels(self, shapes): s = [] - for label, points, line_color, fill_color, difficult in shapes: + flagset = set([btn.text() for btn in self.flagButtons]) + for label, points, line_color, fill_color, flags in shapes: shape = Shape(label=label) for x, y in points: @@ -800,7 +906,7 @@ def loadLabels(self, shapes): self.setDirty() shape.addPoint(QPointF(x, y)) - shape.difficult = difficult + shape.flags = flags shape.close() s.append(shape) @@ -814,6 +920,22 @@ def loadLabels(self, shapes): else: shape.fill_color = generateColorByText(label) + shape_flagset = set(shape.flags.keys()) + # setDirty if the xml file's flag doesn't exist + for flagname in (flagset - shape_flagset): + shape.flags[flagname] = False + self.setDirty() + + # add flag to rightdock if it doesn't exist + if self.discardUnnecessaryFlagsCheckbox.isChecked(): + for flagname in (shape_flagset - flagset): + del shape.flags[flagname] + self.setDirty() + else: + for flagname in (shape_flagset - flagset): + flagset.add(flagname) + self.addFlags(flagname) + self.addLabel(shape) self.updateComboBox() self.canvas.loadShapes(s) @@ -840,11 +962,10 @@ def format_shape(s): line_color=s.line_color.getRgb(), fill_color=s.fill_color.getRgb(), points=[(p.x(), p.y()) for p in s.points], - # add chris - difficult = s.difficult) + flags=s.flags) shapes = [format_shape(shape) for shape in self.canvas.shapes] - # Can add differrent annotation formats here + # Can add different annotation formats here try: if self.labelFileFormat == LabelFileFormat.PASCAL_VOC: if annotationFilePath[-4:].lower() != ".xml": @@ -891,9 +1012,11 @@ def labelSelectionChanged(self): self._noSelectionSlot = True self.canvas.selectShape(self.itemsToShapes[item]) shape = self.itemsToShapes[item] - # Add Chris - self.diffcButton.setChecked(shape.difficult) - + for flagbtn in self.flagButtons: + if hasattr(shape, flagbtn.text()): + flagbtn.setChecked(getattr(shape, flagbtn.text())) + else: + flagbtn.setChecked(False) def labelItemChanged(self, item): shape = self.itemsToShapes[item] label = item.text() @@ -924,8 +1047,8 @@ def newShape(self): else: text = self.defaultLabelTextLine.text() - # Add Chris - self.diffcButton.setChecked(False) + for flagbtn in self.flagButtons: + flagbtn.setChecked(False) if text is not None: self.prevLabelText = text generate_color = generateColorByText(text) @@ -1198,6 +1321,10 @@ def closeEvent(self, event): settings[SETTING_PAINT_LABEL] = self.displayLabelOption.isChecked() settings[SETTING_DRAW_SQUARE] = self.drawSquaresOption.isChecked() settings[SETTING_LABEL_FILE_FORMAT] = self.labelFileFormat + + # save flag info + settings[SETTING_FLAGS_INFO] = list(self.flags.keys()) + settings[SETTING_DISCARD_FLAGS] = self.discardUnnecessaryFlagsCheckbox.isChecked() settings.save() def loadRecent(self, filename): @@ -1275,7 +1402,6 @@ def importDirImages(self, dirpath): self.lastOpenDir = dirpath self.dirname = dirpath self.filePath = None - self.fileListWidget.clear() self.mImgList = self.scanAllImages(dirpath) self.openNextImg() for imgPath in self.mImgList: diff --git a/libs/constants.py b/libs/constants.py index 1efda037c..7a6c82432 100644 --- a/libs/constants.py +++ b/libs/constants.py @@ -18,3 +18,5 @@ SETTING_DRAW_SQUARE = 'draw/square' SETTING_LABEL_FILE_FORMAT= 'labelFileFormat' DEFAULT_ENCODING = 'utf-8' +SETTING_FLAGS_INFO='flagsinfo' +SETTING_DISCARD_FLAGS='discardflags' diff --git a/libs/create_ml_io.py b/libs/create_ml_io.py index 0d0781464..9d95b94ff 100644 --- a/libs/create_ml_io.py +++ b/libs/create_ml_io.py @@ -125,7 +125,7 @@ def add_shape(self, label, bndbox): ymax = bndbox["y"] + (bndbox["height"] / 2) points = [(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)] - self.shapes.append((label, points, None, None, True)) + self.shapes.append((label, points, None, None, {'difficult': False, 'truncated': False})) def get_shapes(self): return self.shapes diff --git a/libs/labelFile.py b/libs/labelFile.py index b366d45e5..b1fe9eaa2 100644 --- a/libs/labelFile.py +++ b/libs/labelFile.py @@ -77,10 +77,10 @@ def savePascalVocFormat(self, filename, shapes, imagePath, imageData, for shape in shapes: points = shape['points'] label = shape['label'] - # Add Chris - difficult = int(shape['difficult']) + + flags = shape['flags'] bndbox = LabelFile.convertPoints2BndBox(points) - writer.addBndBox(bndbox[0], bndbox[1], bndbox[2], bndbox[3], label, difficult) + writer.addBndBox(bndbox[0], bndbox[1], bndbox[2], bndbox[3], label, flags) writer.save(targetFile=filename) return @@ -107,10 +107,10 @@ def saveYoloFormat(self, filename, shapes, imagePath, imageData, classList, for shape in shapes: points = shape['points'] label = shape['label'] - # Add Chris - difficult = int(shape['difficult']) + + flags = shape['flags'] bndbox = LabelFile.convertPoints2BndBox(points) - writer.addBndBox(bndbox[0], bndbox[1], bndbox[2], bndbox[3], label, difficult) + writer.addBndBox(bndbox[0], bndbox[1], bndbox[2], bndbox[3], label, flags) writer.save(targetFile=filename, classList=classList) return diff --git a/libs/pascal_voc_io.py b/libs/pascal_voc_io.py index 627e315b4..f8c334e75 100644 --- a/libs/pascal_voc_io.py +++ b/libs/pascal_voc_io.py @@ -77,10 +77,10 @@ def genXML(self): segmented.text = '0' return top - def addBndBox(self, xmin, ymin, xmax, ymax, name, difficult): + def addBndBox(self, xmin, ymin, xmax, ymax, name, flags): bndbox = {'xmin': xmin, 'ymin': ymin, 'xmax': xmax, 'ymax': ymax} bndbox['name'] = name - bndbox['difficult'] = difficult + bndbox['flags'] = flags self.boxlist.append(bndbox) def appendObjects(self, top): @@ -90,15 +90,11 @@ def appendObjects(self, top): name.text = ustr(each_object['name']) pose = SubElement(object_item, 'pose') pose.text = "Unspecified" - truncated = SubElement(object_item, 'truncated') - if int(float(each_object['ymax'])) == int(float(self.imgSize[0])) or (int(float(each_object['ymin']))== 1): - truncated.text = "1" # max == height or min - elif (int(float(each_object['xmax']))==int(float(self.imgSize[1]))) or (int(float(each_object['xmin']))== 1): - truncated.text = "1" # max == width or min - else: - truncated.text = "0" - difficult = SubElement(object_item, 'difficult') - difficult.text = str( bool(each_object['difficult']) & 1 ) + + for key, val in each_object['flags'].items(): + element = SubElement(object_item, key) + element.text = str( bool(val) & 1 ) + bndbox = SubElement(object_item, 'bndbox') xmin = SubElement(bndbox, 'xmin') xmin.text = str(each_object['xmin']) @@ -128,7 +124,7 @@ class PascalVocReader: def __init__(self, filepath): # shapes type: - # [labbel, [(x1,y1), (x2,y2), (x3,y3), (x4,y4)], color, color, difficult] + # [labbel, [(x1,y1), (x2,y2), (x3,y3), (x4,y4)], color, color, flags] self.shapes = [] self.filepath = filepath self.verified = False @@ -140,13 +136,13 @@ def __init__(self, filepath): def getShapes(self): return self.shapes - def addShape(self, label, bndbox, difficult): + def addShape(self, label, bndbox, flags): xmin = int(float(bndbox.find('xmin').text)) ymin = int(float(bndbox.find('ymin').text)) xmax = int(float(bndbox.find('xmax').text)) ymax = int(float(bndbox.find('ymax').text)) points = [(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)] - self.shapes.append((label, points, None, None, difficult)) + self.shapes.append((label, points, None, None, flags)) def parseXML(self): assert self.filepath.endswith(XML_EXT), "Unsupport file format" @@ -160,12 +156,18 @@ def parseXML(self): except KeyError: self.verified = False + noflag_list = ['name', 'pose', 'bndbox'] for object_iter in xmltree.findall('object'): bndbox = object_iter.find("bndbox") label = object_iter.find('name').text - # Add chris - difficult = False - if object_iter.find('difficult') is not None: - difficult = bool(int(object_iter.find('difficult').text)) - self.addShape(label, bndbox, difficult) + + flags = {'difficult': False, 'truncated': False} + + for obj_node in object_iter: + if obj_node.tag in noflag_list: + continue + val = bool(int(obj_node.text)) + flags[obj_node.tag] = val + + self.addShape(label, bndbox, flags=flags) return True diff --git a/libs/shape.py b/libs/shape.py index 466e046dc..a0d039e0f 100644 --- a/libs/shape.py +++ b/libs/shape.py @@ -38,12 +38,12 @@ class Shape(object): scale = 1.0 labelFontSize = 8 - def __init__(self, label=None, line_color=None, difficult=False, paintLabel=False): + def __init__(self, label=None, line_color=None, flags={'difficult': False, 'truncated': False}, paintLabel=False): self.label = label self.points = [] self.fill = False self.selected = False - self.difficult = difficult + self.flags = flags self.paintLabel = paintLabel self._highlightIndex = None @@ -193,9 +193,34 @@ def copy(self): shape.line_color = self.line_color if self.fill_color != Shape.fill_color: shape.fill_color = self.fill_color - shape.difficult = self.difficult + shape.flags = self.flags return shape + + def setChangedFlags(self, newflags): + """ + Set changed flags and return the boolean representing whether to have changed flags + Returns + ------- + Bool + Whether to have changed flags + """ + isChanged = False + for newkey, newval in newflags.items(): + if newkey not in self.flags.keys() or self.flags[newkey] != newval: + isChanged = True + break + # check to discard unnecessary flags + newflags_set = set(newflags.keys()) + origflags_set = set(self.flags.keys()) + isChanged = isChanged or len(newflags_set.symmetric_difference(origflags_set)) > 0 + + if isChanged: + self.flags = newflags + + return isChanged + + def __len__(self): return len(self.points) @@ -204,3 +229,19 @@ def __getitem__(self, key): def __setitem__(self, key, value): self.points[key] = value + + # allow to access the flag's key as Shape's attribute + def __getattr__(self, item): + if item in self.flags.keys(): + return self.flags[item] + else: + raise AttributeError('Shape has no attribute \'{}\''.format(item)) + + def __setattr__(self, key, value): + try: + super().__setattr__(key, value) + except AttributeError: + if key in self.flags.keys(): + self.flags[key] = value + else: + raise AttributeError('Shape has no attribute \'{}\''.format(key)) \ No newline at end of file diff --git a/libs/yolo_io.py b/libs/yolo_io.py index 216fba388..f8aac6a95 100644 --- a/libs/yolo_io.py +++ b/libs/yolo_io.py @@ -22,10 +22,10 @@ def __init__(self, foldername, filename, imgSize, databaseSrc='Unknown', localIm self.localImgPath = localImgPath self.verified = False - def addBndBox(self, xmin, ymin, xmax, ymax, name, difficult): + def addBndBox(self, xmin, ymin, xmax, ymax, name, flags): bndbox = {'xmin': xmin, 'ymin': ymin, 'xmax': xmax, 'ymax': ymax} bndbox['name'] = name - bndbox['difficult'] = difficult + bndbox['flags'] = flags self.boxlist.append(bndbox) def BndBox2YoloLine(self, box, classList=[]): @@ -85,7 +85,7 @@ class YoloReader: def __init__(self, filepath, image, classListPath=None): # shapes type: - # [labbel, [(x1,y1), (x2,y2), (x3,y3), (x4,y4)], color, color, difficult] + # [labbel, [(x1,y1), (x2,y2), (x3,y3), (x4,y4)], color, color, flags] self.shapes = [] self.filepath = filepath @@ -116,10 +116,10 @@ def __init__(self, filepath, image, classListPath=None): def getShapes(self): return self.shapes - def addShape(self, label, xmin, ymin, xmax, ymax, difficult): + def addShape(self, label, xmin, ymin, xmax, ymax, flags): points = [(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)] - self.shapes.append((label, points, None, None, difficult)) + self.shapes.append((label, points, None, None, flags)) def yoloLine2Shape(self, classIndex, xcen, ycen, w, h): label = self.classes[int(classIndex)] @@ -142,5 +142,5 @@ def parseYoloFormat(self): classIndex, xcen, ycen, w, h = bndBox.strip().split(' ') label, xmin, ymin, xmax, ymax = self.yoloLine2Shape(classIndex, xcen, ycen, w, h) - # Caveat: difficult flag is discarded when saved as yolo format. - self.addShape(label, xmin, ymin, xmax, ymax, False) + # Caveat: flags are discarded when saved as yolo format. + self.addShape(label, xmin, ymin, xmax, ymax, {'difficult': False, 'truncated': False}) diff --git a/resources/strings/strings.properties b/resources/strings/strings.properties index d684e4ada..fe31ea47b 100644 --- a/resources/strings/strings.properties +++ b/resources/strings/strings.properties @@ -55,6 +55,8 @@ shapeFillColorDetail=Change the fill color for this specific shape showHide=Show/Hide Label Panel useDefaultLabel=Use default label useDifficult=difficult +useTruncated=truncated +discardFlags=Discard the unnecessary flags boxLabelText=Box Labels labels=Labels autoSaveMode=Auto Save mode