diff --git a/.travis.yml b/.travis.yml index 72b07382..f0ea98c0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,8 @@ # vim: set ts=2 et: +# run xvfb with 32-bit color +# xvfb-run -s '-screen 0 1600x1200x24+32' command_goes_here + matrix: include: @@ -15,13 +18,59 @@ matrix: apt: packages: - cmake + - python-qt4 - pyqt4-dev-tools - xvfb before_install: - sudo pip install lxml - - make qt4 + - make qt4py2 - ( xvfb-run python2.7 labelImg.py ) & sleep 10 ; kill $! + # Python 2 + QT5 + # disabled; can't get it to work + #- os: linux + # dist: trusty + # sudo: required + # language: generic + # python: "2.7" + # env: + # - QT=5 + # addons: + # apt: + # packages: + # - cmake + # - pyqt5-dev-tools + # - xvfb + # before_install: + # - sudo apt-get update + # - sudo apt-get install -y python-pip + # - sudo pip install lxml + # - pyrcc5 --help || true # does QT5 support python2 out of the box? + # - make qt5py3 + # - ( xvfb-run python2.7 labelImg.py ) & sleep 10 ; kill $! + + # Python 3 + QT4 + - os: linux + dist: trusty + sudo: required + language: generic + python: "3.4" + env: + - QT=4 + addons: + apt: + packages: + - cmake + - python3-pyqt4 + - pyqt4-dev-tools + - xvfb + before_install: + - sudo apt-get update + - sudo apt-get install -y python3-pip + - sudo pip3 install lxml + - make qt4py3 + - ( xvfb-run python3 labelImg.py ) & sleep 10 ; kill $! + # Python 3 + QT5 - os: linux dist: trusty @@ -34,13 +83,13 @@ matrix: apt: packages: - cmake + - pyqt5-dev-tools - xvfb before_install: - sudo apt-get update - - sudo apt-get install -y pyqt5-dev-tools - sudo apt-get install -y python3-pip - sudo pip3 install lxml - - make qt5 + - make qt5py3 - ( xvfb-run python3 labelImg.py ) & sleep 10 ; kill $! # OS X Python 3 + QT5 @@ -61,7 +110,7 @@ matrix: - sudo -H easy_install-3.6 lxml || true - python3 -c 'import sys; print(sys.path)' - python3 -c 'import lxml' - - make qt5 + - make qt5py3 - python3 -c 'help("modules")' - ( python3 labelImg.py ) & sleep 10 ; kill $! diff --git a/Makefile b/Makefile index 28aa5a63..54c88897 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,16 @@ -all: resources.py +all: qt4 -%.py: %.qrc - pyrcc4 -o $@ $< +qt4: qt4py2 + +qt5: qt4py3 + +qt4py2: + pyrcc4 -py2 -o resources.py resources.qrc + +qt4py3: + pyrcc4 -py3 -o resources.py resources.qrc + +qt5py3: + pyrcc5 -o resources.py resources.qrc diff --git a/labelImg.py b/labelImg.py index 35d4bb34..a79e1c39 100755 --- a/labelImg.py +++ b/labelImg.py @@ -10,8 +10,19 @@ import subprocess from functools import partial from collections import defaultdict -from PyQt4.QtGui import * -from PyQt4.QtCore import * +try: + from PyQt5.QtGui import * + from PyQt5.QtCore import * + from PyQt5.QtWidgets import * +except ImportError: + # needed for py3+qt4 + # ref: http://pyqt.sourceforge.net/Docs/PyQt4/incompatible_apis.html + # ref: http://stackoverflow.com/questions/21217399/pyqt4-qtcore-qvariant-object-instead-of-a-string + if sys.version_info.major >= 3: + import sip + sip.setapi('QVariant', 2) + from PyQt4.QtGui import * + from PyQt4.QtCore import * import resources @@ -31,13 +42,22 @@ __appname__ = 'labelImg' def u(x): - '''unicode helper''' + '''py2/py3 unicode helper''' try: return x.decode('utf8') # py2 except AttributeError: return x # py3 +def have_qstring(): + '''p3/qt5 get rid of QString wrapper as py3 has native unicode str type''' + return not (sys.version_info.major >= 3 or QT_VERSION_STR.startswith('5.')) + + +def util_qt_strlistclass(): + return QStringList if have_qstring() else list + + class WindowMixin(object): def menu(self, title, actions=None): menu = self.menuBar().addMenu(title) @@ -56,6 +76,14 @@ class WindowMixin(object): return toolbar +# PyQt5: TypeError: unhashable type: 'QListWidgetItem' +class HashableQListWidgetItem(QListWidgetItem): + def __init__(self, *args): + super(HashableQListWidgetItem, self).__init__(*args) + def __hash__(self): + return hash(id(self)) + + class MainWindow(QMainWindow, WindowMixin): FIT_WINDOW, FIT_WIDTH, MANUAL_ZOOM = list(range(3)) @@ -332,19 +360,40 @@ class MainWindow(QMainWindow, WindowMixin): # XXX: Could be completely declarative. # Restore application settings. - types = { - 'filename': QString, - 'recentFiles': QStringList, - 'window/size': QSize, - 'window/position': QPoint, - 'window/geometry': QByteArray, - # Docks and toolbars: - 'window/state': QByteArray, - 'savedir': QString, - 'lastOpenDir': QString, - } + + if have_qstring(): + types = { + 'filename': QString, + 'recentFiles': QStringList, + 'window/size': QSize, + 'window/position': QPoint, + 'window/geometry': QByteArray, + 'line/color': QColor, + 'fill/color': QColor, + 'advanced': bool, + # Docks and toolbars: + 'window/state': QByteArray, + 'savedir': QString, + 'lastOpenDir': QString, + } + else: + types = { + 'filename': str, + 'recentFiles': list, + 'window/size': QSize, + 'window/position': QPoint, + 'window/geometry': QByteArray, + 'line/color': QColor, + 'fill/color': QColor, + 'advanced': bool, + # Docks and toolbars: + 'window/state': QByteArray, + 'savedir': str, + 'lastOpenDir': str, + } + self.settings = settings = Settings(types) - self.recentFiles = list(settings['recentFiles']) + self.recentFiles = list(settings.get('recentFiles', [])) size = settings.get('window/size', QSize(600, 500)) position = settings.get('window/position', QPoint(0, 0)) self.resize(size) @@ -358,13 +407,18 @@ class MainWindow(QMainWindow, WindowMixin): # or simply: #self.restoreGeometry(settings['window/geometry'] - self.restoreState(settings['window/state']) + self.restoreState(settings.get('window/state', QByteArray())) self.lineColor = QColor(settings.get('line/color', Shape.line_color)) self.fillColor = QColor(settings.get('fill/color', Shape.fill_color)) Shape.line_color = self.lineColor Shape.fill_color = self.fillColor - if settings.get('advanced', QVariant()).toBool(): + def xbool(x): + if isinstance(x, QVariant): + return x.toBool() + return bool(x) + + if xbool(settings.get('advanced', False)): self.actions.advancedMode.setChecked(True) self.toggleAdvancedMode() @@ -540,7 +594,7 @@ class MainWindow(QMainWindow, WindowMixin): else: shape = self.canvas.selectedShape if shape: - self.labelList.setItemSelected(self.shapesToItems[shape], True) + self.shapesToItems[shape].setSelected(True) else: self.labelList.clearSelection() self.actions.delete.setEnabled(selected) @@ -550,7 +604,7 @@ class MainWindow(QMainWindow, WindowMixin): self.actions.shapeFillColor.setEnabled(selected) def addLabel(self, shape): - item = QListWidgetItem(shape.label) + item = HashableQListWidgetItem(shape.label) item.setFlags(item.flags() | Qt.ItemIsUserCheckable) item.setCheckState(Qt.Checked) self.itemsToShapes[item] = shape @@ -694,17 +748,16 @@ class MainWindow(QMainWindow, WindowMixin): self.resetState() self.canvas.setEnabled(False) if filename is None: - filename = self.settings['filename'] - filename = filename + filename = self.settings.get('filename') # Tzutalin 20160906 : Add file list and dock to move faster # Highlight the file item if filename and self.fileListWidget.count() > 0: index = self.mImgList.index(filename) fileWidgetItem = self.fileListWidget.item(index) - self.fileListWidget.setItemSelected(fileWidgetItem, True) + fileWidgetItem.setSelected(True) - if QFile.exists(filename): + if filename and QFile.exists(filename): if LabelFile.isLabelFile(filename): try: self.labelFile = LabelFile(filename) @@ -791,7 +844,7 @@ class MainWindow(QMainWindow, WindowMixin): s = self.settings # If it loads images from dir, don't load it at the begining if self.dirname is None: - s['filename'] = self.filename if self.filename else QString() + s['filename'] = self.filename if self.filename else '' else: s['filename'] = '' @@ -967,7 +1020,6 @@ class MainWindow(QMainWindow, WindowMixin): dlg = QFileDialog(self, caption, openDialogPath, filters) dlg.setDefaultSuffix(LabelFile.suffix[1:]) dlg.setAcceptMode(QFileDialog.AcceptSave) - dlg.setConfirmOverwrite(True) filenameWithoutExtension = os.path.splitext(self.filename)[0] dlg.selectFile(filenameWithoutExtension) dlg.setOption(QFileDialog.DontUseNativeDialog, False) @@ -1108,10 +1160,20 @@ class Settings(object): def _cast(self, key, value): # XXX: Very nasty way of converting types to QVariant methods :P - t = self.types[key] - if t != QVariant: - method = getattr(QVariant, re.sub('^Q', 'to', t.__name__, count=1)) - return method(value) + print('_cast', key, repr(value)) + t = self.types.get(key) + print('t', t) + if t is not None and t != QVariant: + if t is str: + return str(value) + else: + # XXX: awful + try: + method = getattr(QVariant, re.sub('^Q', 'to', t.__name__, count=1)) + return method(value) + except AttributeError as e: + print(e) + return value return value diff --git a/libs/canvas.py b/libs/canvas.py index 1e76331c..b7e4aafd 100644 --- a/libs/canvas.py +++ b/libs/canvas.py @@ -1,5 +1,12 @@ -from PyQt4.QtGui import * -from PyQt4.QtCore import * + +try: + from PyQt5.QtGui import * + from PyQt5.QtCore import * + from PyQt5.QtWidgets import * +except ImportError: + from PyQt4.QtGui import * + from PyQt4.QtCore import * + #from PyQt4.QtOpenGL import * from shape import Shape @@ -85,7 +92,7 @@ class Canvas(QWidget): def mouseMoveEvent(self, ev): """Update line with last point and current coordinates.""" - pos = self.transformPos(ev.posF()) + pos = self.transformPos(ev.pos()) self.restoreCursor() @@ -169,7 +176,8 @@ class Canvas(QWidget): self.hVertex, self.hShape = None, None def mousePressEvent(self, ev): - pos = self.transformPos(ev.posF()) + pos = self.transformPos(ev.pos()) + if ev.button() == Qt.LeftButton: if self.drawing(): if self.current and self.current.reachMaxPoints() is False: diff --git a/libs/colorDialog.py b/libs/colorDialog.py index 3e712f8a..8c0164a9 100644 --- a/libs/colorDialog.py +++ b/libs/colorDialog.py @@ -1,5 +1,10 @@ -from PyQt4.QtGui import * -from PyQt4.QtCore import * +try: + from PyQt5.QtGui import * + from PyQt5.QtCore import * + from PyQt5.QtWidgets import QColorDialog, QDialogButtonBox +except ImportError: + from PyQt4.QtGui import * + from PyQt4.QtCore import * BB = QDialogButtonBox diff --git a/libs/labelDialog.py b/libs/labelDialog.py index 7f2b8992..c7f70575 100644 --- a/libs/labelDialog.py +++ b/libs/labelDialog.py @@ -1,5 +1,10 @@ -from PyQt4.QtGui import * -from PyQt4.QtCore import * +try: + from PyQt5.QtGui import * + from PyQt5.QtCore import * + from PyQt5.QtWidgets import * +except ImportError: + from PyQt4.QtGui import * + from PyQt4.QtCore import * from lib import newIcon, labelValidator @@ -32,11 +37,20 @@ class LabelDialog(QDialog): self.setLayout(layout) def validate(self): - if self.edit.text().trimmed(): - self.accept() + try: + if self.edit.text().trimmed(): + self.accept() + except AttributeError: + # PyQt5: AttributeError: 'str' object has no attribute 'trimmed' + if self.edit.text().strip(): + self.accept() def postProcess(self): - self.edit.setText(self.edit.text().trimmed()) + try: + self.edit.setText(self.edit.text().trimmed()) + except AttributeError: + # PyQt5: AttributeError: 'str' object has no attribute 'trimmed' + self.edit.setText(self.edit.text()) def popUp(self, text='', move=True): self.edit.setText(text) @@ -47,6 +61,10 @@ class LabelDialog(QDialog): return self.edit.text() if self.exec_() else None def listItemClick(self, tQListWidgetItem): - text = tQListWidgetItem.text().trimmed() + try: + text = tQListWidgetItem.text().trimmed() + except AttributeError: + # PyQt5: AttributeError: 'str' object has no attribute 'trimmed' + text = tQListWidgetItem.text().strip() self.edit.setText(text) self.validate() diff --git a/libs/labelFile.py b/libs/labelFile.py index 2665d7f7..f4e490ce 100644 --- a/libs/labelFile.py +++ b/libs/labelFile.py @@ -1,7 +1,11 @@ # Copyright (c) 2016 Tzutalin # Create by TzuTaLin -from PyQt4.QtGui import QImage +try: + from PyQt5.QtGui import QImage +except ImportError: + from PyQt4.QtGui import QImage + from base64 import b64encode, b64decode from pascal_voc_io import PascalVocWriter import os.path diff --git a/libs/lib.py b/libs/lib.py index 821733b2..854309d7 100644 --- a/libs/lib.py +++ b/libs/lib.py @@ -1,7 +1,12 @@ from math import sqrt -from PyQt4.QtGui import * -from PyQt4.QtCore import * +try: + from PyQt5.QtGui import * + from PyQt5.QtCore import * + from PyQt5.QtWidgets import * +except ImportError: + from PyQt4.QtGui import * + from PyQt4.QtCore import * def newIcon(icon): diff --git a/libs/shape.py b/libs/shape.py index 8f6c237f..317fd878 100644 --- a/libs/shape.py +++ b/libs/shape.py @@ -1,8 +1,13 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -from PyQt4.QtGui import * -from PyQt4.QtCore import * + +try: + from PyQt5.QtGui import * + from PyQt5.QtCore import * +except ImportError: + from PyQt4.QtGui import * + from PyQt4.QtCore import * from lib import distance diff --git a/libs/toolBar.py b/libs/toolBar.py index c0f370d5..357f8e36 100644 --- a/libs/toolBar.py +++ b/libs/toolBar.py @@ -1,5 +1,11 @@ -from PyQt4.QtGui import * -from PyQt4.QtCore import * +try: + from PyQt5.QtGui import * + from PyQt5.QtCore import * + from PyQt5.QtWidgets import * +except ImportError: + from PyQt4.QtGui import * + from PyQt4.QtCore import * + class ToolBar(QToolBar): def __init__(self, title): diff --git a/libs/zoomWidget.py b/libs/zoomWidget.py index 7c3d160f..17c5b9f5 100644 --- a/libs/zoomWidget.py +++ b/libs/zoomWidget.py @@ -1,5 +1,10 @@ -from PyQt4.QtGui import * -from PyQt4.QtCore import * +try: + from PyQt5.QtGui import * + from PyQt5.QtCore import * + from PyQt5.QtWidgets import * +except ImportError: + from PyQt4.QtGui import * + from PyQt4.QtCore import * class ZoomWidget(QSpinBox): def __init__(self, value=100):