diff --git a/.gitignore b/.gitignore index 6ba2c81..2fab267 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ !labelCloud/resources/examples/exemplary.ply !labelCloud/resources/examples/exemplary.json !labelCloud/resources/labelCloud_icon.pcd -!labels/segmentation/schema/label_definition.json +!labels/schema/label_definition.json !labels/segmentation/exemplary.bin # ---------------------------------------------------------------------------- # diff --git a/config.ini b/config.ini index 7a904df..6aefc9a 100644 --- a/config.ini +++ b/config.ini @@ -1,5 +1,5 @@ [MODE] -segmentation = true +segmentation = false [FILE] ; source of point clouds @@ -28,8 +28,6 @@ label_color_mix_ratio = 0.3 [LABEL] ; format for exporting labels. choose from (vertices, centroid_rel, centroid_abs, kitti) label_format = centroid_abs -; list of object classes for autocompletion in the text field -object_classes = cart, box ; default object class for new bounding boxes std_object_class = cart ; number of decimal places for exporting the bounding box parameter. diff --git a/docs/documentation.md b/docs/documentation.md index 661f091..ea3ed89 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -47,7 +47,6 @@ The following parameters can be changed: | `std_zoom` | Standard step for zooming (with mouse scroll). | *0.0025* | | **[LABEL]** | | `label_format` | Format for exporting labels, choose from `vertices`, `centroid_rel`, `centroid_abs` or `kitti`. | *centroid_abs* | -| `object_classes` | List of object classes for autocompletion in the class text field. | *class1, class2, ...* | | `std_object_class` | Default object class for new bounding boxes. | *default_class* | | `export_precision` | Number of decimal places for exporting the bounding box parameters. | *8* | | `std_boundingbox_length` | Default length of the bounding box (for picking mode). | *0.75* | diff --git a/labelCloud/control/bbox_controller.py b/labelCloud/control/bbox_controller.py index 73e2b7a..7328cdf 100644 --- a/labelCloud/control/bbox_controller.py +++ b/labelCloud/control/bbox_controller.py @@ -84,6 +84,9 @@ def add_bbox(self, bbox: BBox) -> None: if isinstance(bbox, BBox): self.bboxes.append(bbox) self.set_active_bbox(self.bboxes.index(bbox)) + self.view.current_class_dropdown.setCurrentText( + self.get_active_bbox().classname # type: ignore + ) self.view.status_manager.update_status( "Bounding Box added, it can now be corrected.", Mode.CORRECTION ) @@ -306,9 +309,11 @@ def update_z_dial(self) -> None: def update_curr_class(self) -> None: if self.has_active_bbox(): - self.view.update_curr_class_edit() + self.view.current_class_dropdown.setCurrentText( + self.get_active_bbox().classname # type: ignore + ) else: - self.view.update_curr_class_edit(force="") + self.view.controller.pcd_manager.populate_class_dropdown() def update_label_list(self) -> None: """Updates the list of drawn labels and highlights the active label. diff --git a/labelCloud/control/pcd_manager.py b/labelCloud/control/pcd_manager.py index ffb3939..1be1d26 100644 --- a/labelCloud/control/pcd_manager.py +++ b/labelCloud/control/pcd_manager.py @@ -7,10 +7,9 @@ from shutil import copyfile from typing import TYPE_CHECKING, List, Optional, Set, Tuple -import pkg_resources - import numpy as np import open3d as o3d +import pkg_resources from ..definitions.types import Point3D from ..io.pointclouds import BasePointCloudHandler, Open3DHandler @@ -41,7 +40,9 @@ def __init__(self) -> None: # Point cloud control self.pointcloud: Optional[PointCloud] = None - self.collected_object_classes: Set[str] = set() + self.collected_object_classes: Set[ + str + ] = set() # TODO: this should integrate with the new label definition setup. self.saved_perspective: Optional[Perspective] = None @property @@ -134,6 +135,13 @@ def get_prev_pcd(self) -> None: else: raise Exception("No point cloud left for loading!") + def populate_class_dropdown(self) -> None: + # Add point label list + self.view.current_class_dropdown.clear() + assert self.pointcloud is not None + for key in self.pointcloud.label_definition: + self.view.current_class_dropdown.addItem(key) + def get_labels_from_file(self) -> List[BBox]: bboxes = self.label_manager.import_labels(self.pcd_path) logging.info(green("Loaded %s bboxes!" % len(bboxes))) @@ -150,8 +158,6 @@ def save_labels_into_file(self, bboxes: List[BBox]) -> None: self.collected_object_classes.update( {bbox.get_classname() for bbox in bboxes} ) - self.view.update_label_completer(self.collected_object_classes) - self.view.update_default_object_class_menu(self.collected_object_classes) else: logging.warning("No point clouds to save labels for!") @@ -225,7 +231,7 @@ def rotate_pointcloud( rotation_matrix = o3d.geometry.get_rotation_matrix_from_axis_angle( np.multiply(axis, angle) ) - o3d_pointcloud = Open3DHandler().to_open3d_point_cloud(self.pointcloud) + o3d_pointcloud = Open3DHandler.to_open3d_point_cloud(self.pointcloud) o3d_pointcloud.rotate(rotation_matrix, center=tuple(rotation_point)) o3d_pointcloud.translate([0, 0, -rotation_point[2]]) logging.info("Rotating point cloud...") @@ -241,8 +247,13 @@ def rotate_pointcloud( center=(0, 0, 0), ) + points, colors = Open3DHandler.to_point_cloud(o3d_pointcloud) self.pointcloud = PointCloud( - self.pcd_path, *Open3DHandler().to_point_cloud(o3d_pointcloud) + self.pcd_path, + points, + self.pointcloud.label_definition, + colors, + self.pointcloud.labels, ) self.pointcloud.to_file() diff --git a/labelCloud/io/__init__.py b/labelCloud/io/__init__.py index e69de29..f0dfbc2 100644 --- a/labelCloud/io/__init__.py +++ b/labelCloud/io/__init__.py @@ -0,0 +1,10 @@ +import json +from pathlib import Path +from typing import Dict + + +def read_label_definition(label_definition_path: Path) -> Dict[str, int]: + with open(label_definition_path, "r") as f: + label_definition: Dict[str, int] = json.loads(f.read()) + assert len(label_definition) > 0 + return label_definition diff --git a/labelCloud/io/pointclouds/open3d.py b/labelCloud/io/pointclouds/open3d.py index eb50271..cdd13f1 100644 --- a/labelCloud/io/pointclouds/open3d.py +++ b/labelCloud/io/pointclouds/open3d.py @@ -3,7 +3,6 @@ import numpy as np import numpy.typing as npt - import open3d as o3d from . import BasePointCloudHandler @@ -18,17 +17,17 @@ class Open3DHandler(BasePointCloudHandler): def __init__(self) -> None: super().__init__() + @staticmethod def to_point_cloud( - self, pointcloud: o3d.geometry.PointCloud + pointcloud: o3d.geometry.PointCloud, ) -> Tuple[npt.NDArray, Optional[npt.NDArray]]: return ( np.asarray(pointcloud.points).astype("float32"), np.asarray(pointcloud.colors).astype("float32"), ) - def to_open3d_point_cloud( - self, pointcloud: "PointCloud" - ) -> o3d.geometry.PointCloud: + @staticmethod + def to_open3d_point_cloud(pointcloud: "PointCloud") -> o3d.geometry.PointCloud: o3d_pointcloud = o3d.geometry.PointCloud( o3d.utility.Vector3dVector(pointcloud.points) ) diff --git a/labelCloud/io/segmentations/base.py b/labelCloud/io/segmentations/base.py index 3c4cd90..c30c446 100644 --- a/labelCloud/io/segmentations/base.py +++ b/labelCloud/io/segmentations/base.py @@ -1,7 +1,6 @@ -import json from abc import abstractmethod from pathlib import Path -from typing import Dict, Tuple, Type +from typing import Dict, Set, Type import numpy as np import numpy.typing as npt @@ -10,15 +9,10 @@ class BaseSegmentationHandler(object, metaclass=SingletonABCMeta): - EXTENSIONS = set() # should be set in subclasses + EXTENSIONS: Set[str] = set() # should be set in subclasses - def __init__(self, label_definition_path: Path) -> None: - self.read_label_definition(label_definition_path) - - def read_label_definition(self, label_definition_path: Path) -> None: - with open(label_definition_path, "r") as f: - self.label_definition: Dict[str, int] = json.loads(f.read()) - assert len(self.label_definition) > 0 + def __init__(self, label_definition: Dict[str, int]) -> None: + self.label_definition = label_definition @property def default_label(self) -> int: @@ -26,7 +20,7 @@ def default_label(self) -> int: def read_or_create_labels( self, label_path: Path, num_points: int - ) -> Tuple[Dict[str, int], npt.NDArray[np.int8]]: + ) -> npt.NDArray[np.int8]: """Read labels per point and its schema""" if label_path.exists(): labels = self._read_labels(label_path) @@ -36,7 +30,7 @@ def read_or_create_labels( ) else: labels = self._create_labels(num_points) - return self.label_definition, labels + return labels def overwrite_labels(self, label_path: Path, labels: npt.NDArray[np.int8]) -> None: return self._write_labels(label_path, labels) @@ -58,3 +52,6 @@ def get_handler(cls, file_extension: str) -> Type["BaseSegmentationHandler"]: for subclass in cls.__subclasses__(): if file_extension in subclass.EXTENSIONS: return subclass + raise NotImplementedError( + f"{file_extension} is not supported for segmentation labels." + ) diff --git a/labelCloud/io/segmentations/numpy.py b/labelCloud/io/segmentations/numpy.py index 47d8dfd..d6f4b4f 100644 --- a/labelCloud/io/segmentations/numpy.py +++ b/labelCloud/io/segmentations/numpy.py @@ -12,7 +12,7 @@ class NumpySegmentationHandler(BaseSegmentationHandler): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - def _create_labels(self, num_points: int) -> npt.NDArray[np.int8]: + def _create_labels(self, num_points: int, *args, **kwargs) -> npt.NDArray[np.int8]: return np.ones(shape=(num_points,), dtype=np.int8) * self.default_label def _read_labels(self, label_path: Path) -> npt.NDArray[np.int8]: diff --git a/labelCloud/model/point_cloud.py b/labelCloud/model/point_cloud.py index c8b6e28..be4793f 100644 --- a/labelCloud/model/point_cloud.py +++ b/labelCloud/model/point_cloud.py @@ -1,9 +1,7 @@ import ctypes import logging from pathlib import Path -from typing import Dict, Optional, Tuple - -import pkg_resources +from typing import Dict, Optional, Tuple, cast import numpy as np import numpy.typing as npt @@ -11,6 +9,7 @@ from ..control.config_manager import config from ..definitions.types import Point3D, Rotations3D, Translation3D +from ..io import read_label_definition from ..io.pointclouds import BasePointCloudHandler from ..io.segmentations import BaseSegmentationHandler from ..utils.color import colorize_points_with_height, get_distinct_colors @@ -43,10 +42,10 @@ class PointCloud(object): def __init__( self, path: Path, - points: np.ndarray, + points: npt.NDArray[np.float32], + label_definition: Dict[str, int], colors: Optional[np.ndarray] = None, segmentation_labels: Optional[npt.NDArray[np.int8]] = None, - label_definition: Optional[Dict[str, int]] = None, init_translation: Optional[Tuple[float, float, float]] = None, init_rotation: Optional[Tuple[float, float, float]] = None, write_buffer: bool = True, @@ -55,11 +54,11 @@ def __init__( self.path = path self.points = points self.colors = colors if type(colors) == np.ndarray and len(colors) > 0 else None + self.label_definition = label_definition - self.labels = self.label_definition = self.label_color_map = None + self.labels = self.label_color_map = None if self.SEGMENTATION: self.labels = segmentation_labels - self.label_definition = label_definition self.label_color_map = get_distinct_colors(len(label_definition)) self.mix_ratio = config.getfloat("POINTCLOUD", "label_color_mix_ratio") @@ -95,7 +94,6 @@ def __init__( logging.info( "Generated colors for colorless point cloud based on `colorless_color`." ) - if write_buffer: self.create_buffers() @@ -109,6 +107,7 @@ def point_size(self) -> float: def create_buffers(self) -> None: """Create 3 different buffers holding points, colors and label colors information""" + self.colors = cast(npt.NDArray[np.float32], self.colors) ( self.position_vbo, self.color_vbo, @@ -126,9 +125,10 @@ def create_buffers(self) -> None: @property def label_colors(self) -> npt.NDArray[np.float32]: """blend the points with label color map""" + self.colors = cast(npt.NDArray[np.float32], self.colors) if self.labels is not None: label_one_hot = np.eye(len(self.label_definition))[self.labels] - colors = np.dot(label_one_hot, self.label_color_map).astype(np.float32) + colors = np.dot(label_one_hot, self.label_color_map).astype(np.float32) # type: ignore return colors * self.mix_ratio + self.colors * (1 - self.mix_ratio) else: return self.colors @@ -149,30 +149,30 @@ def from_file( path.suffix ).read_point_cloud(path=path) - labels = label_def = None + label_definition = read_label_definition( + config.getpath("FILE", "label_folder") + / Path(f"schema/label_definition.json") + ) + labels = None if cls.SEGMENTATION: label_path = config.getpath("FILE", "label_folder") / Path( f"segmentation/{path.stem}.bin" ) - label_defintion_path = config.getpath("FILE", "label_folder") / Path( - f"segmentation/schema/label_definition.json" - ) - logging.info(f"Loading segmentation labels from {label_path}.") seg_handler = BaseSegmentationHandler.get_handler(label_path.suffix)( - label_definition_path=label_defintion_path + label_definition=label_definition ) - label_def, labels = seg_handler.read_or_create_labels( + labels = seg_handler.read_or_create_labels( label_path=label_path, num_points=points.shape[0] ) return cls( path, points, + label_definition, colors, labels, - label_def, init_translation, init_rotation, write_buffer, @@ -194,13 +194,11 @@ def color_with_label(self) -> bool: return config.getboolean("POINTCLOUD", "color_with_label") @property - def int2label(self) -> Optional[Dict[int, str]]: - if self.label_definition is not None: - return {ind: label for label, ind in self.label_definition.items()} - return None + def int2label(self) -> Dict[int, str]: + return {ind: label for label, ind in self.label_definition.items()} @property - def label_counts(self) -> Optional[Dict[int, int]]: + def label_counts(self) -> Optional[Dict[str, int]]: if self.labels is not None and self.label_definition: counter = {k: 0 for k in self.label_definition} diff --git a/labelCloud/resources/default_config.ini b/labelCloud/resources/default_config.ini index b7f5d4d..d5b50a4 100644 --- a/labelCloud/resources/default_config.ini +++ b/labelCloud/resources/default_config.ini @@ -19,8 +19,6 @@ std_zoom = 0.0025 [LABEL] ; format for exporting labels. choose from (vertices, centroid_rel, centroid_abs, kitti) label_format = centroid_abs -; list of object classes for autocompletion in the text field -object_classes = cart, box ; default object class for new bounding boxes std_object_class = cart ; number of decimal places for exporting the bounding box parameter. diff --git a/labelCloud/resources/interfaces/interface.ui b/labelCloud/resources/interfaces/interface.ui index c738965..7b4ecd3 100644 --- a/labelCloud/resources/interfaces/interface.ui +++ b/labelCloud/resources/interfaces/interface.ui @@ -893,16 +893,16 @@ - - - - - 0 - 0 - - - - + + + + + 0 + 0 + + + + @@ -1685,7 +1685,7 @@ button_pick_bbox button_span_bbox button_save_label - edit_current_class + current_class_dropdown edit_pos_x edit_pos_y edit_pos_z diff --git a/labelCloud/tests/unit/segmentation_handler/test_numpy_segmentation_handler.py b/labelCloud/tests/unit/segmentation_handler/test_numpy_segmentation_handler.py index cf9e651..b3ca4f2 100644 --- a/labelCloud/tests/unit/segmentation_handler/test_numpy_segmentation_handler.py +++ b/labelCloud/tests/unit/segmentation_handler/test_numpy_segmentation_handler.py @@ -5,6 +5,7 @@ import numpy as np import pytest +from labelCloud.io import read_label_definition from labelCloud.io.segmentations import NumpySegmentationHandler @@ -16,12 +17,17 @@ def segmentation_path() -> Path: @pytest.fixture -def label_definition_path(segmentation_path) -> Path: - path = segmentation_path / Path("schema/label_definition.json") +def label_definition_path() -> Path: + path = Path("labels/schema/label_definition.json") assert path.exists() return path +@pytest.fixture +def label_definition(label_definition_path: Path) -> Dict[str, int]: + return read_label_definition(label_definition_path) + + @pytest.fixture def label_path(segmentation_path) -> Path: path = segmentation_path / Path("exemplary.bin") @@ -41,15 +47,15 @@ def expected_label_definition() -> Dict[str, int]: return { "unassigned": 0, "person": 1, - "car": 2, + "cart": 2, "wall": 3, "floor": 4, } @pytest.fixture -def handler(label_definition_path) -> NumpySegmentationHandler: - return NumpySegmentationHandler(label_definition_path=label_definition_path) +def handler(label_definition) -> NumpySegmentationHandler: + return NumpySegmentationHandler(label_definition=label_definition) def test_label_definition( @@ -102,10 +108,10 @@ def test_read_or_create_labels_when_exist( expected_label_definition: Dict[str, int], ) -> None: with exception: - label_definition, labels = handler.read_or_create_labels( + labels = handler.read_or_create_labels( label_path=label_path, num_points=num_points ) - assert label_definition == expected_label_definition + assert handler.label_definition == expected_label_definition assert labels.dtype == np.int8 assert labels.shape == (num_points,) @@ -115,10 +121,8 @@ def test_read_or_create_labels_when_not_exist( not_label_path: Path, expected_label_definition: Dict[str, int], ) -> None: - label_definition, labels = handler.read_or_create_labels( - label_path=not_label_path, num_points=420 - ) - assert label_definition == expected_label_definition + labels = handler.read_or_create_labels(label_path=not_label_path, num_points=420) + assert handler.label_definition == expected_label_definition assert labels.dtype == np.int8 assert labels.shape == (420,) assert (labels == np.zeros((420,))).all() diff --git a/labelCloud/utils/color.py b/labelCloud/utils/color.py index ef56b70..4507deb 100644 --- a/labelCloud/utils/color.py +++ b/labelCloud/utils/color.py @@ -29,7 +29,7 @@ def get_distinct_colors(n: int) -> npt.NDArray[np.float32]: def colorize_points_with_height( points: np.ndarray, z_min: float, z_max: float -) -> np.ndarray: +) -> npt.NDArray[np.float32]: palette = np.loadtxt( pkg_resources.resource_filename("labelCloud.resources", "rocket-palette.txt") ) diff --git a/labelCloud/view/gui.py b/labelCloud/view/gui.py index 17b3d59..eed01d8 100644 --- a/labelCloud/view/gui.py +++ b/labelCloud/view/gui.py @@ -11,7 +11,6 @@ from PyQt5.QtWidgets import ( QAction, QActionGroup, - QCompleter, QFileDialog, QInputDialog, QLabel, @@ -88,6 +87,9 @@ def set_keep_perspective(state: bool) -> None: background: rgb(0, 0, 255); background: url("{icons_dir}/cube-outline_white.svg") center left no-repeat, #0000ff; }} + QComboBox#current_class_dropdown {{ + selection-background-color: gray; + }} """ @@ -171,7 +173,7 @@ def __init__(self, control: "Controller") -> None: # RIGHT PANEL self.label_list: QtWidgets.QListWidget - self.edit_current_class: QtWidgets.QLineEdit + self.current_class_dropdown: QtWidgets.QComboBox self.button_deselect_label: QtWidgets.QPushButton self.button_delete_label: QtWidgets.QPushButton @@ -189,7 +191,6 @@ def __init__(self, control: "Controller") -> None: self.edit_rot_z: QtWidgets.QLineEdit self.all_line_edits = [ - self.edit_current_class, self.edit_pos_x, self.edit_pos_y, self.edit_pos_z, @@ -210,8 +211,6 @@ def __init__(self, control: "Controller") -> None: # Connect all events to functions self.connect_events() self.set_checkbox_states() # tick in menu - self.update_label_completer() # initialize label completer with classes in config - self.update_default_object_class_menu() # Start event cycle self.timer = QtCore.QTimer(self) @@ -259,7 +258,7 @@ def connect_events(self) -> None: ) # LABELING CONTROL - self.edit_current_class.textChanged.connect( + self.current_class_dropdown.currentTextChanged.connect( self.controller.bbox_controller.set_classname ) self.button_deselect_label.clicked.connect( @@ -381,9 +380,9 @@ def eventFilter(self, event_object, event) -> bool: self.controller.mouse_clicked(event) self.update_bbox_stats(self.controller.bbox_controller.get_active_bbox()) elif (event.type() == QEvent.MouseButtonPress) and ( - event_object != self.edit_current_class + event_object != self.current_class_dropdown ): - self.edit_current_class.clearFocus() + self.current_class_dropdown.clearFocus() self.update_bbox_stats(self.controller.bbox_controller.get_active_bbox()) return False @@ -456,19 +455,8 @@ def init_progress(self, min_value, max_value): def update_progress(self, value) -> None: self.progressbar_pcds.setValue(value) - def update_curr_class_edit(self, force: str = None) -> None: - if force is not None: - self.edit_current_class.setText(force) - else: - bbox = self.controller.bbox_controller.get_active_bbox() - if bbox: - self.edit_current_class.setText(bbox.get_classname()) - - def update_label_completer(self, classnames=None) -> None: - if classnames is None: - classnames = set() - classnames.update(config.getlist("LABEL", "object_classes")) - self.edit_current_class.setCompleter(QCompleter(classnames)) + def update_current_class_dropdown(self) -> None: + self.controller.pcd_manager.populate_class_dropdown() def update_bbox_stats(self, bbox) -> None: viewing_precision = config.getint("USER_INTERFACE", "viewing_precision") @@ -573,24 +561,6 @@ def change_label_folder(self) -> None: ) logging.info("Changed label folder to %s!" % path_to_folder) - def update_default_object_class_menu(self, new_classes: Set[str] = None) -> None: - object_classes = { - str(class_name) for class_name in config.getlist("LABEL", "object_classes") - } - object_classes.update(new_classes or []) - existing_classes = { - action.text() for action in self.actiongroup_default_class.actions() - } - for object_class in object_classes.difference(existing_classes): - action = self.actiongroup_default_class.addAction( - object_class - ) # TODO: Add limiter for number of classes - action.setCheckable(True) - if object_class == config.get("LABEL", "std_object_class"): - action.setChecked(True) - - self.act_set_default_class.addActions(self.actiongroup_default_class.actions()) - def change_default_object_class(self, action: QAction) -> None: config.set("LABEL", "std_object_class", action.text()) logging.info("Changed default object class to %s.", action.text()) diff --git a/labelCloud/view/settings_dialog.py b/labelCloud/view/settings_dialog.py index 7e4c72c..083b384 100644 --- a/labelCloud/view/settings_dialog.py +++ b/labelCloud/view/settings_dialog.py @@ -52,9 +52,6 @@ def fill_with_current_settings(self) -> None: LabelManager.LABEL_FORMATS ) # TODO: Fix visualization self.comboBox_labelformat.setCurrentText(config.get("LABEL", "label_format")) - self.plainTextEdit_objectclasses.setPlainText( - config.get("LABEL", "object_classes") - ) self.lineEdit_standardobjectclass.setText( config.get("LABEL", "std_object_class") ) @@ -124,9 +121,6 @@ def save(self) -> None: # Label config["LABEL"]["label_format"] = self.comboBox_labelformat.currentText() - config["LABEL"][ - "object_classes" - ] = self.plainTextEdit_objectclasses.toPlainText() config["LABEL"]["std_object_class"] = self.lineEdit_standardobjectclass.text() config["LABEL"]["export_precision"] = str(self.spinBox_exportprecision.value()) config["LABEL"]["min_boundingbox_dimension"] = str( diff --git a/labels/segmentation/schema/label_definition.json b/labels/schema/label_definition.json similarity index 81% rename from labels/segmentation/schema/label_definition.json rename to labels/schema/label_definition.json index 1b2fe50..4b4b152 100644 --- a/labels/segmentation/schema/label_definition.json +++ b/labels/schema/label_definition.json @@ -1,7 +1,7 @@ { "unassigned": 0, "person": 1, - "car": 2, + "cart": 2, "wall": 3, "floor": 4 -} +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9a92581..54d8a4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pytest~=7.1.2 pytest-qt~=4.1.0 # Development -black~=22.1.0 +black~=22.3.0 mypy~=0.971 PyQt5-stubs~=5.15.6 types-setuptools~=57.4.17