5
\$\begingroup\$

For the full code, please go to https://github.com/emadboctorx/labelpix

This is an object detection tool for drawing bounding boxes over images and save output to csv/hdf or yolo (You Only Look Once) format. Suggestions for improvement / features to add / general feedback are more than welcome.

Features

  • Preview and edit interfaces.
  • Save bounding box relative coordinates to csv / hdf formats.
  • Save relative coordinates to yolo format

Instructions

  • Upload photos.
  • Add labels to session labels.
  • Click on a photo from the photo list.
  • Click on the desired label from the labels you added.
  • Activate edit mode.
  • Draw bounding boxes.
  • Switch photos by scrolling/clicking on images in the list.
  • Save data by entering filename_example.csv or filename_example.h5
  • You can also save to yolo formatted txt outputs.
  • For deleting any of the 3 right lists (session labels / Labels of the current image / Photo list) items, check item and press the delete button

Image

ui

Code

labelpix.py:

from PyQt5.QtGui import QIcon, QPixmap, QPainter, QPen
from PyQt5.QtWidgets import (QMainWindow, QApplication, QDesktopWidget, QAction, QStatusBar, QHBoxLayout,
                             QVBoxLayout, QWidget, QLabel, QListWidget, QFileDialog, QFrame,
                             QLineEdit, QListWidgetItem, QDockWidget, QMessageBox)
from PyQt5.QtCore import Qt, QPoint, QRect
from settings import *
import pandas as pd
import cv2
import sys
import os


class RegularImageArea(QLabel):
    """
    Display only area within the main interface.
    """
    def __init__(self, current_image, main_window):
        """
        Initialize current image for display.
        Args:
            current_image: Path to target image.
            main_window: ImageLabeler instance.
        """
        super().__init__()
        self.setFrameStyle(QFrame.StyledPanel)
        self.current_image = current_image
        self.main_window = main_window

    def get_image_names(self):
        """
        Return:
            Directory of the current image and the image name.
        """
        full_name = self.current_image.split('/')
        return '/'.join(full_name[:-1]), full_name[-1].replace('temp-', '')

    def paintEvent(self, event):
        """
        Adjust image size to current window.
        Args:
            event: QPaintEvent object.

        Return:
            None
        """
        painter = QPainter(self)
        current_size = self.size()
        origin = QPoint(0, 0)
        if self.current_image:
            scaled_image = QPixmap(self.current_image).scaled(
                current_size, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
            painter.drawPixmap(origin, scaled_image)

    def switch_image(self, img):
        """
        Switch the current image displayed in the main window with the new one.
        Args:
            img: Path to new image to display.

        Return:
            None
        """
        self.current_image = img
        self.repaint()

    @staticmethod
    def calculate_ratios(x1, y1, x2, y2, width, height):
        """
        Calculate relative object ratios in the labeled image.
        Args:
            x1: Start x coordinate.
            y1: Start y coordinate.
            x2: End x coordinate.
            y2: End y coordinate.
            width: Bounding box width.
            height: Bounding box height.

        Return:
            bx: Relative center x coordinate.
            by: Relative center y coordinate.
            bw: Relative box width.
            bh: Relative box height.
        """
        box_width = abs(x2 - x1)
        box_height = abs(y2 - y1)
        bx = 1 - ((width - min(x1, x2) + (box_width / 2)) / width)
        by = 1 - ((height - min(y1, y2) + (box_height / 2)) / height)
        bw = box_width / width
        bh = box_height / height
        return bx, by, bw, bh

    @staticmethod
    def ratios_to_coordinates(bx, by, bw, bh, width, height):
        """
        Convert relative coordinates to actual coordinates.
        Args:
            bx: Relative center x coordinate.
            by: Relative center y coordinate.
            bw: Relative box width.
            bh: Relative box height.
            width: Current image display space width.
            height: Current image display space height.

        Return:
            x: x coordinate.
            y: y coordinate.
            w: Bounding box width.
            h: Bounding box height.
        """
        w, h = bw * width, bh * height
        x, y = bx * width + (w / 2), by * height + (h / 2)
        return x, y, w, h

    def draw_boxes(self, ratios):
        """
        Draw boxes over the current image using given ratios.
        Args:
            ratios: A list of [[bx, by, bw, bh], ...]

        Return:
            None
        """
        img_dir, img_name = self.get_image_names()
        to_label = cv2.imread(self.current_image)
        to_label = cv2.resize(to_label, (self.width(), self.height()))
        for bx, by, bw, bh in ratios:
            x, y, w, h = self.ratios_to_coordinates(bx, by, bw, bh, self.width(), self.height())
            to_label = cv2.rectangle(to_label, (int(x), int(y)), (int(x + w), int(y + h)), (0, 0, 255), 1)
        temp = f'{img_dir}/temp-{img_name}'
        cv2.imwrite(f'{img_dir}/temp-{img_name}', to_label)
        self.switch_image(temp)


class ImageEditorArea(RegularImageArea):
    """
    Edit and display area within the main interface.
    """
    def __init__(self, current_image, main_window):
        """
        Initialize current image for display.
        Args:
            current_image: Path to target image.
            main_window: ImageLabeler instance.
        """
        super().__init__(current_image, main_window)
        self.main_window = main_window
        self.start_point = QPoint()
        self.end_point = QPoint()
        self.begin = QPoint()
        self.end = QPoint()

    def paintEvent(self, event):
        """
        Adjust image size to current window and draw bounding box.
        Args:
            event: QPaintEvent object.

        Return:
            None
        """
        super().paintEvent(event)
        qp = QPainter(self)
        pen = QPen(Qt.red)
        qp.setPen(pen)
        qp.drawRect(QRect(self.begin, self.end))

    def mousePressEvent(self, event):
        """
        Start drawing the box.
        Args:
            event: QMouseEvent object.

        Return:
            None
        """
        self.start_point = event.pos()
        self.begin = event.pos()
        self.end = event.pos()
        self.update()

    def mouseMoveEvent(self, event):
        """
        Update size with mouse move.
        Args:
            event: QMouseEvent object.

        Return:
            None
        """
        self.end = event.pos()
        self.update()

    def mouseReleaseEvent(self, event):
        """
        Calculate coordinates of the bounding box, display a message, update session data.
        Args:
            event: QMouseEvent object.

        Return:
            None
        """
        self.begin = event.pos()
        self.end = event.pos()
        self.end_point = event.pos()
        x1, y1, x2, y2 = (self.start_point.x(), self.start_point.y(),
                          self.end_point.x(), self.end_point.y())
        self.main_window.statusBar().showMessage(f'Start: {x1}, {y1}, End: {x2}, {y2}')
        self.update()
        if self.current_image:
            bx, by, bw, bh = self.calculate_ratios(x1, y1, x2, y2, self.width(), self.height())
            self.update_session_data(x1, y1, x2, y2)
            current_label_index = self.main_window.get_current_selection('slabels')
            if current_label_index is None or current_label_index < 0:
                return
            self.draw_boxes([[bx, by, bw, bh]])

    def update_session_data(self, x1, y1, x2, y2):
        """
        Add a row to session_data containing calculated ratios.
        Args:
            x1: Start x coordinate.
            y1: Start y coordinate.
            x2: End x coordinate.
            y2: End y coordinate.

        Return:
            None
        """
        current_label_index = self.main_window.get_current_selection('slabels')
        if current_label_index is None or current_label_index < 0:
            return
        window_width, window_height = self.width(), self.height()
        object_name = self.main_window.right_widgets['Session Labels'].item(current_label_index).text()
        bx, by, bw, bh = self.calculate_ratios(x1, y1, x2, y2, window_width, window_height)
        data = [[self.get_image_names()[1], object_name, current_label_index, bx, by, bw, bh]]
        to_add = pd.DataFrame(data, columns=self.main_window.session_data.columns)
        self.main_window.session_data = pd.concat([self.main_window.session_data, to_add], ignore_index=True)
        self.main_window.add_to_list(f'{data}', self.main_window.right_widgets['Image Label List'])


class ImageLabeler(QMainWindow):
    """
    Image labeling main interface.
    """
    def __init__(self, window_title='Image Labeler', current_image_area=RegularImageArea):
        """
        Initialize main interface and display.
        Args:
            window_title: Title of the window.
            current_image_area: RegularImageArea or ImageEditorArea object.
        """
        super().__init__()
        self.current_image = None
        self.label_file = None
        self.current_image_area = current_image_area
        self.images = []
        self.image_paths = {}
        self.session_data = pd.DataFrame(
            columns=['Image', 'Object Name', 'Object Index', 'bx', 'by', 'bw', 'bh'])
        self.window_title = window_title
        self.setWindowTitle(self.window_title)
        win_rectangle = self.frameGeometry()
        center_point = QDesktopWidget().availableGeometry().center()
        win_rectangle.moveCenter(center_point)
        self.move(win_rectangle.topLeft())
        self.setStyleSheet('QPushButton:!hover {color: orange} QLineEdit:!hover {color: orange}')
        self.tools = self.addToolBar('Tools')
        self.tool_items = setup_toolbar(self)
        self.top_right_widgets = {'Add Label': (QLineEdit(), self.add_session_label)}
        self.right_widgets = {'Session Labels': QListWidget(),
                              'Image Label List': QListWidget(),
                              'Photo List': QListWidget()}
        self.left_widgets = {'Image': self.current_image_area('', self)}
        self.setStatusBar(QStatusBar(self))
        self.adjust_tool_bar()
        self.central_widget = QWidget(self)
        self.main_layout = QHBoxLayout()
        self.left_layout = QVBoxLayout()
        self.adjust_widgets()
        self.adjust_layouts()
        self.show()

    def adjust_tool_bar(self):
        """
        Adjust the top tool bar and setup buttons/icons.

        Return:
            None
        """
        self.tools.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
        if sys.platform == 'darwin':
            self.setUnifiedTitleAndToolBarOnMac(True)
        for label, icon_file, widget_method, status_tip, key, check in self.tool_items.values():
            action = QAction(QIcon(f'../Icons/{icon_file}'), label, self)
            action.setStatusTip(status_tip)
            action.setShortcut(key)
            if check:
                action.setCheckable(True)
            if label == 'Delete':
                action.setShortcut('Backspace')
            action.triggered.connect(widget_method)
            self.tools.addAction(action)
            self.tools.addSeparator()

    def adjust_layouts(self):
        """
        Adjust window layouts.

        Return:
            None
        """
        self.main_layout.addLayout(self.left_layout)
        self.central_widget.setLayout(self.main_layout)
        self.setCentralWidget(self.central_widget)

    def adjust_widgets(self):
        """
        Adjust window widgets.

        Return:
            None
        """
        self.left_layout.addWidget(self.left_widgets['Image'])
        for text, (widget, widget_method) in self.top_right_widgets.items():
            dock_widget = QDockWidget(text)
            dock_widget.setFeatures(QDockWidget.NoDockWidgetFeatures)
            dock_widget.setWidget(widget)
            self.addDockWidget(Qt.RightDockWidgetArea, dock_widget)
            if widget_method:
                widget.editingFinished.connect(widget_method)
        self.top_right_widgets['Add Label'][0].setPlaceholderText('Add Label')
        self.right_widgets['Photo List'].selectionModel().currentChanged.connect(
            self.display_selection)
        for text, widget in self.right_widgets.items():
            dock_widget = QDockWidget(text)
            dock_widget.setFeatures(QDockWidget.NoDockWidgetFeatures)
            dock_widget.setWidget(widget)
            self.addDockWidget(Qt.RightDockWidgetArea, dock_widget)

    def get_current_selection(self, display_list):
        """
        Get current selected item data.
        Args:
            display_list: One of the right QWidgetList(s).

        Return:
            Image path or current row.
        """
        if display_list == 'photo':
            current_selection = self.right_widgets['Photo List'].currentRow()
            if current_selection >= 0:
                return self.images[current_selection]
            self.right_widgets['Photo List'].selectionModel().clear()
        if display_list == 'slabels':
            current_selection = self.right_widgets['Session Labels'].currentRow()
            if current_selection >= 0:
                return current_selection

    @staticmethod
    def add_to_list(item, widget_list):
        """
        Add item to one of the right QWidgetList(s).
        Args:
            item: str : Item to add.
            widget_list: One of the right QWidgetList(s).

        Return:
            None
        """
        item = QListWidgetItem(item)
        item.setFlags(item.flags() | Qt.ItemIsSelectable |
                      Qt.ItemIsUserCheckable | Qt.ItemIsEditable)
        item.setCheckState(Qt.Unchecked)
        widget_list.addItem(item)
        widget_list.selectionModel().clear()

    def display_selection(self):
        """
        Display image that is selected in the right Photo list.

        Return:
            None
        """
        ratios = []
        self.right_widgets['Image Label List'].clear()
        self.current_image = self.get_current_selection('photo')
        if not self.current_image:
            return
        self.left_widgets['Image'].switch_image(self.current_image)
        image_dir, img_name = self.left_widgets['Image'].get_image_names()
        for item in self.session_data.loc[self.session_data['Image'] == img_name].values:
            self.add_to_list(f'{[[x for x in item]]}', self.right_widgets['Image Label List'])
            ratios.append([x for x in item][3:])
        self.left_widgets['Image'].draw_boxes(ratios)

    def upload_photos(self):
        """
        Add image(s) to the right photo list.

        Return:
            None
        """
        file_dialog = QFileDialog()
        file_names, _ = file_dialog.getOpenFileNames(self, 'Upload Photos')
        for file_name in file_names:
            image_dir, photo_name = '/'.join(file_name.split('/')[:-1]), file_name.split('/')[-1]
            self.add_to_list(photo_name, self.right_widgets['Photo List'])
            self.images.append(file_name)
            self.image_paths[photo_name] = image_dir

    def upload_vid(self):
        pass

    def upload_folder(self):
        """
        Add images of a folder to the right photo list.

        Return:
            None
        """
        file_dialog = QFileDialog()
        folder_name = file_dialog.getExistingDirectory()
        if folder_name:
            for file_name in os.listdir(folder_name):
                if not file_name.startswith('.'):
                    photo_name = file_name.split('/')[-1]
                    self.add_to_list(photo_name, self.right_widgets['Photo List'])
                    self.images.append(f'{folder_name}/{file_name}')
                    self.image_paths[file_name] = folder_name

    def switch_editor(self, image_area):
        """
        Switch between the display/edit interfaces.
        Args:
            image_area: RegularImageArea or ImageEditorArea object.

        Return:
            None
        """
        self.left_layout.removeWidget(self.left_widgets['Image'])
        self.left_widgets['Image'] = image_area(self.current_image, self)
        self.left_layout.addWidget(self.left_widgets['Image'])

    def edit_mode(self):
        """
        Switch between the display/edit interfaces.

        Return:
            None
        """
        if self.windowTitle() == 'Image Labeler':
            self.setWindowTitle('Image Labeler(Editor Mode)')
            self.switch_editor(ImageEditorArea)
        else:
            self.setWindowTitle('Image Labeler')
            self.switch_editor(RegularImageArea)
        self.display_selection()

    def save_session_data(self, location):
        """
        Save session data to csv/hdf.
        Args:
            location: Path to save session data file.

        Return:
            None
        """
        if location.endswith('.csv'):
            self.session_data.to_csv(location, index=False)
        if location.endswith('h5'):
            self.session_data.to_hdf(location, key='session_data', index=False)

    def read_session_data(self, location):
        """
        Read session data from csv/hdf
        Args:
            location: Path to session data file.

        Return:
            data.
        """
        data = self.session_data
        if location.endswith('.csv'):
            data = pd.read_csv(location)
        if location.endswith('.h5'):
            data = pd.read_hdf(location, 'session_data')
        return data

    def save_changes_table(self):
        """
        Save the data in self.session_data to new/existing csv/hdf format.

        Return:
            None
        """
        if self.label_file:
            location = self.label_file
            old_session_data = self.read_session_data(location)
            self.session_data = pd.concat([old_session_data, self.session_data], ignore_index=True)
            self.session_data.drop_duplicates(inplace=True)
            self.save_session_data(location)
        else:
            dialog = QFileDialog()
            location, _ = dialog.getSaveFileName(self, 'Save as')
            self.label_file = location
            self.save_session_data(location)
        self.statusBar().showMessage(f'Labels Saved to {location}')

    def clear_yolo_txt(self):
        """
        Delete txt files in working directories.

        Return:
            None
        """
        working_directories = set(['/'.join(item.split('/')[:-1]) for item in self.images])
        for working_directory in working_directories:
            for file_name in os.listdir(working_directory):
                if file_name.endswith('.txt'):
                    os.remove(f'{working_directory}/{file_name}')

    def save_changes_yolo(self):
        """
        Save session data to txt files in yolo format.

        Return:
            None
        """
        if self.session_data.empty:
            return
        self.clear_yolo_txt()
        txt_file_names = set()
        for index, data in self.session_data.iterrows():
            image_name, object_name, object_index, bx, by, bw, bh = data
            image_path = self.image_paths[image_name]
            txt_file_name = f'{image_path}/{image_name.split(".")[0]}.txt'
            txt_file_names.add(txt_file_name)
            with open(txt_file_name, 'a') as txt:
                txt.write(f'{object_index!s} {bx!s} {by!s} {bw!s} {bh!s}\n')
            self.statusBar().showMessage(f'Saved {len(txt_file_names)} txt files')

    @staticmethod
    def get_list_selections(widget_list):
        """
        Get in-list index of checked items in the given QWidgetList.
        Args:
            widget_list: One of the right QWidgetList(s).

        Return:
            A list of checked indexes.
        """
        items = [widget_list.item(i) for i in range(widget_list.count())]
        checked_indexes = [checked_index for checked_index, item in enumerate(items)
                           if item.checkState() == Qt.Checked]
        return checked_indexes

    def delete_list_selections(self, checked_indexes, widget_list):
        """
        Delete checked indexes in the given QWidgetList.
        Args:
            checked_indexes: A list of checked indexes.
            widget_list: One of the right QWidgetList(s).

        Return:
            None
        """
        if checked_indexes:
            for q_list_index in reversed(checked_indexes):
                if widget_list is self.right_widgets['Photo List']:
                    image_name = self.images[q_list_index].split('/')[-1]
                    del self.images[q_list_index]
                    del self.image_paths[image_name]
                if widget_list is self.right_widgets['Image Label List']:
                    current_row = eval(f'{self.right_widgets["Image Label List"].item(q_list_index).text()}')[0]
                    row_items = dict(zip(self.session_data.columns, current_row))
                    current_boxes = self.session_data.loc[self.session_data['Image'] == current_row[0]]
                    for index, box in current_boxes[['bx', 'by', 'bw', 'bh']].iterrows():
                        if box['bx'] == row_items['bx'] and box['by'] == row_items['by']:
                            self.session_data = self.session_data.drop(index)
                            break
                widget_list.takeItem(q_list_index)

    def delete_selections(self):
        """
        Delete all checked items in all 3 right QWidgetList(s).

        Return:
            None
        """
        checked_session_labels = self.get_list_selections(self.right_widgets['Session Labels'])
        checked_image_labels = self.get_list_selections(self.right_widgets['Image Label List'])
        checked_photos = self.get_list_selections(self.right_widgets['Photo List'])
        self.delete_list_selections(checked_session_labels, self.right_widgets['Session Labels'])
        self.delete_list_selections(checked_image_labels, self.right_widgets['Image Label List'])
        self.delete_list_selections(checked_photos, self.right_widgets['Photo List'])

    def upload_labels(self):
        """
        Upload labels from csv or hdf.

        Return:
            None
        """
        dialog = QFileDialog()
        file_name, _ = dialog.getOpenFileName(self, 'Load labels')
        self.label_file = file_name
        new_data = self.read_session_data(file_name)
        labels_to_add = new_data[['Object Name', 'Object Index']].drop_duplicates().sort_values(
            by='Object Index').values
        self.right_widgets['Session Labels'].clear()
        for label, index in labels_to_add:
            self.add_session_label(label)
        self.session_data = pd.concat([self.session_data, new_data], ignore_index=True).drop_duplicates()
        if file_name:
            self.statusBar().showMessage(f'Labels loaded from {file_name}')

    def reset_labels(self):
        """
        Delete all labels in the current session_data.

        Return:
            None
        """
        message = QMessageBox()
        answer = message.question(
            self, 'Question', 'Are you sure, do you want to delete all current session labels?')
        if answer == message.Yes:
            self.session_data.drop(self.session_data.index, inplace=True)
            self.statusBar().showMessage(f'Session labels deleted successfully')

    def display_settings(self):
        pass

    def display_help(self):
        pass

    def add_session_label(self, label=None):
        """
        Add label entered to the session labels list.

        Return:
            None
        """
        labels = self.right_widgets['Session Labels']
        new_label = label or self.top_right_widgets['Add Label'][0].text()
        session_labels = [str(labels.item(i).text()) for i in range(labels.count())]
        if new_label and new_label not in session_labels:
            self.add_to_list(new_label, labels)
            self.top_right_widgets['Add Label'][0].clear()

    def remove_temps(self):
        """
        Remove temporary image files from working directories.

        Return:
            None
        """
        working_dirs = set(['/'.join(item.split('/')[:-1]) for item in self.images])
        for working_dir in working_dirs:
            for file_name in os.listdir(working_dir):
                if 'temp-' in file_name:
                    os.remove(f'{working_dir}/{file_name}')

    def closeEvent(self, event):
        """
        Save session data, clear cache, and close with or without saving.
        Args:
            event: QCloseEvent object.

        Return:
            None
        """
        if not self.label_file and not self.session_data.empty:
            message = QMessageBox()
            answer = message.question(self, 'Question', 'Quit without saving?')
            if answer == message.No:
                self.save_changes_table()
        if self.label_file and not self.session_data.empty:
            self.save_changes_table()
        self.remove_temps()
        event.accept()


if __name__ == '__main__':
    test = QApplication(sys.argv)
    test_window = ImageLabeler()
    sys.exit(test.exec_())

settings.py

def setup_toolbar(qt_obj):
    tools = {}
    names = ['Upload photos', 'Upload Labels', 'Save', 'Save Yolo', 'Upload Photo Folder',
             'Upload video', 'Edit Mode', 'Delete Selection(s)', 'Reset', 'Settings', 'Help']
    icons = ['upload_photo6.png', 'labels.png', 'save3.png', 'yolo.png', 'upload_folder5.png', 'upload_vid3.png',
             'draw_rectangle3.png', 'delete.png', 'reset4.png', 'settings.png', 'help.png']
    methods = [qt_obj.upload_photos, qt_obj.upload_labels, qt_obj.save_changes_table, qt_obj.save_changes_yolo,
               qt_obj.upload_folder, qt_obj.upload_vid, qt_obj.edit_mode, qt_obj.delete_selections,
               qt_obj.reset_labels, qt_obj.display_settings, qt_obj.display_help]
    keys = 'OLSYFVRDJAH'
    tips = ['Select photos from a folder and add them to the photo list',
            'Upload labels from csv, hdf',
            'Save changes to csv or hdf',
            'Save changes to txt files with Yolo format',
            'Open a folder from the last saved point or open a new one containing '
            'photos and add them to the photo list',
            'Add a video and convert it to .png frames and add them to the photo list',
            'Activate editor mode',
            'Delete all selections(checked items)', 'Delete all labels in the current working folder',
            'Display settings', 'Display help']
    tips = [f'Press ⌘⇧{key}:  ' + tip for key, tip in zip(keys, tips)]
    key_shorts = [f'Ctrl+Shift+{key}' for key in keys]
    check_status = [False, False, False, False, False, False, False, True, False, False, False, False]
    assert len(names) == len(icons) == len(methods) == len(tips) == len(key_shorts)
    for name, icon, method, tip, key, check in zip(names, icons, methods, tips, key_shorts, check_status):
        tools[name] = [name, icon, method, tip, key, check]
    return tools
\$\endgroup\$
1
  • \$\begingroup\$ @AlexV it doesn't really matter anyway, if you have any suggestions for improvement / questions / problems running the code, feel free to ask or maybe provide some feedback \$\endgroup\$ – bullseye Mar 9 '20 at 8:52
4
\$\begingroup\$

setup_toolbar from settings.py

Maintaining a several lists in parallel is tedious. setup_toolbar has a minimal check with assert len(names) == len(icons) == len(methods) == len(tips) == len(key_shorts) to help here, but even if all the lists have the same length, there is no way to make sure that those values are really consistent.

I'd propose to use something like the following:

def setup_toolbar(qt_obj):
    tools = {
        'Upload photos': {
            'icon': 'upload_photo6.png', 
            'callback': qt_obj.upload_photos, 'key': 'O',
            'hint': 'Select photos from a folder and add them to the photo list',
            'checkable': False
        },
        # and so on ...
    }

    # auto-generate shortcuts and rich hints
    for name, properties in tools.items():
        shortcut = f'Ctrl+Shift+{properties['key']}'
        properties['shortcut'] = shortcut
        # Mac symbols omitted out of lazyness ;-)
        properties['hint'] = f'Press {shortcut}:  {properties["hint"]}'
        # this /is redundant, but is in line with the original code
        properties['name'] = name

    return tools

This should be more robust, since all the relevant parts are closer together. Using a dict here is also more robust than a list because the properties can now be accessed using their names instead of having to remember to order in the list. Of course, ImageLabeler.adjust_tool_bar would have to be adapted to this change.

More on a semantic note, maybe also replace Upload with Load or Open, since, at least in my opinion, "upload" is usually used when pushing some content onto a remote system or device. I guess that's not your intention.

labelpix.py

Imports

Imports should be sorted and grouped. Also, avoid wildcard * imports, especially if you only need a single function like setup_toolbar. With these changes the code looks as follows:

# built-in libraries
import os
import sys

# third-party libraries
import cv2
import pandas as pd
from PyQt5.QtCore import QPoint, QRect, Qt
from PyQt5.QtGui import QIcon, QPainter, QPen, QPixmap
from PyQt5.QtWidgets import (QAction, QApplication, QDesktopWidget,
                             QDockWidget, QFileDialog, QFrame, QHBoxLayout,
                             QLabel, QLineEdit, QListWidget, QListWidgetItem,
                             QMainWindow, QMessageBox, QStatusBar, QVBoxLayout,
                             QWidget)

# libraries from this module
from settings import setup_toolbar

The comments between the groups are only for educational purposes.

Path handling

The code handles paths at several points. Doing it "manually" like here in foo

def get_image_names(self):
    """
    Return:
        Directory of the current image and the image name.
    """
    full_name = self.current_image.split('/')
    return '/'.join(full_name[:-1]), full_name[-1].replace('temp-', '')

is error-prone and won't work on Windows (and possibly other operating systems) where / is not used as path separator.

Fortunately, Python can help here. The could should use os.path.split or os.path.dirname/os.path.basename from the built-in os module, or the pathlib module, which provides a higher level, more OOP-like abstraction to the whole problem. Similarly building paths should use os.path.join or the corresponding functionality from pathlib.

General feedback

I tried to use the program on some more or less random example images. Since the aspect ratio of the image display area is fixed to that of the window, images become squished once you resize the window or simply if they don't have the correct aspect ratio. There also seems to be a bug where the original image seems to persist in the background (see screenshot below).

screenshot of a demo image

The image is similar to the one in a question of mine here on Code Review, so the circle on the left should really be a circle, not an ellipse.

Sometimes also freshly drawn bounding boxes vanished immediately and where also not listed in the Image Label List on the right. I admit that I did not really try to look into this, so it might be a simple user error on my side.

I'd also prefer to have a little bit more control over where the label files are stored, or at least have some indication where they were put.


There is likely more to say about the code, but that's all for now. Maybe I will have another go at it later. Till then: Happy Coding!

\$\endgroup\$
3
  • \$\begingroup\$ Thanks for the feedback, boxes usually vanish without adding coordinates to the image label list if no labels are selected in the upper list(session labels) which specifies the type of object. Regarding the bug, I'm aware of its presence but it only occurs if you delete all photos, I'll be working on fixing it. I'll take your mentioned points in consideration in my next follow up. Thanks again \$\endgroup\$ – bullseye Mar 9 '20 at 21:47
  • \$\begingroup\$ Regarding the aspect ratio: if I set it to keep the ratio during resizes this would create dead zones that would count as valid coordinates even if the box is drawn over blank, which will lead to false results and i'm not sure how to handle it while keeping the aspect ratio. \$\endgroup\$ – bullseye Mar 10 '20 at 14:58
  • \$\begingroup\$ Try replacing all Qt.IgnoreAspectRatio with to Qt.KeepAspectRatio and draw boxes in blank space, they will count as valid boxes \$\endgroup\$ – bullseye Mar 10 '20 at 15:01

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.