├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── docs.yaml │ ├── python-publish.yml │ └── unit-tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── config.ini ├── docs ├── assets │ ├── io_overview.png │ ├── logo.png │ ├── screencast_small.gif │ └── welcome_dialog.png ├── configuration.md ├── conventions.md ├── index.md ├── setup.md └── shortcuts.md ├── labelCloud.py ├── labelCloud ├── __init__.py ├── __main__.py ├── control │ ├── __init__.py │ ├── alignmode.py │ ├── bbox_controller.py │ ├── config_manager.py │ ├── controller.py │ ├── drawing_manager.py │ ├── label_manager.py │ └── pcd_manager.py ├── definitions │ ├── __init__.py │ ├── bbox.py │ ├── colors.py │ ├── context.py │ ├── label_formats │ │ ├── __init__.py │ │ ├── base.py │ │ ├── object_detection.py │ │ └── semantic_segmentation.py │ ├── labeling_mode.py │ ├── mode.py │ └── types.py ├── io │ ├── __init__.py │ ├── labels │ │ ├── __init__.py │ │ ├── base.py │ │ ├── centroid.py │ │ ├── config.py │ │ ├── exceptions.py │ │ ├── kitti.py │ │ └── vertices.py │ ├── pointclouds │ │ ├── __init__.py │ │ ├── base.py │ │ ├── numpy.py │ │ └── open3d.py │ └── segmentations │ │ ├── __init__.py │ │ ├── base.py │ │ └── numpy.py ├── labeling_strategies │ ├── __init__.py │ ├── base.py │ ├── picking.py │ └── spanning.py ├── model │ ├── __init__.py │ ├── bbox.py │ ├── perspective.py │ └── point_cloud.py ├── resources │ ├── __init__.py │ ├── default_classes.json │ ├── default_config.ini │ ├── examples │ │ ├── __init__.py │ │ ├── exemplary.json │ │ └── exemplary.ply │ ├── icons │ │ ├── ICON_LICENSES.txt │ │ ├── __init__.py │ │ ├── arrow-down-bold.svg │ │ ├── arrow-left-bold.svg │ │ ├── arrow-right-bold.svg │ │ ├── arrow-up-bold.svg │ │ ├── content-save-outline.svg │ │ ├── cube-outline.svg │ │ ├── cube-outline_white.svg │ │ ├── cursor-default-click.svg │ │ ├── delete-outline.svg │ │ ├── download.svg │ │ ├── labelCloud.ico │ │ ├── labelCloud_icon.png │ │ ├── minus-box-multiple-outline.svg │ │ ├── panorama.svg │ │ ├── plus-box-multiple-outline.svg │ │ ├── resize.svg │ │ ├── select-off.svg │ │ └── upload.svg │ ├── interfaces │ │ ├── __init__.py │ │ ├── interface.ui │ │ └── settings_interface.ui │ ├── labelCloud_icon.pcd │ └── rocket-palette.txt ├── tests │ ├── __init__.py │ ├── integration │ │ ├── conftest.py │ │ ├── test_gui.py │ │ └── test_labeling.py │ └── unit │ │ ├── conftest.py │ │ ├── segmentation_handler │ │ ├── test_base_segmentation_handler.py │ │ └── test_numpy_segmentation_handler.py │ │ ├── test_color.py │ │ ├── test_label_export.py │ │ └── test_label_import.py ├── utils │ ├── __init__.py │ ├── color.py │ ├── logger.py │ ├── math3d.py │ ├── oglhelper.py │ └── singleton.py └── view │ ├── __init__.py │ ├── gui.py │ ├── settings_dialog.py │ ├── startup │ ├── __init__.py │ ├── class_list.py │ ├── color_button.py │ ├── dialog.py │ └── labeling_mode.py │ ├── status_manager.py │ └── viewer.py ├── labels ├── _classes.json ├── exemplary.json └── segmentation │ └── exemplary.bin ├── mkdocs.yml ├── pointclouds └── exemplary.ply ├── pyproject.toml ├── requirements.txt ├── setup.cfg └── setup.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: ch-sa 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. Ubuntu 64-bit] 28 | - Python Version: [e.g. 3.7.9] 29 | - Installation: [pip or manual/ git] 30 | 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-python@v2 12 | with: 13 | python-version: 3.x 14 | - run: pip install mkdocs-material 15 | - run: mkdocs gh-deploy --force 16 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Build and Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install build 25 | - name: Build package 26 | run: python -m build 27 | - name: Publish package 28 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 29 | with: 30 | user: __token__ 31 | password: ${{ secrets.PYPI_API_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | 6 | testing: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, windows-latest] # macos-latest has OpenGL import error 11 | python-version: ["3.8", "3.9"] # "3.10" wait for Open3D support 12 | 13 | steps: 14 | - name: Get repository 15 | uses: actions/checkout@v3 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v3 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip setuptools wheel 25 | pip install -r requirements.txt 26 | 27 | - name: Check black formatting 28 | uses: psf/black@stable 29 | 30 | - name: Lint with mypy 31 | if: ${{ matrix.python-version == '3.9' }} # only lint last Python version 32 | run: | 33 | mypy labelCloud/. 34 | 35 | - name: Test with pytest 36 | run: | 37 | python -m pytest labelCloud/tests/unit/ 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ---------------------------------------------------------------------------- # 2 | # CUSTOM # 3 | # ---------------------------------------------------------------------------- # 4 | 5 | *.ply 6 | *.json 7 | *.zip 8 | *.pyc 9 | *.pcd 10 | *.qt_for_python 11 | *.bin 12 | .idea 13 | 14 | !labelCloud/resources/examples/exemplary.ply 15 | !labelCloud/resources/examples/exemplary.json 16 | !labelCloud/resources/labelCloud_icon.pcd 17 | !labels/schema/label_definition.json 18 | !labels/segmentation/exemplary.bin 19 | 20 | # ---------------------------------------------------------------------------- # 21 | # GENERATED # 22 | # ---------------------------------------------------------------------------- # 23 | 24 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python 25 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python 26 | 27 | ### Python ### 28 | # Byte-compiled / optimized / DLL files 29 | __pycache__/ 30 | *.py[cod] 31 | *$py.class 32 | 33 | # C extensions 34 | *.so 35 | 36 | # Distribution / packaging 37 | .Python 38 | build/ 39 | develop-eggs/ 40 | dist/ 41 | downloads/ 42 | eggs/ 43 | .eggs/ 44 | lib/ 45 | lib64/ 46 | parts/ 47 | sdist/ 48 | var/ 49 | wheels/ 50 | share/python-wheels/ 51 | *.egg-info/ 52 | .installed.cfg 53 | *.egg 54 | MANIFEST 55 | 56 | # PyInstaller 57 | # Usually these files are written by a python script from a template 58 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 59 | *.manifest 60 | *.spec 61 | 62 | # Installer logs 63 | pip-log.txt 64 | pip-delete-this-directory.txt 65 | 66 | # Unit test / coverage reports 67 | htmlcov/ 68 | .tox/ 69 | .nox/ 70 | .coverage 71 | .coverage.* 72 | .cache 73 | nosetests.xml 74 | coverage.xml 75 | *.cover 76 | *.py,cover 77 | .hypothesis/ 78 | .pytest_cache/ 79 | cover/ 80 | 81 | # Translations 82 | *.mo 83 | *.pot 84 | 85 | # Django stuff: 86 | *.log 87 | local_settings.py 88 | db.sqlite3 89 | db.sqlite3-journal 90 | 91 | # Flask stuff: 92 | instance/ 93 | .webassets-cache 94 | 95 | # Scrapy stuff: 96 | .scrapy 97 | 98 | # Sphinx documentation 99 | docs/_build/ 100 | 101 | # PyBuilder 102 | .pybuilder/ 103 | target/ 104 | 105 | # Jupyter Notebook 106 | .ipynb_checkpoints 107 | 108 | # IPython 109 | profile_default/ 110 | ipython_config.py 111 | 112 | # pyenv 113 | # For a library or package, you might want to ignore these files since the code is 114 | # intended to run in multiple environments; otherwise, check them in: 115 | # .python-version 116 | 117 | # pipenv 118 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 119 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 120 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 121 | # install all needed dependencies. 122 | #Pipfile.lock 123 | 124 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 125 | __pypackages__/ 126 | 127 | # Celery stuff 128 | celerybeat-schedule 129 | celerybeat.pid 130 | 131 | # SageMath parsed files 132 | *.sage.py 133 | 134 | # Environments 135 | .env 136 | .venv 137 | env/ 138 | venv/ 139 | ENV/ 140 | env.bak/ 141 | venv.bak/ 142 | 143 | # Spyder project settings 144 | .spyderproject 145 | .spyproject 146 | 147 | # Rope project settings 148 | .ropeproject 149 | 150 | # mkdocs documentation 151 | /site 152 | 153 | # mypy 154 | .mypy_cache/ 155 | .dmypy.json 156 | dmypy.json 157 | 158 | # Pyre type checker 159 | .pyre/ 160 | 161 | # pytype static type analyzer 162 | .pytype/ 163 | 164 | # Cython debug symbols 165 | cython_debug/ 166 | 167 | ### VisualStudioCode ### 168 | .vscode/* 169 | *.code-workspace 170 | 171 | # Local History for Visual Studio Code 172 | .history/ 173 | 174 | ### VisualStudioCode Patch ### 175 | # Ignore all local history of files 176 | .history 177 | .ionide 178 | 179 | # Support for Project snippet scope 180 | !.vscode/*.code-snippets 181 | 182 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python 183 | .vscode/launch.json 184 | 185 | # Mac Stuff 186 | *.DS_Store 187 | 188 | # pyenv local 189 | .python-version -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [FILE] 2 | ; source of point clouds 3 | pointcloud_folder = pointclouds/ 4 | ; sink for label files 5 | label_folder = labels/ 6 | ; definition of classes and export format 7 | class_definitions = labels/_classes.json 8 | ; only for kitti: calibration file for each point cloud 9 | calib_folder = calib/ 10 | ; sink for segmentation files (*.bin point clouds) [optional] 11 | segmentation_folder = labels/segmentation/ 12 | ; 2d image folder [optional] 13 | image_folder = pointclouds/ 14 | 15 | [POINTCLOUD] 16 | ; drawing size for points in point cloud 17 | point_size = 4.0 18 | ; point color for colorless point clouds (r,g,b) 19 | colorless_color = 0.9, 0.9, 0.9 20 | ; colerize colorless point clouds by height value [optional] 21 | colorless_colorize = True 22 | ; standard step for point cloud translation (for mouse move) 23 | std_translation = 0.03 24 | ; standard step for zooming (for scrolling) 25 | std_zoom = 0.0025 26 | ; blend the color with segmentation labels [optional] 27 | color_with_label = True 28 | ; mix ratio between label colors and rgb colors [optional] 29 | label_color_mix_ratio = 0.3 30 | 31 | [LABEL] 32 | ; number of decimal places for exporting the bounding box parameter. 33 | export_precision = 8 34 | ; default length of the bounding box (for picking mode) 35 | std_boundingbox_length = 0.75 36 | ; default width of the bounding box (for picking mode) 37 | std_boundingbox_width = 0.55 38 | ; default height of the bounding box (for picking mode) 39 | std_boundingbox_height = 0.15 40 | ; standard step for translating the bounding box with button or key (in meter) 41 | std_translation = 0.03 42 | ; standard step for rotating the bounding box with button or key (in degree) 43 | std_rotation = 0.5 44 | ; standard step for scaling the bounding box with button 45 | std_scaling = 0.03 46 | ; minimum value for the length, width and height of a bounding box 47 | min_boundingbox_dimension = 0.01 48 | ; propagate labels to next point cloud if it has no labels yet 49 | propagate_labels = False 50 | 51 | [USER_INTERFACE] 52 | ; only allow z-rotation of bounding boxes. set false to also label x- & y-rotation 53 | z_rotation_only = True 54 | ; visualizes the pointcloud floor (x-y-plane) as a grid 55 | show_floor = True 56 | ; visualizes the object's orientation with an arrow 57 | show_orientation = True 58 | ; background color of the point cloud viewer (rgb) 59 | background_color = 100, 100, 100 60 | ; number of decimal places shown for the parameters of the active bounding box 61 | viewing_precision = 2 62 | ; near and far clipping plane for opengl (where objects are visible, in meter) 63 | near_plane = 0.1 64 | far_plane = 300 65 | ; keep last perspective between point clouds [optional] 66 | keep_perspective = False 67 | ; show button to visualize related images in a separate window [optional] 68 | show_2d_image = False 69 | ; delete the bounding box after assigning the label to the points [optional] 70 | delete_box_after_assign = True 71 | -------------------------------------------------------------------------------- /docs/assets/io_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/docs/assets/io_overview.png -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/assets/screencast_small.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/docs/assets/screencast_small.gif -------------------------------------------------------------------------------- /docs/assets/welcome_dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/docs/assets/welcome_dialog.png -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | The settings of labelCloud can be changed using the config file (`config.ini`) and for most options exists an entry in the graphical settings (accesible via the menu). 4 | The following parameters can be changed: 5 | 6 | | Parameter | Description | Default/ Example | 7 | | :-------------------------: | ----------------------------------------------------------------------------------------------- | :--------------------: | 8 | | **[FILE]** | 9 | | `pointcloud_folder` | Folder from which the point cloud files are loaded. | *pointclouds/* | 10 | | `label_folder` | Folder where the label files will be saved. | *labels/* | 11 | | `class_definitions` | Definition file for class names and colors as well as the default class and export format. | *labels/_classes.json* | 12 | | `image_folder` | Folder from which related images can be loaded (OPTIONAL). | *pointclouds/* | 13 | | `calib_folder` | Folder with calibration files (OPTIONAL, only required for KITTI format). | *calib/* | 14 | | `segmentation_folder` | Folder where the segmentation labels are saved (OPTIONAL, only for semantic segmentation). | *labels/segmentation/* | 15 | | **[POINTCLOUD]** | 16 | | `point_size` | Drawing size for points in point cloud (rasterized diameter). | *4* | 17 | | `colorless_color` | Point color for colorless point clouds (r,g,b). | *0.9, 0.9, 0.9* | 18 | | `colorless_colorize` | Colerize colorless point clouds by height value. | *True* | 19 | | `std_translation` | Standard step for point cloud translation (with mouse move). | *0.03* | 20 | | `std_zoom` | Standard step for zooming (with mouse scroll). | *0.0025* | 21 | | **[LABEL]** | 22 | | `export_precision` | Number of decimal places for exporting the bounding box parameters. | *8* | 23 | | `std_boundingbox_length` | Default length of the bounding box (for picking mode). | *0.75* | 24 | | `std_boundingbox_width` | Default width of the bounding box (for picking mode). | *0.55* | 25 | | `std_boundingbox_height` | Default height of the bounding box (for picking mode). | *0.15* | 26 | | `std_translation` | Standard step for translating the bounding box (with key or button press). | *0.03* | 27 | | `std_rotation` | Standard step for rotating the bounding box (with key press). | *0.5* | 28 | | `std_scaling` | Standard step for scaling the bounding box (with button press). | *0.03* | 29 | | `min_boundingbox_dimension` | Minimum value for the length, width and height of a bounding box. | *0.01* | 30 | | `propagate_labels` | Copy all bounding boxes of the current point cloud to the next point cloud (only forward). | *False* | 31 | | **[USER_INTERFACE]** | 32 | | `z_rotation_only` | Only allow z-rotation of bounding box; deactivate to also label x- & y-rotation. | *True* | 33 | | `show_floor` | Visualizes the floor (x-y-plane) as a grid. | *True* | 34 | | `show_orientation` | Visualizes the object's orientation as an arrow. | *True* | 35 | | `background_color` | Background color of the point cloud viewer (rgb). | *100, 100, 100* | 36 | | `viewing_precision` | Number of decimal places shown on the right side for the parameters of the active bounding box. | *3* | 37 | | `near_plane` | Min. distance of objects to be displayed by OpenGL | *0.1* | 38 | | `far_plane` | Max. distance of objects to be displayed by OpenGL | *300* | 39 | | `keep_perspective` | Save last perspective when leaving a point cloud | *False* | 40 | | `show_2d_image` | Show button to visualize related images in a separate window | *False* | 41 | -------------------------------------------------------------------------------- /docs/conventions.md: -------------------------------------------------------------------------------- 1 | # Conventions 2 | 3 | ## Coordinate System 4 | 5 | The point cloud is rendered in a right-handed coordinate system (see [OpenGL description](https://learnopengl.com/Getting-started/Coordinate-Systems)). 6 | 7 | ## Bounding Boxes 8 | 9 | The bounding box is internally represented with a centroid, three dimensions and absolute rotations in Euler angles. 10 | Rotations are counter-clockwise and inside 0° and 360°. 11 | The initial bounding box is oriented with the x-axis representing the length of the object. 12 | The bounding box vertices are ordered clockwise from bottom to top starting at the origin. 13 | The sequence is adopted from the [bbox library](https://varunagrawal.github.io/bbox/bbox.html#module-bbox.bbox3d). 14 | 15 | | Point | Position (x, y, z) | 16 | | :---: | ------------------ | 17 | | 0 | left back bottom | 18 | | 1 | left front bottom | 19 | | 2 | right front bottom | 20 | | 3 | right back bottom | 21 | | 4 | left back top | 22 | | 5 | left front top | 23 | | 6 | right front top | 24 | | 7 | right back top | 25 | 26 | If the `vertices` label format is selected, the points will get exported in a list in this sequence. 27 | When labelCloud shows the orientation, the arrow points at the right side of the bounding box (2, 3, 6, 7) and upwards (6, 7). 28 | 29 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Introduction to labelCloud 2 | 3 | labelCloud is a lightweight tool for labeling 3D bounding boxes in point clouds. 4 | 5 | ![Overview of the Labeling Tool](assets/io_overview.png) 6 | 7 | It is written in Python and can be installed via `pip` (see [Setup](setup.md)). 8 | 9 | ## Labeling 10 | labelCloud supports two different ways of labeling (*picking* & *spanning*) as well as multiple 11 | mouse and keyboard options for subsequent correction. 12 | 13 | ![Screencast of the Labeling Methods](assets/screencast_small.gif) 14 | (There is also a [short YouTube-Video](https://www.youtube.com/watch?v=8GF9n1WeR8A) that introduces 15 | the tool.) 16 | 17 | ### Picking Mode 18 | 19 | * Pick the location of the bounding box (front-top edge) 20 | * Adjust the z-rotation by scrolling with your mouse wheel 21 | 22 | ### Spanning Mode 23 | 24 | * Subsequently span the length, width and height of the bounding box by selecting four vertices 25 | * The layers for for the last two vertices (width & height) will be locked to allow easy selection 26 | 27 | ### Correction 28 | 29 | * Use the buttons on the left-hand side or shortcuts to correct the *translation*, *dimension* and 30 | *rotation* of the bounding box 31 | * Resize the bounding box by holding your cursor above one side and scrolling with the mouse wheel 32 | 33 | By default the x- and y-rotation of bounding boxes will be prohibited. 34 | For labeling **9 DoF-Bounding Boxes** deactivate `z-Rotation Only Mode` in the menu, settings or 35 | `config.ini` file. 36 | Now you will be able to rotate around all three axes. 37 | 38 | If you have a point clouds with objects that keep their positions over multiple frames, you can 39 | activate the *Propagate Labels* feature in the Labels menu or `config.ini`. 40 | 41 | ## Import & Export Options 42 | labelCloud is built for a versatile use and aims at supporting all common point cloud file formats 43 | and label formats for storing 3D bounding boxes. 44 | The tool is designed to be easily adaptable to multiple use cases. The welcome dialog will ask for 45 | the most common parameters (mode, classes, export format). 46 | 47 | For more configuration, edit the corresponding fields in `labels/_classes.json` for label 48 | configuration or `config.ini` for general settings (see [Configuration](configuration.md)) for a 49 | description of all parameters). 50 | 51 | ### Supported Point Cloud Formats 52 | 53 | | Type | File Formats | 54 | | --------- | ------------------------------------- | 55 | | Colored | `*.pcd`, `*.ply`, `*.pts`, `*.xyzrgb` | 56 | | Colorless | `*.xyz`, `*.xyzn`, `*.bin` (KITTI) | 57 | 58 | ### Supported Label Formats 59 | 60 | | Label Format | Description | 61 | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 62 | | `centroid_rel` | Centroid `[x, y, z]`; Dimensions `[length, width, height]`;
Relative Rotations as Euler angles in radians (-pi..+pi) `[yaw, pitch, roll]` | 63 | | `centroid_abs` | Centroid `[x, y, z]`; Dimensions `[length, width, height]`;
Absolute Rotations as Euler angles in degrees (0..360°) `[yaw, pitch, roll]` | 64 | | `vertices` | 8 Vertices of the bounding box each with `[x, y, z]` (see [Conventions](conventions.md) for order) | 65 | | `kitti` | Centroid; Dimensions; z-Rotation (See [specification](https://github.com/bostondiditeam/kitti/blob/master/resources/devkit_object/readme.txt)); Requires calibration files | 66 | | `kitti_untransformed` | See above, but without transformations (if you just want to use the same label structure). | 67 | 68 | You can easily create your own exporter by subclassing the abstract [BaseLabelFormat](https://github.com/ch-sa/labelCloud/blob/master/labelCloud/label_formats/base.py#L10). 69 | All rotations are counterclockwise (i.e. a z-rotation of 90°/π is from the positive x- to the negative y-axis!). 70 | 71 | 72 | 73 | ## Usage & Attribution 74 | When using the tool feel free to drop me a mail with feedback or a description of your use case 75 | (christoph.sager[at]gmail.com). 76 | If you are using the tool for a scientific project please consider citing our publications: 77 | 78 | 79 | !!! quote "Academic Publications" 80 | 81 | Sager C., Zschech P., Kühl N.: 82 | labelCloud: A Lightweight Labeling Tool for Domain-Agnostic 3D Object Detection in Point Clouds 83 | In: Computer-Aided Design and Applications 19 (2022), p. 1191-1206 84 | ISSN: 1686-4360 85 | DOI: [10.14733/cadaps.2022.1191-1206](https://dx.doi.org/10.14733/cadaps.2022.1191-1206) 86 | URL: [http://cad-journal.net/files/vol_19/CAD_19(6)_2022_1191-1206.pdf](http://cad-journal.net/files/vol_19/CAD_19(6)_2022_1191-1206.pdf) 87 | 88 | ```bibtex 89 | @article{Sager_2022, 90 | doi = {10.14733/cadaps.2022.1191-1206}, 91 | url = {http://cad-journal.net/files/vol_19/CAD_19(6)_2022_1191-1206.pdf}, 92 | year = 2022, 93 | month = {mar}, 94 | publisher = {{CAD} Solutions, {LLC}}, 95 | volume = {19}, 96 | number = {6}, 97 | pages = {1191--1206}, 98 | author = {Christoph Sager and Patrick Zschech and Niklas Kuhl}, 99 | title = {{labelCloud}: A Lightweight Labeling Tool for Domain-Agnostic 3D Object Detection in Point Clouds}, 100 | journal = {Computer-Aided Design and Applications} 101 | } 102 | ``` 103 | 104 | Sager C., Zschech P., Kühl N.: 105 | labelCloud: A Lightweight Domain-Independent Labeling Tool for 3D Object Detection in Point Clouds 106 | International CAD Conference (Barcelona, 5. July 2021 - 7. July 2021) 107 | In: Proceedings of CAD’21 2021 108 | DOI: [10.14733/cadconfP.2021.319-323](https://dx.doi.org/10.14733/cadconfP.2021.319-323) 109 | ```bibtex 110 | @misc{sager2021labelcloud, 111 | title={labelCloud: A Lightweight Domain-Independent Labeling Tool for 3D Object Detection in Point Clouds}, 112 | author={Christoph Sager and Patrick Zschech and Niklas Kühl}, 113 | year={2021}, 114 | eprint={2103.04970}, 115 | archivePrefix={arXiv}, 116 | primaryClass={cs.CV} 117 | } 118 | ``` 119 | 120 | ## Acknowledgment 121 | I would like to thank the [Robotron RCV-Team](https://www.robotron.de/rcv) for the support in the 122 | preparation and user evaluation of the software. 123 | The software was developed as part of my diploma thesis titled "labelCloud: Development of a 124 | Labeling Tool for 3D Object Detection in Point Clouds" at the 125 | [Chair for Business Informatics, especially Intelligent Systems](https://tu-dresden.de/bu/wirtschaft/winf/isd) 126 | of the TU Dresden. The ongoing research can be followed in our 127 | [project on ResearchGate](https://www.researchgate.net/project/Development-of-a-Point-Cloud-Labeling-Tool-to-Generate-Training-Data-for-3D-Object-Detection-and-6D-Pose-Estimation). 128 | -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | ## Installation 4 | !!! info Python Support 5 | Currently labelCloud supports Python 3.7 to 3.9. 6 | 7 | There are two options for installing labelCloud: 8 | * Installation of the package via pip (PyPI). 9 | * Cloning the source files from the GitHub repository. 10 | 11 | The version in the repository tends to be newer, while the pip version is likely more stable. 12 | 13 | ### A) via pip (PyPI) 14 | 15 | Simply install the latest version using pip. 16 | 17 | ``` sh 18 | pip install labelCloud 19 | labelCloud --example # start labelCloud with example point cloud 20 | ``` 21 | 22 | The `labelCloud` command is now globally available. 23 | 24 | ### B) via git (manually) 25 | 26 | Clone this repository and run labelCloud with Python. 27 | 28 | ```sh 29 | git clone https://github.com/ch-sa/labelCloud.git # 1. Clone repository 30 | pip install -r requirements.txt # 2. Install requirements 31 | # 3. Copy point clouds into `pointclouds` folder. 32 | python3 labelCloud.py # 4. Start labelCloud 33 | ``` 34 | 35 | ## Folder Structure 36 | 37 | labelCloud expects a certain folder structure with pre-defined names. 38 | These can be changed in the `config.ini`. 39 | 40 | 41 | ```sh 42 | my_project/ # project folder 43 | ├── config.ini # project configuration 44 | ├── labels # label folder 45 | │   ├── _classes.json # label configuration (names, colors) 46 | │   ├── pcd_01.json 47 | │   ├── pcd_02.json 48 | │   └── ... 49 | └── pointclouds # point cloud folder 50 |    ├── pcd_01.ply 51 |    ├── pcd_02.ply 52 |    └── ... 53 | ``` 54 | 55 | 56 | ## Label Configuration 57 | 58 | On startup labelCloud will welcome you with a dialog to configure the most important parameters: 59 | 60 | 1. Labeling mode (default is *object detection*). 61 | 2. Label classes with their color and id (just relevant for *semantic segmentation*). 62 | 3. Default class (new bounding boxes will be added with this class). 63 | 4. Export format (the format in which the labels will be saved). 64 | 65 | ![Welcome dialog to configure basic labeling settings](assets/welcome_dialog.png) 66 | 67 | You should add here all class names that you expect to label in the point clouds. 68 | Nevertheless, new classes can still be added while labeling. Also you can still edit their colors. 69 | 70 | labelCloud will also automatically add classes that it finds in existing label files for you point 71 | clouds. 72 | 73 | This should cover the setup for most situations. If you need more adaptions, check how to configure 74 | the software to your needs in the [Configuration](configuration.md) page. -------------------------------------------------------------------------------- /docs/shortcuts.md: -------------------------------------------------------------------------------- 1 | # Shortcuts 2 | 3 | There are a number of shortcuts supported for frequently used actions. 4 | 5 | | Shortcut | Description | 6 | | :------------------------------------------------------------------: | ---------------------------------------------------- | 7 | | *Navigation* | | 8 | | Left Mouse Button | Rotates the camera around Point Cloud centroid | 9 | | Right Mouse Button | Translates the camera | 10 | | Mouse Wheel | Zooms into the Point Cloud | 11 | | *Correction* | | 12 | | `W`, `A`, `S`, `D` | Translates the Bounding Box back, left, front, right | 13 | | `Ctrl` + Right Mouse Button | Translates the Bounding Box in all dimensions | 14 | | `Q`, `E` | Lifts the Bounding Box up, down | 15 | | `Z`, `X` | Rotates the Bounding Box around z-Axis | 16 | | `C`, `V` | Rotates the Bounding Box around y-Axis | 17 | | `B`, `N` | Rotates the Bounding Box around x-Axis | 18 | | `I`/ `O` | Increase/Decrease the Bounding Box length | 19 | | `K`/ `L` | Increase/Decrease the Bounding Box width | 20 | | `,`/ `.` | Increase/Decrease the Bounding Box height | 21 | | Scrolling with the Cursor above a Bounding Box Side ("Side Pulling") | Changes the Dimension of the Bounding Box | 22 | | `R`/`Left`, `F`/`Right` | Previous/Next sample | 23 | | `T`/`Up`, `G`/`Down` | Previous/Next bbox | 24 | | `Y`, `H` | Change current bbox class to previous/next in list | 25 | | `1`-`9` | Select any of first 9 bboxes with number keys | 26 | | *General* | | 27 | | `Del` | Deletes Current Bounding Box | 28 | | `P`/`Home` | Resets Perspective | 29 | | `Esc` | Cancels Selected Points | 30 | -------------------------------------------------------------------------------- /labelCloud.py: -------------------------------------------------------------------------------- 1 | from labelCloud.__main__ import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /labelCloud/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.1.1" 2 | -------------------------------------------------------------------------------- /labelCloud/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | 4 | from labelCloud import __version__ 5 | 6 | 7 | def main(): 8 | parser = argparse.ArgumentParser( 9 | description="Label 3D bounding boxes inside point clouds." 10 | ) 11 | parser.add_argument( 12 | "-e", 13 | "--example", 14 | action="store_true", 15 | help="Setup a project with an example point cloud and label.", 16 | ) 17 | parser.add_argument( 18 | "-v", "--version", action="version", version="%(prog)s " + __version__ 19 | ) 20 | args = parser.parse_args() 21 | 22 | if args.example: 23 | setup_example_project() 24 | 25 | start_gui() 26 | 27 | 28 | def setup_example_project() -> None: 29 | import shutil 30 | from pathlib import Path 31 | 32 | import pkg_resources 33 | 34 | from labelCloud.control.config_manager import config 35 | 36 | logging.info( 37 | "Starting labelCloud in example mode.\n" 38 | "Setting up project with example point cloud ,label and default config." 39 | ) 40 | cwdir = Path().cwd() 41 | 42 | # Create folders 43 | pcd_folder = cwdir.joinpath(config.get("FILE", "pointcloud_folder")) 44 | pcd_folder.mkdir(exist_ok=True) 45 | label_folder = cwdir.joinpath(config.get("FILE", "label_folder")) 46 | label_folder.mkdir(exist_ok=True) 47 | 48 | # Copy example files 49 | shutil.copy( 50 | pkg_resources.resource_filename("labelCloud.resources", "default_config.ini"), 51 | str(cwdir.joinpath("config.ini")), 52 | ) 53 | shutil.copy( 54 | pkg_resources.resource_filename( 55 | "labelCloud.resources.examples", "exemplary.ply" 56 | ), 57 | str(pcd_folder.joinpath("exemplary.ply")), 58 | ) 59 | shutil.copy( 60 | pkg_resources.resource_filename("labelCloud.resources", "default_classes.json"), 61 | str(label_folder.joinpath("_classes.json")), 62 | ) 63 | shutil.copy( 64 | pkg_resources.resource_filename( 65 | "labelCloud.resources.examples", "exemplary.json" 66 | ), 67 | str(label_folder.joinpath("exemplary.json")), 68 | ) 69 | logging.info( 70 | f"Setup example project in {cwdir}:" 71 | "\n - config.ini" 72 | "\n - pointclouds/exemplary.ply" 73 | "\n - labels/exemplary.json" 74 | ) 75 | 76 | 77 | def start_gui(): 78 | import sys 79 | 80 | from PyQt5.QtWidgets import QApplication, QDesktopWidget 81 | 82 | from labelCloud.control.controller import Controller 83 | from labelCloud.view.gui import GUI 84 | 85 | app = QApplication(sys.argv) 86 | 87 | # Setup Model-View-Control structure 88 | control = Controller() 89 | view = GUI(control) 90 | 91 | # Install event filter to catch user interventions 92 | app.installEventFilter(view) 93 | 94 | # Start GUI 95 | view.show() 96 | 97 | app.setStyle("Fusion") 98 | desktop = QDesktopWidget().availableGeometry() 99 | width = (desktop.width() - view.width()) // 2 100 | height = (desktop.height() - view.height()) // 2 101 | view.move(width, height) 102 | 103 | logging.info("Showing GUI...") 104 | sys.exit(app.exec_()) 105 | 106 | 107 | if __name__ == "__main__": 108 | main() 109 | -------------------------------------------------------------------------------- /labelCloud/control/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/labelCloud/control/__init__.py -------------------------------------------------------------------------------- /labelCloud/control/alignmode.py: -------------------------------------------------------------------------------- 1 | """ 2 | A module for aligning point clouds with the floor. The user has to span a triangle with 3 | three points on the plane that serves as the ground. Then the old point cloud will be 4 | saved up and the aligned current will overwrite the old. 5 | """ 6 | 7 | import logging 8 | from typing import TYPE_CHECKING, Optional 9 | 10 | import numpy as np 11 | 12 | from ..definitions import Mode, Point3D 13 | from ..utils import oglhelper as ogl 14 | from .pcd_manager import PointCloudManger 15 | 16 | if TYPE_CHECKING: 17 | from ..view.gui import GUI 18 | 19 | 20 | class AlignMode(object): 21 | def __init__(self, pcd_manager: PointCloudManger) -> None: 22 | self.pcd_manager = pcd_manager 23 | self.view: GUI 24 | self.is_active = False 25 | self.point_color = (1, 1, 0, 1) 26 | self.area_color = (1, 1, 0, 0.6) 27 | self.plane1: Optional[Point3D] = None 28 | self.plane2: Optional[Point3D] = None 29 | self.plane3: Optional[Point3D] = None 30 | 31 | self.tmp_p2: Optional[Point3D] = None 32 | self.tmp_p3: Optional[Point3D] = None 33 | 34 | def set_view(self, view: "GUI") -> None: 35 | self.view = view 36 | self.view.gl_widget.align_mode = self 37 | 38 | def change_activation(self, force=None) -> None: 39 | if force is not None: 40 | self.is_active = force 41 | elif self.is_active: 42 | self.is_active = False 43 | self.reset() 44 | else: 45 | self.is_active = True 46 | 47 | if self.is_active: 48 | self.view.status_manager.update_status( 49 | "Select three points on the plane that should be the floor.", 50 | Mode.ALIGNMENT, 51 | ) 52 | self.view.act_align_pcd.setChecked(self.is_active) 53 | self.view.activate_draw_modes( 54 | not self.is_active 55 | ) # Prevent bbox drawing while aligning 56 | logging.info(f"Alignmode was changed to {self.is_active}!") 57 | 58 | def reset(self, points_only: bool = False) -> None: 59 | self.plane1, self.plane2, self.plane3 = (None, None, None) 60 | self.tmp_p2, self.tmp_p3 = (None, None) 61 | if not points_only: 62 | self.change_activation(force=False) 63 | 64 | def register_point(self, new_point) -> None: 65 | if self.plane1 is None: 66 | self.plane1 = new_point 67 | elif not self.plane2: 68 | self.plane2 = new_point 69 | self.view.status_manager.set_message( 70 | "The triangle area should be part over and part under the floor points." 71 | ) 72 | elif not self.plane3: 73 | self.plane3 = new_point 74 | self.calculate_angles() 75 | else: 76 | logging.warning("Cannot register point.") 77 | 78 | def register_tmp_point(self, new_tmp_point) -> None: 79 | if self.plane1 and (not self.plane2): 80 | self.tmp_p2 = new_tmp_point 81 | elif self.plane2 and (not self.plane3): 82 | self.tmp_p3 = new_tmp_point 83 | 84 | def draw_preview(self) -> None: 85 | if not self.plane3: 86 | if self.plane1: 87 | ogl.draw_points([self.plane1], color=self.point_color) 88 | 89 | if self.plane1 and (self.plane2 or self.tmp_p2): 90 | if self.plane2: 91 | self.tmp_p2 = self.plane2 92 | assert self.tmp_p2 is not None 93 | ogl.draw_points([self.tmp_p2], color=self.point_color) 94 | ogl.draw_lines([self.plane1, self.tmp_p2], color=self.point_color) 95 | 96 | if self.plane1 and self.plane2 and (self.tmp_p3 or self.plane3): 97 | if self.plane3: 98 | self.tmp_p3 = self.plane3 99 | assert self.tmp_p3 is not None 100 | ogl.draw_points( 101 | [self.plane1, self.plane2, self.tmp_p3], color=self.point_color 102 | ) 103 | ogl.draw_triangles( 104 | [self.plane1, self.plane2, self.tmp_p3], color=self.area_color 105 | ) 106 | 107 | elif self.plane1 and self.plane2 and self.plane3: 108 | ogl.draw_points( 109 | [self.plane1, self.plane2, self.plane3], color=self.point_color 110 | ) 111 | ogl.draw_triangles( 112 | [self.plane1, self.plane2, self.plane3], color=self.area_color 113 | ) 114 | 115 | def calculate_angles(self) -> None: 116 | if self.plane1 is None or self.plane2 is None or self.plane3 is None: 117 | raise Exception("Missing point for alignment") 118 | 119 | # Calculate plane normal with self.plane1 as origin 120 | plane_normal = np.cross( 121 | np.subtract(self.plane2, self.plane1), np.subtract(self.plane3, self.plane1) 122 | ) 123 | pn_normalized = plane_normal / np.linalg.norm(plane_normal) # normalize normal 124 | z_axis = np.array([0, 0, 1]) 125 | 126 | # Calculate axis-angle-rotation 127 | rotation_angle = np.arccos(np.dot(pn_normalized, z_axis)) 128 | rotation_axis = np.cross(pn_normalized, z_axis) / np.linalg.norm( 129 | np.cross(pn_normalized, z_axis) 130 | ) 131 | logging.info( 132 | f"Alignment rotation: {round(rotation_angle, 2)} " 133 | f"around {np.round(rotation_axis, 2)}" 134 | ) 135 | 136 | # Initiate point cloud rotation 137 | self.pcd_manager.rotate_pointcloud(rotation_axis, rotation_angle, self.plane1) 138 | 139 | self.view.status_manager.update_status( 140 | "Aligned point cloud with the selected floor.", Mode.NAVIGATION 141 | ) 142 | self.change_activation(force=False) 143 | self.reset() 144 | -------------------------------------------------------------------------------- /labelCloud/control/config_manager.py: -------------------------------------------------------------------------------- 1 | """Load configuration from .ini file.""" 2 | 3 | import configparser 4 | from pathlib import Path 5 | from typing import List, Union 6 | 7 | import pkg_resources 8 | 9 | 10 | class ExtendedConfigParser(configparser.ConfigParser): 11 | """Extends the ConfigParser with the ability to read and parse lists. 12 | 13 | Can automatically parse float values besides plain strings. 14 | """ 15 | 16 | def getlist( 17 | self, section, option, raw=False, vars=None, fallback=None 18 | ) -> Union[List[str], List[float], str]: 19 | raw_value = self.get(section, option, raw=raw, vars=vars, fallback=fallback) 20 | if "," in raw_value: 21 | values = [x.strip() for x in raw_value.split(",")] 22 | try: 23 | return [float(item) for item in values] 24 | except ValueError: 25 | return values 26 | return raw_value 27 | 28 | def getpath(self, section, option, raw=False, vars=None, fallback=None) -> Path: 29 | """Get a path from the configuration file.""" 30 | return Path(self.get(section, option, raw=raw, vars=vars, fallback=fallback)) 31 | 32 | 33 | class ConfigManager(object): 34 | PATH_TO_CONFIG = Path.cwd().joinpath("config.ini") 35 | PATH_TO_DEFAULT_CONFIG = Path( 36 | pkg_resources.resource_filename("labelCloud.resources", "default_config.ini") 37 | ) 38 | 39 | def __init__(self) -> None: 40 | self.config = ExtendedConfigParser(comment_prefixes="/", allow_no_value=True) 41 | self.read_from_file() 42 | 43 | def read_from_file(self) -> None: 44 | if ConfigManager.PATH_TO_CONFIG.is_file(): 45 | self.config.read(ConfigManager.PATH_TO_CONFIG) 46 | else: 47 | self.config.read(ConfigManager.PATH_TO_DEFAULT_CONFIG) 48 | 49 | def write_into_file(self) -> None: 50 | with ConfigManager.PATH_TO_CONFIG.open("w") as configfile: 51 | self.config.write(configfile, space_around_delimiters=True) 52 | 53 | def reset_to_default(self) -> None: 54 | self.config.read(ConfigManager.PATH_TO_DEFAULT_CONFIG) 55 | 56 | def get_file_settings(self, key: str) -> str: 57 | return self.config["FILE"][key] 58 | 59 | 60 | config_manager = ConfigManager() 61 | config = config_manager.config 62 | -------------------------------------------------------------------------------- /labelCloud/control/drawing_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING, Union 3 | 4 | from ..labeling_strategies import BaseLabelingStrategy 5 | from .bbox_controller import BoundingBoxController 6 | 7 | if TYPE_CHECKING: 8 | from ..view.gui import GUI 9 | 10 | 11 | class DrawingManager(object): 12 | def __init__(self, bbox_controller: BoundingBoxController) -> None: 13 | self.view: "GUI" 14 | self.bbox_controller = bbox_controller 15 | self.drawing_strategy: Union[BaseLabelingStrategy, None] = None 16 | 17 | def set_view(self, view: "GUI") -> None: 18 | self.view = view 19 | self.view.gl_widget.drawing_mode = self 20 | 21 | def is_active(self) -> bool: 22 | return self.drawing_strategy is not None and isinstance( 23 | self.drawing_strategy, BaseLabelingStrategy 24 | ) 25 | 26 | def has_preview(self) -> bool: 27 | if self.is_active(): 28 | return self.drawing_strategy.__class__.PREVIEW # type: ignore 29 | return False 30 | 31 | def set_drawing_strategy(self, strategy: BaseLabelingStrategy) -> None: 32 | if self.is_active() and self.drawing_strategy == strategy: 33 | self.reset() 34 | logging.info("Deactivated drawing!") 35 | else: 36 | if self.is_active(): 37 | self.reset() 38 | logging.info("Resetted previous active drawing mode!") 39 | 40 | self.drawing_strategy = strategy 41 | 42 | def register_point( 43 | self, x: float, y: float, correction: bool = False, is_temporary: bool = False 44 | ) -> None: 45 | assert self.drawing_strategy is not None 46 | world_point = self.view.gl_widget.get_world_coords(x, y, correction=correction) 47 | 48 | if is_temporary: 49 | self.drawing_strategy.register_tmp_point(world_point) 50 | else: 51 | self.drawing_strategy.register_point(world_point) 52 | if ( 53 | self.drawing_strategy.is_bbox_finished() 54 | ): # Register bbox to bbox controller when finished 55 | self.bbox_controller.add_bbox(self.drawing_strategy.get_bbox()) 56 | self.drawing_strategy.reset() 57 | self.drawing_strategy = None 58 | 59 | def draw_preview(self) -> None: 60 | if self.drawing_strategy is not None: 61 | self.drawing_strategy.draw_preview() 62 | 63 | def reset(self, points_only: bool = False) -> None: 64 | if self.is_active(): 65 | self.drawing_strategy.reset() # type: ignore 66 | if not points_only: 67 | self.drawing_strategy = None 68 | -------------------------------------------------------------------------------- /labelCloud/control/label_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from typing import List, Optional 4 | 5 | from ..io.labels import BaseLabelFormat, CentroidFormat, KittiFormat, VerticesFormat 6 | from ..io.labels.config import LabelConfig 7 | from ..model import BBox 8 | from .config_manager import config 9 | 10 | 11 | def get_label_strategy(export_format: str, label_folder: Path) -> "BaseLabelFormat": 12 | if export_format == "vertices": 13 | return VerticesFormat(label_folder, LabelManager.EXPORT_PRECISION) 14 | elif export_format == "centroid_rel": 15 | return CentroidFormat( 16 | label_folder, LabelManager.EXPORT_PRECISION, relative_rotation=True 17 | ) 18 | elif export_format == "kitti": 19 | return KittiFormat( 20 | label_folder, LabelManager.EXPORT_PRECISION, relative_rotation=True 21 | ) 22 | elif export_format == "kitti_untransformed": 23 | return KittiFormat( 24 | label_folder, 25 | LabelManager.EXPORT_PRECISION, 26 | relative_rotation=True, 27 | transformed=False, 28 | ) 29 | elif export_format != "centroid_abs": 30 | logging.warning( 31 | f"Unknown export strategy '{export_format}'. Proceeding with default (centroid_abs)!" 32 | ) 33 | return CentroidFormat( 34 | label_folder, LabelManager.EXPORT_PRECISION, relative_rotation=False 35 | ) 36 | 37 | 38 | class LabelManager(object): 39 | STD_LABEL_FORMAT = LabelConfig().format 40 | EXPORT_PRECISION = config.getint("LABEL", "export_precision") 41 | 42 | def __init__( 43 | self, 44 | strategy: str = STD_LABEL_FORMAT, 45 | path_to_label_folder: Optional[Path] = None, 46 | ) -> None: 47 | self.label_folder = path_to_label_folder or config.getpath( 48 | "FILE", "label_folder" 49 | ) 50 | if not self.label_folder.is_dir(): 51 | self.label_folder.mkdir(parents=True) 52 | 53 | self.label_strategy = get_label_strategy(strategy, self.label_folder) 54 | 55 | def import_labels(self, pcd_path: Path) -> List[BBox]: 56 | try: 57 | return self.label_strategy.import_labels(pcd_path) 58 | except KeyError as key_error: 59 | logging.warning("Found a key error with %s in the dictionary." % key_error) 60 | logging.warning( 61 | "Could not import labels, please check the consistency of the label format." 62 | ) 63 | return [] 64 | except AttributeError as attribute_error: 65 | logging.warning( 66 | "Attribute Error: %s. Expected a dictionary." % attribute_error 67 | ) 68 | logging.warning( 69 | "Could not import labels, please check the consistency of the label format." 70 | ) 71 | return [] 72 | 73 | def export_labels(self, pcd_path: Path, bboxes: List[BBox]) -> None: 74 | self.label_strategy.export_labels(bboxes, pcd_path) 75 | -------------------------------------------------------------------------------- /labelCloud/definitions/__init__.py: -------------------------------------------------------------------------------- 1 | from .bbox import BBOX_EDGES, BBOX_SIDES 2 | from .colors import Colors 3 | from .context import Context 4 | from .label_formats import ObjectDetectionFormat, SemanticSegmentationFormat 5 | from .labeling_mode import LabelingMode 6 | from .mode import Mode 7 | from .types import ( 8 | Color3f, 9 | Color4f, 10 | Dimensions3D, 11 | Point2D, 12 | Point3D, 13 | Rotations3D, 14 | Translation3D, 15 | ) 16 | -------------------------------------------------------------------------------- /labelCloud/definitions/bbox.py: -------------------------------------------------------------------------------- 1 | # order in which the bounding box edges are drawn 2 | BBOX_EDGES = [ 3 | (0, 1), 4 | (0, 3), 5 | (0, 4), 6 | (2, 1), 7 | (2, 3), 8 | (2, 6), 9 | (5, 1), 10 | (5, 4), 11 | (5, 6), 12 | (7, 3), 13 | (7, 4), 14 | (7, 6), 15 | ] 16 | 17 | 18 | # vertices of each side 19 | BBOX_SIDES = { 20 | "top": [4, 5, 6, 7], 21 | "bottom": [0, 1, 2, 3], 22 | "right": [2, 3, 7, 6], 23 | "back": [0, 3, 7, 4], 24 | "left": [0, 1, 5, 4], 25 | "front": [1, 2, 6, 5], 26 | } 27 | -------------------------------------------------------------------------------- /labelCloud/definitions/colors.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Colors(tuple, Enum): 5 | RED = (1, 0, 0, 1) 6 | GREEN = (0, 1, 0, 1) 7 | -------------------------------------------------------------------------------- /labelCloud/definitions/context.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | class Context(IntEnum): 5 | """Context of a Status hint. 6 | 7 | - integer determines the importance of the related message (higher = more important) 8 | """ 9 | 10 | DEFAULT = 1 11 | SIDE_HOVERED = 2 12 | CONTROL_PRESSED = 3 13 | -------------------------------------------------------------------------------- /labelCloud/definitions/label_formats/__init__.py: -------------------------------------------------------------------------------- 1 | from .object_detection import ObjectDetectionFormat 2 | from .semantic_segmentation import SemanticSegmentationFormat 3 | -------------------------------------------------------------------------------- /labelCloud/definitions/label_formats/base.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import List 3 | 4 | 5 | class BaseLabelFormat(str, Enum): 6 | @classmethod 7 | def list(cls) -> List["BaseLabelFormat"]: 8 | return [e.value for e in cls] 9 | -------------------------------------------------------------------------------- /labelCloud/definitions/label_formats/object_detection.py: -------------------------------------------------------------------------------- 1 | from .base import BaseLabelFormat 2 | 3 | 4 | class ObjectDetectionFormat(BaseLabelFormat): 5 | VERTICES = "vertices" 6 | CENTROID_REL = "centroid_rel" 7 | CENTROID_ABS = "centroid_abs" 8 | KITTI = "kitti" 9 | KITTI_UNTRANSFORMED = "kitti_untransformed" 10 | -------------------------------------------------------------------------------- /labelCloud/definitions/label_formats/semantic_segmentation.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from .base import BaseLabelFormat 4 | 5 | 6 | class SemanticSegmentationFormat(BaseLabelFormat): 7 | BINARY = "binary" 8 | -------------------------------------------------------------------------------- /labelCloud/definitions/labeling_mode.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Dict, List, Type 3 | 4 | from . import ObjectDetectionFormat, SemanticSegmentationFormat 5 | from .label_formats.base import BaseLabelFormat 6 | 7 | 8 | class LabelingMode(str, Enum): 9 | OBJECT_DETECTION = "object_detection" 10 | SEMANTIC_SEGMENTATION = "semantic_segmentation" 11 | 12 | def get_available_formats( 13 | self, 14 | ) -> List[BaseLabelFormat]: 15 | return LABELING_MODE_TO_FORMAT[self].list() 16 | 17 | 18 | LABELING_MODE_TO_FORMAT: Dict[LabelingMode, Type[BaseLabelFormat]] = { 19 | LabelingMode.OBJECT_DETECTION: ObjectDetectionFormat, 20 | LabelingMode.SEMANTIC_SEGMENTATION: SemanticSegmentationFormat, 21 | } 22 | -------------------------------------------------------------------------------- /labelCloud/definitions/mode.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Mode(Enum): 5 | ALIGNMENT = "Alignment Mode" 6 | CORRECTION = "Correction Mode" 7 | DRAWING = "Drawing Mode" 8 | NAVIGATION = "Navigation Mode" 9 | -------------------------------------------------------------------------------- /labelCloud/definitions/types.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from PyQt5.QtGui import QColor 4 | 5 | Point2D = Tuple[float, float] 6 | Point3D = Tuple[float, float, float] 7 | 8 | Rotations3D = Tuple[float, float, float] # euler angles in degrees 9 | 10 | Translation3D = Point3D 11 | 12 | Dimensions3D = Tuple[float, float, float] # length, width, height in meters 13 | 14 | Color4f = Tuple[float, float, float, float] # type alias for type hinting 15 | 16 | 17 | class Color3f(tuple): 18 | def __new__(cls, r, g, b): 19 | return super(Color3f, cls).__new__(cls, (r, g, b)) 20 | 21 | def __repr__(self): 22 | return "ColorRGB(r={}, g={}, b={})".format(*self) 23 | 24 | @classmethod 25 | def from_qcolor(cls, color: QColor): 26 | return cls(color.red() / 255, color.green() / 255, color.blue() / 255) 27 | 28 | @staticmethod 29 | def to_rgba(color: "Color3f", alpha: float = 1.0) -> Color4f: 30 | return (*color, alpha) # type: ignore 31 | -------------------------------------------------------------------------------- /labelCloud/io/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Dict 4 | 5 | 6 | def read_label_definition(label_definition_path: Path) -> Dict[str, int]: 7 | with open(label_definition_path, "r") as f: 8 | label_definition: Dict[str, int] = json.loads(f.read()) 9 | assert len(label_definition) > 0 10 | return label_definition 11 | -------------------------------------------------------------------------------- /labelCloud/io/labels/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import ( 2 | BaseLabelFormat, 3 | abs2rel_rotation, 4 | rel2abs_rotation, 5 | ) 6 | from .centroid import CentroidFormat 7 | from .kitti import KittiFormat 8 | from .vertices import VerticesFormat 9 | -------------------------------------------------------------------------------- /labelCloud/io/labels/base.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from abc import ABC, abstractmethod 4 | from pathlib import Path 5 | from typing import List, Optional, Union 6 | 7 | import numpy as np 8 | 9 | from ...model import BBox 10 | from .config import LabelConfig 11 | 12 | 13 | class BaseLabelFormat(ABC): 14 | FILE_ENDING = ".json" 15 | 16 | def __init__( 17 | self, label_folder: Path, export_precision: int, relative_rotation: bool = False 18 | ) -> None: 19 | self.label_folder = label_folder 20 | logging.info("Set export strategy to %s." % self.__class__.__name__) 21 | self.export_precision = export_precision 22 | self.relative_rotation = relative_rotation 23 | self.file_ending = ".json" 24 | 25 | if relative_rotation: 26 | logging.info( 27 | "Saving rotations relatively to positve x-axis in radians (-pi..+pi)." 28 | ) 29 | elif self.__class__.__name__ == "VerticesFormat": 30 | logging.info("Saving rotations implicitly in the vertices coordinates.") 31 | else: 32 | logging.info( 33 | "Saving rotations absolutely to positve x-axis in degrees (0..360°)." 34 | ) 35 | 36 | def update_label_folder(self, new_label_folder: Path) -> None: 37 | self.label_folder = new_label_folder 38 | LabelConfig().load_config() 39 | logging.info(f"Updated label folder to {new_label_folder}.") 40 | 41 | def round_dec(self, x, decimal_places: Optional[int] = None) -> List[float]: 42 | if not decimal_places: 43 | decimal_places = self.export_precision 44 | return np.round(x, decimal_places).tolist() 45 | 46 | def save_label_to_file(self, pcd_path: Path, data: Union[dict, str]) -> Path: 47 | label_path = self.label_folder.joinpath(pcd_path.stem + self.FILE_ENDING) 48 | 49 | if label_path.is_file(): 50 | logging.info("File %s already exists, replacing file ..." % label_path) 51 | if label_path.suffix == ".json": 52 | with open(label_path, "w") as write_file: 53 | json.dump(data, write_file, indent="\t") 54 | elif label_path.suffix == ".txt" and isinstance(data, str): 55 | with open(label_path, "w") as write_file: 56 | write_file.write(data) 57 | else: 58 | raise ValueError("Received unknown label format/ type.") 59 | return label_path 60 | 61 | @abstractmethod 62 | def import_labels(self, pcd_path: Path) -> List[BBox]: 63 | raise NotImplementedError 64 | 65 | @abstractmethod 66 | def export_labels(self, bboxes: List[BBox], pcd_path: Path) -> None: 67 | raise NotImplementedError 68 | 69 | 70 | # ---------------------------------------------------------------------------- # 71 | # Helper Functions # 72 | # ---------------------------------------------------------------------------- # 73 | 74 | 75 | def abs2rel_rotation(abs_rotation: float) -> float: 76 | """Convert absolute rotation 0..360° into -pi..+pi from x-Axis. 77 | 78 | :param abs_rotation: Counterclockwise rotation from x-axis around z-axis 79 | :return: Relative rotation from x-axis around z-axis 80 | """ 81 | rel_rotation = np.deg2rad(abs_rotation) 82 | if rel_rotation > np.pi: 83 | rel_rotation = rel_rotation - 2 * np.pi 84 | return rel_rotation 85 | 86 | 87 | def rel2abs_rotation(rel_rotation: float) -> float: 88 | """Convert relative rotation from -pi..+pi into 0..360° from x-Axis. 89 | 90 | :param rel_rotation: Rotation from x-axis around z-axis 91 | :return: Counterclockwise rotation from x-axis around z-axis 92 | """ 93 | abs_rotation = np.rad2deg(rel_rotation) 94 | if abs_rotation < 0: 95 | abs_rotation = abs_rotation + 360 96 | return abs_rotation 97 | -------------------------------------------------------------------------------- /labelCloud/io/labels/centroid.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from pathlib import Path 4 | from typing import Any, Dict, List 5 | 6 | from ...model import BBox 7 | from . import BaseLabelFormat, abs2rel_rotation, rel2abs_rotation 8 | 9 | 10 | class CentroidFormat(BaseLabelFormat): 11 | FILE_ENDING = ".json" 12 | 13 | def import_labels(self, pcd_path: Path) -> List[BBox]: 14 | labels = [] 15 | 16 | label_path = self.label_folder.joinpath(pcd_path.stem + self.FILE_ENDING) 17 | if label_path.is_file(): 18 | with label_path.open("r") as read_file: 19 | data = json.load(read_file) 20 | 21 | for label in data["objects"]: 22 | x = label["centroid"]["x"] 23 | y = label["centroid"]["y"] 24 | z = label["centroid"]["z"] 25 | length = label["dimensions"]["length"] 26 | width = label["dimensions"]["width"] 27 | height = label["dimensions"]["height"] 28 | bbox = BBox(x, y, z, length, width, height) 29 | rotations = label["rotations"].values() 30 | if self.relative_rotation: 31 | rotations = map(rel2abs_rotation, rotations) 32 | bbox.set_rotations(*rotations) 33 | bbox.set_classname(label["name"]) 34 | labels.append(bbox) 35 | logging.info( 36 | "Imported %s labels from %s." % (len(data["objects"]), label_path) 37 | ) 38 | return labels 39 | 40 | def export_labels(self, bboxes: List[BBox], pcd_path: Path) -> None: 41 | data: Dict[str, Any] = {} 42 | # Header 43 | data["folder"] = pcd_path.parent.name 44 | data["filename"] = pcd_path.name 45 | data["path"] = str(pcd_path) 46 | 47 | # Labels 48 | data["objects"] = [] 49 | for bbox in bboxes: 50 | label: Dict[str, Any] = {} 51 | label["name"] = bbox.get_classname() 52 | label["centroid"] = { 53 | str(axis): self.round_dec(val) 54 | for axis, val in zip(["x", "y", "z"], bbox.get_center()) 55 | } 56 | label["dimensions"] = { 57 | str(dim): self.round_dec(val) 58 | for dim, val in zip( 59 | ["length", "width", "height"], bbox.get_dimensions() 60 | ) 61 | } 62 | conv_rotations = bbox.get_rotations() 63 | if self.relative_rotation: 64 | conv_rotations = map(abs2rel_rotation, conv_rotations) # type: ignore 65 | 66 | label["rotations"] = { 67 | str(axis): self.round_dec(angle) 68 | for axis, angle in zip(["x", "y", "z"], conv_rotations) 69 | } 70 | data["objects"].append(label) 71 | 72 | # Save to JSON 73 | label_path = self.save_label_to_file(pcd_path, data) 74 | logging.info( 75 | f"Exported {len(bboxes)} labels to {label_path} " 76 | f"in {self.__class__.__name__} formatting!" 77 | ) 78 | -------------------------------------------------------------------------------- /labelCloud/io/labels/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass 3 | from typing import Dict, List, Union 4 | 5 | import numpy as np 6 | import numpy.typing as npt 7 | 8 | from ... import __version__ 9 | from ...control.config_manager import config 10 | from ...definitions import ( 11 | Color3f, 12 | LabelingMode, 13 | ObjectDetectionFormat, 14 | SemanticSegmentationFormat, 15 | ) 16 | from ...definitions.label_formats.base import BaseLabelFormat 17 | from ...utils.color import hex_to_rgb, rgb_to_hex 18 | from ...utils.logger import warn_once 19 | from ...utils.singleton import SingletonABCMeta 20 | from .exceptions import ( 21 | DefaultIdMismatchException, 22 | LabelClassNameEmpty, 23 | LabelIdsNotUniqueException, 24 | UnknownLabelFormat, 25 | ZeroLabelException, 26 | ) 27 | 28 | 29 | @dataclass 30 | class ClassConfig: 31 | name: str 32 | id: int 33 | color: Color3f 34 | 35 | @classmethod 36 | def from_dict(cls, data: dict) -> "ClassConfig": 37 | return cls(name=data["name"], id=data["id"], color=hex_to_rgb(data["color"])) 38 | 39 | def to_dict(self) -> dict: 40 | return { 41 | "name": self.name, 42 | "id": self.id, 43 | "color": rgb_to_hex(self.color), 44 | } 45 | 46 | 47 | class LabelConfig(object, metaclass=SingletonABCMeta): 48 | def __init__(self) -> None: 49 | self.classes: List[ClassConfig] 50 | self.default: int 51 | self.type: LabelingMode 52 | self.format: BaseLabelFormat 53 | 54 | if getattr(self, "_loaded", False) != True: 55 | self.load_config() 56 | 57 | def load_config(self) -> None: 58 | class_definition_path = config.getpath("FILE", "class_definitions") 59 | if class_definition_path.exists(): 60 | with config.getpath("FILE", "class_definitions").open("r") as stream: 61 | data = json.load(stream) 62 | 63 | self.classes = [ClassConfig.from_dict(c) for c in data["classes"]] 64 | self.default = data["default"] 65 | self.type = LabelingMode(data["type"]) 66 | self.format = data["format"] 67 | else: 68 | self.classes = [ClassConfig("cart", 0, color=Color3f(1, 0, 0))] 69 | self.default = 0 70 | self.type = LabelingMode.OBJECT_DETECTION 71 | self.format = ObjectDetectionFormat.CENTROID_REL 72 | self.validate() 73 | self._loaded = True 74 | 75 | def save_config(self) -> None: 76 | self.validate() 77 | data = { 78 | "classes": [c.to_dict() for c in self.classes], 79 | "default": self.default, 80 | "type": self.type.value, 81 | "format": self.format, 82 | "created_with": {"name": "labelCloud", "version": __version__}, 83 | } 84 | with config.getpath("FILE", "class_definitions").open("w") as stream: 85 | json.dump(data, stream, indent=4) 86 | 87 | @property 88 | def nb_of_classes(self) -> int: 89 | return len(self.classes) 90 | 91 | @property 92 | def color_map(self) -> npt.NDArray[np.float32]: 93 | """An (N, 3) array where N is the number of classes and color_map[i] represents the i-th class' rgb color.""" 94 | return np.array([c.color[0:3] for c in self.classes]).astype(np.float32) 95 | 96 | @property 97 | def class_order(self) -> npt.NDArray[np.int8]: 98 | """An array lookup table to look up the order of a class id in the label definition.""" 99 | max_class_id = max(c.id for c in self.classes) + 1 100 | lookup = -np.ones((max_class_id,), dtype=np.int8) 101 | for order, c in enumerate(self.classes): 102 | lookup[c.id] = order 103 | return lookup 104 | 105 | # GETTERS 106 | 107 | def get_classes(self) -> Dict[str, ClassConfig]: 108 | return {c.name: c for c in self.classes} 109 | 110 | def get_class(self, class_name: str) -> ClassConfig: 111 | return self.get_classes()[class_name] 112 | 113 | def get_relative_class(self, current_class: str, step: int) -> str: 114 | """Get class, relative to current by id according to given step""" 115 | if step == 0: 116 | return current_class 117 | id2name = {cc.id: cc.name for cc in self.classes} 118 | name2id = {v: k for k, v in id2name.items()} 119 | ids = name2id.values() 120 | corner_case_id = max(ids) if step < 0 else min(ids) 121 | current_id = name2id[current_class] 122 | result_id = current_id + step 123 | result_id = result_id if result_id in ids else corner_case_id 124 | return id2name[result_id] 125 | 126 | def get_class_color(self, class_name: str) -> Color3f: 127 | try: 128 | return self.get_classes()[class_name].color 129 | except KeyError: 130 | warn_once( 131 | "No color defined for class '%s'!" "Proceeding with red.", class_name 132 | ) 133 | return hex_to_rgb("#FF0000") 134 | 135 | def has_valid_default_class(self) -> bool: 136 | for c in self.classes: 137 | if c.id == self.default: 138 | return True 139 | return False 140 | 141 | def get_default_class_name(self) -> str: 142 | for c in self.classes: 143 | if c.id == self.default: 144 | return c.name 145 | raise DefaultIdMismatchException( 146 | f"Default class id `{self.default}` is missing in the class list." 147 | ) 148 | 149 | # SETTERS 150 | 151 | def set_first_as_default(self) -> None: 152 | self.default = self.classes[0].id 153 | 154 | def set_default_class(self, class_name: str) -> None: 155 | self.default = next((c.id for c in self.classes if c.name == class_name)) 156 | self.save_config() 157 | 158 | def set_class_color(self, class_name: str, color: Color3f) -> None: 159 | self.get_class(class_name).color = color 160 | self.save_config() 161 | 162 | def set_label_format(self, label_format: Union[BaseLabelFormat, str]) -> None: 163 | if label_format not in { 164 | *ObjectDetectionFormat.list(), 165 | *SemanticSegmentationFormat.list(), 166 | }: 167 | raise UnknownLabelFormat(label_format) 168 | 169 | self.format = label_format # type: ignore 170 | 171 | # VALIDATION 172 | def validate(self) -> None: 173 | if self.nb_of_classes == 0: 174 | raise ZeroLabelException("At least one label required.") 175 | # validate the default id presents in the classes 176 | self.get_default_class_name() 177 | # validate the ids are unique 178 | if len({c.id for c in self.classes}) != self.nb_of_classes: 179 | raise LabelIdsNotUniqueException("Class ids are not unique.") 180 | 181 | for label_class in self.classes: 182 | if label_class.name == "": 183 | raise LabelClassNameEmpty("At least one class name is empty.") 184 | -------------------------------------------------------------------------------- /labelCloud/io/labels/exceptions.py: -------------------------------------------------------------------------------- 1 | class ZeroLabelException(Exception): 2 | pass 3 | 4 | 5 | class LabelIdsNotUniqueException(Exception): 6 | pass 7 | 8 | 9 | class DefaultIdMismatchException(Exception): 10 | pass 11 | 12 | 13 | class LabelClassNameEmpty(Exception): 14 | pass 15 | 16 | 17 | class UnknownLabelFormat(Exception): 18 | def __init__(self, label_format: str) -> None: 19 | super().__init__(f"Unknown label format '{label_format}'.") 20 | -------------------------------------------------------------------------------- /labelCloud/io/labels/kitti.py: -------------------------------------------------------------------------------- 1 | # 2 | # Implementation according to: 3 | # https://github.com/bostondiditeam/kitti/blob/master/resources/devkit_object/readme.txt 4 | # 5 | 6 | 7 | import logging 8 | import math 9 | from collections import defaultdict 10 | from pathlib import Path 11 | from typing import Dict, List, Optional 12 | 13 | import numpy as np 14 | import numpy.typing as npt 15 | 16 | from ...control.config_manager import config 17 | from ...model import BBox 18 | from . import BaseLabelFormat, abs2rel_rotation, rel2abs_rotation 19 | 20 | 21 | def _read_calibration_file(calib_path: Path) -> Dict[str, np.ndarray]: 22 | lines = [] 23 | with open(calib_path, "r") as f: 24 | lines = f.readlines() 25 | calib_dict = {} 26 | for line in lines: 27 | vals = line.split() 28 | if not vals: 29 | continue 30 | calib_dict[vals[0][:-1]] = np.array(vals[1:]).astype(np.float64) 31 | return calib_dict 32 | 33 | 34 | class CalibrationFileNotFound(Exception): 35 | def __init__(self, calib_path: Path, pcd_name: str) -> None: 36 | self.calib_path = calib_path 37 | self.pcd_name = pcd_name 38 | super().__init__( 39 | f"There is no calibration file at {self.calib_path.name} for point cloud" 40 | f" {self.pcd_name}. If you want to load labels in lidar frame without" 41 | " transformation use the label format 'kitti_untransformed'." 42 | ) 43 | 44 | 45 | TEMPLATE_META = { 46 | "type": "", 47 | "truncated": "0", 48 | "occluded": "0", 49 | "alpha": "0", 50 | "bbox": "0 0 0 0", 51 | "dimensions": "0 0 0", 52 | "location": "0 0 0", 53 | "rotation_y": "0", 54 | } 55 | 56 | 57 | class KittiFormat(BaseLabelFormat): 58 | FILE_ENDING = ".txt" 59 | 60 | def __init__( 61 | self, 62 | label_folder: Path, 63 | export_precision: int, 64 | relative_rotation: bool = False, 65 | transformed: bool = True, 66 | ) -> None: 67 | super().__init__(label_folder, export_precision, relative_rotation) 68 | self.transformed = transformed 69 | 70 | self.calib_folder = config.getpath("FILE", "calib_folder") 71 | self.T_v2c: Optional[npt.ArrayLike] = None 72 | self.T_c2v: Optional[npt.ArrayLike] = None 73 | 74 | self.bboxes_meta: Dict[int, Dict] = defaultdict( 75 | lambda: TEMPLATE_META 76 | ) # id: meta 77 | 78 | def import_labels(self, pcd_path: Path) -> List[BBox]: 79 | bboxes = [] 80 | 81 | label_path = self.label_folder.joinpath(pcd_path.stem + self.FILE_ENDING) 82 | if label_path.is_file(): 83 | with label_path.open("r") as read_file: 84 | label_lines = read_file.readlines() 85 | 86 | for line in label_lines: 87 | line_elements = line.split() 88 | meta = { 89 | "type": line_elements[0], 90 | "truncated": line_elements[1], 91 | "occluded": line_elements[2], 92 | "alpha": line_elements[3], 93 | "bbox": " ".join(line_elements[4:8]), 94 | "dimensions": " ".join(line_elements[8:11]), 95 | "location": " ".join(line_elements[11:14]), 96 | "rotation_y": line_elements[14], 97 | } 98 | 99 | centroid = tuple([float(v) for v in meta["location"].split()]) 100 | 101 | height, width, length = tuple( 102 | [float(v) for v in meta["dimensions"].split()] 103 | ) 104 | 105 | if self.transformed: 106 | try: 107 | self._get_transforms(pcd_path) 108 | except CalibrationFileNotFound as exc: 109 | logging.exception("Calibration file not found") 110 | logging.warning( 111 | "Skipping loading of labels for this point cloud" 112 | ) 113 | return [] 114 | 115 | xyz1 = np.insert(np.asarray(centroid), 3, values=[1]) 116 | xyz1 = self.T_c2v @ xyz1 117 | centroid = tuple([float(n) for n in xyz1[:-1]]) 118 | centroid = ( 119 | centroid[0], 120 | centroid[1], 121 | centroid[2] + height / 2, 122 | ) # centroid in KITTI located on bottom face of bbox 123 | 124 | bbox = BBox(*centroid, length, width, height) # type: ignore 125 | self.bboxes_meta[id(bbox)] = meta 126 | 127 | rotation = ( 128 | -float(meta["rotation_y"]) + math.pi / 2 129 | if self.transformed 130 | else float(meta["rotation_y"]) 131 | ) 132 | 133 | bbox.set_rotations(0, 0, rel2abs_rotation(rotation)) 134 | bbox.set_classname(meta["type"]) 135 | bboxes.append(bbox) 136 | 137 | logging.info("Imported %s labels from %s." % (len(label_lines), label_path)) 138 | return bboxes 139 | 140 | def export_labels(self, bboxes: List[BBox], pcd_path: Path) -> None: 141 | data = str() 142 | 143 | # Labels 144 | for bbox in bboxes: 145 | obj_type = bbox.get_classname() 146 | centroid = bbox.get_center() 147 | length, width, height = bbox.get_dimensions() 148 | 149 | # invert sequence to height, width, length 150 | dimensions = height, width, length 151 | 152 | if self.transformed: 153 | try: 154 | self._get_transforms(pcd_path) 155 | except CalibrationFileNotFound: 156 | logging.exception("Calibration file not found") 157 | logging.warning("Skipping writing of labels for this point cloud") 158 | return 159 | 160 | centroid = ( 161 | centroid[0], 162 | centroid[1], 163 | centroid[2] - height / 2, 164 | ) # centroid in KITTI located on bottom face of bbox 165 | xyz1 = np.insert(np.asarray(centroid), 3, values=[1]) 166 | xyz1 = self.T_v2c @ xyz1 167 | centroid = tuple([float(n) for n in xyz1[:-1]]) # type: ignore 168 | 169 | rotation = bbox.get_z_rotation() 170 | rotation = abs2rel_rotation(rotation) 171 | rotation = -(rotation - math.pi / 2) if self.transformed else rotation 172 | rotation = str(self.round_dec(rotation)) # type: ignore 173 | 174 | location_str = " ".join([str(self.round_dec(v)) for v in centroid]) 175 | dimensions_str = " ".join([str(self.round_dec(v)) for v in dimensions]) 176 | 177 | out_str = list(self.bboxes_meta[id(bbox)].values()) 178 | if obj_type != "DontCare": 179 | out_str[0] = obj_type 180 | out_str[5] = dimensions_str 181 | out_str[6] = location_str 182 | out_str[7] = rotation 183 | 184 | data += " ".join(out_str) + "\n" 185 | 186 | # Save to TXT 187 | path_to_file = self.save_label_to_file(pcd_path, data) 188 | logging.info( 189 | f"Exported {len(bboxes)} labels to {path_to_file} " 190 | f"in {self.__class__.__name__} formatting!" 191 | ) 192 | self.T_v2c = None 193 | self.T_c2v = None 194 | 195 | # ---------------------------------------------------------------------------- # 196 | # Helper Functions # 197 | # ---------------------------------------------------------------------------- # 198 | 199 | def _get_transforms(self, pcd_path: Path) -> None: 200 | if self.T_v2c is None or self.T_c2v is None: 201 | calib_path = self.calib_folder.joinpath(pcd_path.stem + self.FILE_ENDING) 202 | 203 | if not calib_path.is_file(): 204 | logging.exception( 205 | " Skipping the loading of labels for this point cloud ..." 206 | ) 207 | raise CalibrationFileNotFound(calib_path, pcd_path.name) 208 | 209 | calib_dict = _read_calibration_file(calib_path) 210 | 211 | T_rect = calib_dict["R0_rect"] 212 | T_rect = T_rect.reshape(3, 3) 213 | T_rect = np.insert(T_rect, 3, values=[0, 0, 0], axis=0) 214 | T_rect = np.insert(T_rect, 3, values=[0, 0, 0, 1], axis=1) 215 | 216 | T_v2c = calib_dict["Tr_velo_to_cam"] 217 | T_v2c = T_v2c.reshape(3, 4) 218 | T_v2c = np.insert(T_v2c, 3, values=[0, 0, 0, 1], axis=0) 219 | 220 | self.T_v2c = T_rect @ T_v2c 221 | self.T_c2v = np.linalg.inv(self.T_v2c) # type: ignore 222 | -------------------------------------------------------------------------------- /labelCloud/io/labels/vertices.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from pathlib import Path 4 | from typing import Any, Dict, List 5 | 6 | import numpy as np 7 | 8 | from . import BaseLabelFormat 9 | from ...definitions import Point3D 10 | from ...model import BBox 11 | from ...utils import math3d 12 | 13 | 14 | class VerticesFormat(BaseLabelFormat): 15 | FILE_ENDING = ".json" 16 | 17 | def import_labels(self, pcd_path: Path) -> List[BBox]: 18 | labels = [] 19 | 20 | label_path = self.label_folder.joinpath(pcd_path.stem + self.FILE_ENDING) 21 | if label_path.is_file(): 22 | with label_path.open("r") as read_file: 23 | data = json.load(read_file) 24 | 25 | for label in data["objects"]: 26 | vertices = label["vertices"] 27 | 28 | # Calculate centroid 29 | centroid: Point3D = tuple( # type: ignore 30 | np.add(np.subtract(vertices[4], vertices[2]) / 2, vertices[2]) 31 | ) 32 | 33 | # Calculate dimensions 34 | length = math3d.vector_length(np.subtract(vertices[0], vertices[3])) 35 | width = math3d.vector_length(np.subtract(vertices[0], vertices[1])) 36 | height = math3d.vector_length(np.subtract(vertices[0], vertices[4])) 37 | 38 | # Calculate rotations 39 | rotations = math3d.vertices2rotations(vertices, centroid) 40 | 41 | bbox = BBox(*centroid, length, width, height) 42 | bbox.set_rotations(*rotations) 43 | bbox.set_classname(label["name"]) 44 | labels.append(bbox) 45 | logging.info( 46 | "Imported %s labels from %s." % (len(data["objects"]), label_path) 47 | ) 48 | return labels 49 | 50 | def export_labels(self, bboxes: List[BBox], pcd_path: Path) -> None: 51 | data: Dict[str, Any] = dict() 52 | # Header 53 | data["folder"] = pcd_path.parent.name 54 | data["filename"] = pcd_path.name 55 | data["path"] = str(pcd_path) 56 | 57 | # Labels 58 | data["objects"] = [] 59 | for bbox in bboxes: 60 | label: Dict[str, Any] = dict() 61 | label["name"] = bbox.get_classname() 62 | label["vertices"] = self.round_dec( 63 | bbox.get_vertices().tolist() 64 | ) # TODO: Add option for axis-aligned vertices 65 | data["objects"].append(label) 66 | 67 | # Save to JSON 68 | label_path = self.save_label_to_file(pcd_path, data) 69 | logging.info( 70 | f"Exported {len(bboxes)} labels to {label_path} " 71 | f"in {self.__class__.__name__} formatting!" 72 | ) 73 | -------------------------------------------------------------------------------- /labelCloud/io/pointclouds/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BasePointCloudHandler 2 | from .numpy import NumpyHandler 3 | from .open3d import Open3DHandler 4 | -------------------------------------------------------------------------------- /labelCloud/io/pointclouds/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import abstractmethod 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING, Optional, Set, Tuple 5 | 6 | import numpy as np 7 | 8 | from ...utils.logger import blue 9 | from ...utils.singleton import SingletonABCMeta 10 | 11 | if TYPE_CHECKING: 12 | from ...model import PointCloud 13 | 14 | 15 | class BasePointCloudHandler(object, metaclass=SingletonABCMeta): 16 | EXTENSIONS: Set[str] = set() # should be set in subclasses 17 | 18 | @abstractmethod 19 | def read_point_cloud(self, path: Path) -> Tuple[np.ndarray, Optional[np.ndarray]]: # type: ignore 20 | """Read a point cloud file and return only the points and colors as array.""" 21 | logging.info( 22 | blue("Loading point cloud from %s using %s."), path, self.__class__.__name__ 23 | ) 24 | pass 25 | 26 | @abstractmethod 27 | def write_point_cloud(self, path: Path, pointcloud: "PointCloud") -> None: 28 | logging.info( 29 | blue("Writing point cloud to %s using %s."), path, self.__class__.__name__ 30 | ) 31 | pass 32 | 33 | @classmethod 34 | def get_supported_extensions(cls) -> Set[str]: 35 | return set().union(*[handler.EXTENSIONS for handler in cls.__subclasses__()]) 36 | 37 | @classmethod 38 | def get_handler(cls, file_extension: str) -> "BasePointCloudHandler": 39 | """Return a point cloud handler for the given file extension.""" 40 | for subclass in cls.__subclasses__(): 41 | if file_extension in subclass.EXTENSIONS: 42 | return subclass() 43 | 44 | raise ValueError( 45 | "No point cloud handler found for file extension %s.", file_extension 46 | ) 47 | -------------------------------------------------------------------------------- /labelCloud/io/pointclouds/numpy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from typing import TYPE_CHECKING, Tuple 4 | 5 | import numpy as np 6 | import numpy.typing as npt 7 | 8 | from . import BasePointCloudHandler 9 | 10 | if TYPE_CHECKING: 11 | from ...model import PointCloud 12 | 13 | 14 | class NumpyHandler(BasePointCloudHandler): 15 | EXTENSIONS = {".bin"} 16 | 17 | def __init__(self) -> None: 18 | super().__init__() 19 | 20 | def read_point_cloud(self, path: Path) -> Tuple[npt.NDArray, None]: 21 | """Read point cloud file as array and drop reflection and nan values.""" 22 | super().read_point_cloud(path) 23 | points = np.fromfile(path, dtype=np.float32) 24 | points = points.reshape((-1, 4 if len(points) % 4 == 0 else 3))[:, 0:3] 25 | return (points[~np.isnan(points).any(axis=1)], None) 26 | 27 | def write_point_cloud(self, path: Path, pointcloud: "PointCloud") -> None: 28 | """Write point cloud points into binary file.""" 29 | super().write_point_cloud(path, pointcloud) 30 | logging.warning( 31 | "Only writing point coordinates, any previous reflection values will be dropped." 32 | ) 33 | pointcloud.points.tofile(path) 34 | -------------------------------------------------------------------------------- /labelCloud/io/pointclouds/open3d.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Optional, Tuple 3 | 4 | import numpy as np 5 | import numpy.typing as npt 6 | import open3d as o3d 7 | 8 | from . import BasePointCloudHandler 9 | 10 | if TYPE_CHECKING: 11 | from ...model import PointCloud 12 | 13 | 14 | class Open3DHandler(BasePointCloudHandler): 15 | EXTENSIONS = {".pcd", ".ply", ".pts", ".xyz", ".xyzn", ".xyzrgb"} 16 | 17 | def __init__(self) -> None: 18 | super().__init__() 19 | 20 | @staticmethod 21 | def to_point_cloud( 22 | pointcloud: o3d.geometry.PointCloud, 23 | ) -> Tuple[npt.NDArray, Optional[npt.NDArray]]: 24 | return ( 25 | np.asarray(pointcloud.points).astype("float32"), 26 | np.asarray(pointcloud.colors).astype("float32"), 27 | ) 28 | 29 | @staticmethod 30 | def to_open3d_point_cloud(pointcloud: "PointCloud") -> o3d.geometry.PointCloud: 31 | o3d_pointcloud = o3d.geometry.PointCloud( 32 | o3d.utility.Vector3dVector(pointcloud.points) 33 | ) 34 | o3d_pointcloud.colors = o3d.utility.Vector3dVector(pointcloud.colors) 35 | return o3d_pointcloud 36 | 37 | def read_point_cloud(self, path: Path) -> Tuple[npt.NDArray, Optional[npt.NDArray]]: 38 | super().read_point_cloud(path) 39 | return self.to_point_cloud( 40 | o3d.io.read_point_cloud(str(path), remove_nan_points=True) 41 | ) 42 | 43 | def write_point_cloud(self, path: Path, pointcloud: "PointCloud") -> None: 44 | super().write_point_cloud(path, pointcloud) 45 | o3d.io.write_point_cloud(str(path), self.to_open3d_point_cloud(pointcloud)) 46 | -------------------------------------------------------------------------------- /labelCloud/io/segmentations/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseSegmentationHandler 2 | from .numpy import NumpySegmentationHandler 3 | -------------------------------------------------------------------------------- /labelCloud/io/segmentations/base.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from pathlib import Path 3 | from typing import Dict, Set, Type 4 | 5 | import numpy as np 6 | import numpy.typing as npt 7 | 8 | from ...utils.singleton import SingletonABCMeta 9 | from ..labels.config import LabelConfig 10 | 11 | 12 | class BaseSegmentationHandler(object, metaclass=SingletonABCMeta): 13 | EXTENSIONS: Set[str] = set() # should be set in subclasses 14 | 15 | @property 16 | def default_label(self) -> int: 17 | return LabelConfig().default 18 | 19 | def read_or_create_labels( 20 | self, label_path: Path, num_points: int 21 | ) -> npt.NDArray[np.int8]: 22 | """Read labels per point and its schema""" 23 | if label_path.exists(): 24 | labels = self._read_labels(label_path) 25 | if labels.shape[0] != num_points: 26 | raise ValueError( 27 | f"The segmentation label doesn't match with the point cloud, " 28 | "label file contains {labels.shape[0]} while point cloud contains {num_points}." 29 | ) 30 | else: 31 | labels = self._create_labels(num_points) 32 | return labels 33 | 34 | def overwrite_labels(self, label_path: Path, labels: npt.NDArray[np.int8]) -> None: 35 | return self._write_labels(label_path, labels) 36 | 37 | @abstractmethod 38 | def _read_labels(self, label_path: Path) -> npt.NDArray[np.int8]: 39 | raise NotImplementedError 40 | 41 | @abstractmethod 42 | def _create_labels(self, num_points: int) -> npt.NDArray[np.int8]: 43 | raise NotImplementedError 44 | 45 | @abstractmethod 46 | def _write_labels(self, label_path: Path, labels: npt.NDArray[np.int8]) -> None: 47 | raise NotImplementedError 48 | 49 | @classmethod 50 | def get_handler(cls, file_extension: str) -> Type["BaseSegmentationHandler"]: 51 | for subclass in cls.__subclasses__(): 52 | if file_extension in subclass.EXTENSIONS: 53 | return subclass 54 | raise NotImplementedError( 55 | f"{file_extension} is not supported for segmentation labels." 56 | ) 57 | -------------------------------------------------------------------------------- /labelCloud/io/segmentations/numpy.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import numpy as np 4 | import numpy.typing as npt 5 | 6 | from .base import BaseSegmentationHandler 7 | 8 | 9 | class NumpySegmentationHandler(BaseSegmentationHandler): 10 | EXTENSIONS = {".bin"} 11 | 12 | def __init__(self, *args, **kwargs) -> None: 13 | super().__init__(*args, **kwargs) 14 | 15 | def _create_labels(self, num_points: int) -> npt.NDArray[np.int8]: 16 | return np.ones(shape=(num_points,), dtype=np.int8) * self.default_label 17 | 18 | def _read_labels(self, label_path: Path) -> npt.NDArray[np.int8]: 19 | labels = np.fromfile(label_path, dtype=np.int8) 20 | return labels 21 | 22 | def _write_labels(self, label_path: Path, labels: npt.NDArray[np.int8]) -> None: 23 | if not label_path.parent.exists(): 24 | label_path.parent.mkdir(parents=True) 25 | 26 | labels.tofile(label_path) 27 | -------------------------------------------------------------------------------- /labelCloud/labeling_strategies/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseLabelingStrategy 2 | from .picking import PickingStrategy 3 | from .spanning import SpanningStrategy 4 | -------------------------------------------------------------------------------- /labelCloud/labeling_strategies/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | from ..definitions import Point3D 5 | 6 | if TYPE_CHECKING: 7 | from ..model import BBox 8 | from ..view.gui import GUI 9 | 10 | 11 | class BaseLabelingStrategy(ABC): 12 | POINTS_NEEDED: int 13 | PREVIEW: bool = False 14 | 15 | def __init__(self, view: "GUI") -> None: 16 | self.view = view 17 | self.points_registered = 0 18 | self.point_1: Optional[Point3D] = None 19 | 20 | def is_bbox_finished(self) -> bool: 21 | return self.points_registered >= self.__class__.POINTS_NEEDED 22 | 23 | @abstractmethod 24 | def register_point(self, new_point: Point3D) -> None: 25 | raise NotImplementedError 26 | 27 | def register_tmp_point(self, new_tmp_point: Point3D) -> None: 28 | pass 29 | 30 | def register_scrolling(self, distance: float) -> None: 31 | pass 32 | 33 | @abstractmethod 34 | def get_bbox(self) -> "BBox": 35 | raise NotImplementedError 36 | 37 | def draw_preview(self) -> None: 38 | pass 39 | 40 | def reset(self) -> None: 41 | self.points_registered = 0 42 | self.point_1 = None 43 | -------------------------------------------------------------------------------- /labelCloud/labeling_strategies/picking.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | import numpy as np 5 | 6 | from . import BaseLabelingStrategy 7 | from ..control.config_manager import config 8 | from ..definitions import Mode, Point3D 9 | from ..definitions.types import Point3D 10 | from ..model import BBox 11 | from ..utils import oglhelper as ogl 12 | 13 | if TYPE_CHECKING: 14 | from ..view.gui import GUI 15 | 16 | 17 | class PickingStrategy(BaseLabelingStrategy): 18 | POINTS_NEEDED = 1 19 | PREVIEW = True 20 | 21 | def __init__(self, view: "GUI") -> None: 22 | super().__init__(view) 23 | logging.info("Enabled drawing mode.") 24 | self.view.status_manager.update_status( 25 | "Please pick the location for the bounding box front center.", 26 | mode=Mode.DRAWING, 27 | ) 28 | self.tmp_p1: Optional[Point3D] = None 29 | self.bbox_z_rotation: float = 0 30 | 31 | def register_point(self, new_point: Point3D) -> None: 32 | self.point_1 = new_point 33 | self.points_registered += 1 34 | 35 | def register_tmp_point(self, new_tmp_point: Point3D) -> None: 36 | self.tmp_p1 = new_tmp_point 37 | 38 | def register_scrolling(self, distance: float) -> None: 39 | self.bbox_z_rotation += distance // 30 40 | 41 | def draw_preview(self) -> None: # TODO: Refactor 42 | if self.tmp_p1: 43 | tmp_bbox = BBox( 44 | *np.add( 45 | self.tmp_p1, 46 | [ 47 | 0, 48 | config.getfloat("LABEL", "STD_BOUNDINGBOX_WIDTH") / 2, 49 | -config.getfloat("LABEL", "STD_BOUNDINGBOX_HEIGHT") / 3, 50 | ], 51 | ) 52 | ) 53 | tmp_bbox.set_z_rotation(self.bbox_z_rotation) 54 | ogl.draw_cuboid( 55 | tmp_bbox.get_vertices(), draw_vertices=True, vertex_color=(1, 1, 0, 1) 56 | ) 57 | 58 | # Draw bbox with fixed dimensions and rotation at x,y in world space 59 | def get_bbox(self) -> BBox: # TODO: Refactor 60 | assert self.point_1 is not None 61 | final_bbox = BBox( 62 | *np.add( 63 | self.point_1, 64 | [ 65 | 0, 66 | config.getfloat("LABEL", "STD_BOUNDINGBOX_WIDTH") / 2, 67 | -config.getfloat("LABEL", "STD_BOUNDINGBOX_HEIGHT") / 3, 68 | ], 69 | ) 70 | ) 71 | final_bbox.set_z_rotation(self.bbox_z_rotation) 72 | return final_bbox 73 | 74 | def reset(self) -> None: 75 | super().reset() 76 | self.tmp_p1 = None 77 | self.view.button_pick_bbox.setChecked(False) 78 | -------------------------------------------------------------------------------- /labelCloud/labeling_strategies/spanning.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING, List, Optional, cast 3 | 4 | import numpy as np 5 | 6 | from . import BaseLabelingStrategy 7 | from ..control.config_manager import config 8 | from ..definitions import Mode, Point3D 9 | from ..model import BBox 10 | from ..utils import math3d as math3d 11 | from ..utils import oglhelper as ogl 12 | 13 | if TYPE_CHECKING: 14 | from ..view.gui import GUI 15 | 16 | 17 | class SpanningStrategy(BaseLabelingStrategy): 18 | POINTS_NEEDED = 4 19 | PREVIEW = True 20 | CORRECTION = False # Increases dimensions after drawing 21 | 22 | def __init__(self, view: "GUI") -> None: 23 | super().__init__(view) 24 | logging.info("Enabled spanning mode.") 25 | self.view.status_manager.update_status( 26 | "Begin by selecting a vertex of the bounding box.", mode=Mode.DRAWING 27 | ) 28 | self.preview_color = (1, 1, 0, 1) 29 | self.point_2: Optional[Point3D] = None # second edge 30 | self.point_3: Optional[Point3D] = None # width 31 | self.point_4: Optional[Point3D] = None # height 32 | self.tmp_p2: Optional[Point3D] = None # tmp points for preview 33 | self.tmp_p3: Optional[Point3D] = None 34 | self.tmp_p4: Optional[Point3D] = None 35 | self.p1_w: Optional[Point3D] = None # p1 + dir_vector 36 | self.p2_w: Optional[Point3D] = None # p2 + dir_vector 37 | self.dir_vector: Optional[Point3D] = None # p1 + dir_vector 38 | 39 | def reset(self) -> None: 40 | super().reset() 41 | self.point_2, self.point_3, self.point_4 = (None, None, None) 42 | self.tmp_p2, self.tmp_p3, self.tmp_p4, self.p1_w, self.p2_w = ( 43 | None, 44 | None, 45 | None, 46 | None, 47 | None, 48 | ) 49 | self.view.button_span_bbox.setChecked(False) 50 | 51 | def register_point(self, new_point: Point3D) -> None: 52 | if self.point_1 is None: 53 | self.point_1 = new_point 54 | self.view.status_manager.set_message( 55 | "Select a point representing the length of the bounding box." 56 | ) 57 | elif not self.point_2: 58 | self.point_2 = new_point 59 | self.view.status_manager.set_message( 60 | "Select any point for the depth of the bounding box." 61 | ) 62 | elif not self.point_3: 63 | self.point_3 = new_point 64 | self.view.status_manager.set_message( 65 | "Select any point for the height of the bounding box." 66 | ) 67 | elif not self.point_4: 68 | self.point_4 = new_point 69 | else: 70 | logging.warning("Cannot register point.") 71 | self.points_registered += 1 72 | 73 | def register_tmp_point(self, new_tmp_point: Point3D) -> None: 74 | if self.point_1 and (not self.point_2): 75 | self.tmp_p2 = new_tmp_point 76 | elif self.point_2 and (not self.point_3): 77 | self.tmp_p3 = new_tmp_point 78 | elif self.point_3: 79 | self.tmp_p4 = new_tmp_point 80 | 81 | def get_bbox(self) -> BBox: 82 | assert self.point_1 is not None and self.point_2 is not None 83 | length = math3d.vector_length(np.subtract(self.point_1, self.point_2)) 84 | 85 | assert self.dir_vector is not None 86 | width = math3d.vector_length(self.dir_vector) 87 | 88 | assert self.point_4 is not None 89 | height = self.point_4[2] - self.point_1[2] # can also be negative 90 | 91 | line_center = np.add(self.point_1, self.point_2) / 2 92 | area_center = np.add(line_center * 2, self.dir_vector) / 2 93 | center = np.add(area_center, [0, 0, height / 2]) 94 | 95 | # Calculating z-rotation 96 | len_vec_2d = np.subtract(self.point_1, self.point_2) 97 | z_angle = np.arctan(len_vec_2d[1] / len_vec_2d[0]) 98 | 99 | if SpanningStrategy.CORRECTION: 100 | length *= 1.1 101 | width *= 1.1 102 | height *= 1.1 103 | 104 | bbox = BBox(*center, length=length, width=width, height=abs(height)) # type: ignore 105 | bbox.set_z_rotation(math3d.radians_to_degrees(z_angle)) 106 | 107 | if not config.getboolean("USER_INTERFACE", "z_rotation_only"): 108 | # Also calculate y_angle 109 | y_angle = np.arctan(len_vec_2d[2] / len_vec_2d[0]) 110 | bbox.set_y_rotation(-math3d.radians_to_degrees(y_angle)) 111 | return bbox 112 | 113 | def draw_preview(self) -> None: 114 | if not self.tmp_p4: 115 | if self.point_1: 116 | ogl.draw_points([self.point_1], color=self.preview_color) 117 | 118 | if self.point_1 and (self.point_2 or self.tmp_p2): 119 | if self.point_2: 120 | self.tmp_p2 = self.point_2 121 | assert self.tmp_p2 is not None 122 | ogl.draw_points([self.tmp_p2], color=(1, 1, 0, 1)) 123 | ogl.draw_lines([self.point_1, self.tmp_p2], color=self.preview_color) 124 | 125 | if self.point_1 and self.point_2 and (self.tmp_p3 or self.point_3): 126 | if self.point_3: 127 | self.tmp_p3 = self.point_3 128 | assert self.tmp_p3 is not None 129 | # Get x-y-aligned vector from line to point with intersection 130 | self.dir_vector, _ = math3d.get_line_perpendicular( 131 | self.point_1, self.point_2, self.tmp_p3 132 | ) 133 | # Calculate projected vertices 134 | assert ( 135 | self.point_1 is not None 136 | and self.point_2 is not None 137 | and self.dir_vector is not None 138 | ) 139 | self.p1_w = cast(Point3D, np.add(self.point_1, self.dir_vector)) 140 | self.p2_w = cast(Point3D, np.add(self.point_2, self.dir_vector)) 141 | ogl.draw_points([self.p1_w, self.p2_w], color=self.preview_color) 142 | ogl.draw_rectangles( 143 | [self.point_1, self.point_2, self.p2_w, self.p1_w], 144 | color=(1, 1, 0, 0.5), 145 | ) 146 | 147 | elif ( 148 | self.point_1 149 | and self.point_2 150 | and self.point_3 151 | and self.tmp_p4 152 | and (not self.point_4) 153 | ): 154 | assert self.p1_w is not None and self.p2_w is not None 155 | height1 = self.tmp_p4[2] - self.point_1[2] 156 | p1_t = cast(Point3D, np.add(self.point_1, [0, 0, height1])) 157 | p2_t = cast(Point3D, np.add(self.point_2, [0, 0, height1])) 158 | p1_wt = cast(Point3D, np.add(self.p1_w, [0, 0, height1])) 159 | p2_wt = cast(Point3D, np.add(self.p2_w, [0, 0, height1])) 160 | 161 | ogl.draw_cuboid( 162 | [ 163 | self.p1_w, 164 | self.point_1, 165 | self.point_2, 166 | self.p2_w, 167 | p1_wt, 168 | p1_t, 169 | p2_t, 170 | p2_wt, 171 | ], 172 | color=(1, 1, 0, 0.5), 173 | draw_vertices=True, 174 | vertex_color=self.preview_color, 175 | ) 176 | -------------------------------------------------------------------------------- /labelCloud/model/__init__.py: -------------------------------------------------------------------------------- 1 | from .bbox import BBox 2 | from .perspective import Perspective 3 | from .point_cloud import PointCloud 4 | -------------------------------------------------------------------------------- /labelCloud/model/bbox.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Optional 3 | 4 | import numpy as np 5 | import numpy.typing as npt 6 | 7 | import OpenGL.GL as GL 8 | 9 | from ..control.config_manager import config 10 | from ..definitions import ( 11 | BBOX_EDGES, 12 | BBOX_SIDES, 13 | Color3f, 14 | Dimensions3D, 15 | Point3D, 16 | Rotations3D, 17 | ) 18 | from ..io.labels.config import LabelConfig 19 | from ..utils import math3d, oglhelper 20 | 21 | 22 | class BBox(object): 23 | MIN_DIMENSION: float = config.getfloat("LABEL", "MIN_BOUNDINGBOX_DIMENSION") 24 | HIGHLIGHTED_COLOR: Color3f = Color3f(0, 1, 0) 25 | 26 | def __init__( 27 | self, 28 | cx: float, 29 | cy: float, 30 | cz: float, 31 | length: Optional[float] = None, 32 | width: Optional[float] = None, 33 | height: Optional[float] = None, 34 | ) -> None: 35 | self.center: Point3D = (cx, cy, cz) 36 | self.length: float = length or config.getfloat( 37 | "LABEL", "STD_BOUNDINGBOX_LENGTH" 38 | ) 39 | self.width: float = width or config.getfloat("LABEL", "STD_BOUNDINGBOX_WIDTH") 40 | self.height: float = height or config.getfloat( 41 | "LABEL", "STD_BOUNDINGBOX_HEIGHT" 42 | ) 43 | self.x_rotation: float = 0 44 | self.y_rotation: float = 0 45 | self.z_rotation: float = 0 46 | self.classname: str = LabelConfig().get_default_class_name() 47 | self.verticies: npt.NDArray = np.zeros((8, 3)) 48 | self.set_axis_aligned_verticies() 49 | 50 | # GETTERS 51 | 52 | def get_center(self) -> Point3D: 53 | return self.center 54 | 55 | def get_dimensions(self) -> Dimensions3D: 56 | return self.length, self.width, self.height 57 | 58 | def get_rotations(self) -> Rotations3D: 59 | return self.x_rotation, self.y_rotation, self.z_rotation 60 | 61 | def get_x_rotation(self) -> float: 62 | return self.x_rotation 63 | 64 | def get_y_rotation(self) -> float: 65 | return self.y_rotation 66 | 67 | def get_z_rotation(self) -> float: 68 | return self.z_rotation 69 | 70 | def get_classname(self) -> str: 71 | return self.classname 72 | 73 | def get_vertices(self) -> npt.NDArray: 74 | rotated_vertices = math3d.rotate_bbox_around_center( 75 | self.get_axis_aligned_vertices(), 76 | self.center, 77 | self.get_rotations(), 78 | ) 79 | return np.array(rotated_vertices) 80 | 81 | def get_axis_aligned_vertices(self) -> List[Point3D]: 82 | coords = [] 83 | for vertex in self.verticies: # Translate relative bbox to center 84 | coords.append(math3d.translate_point(vertex, *self.center)) 85 | return coords 86 | 87 | def get_volume(self) -> float: 88 | return self.length * self.width * self.height 89 | 90 | # SETTERS 91 | 92 | def set_classname(self, classname: str) -> None: 93 | if classname: 94 | self.classname = classname 95 | 96 | def set_length(self, length: float) -> None: 97 | if length > 0: 98 | self.length = length 99 | else: 100 | logging.warning("New length is too small.") 101 | 102 | def set_width(self, width: float) -> None: 103 | if width > 0: 104 | self.width = width 105 | else: 106 | logging.warning("New width is too small.") 107 | 108 | def set_height(self, height: float) -> None: 109 | if height > 0: 110 | self.height = height 111 | else: 112 | logging.warning("New height is too small.") 113 | 114 | def set_dimensions(self, length: float, width: float, height: float) -> None: 115 | if (length > 0) and (width > 0) and (height > 0): 116 | self.length = length 117 | self.width = width 118 | self.height = height 119 | else: 120 | logging.warning("New dimensions are too small.") 121 | 122 | def set_x_rotation(self, angle: float) -> None: 123 | self.x_rotation = angle % 360 124 | 125 | def set_y_rotation(self, angle: float) -> None: 126 | self.y_rotation = angle % 360 127 | 128 | def set_z_rotation(self, angle: float) -> None: 129 | self.z_rotation = angle % 360 130 | 131 | def set_rotations(self, x_angle: float, y_angle: float, z_angle: float): 132 | self.x_rotation = x_angle 133 | self.y_rotation = y_angle 134 | self.z_rotation = z_angle 135 | 136 | def set_x_translation(self, x_translation: float) -> None: 137 | self.center = (x_translation, *self.center[1:]) 138 | 139 | def set_y_translation(self, y_translation: float) -> None: 140 | self.center = (self.center[0], y_translation, self.center[2]) 141 | 142 | def set_z_translation(self, z_translation: float) -> None: 143 | self.center = (*self.center[:2], z_translation) 144 | 145 | # Updates the dimension of the BBox (important after scaling!) 146 | def set_axis_aligned_verticies(self) -> None: 147 | self.verticies = np.array( 148 | [ 149 | [-self.length / 2, -self.width / 2, -self.height / 2], 150 | [-self.length / 2, self.width / 2, -self.height / 2], 151 | [self.length / 2, self.width / 2, -self.height / 2], 152 | [self.length / 2, -self.width / 2, -self.height / 2], 153 | [-self.length / 2, -self.width / 2, self.height / 2], 154 | [-self.length / 2, self.width / 2, self.height / 2], 155 | [self.length / 2, self.width / 2, self.height / 2], 156 | [self.length / 2, -self.width / 2, self.height / 2], 157 | ] 158 | ) 159 | 160 | # Draw the BBox using verticies 161 | def draw_bbox(self, highlighted: bool = False) -> None: 162 | self.set_axis_aligned_verticies() 163 | 164 | GL.glPushMatrix() 165 | bbox_color = LabelConfig().get_class_color(self.classname) 166 | if highlighted: 167 | bbox_color = self.HIGHLIGHTED_COLOR 168 | 169 | vertices = self.get_vertices() 170 | drawing_sequence = [] 171 | for edge in BBOX_EDGES: 172 | for vertex_id in edge: 173 | drawing_sequence.append(vertices[vertex_id]) 174 | 175 | oglhelper.draw_lines(drawing_sequence, color=Color3f.to_rgba(bbox_color)) 176 | GL.glPopMatrix() 177 | 178 | def draw_orientation(self, crossed_side: bool = True) -> None: 179 | # Get object coordinates for arrow 180 | arrow_length = self.length * 0.4 181 | bp2 = [arrow_length, 0, 0] 182 | first_edge = [ 183 | arrow_length * 0.8, 184 | arrow_length * 0.3, 185 | 0, 186 | ] # TODO: Refactor to OGL helper 187 | second_edge = [arrow_length * 0.8, arrow_length * -0.3, 0] 188 | third_edge = [arrow_length * 0.8, 0, arrow_length * 0.3] 189 | 190 | GL.glPushMatrix() 191 | GL.glLineWidth(5) 192 | 193 | # Apply translation and rotation 194 | GL.glTranslate(*self.get_center()) 195 | 196 | GL.glRotate(self.get_z_rotation(), 0.0, 0.0, 1.0) 197 | GL.glRotate(self.get_y_rotation(), 0.0, 1.0, 0.0) 198 | GL.glRotate(self.get_x_rotation(), 1.0, 0.0, 0.0) 199 | 200 | GL.glBegin(GL.GL_LINES) 201 | GL.glVertex3fv([0, 0, 0]) 202 | GL.glVertex3fv(bp2) 203 | GL.glVertex3fv(bp2) 204 | GL.glVertex3fv(first_edge) 205 | GL.glVertex3fv(bp2) 206 | GL.glVertex3fv(second_edge) 207 | GL.glVertex3fv(bp2) 208 | GL.glVertex3fv(third_edge) 209 | if crossed_side: 210 | GL.glVertex3fv(self.verticies[BBOX_SIDES["right"][0]]) 211 | GL.glVertex3fv(self.verticies[BBOX_SIDES["right"][2]]) 212 | GL.glVertex3fv(self.verticies[BBOX_SIDES["right"][1]]) 213 | GL.glVertex3fv(self.verticies[BBOX_SIDES["right"][3]]) 214 | GL.glEnd() 215 | GL.glLineWidth(1) 216 | GL.glPopMatrix() 217 | 218 | # MANIPULATORS 219 | 220 | # Translate bbox by cx, cy, cz 221 | def translate_bbox(self, dx: float, dy: float, dz: float) -> None: 222 | self.center = math3d.translate_point(self.center, dx, dy, dz) 223 | 224 | # Translate bbox away from extension by half distance 225 | def translate_side(self, p_id_s: int, p_id_o: int, distance: float) -> None: 226 | # TODO: add doc string 227 | direction = np.subtract( 228 | self.get_vertices()[p_id_s], self.get_vertices()[p_id_o] 229 | ) 230 | translation_vector = direction / np.linalg.norm(direction) * (distance / 2) 231 | self.center = math3d.translate_point(self.center, *translation_vector) 232 | 233 | # Extend bbox side by distance 234 | def change_side( 235 | self, side: str, distance: float 236 | ) -> None: # ToDo: Move to controller? 237 | if side == "right" and self.length + distance > BBox.MIN_DIMENSION: 238 | self.length += distance 239 | self.translate_side(3, 0, distance) # TODO: Make dependend from side list 240 | if side == "left" and self.length + distance > BBox.MIN_DIMENSION: 241 | self.length += distance 242 | self.translate_side(0, 3, distance) 243 | if side == "front" and self.width + distance > BBox.MIN_DIMENSION: 244 | self.width += distance 245 | self.translate_side(1, 0, distance) 246 | if side == "back" and self.width + distance > BBox.MIN_DIMENSION: 247 | self.width += distance 248 | self.translate_side(0, 1, distance) 249 | if side == "top" and self.height + distance > BBox.MIN_DIMENSION: 250 | self.height += distance 251 | self.translate_side(4, 0, distance) 252 | if side == "bottom" and self.height + distance > BBox.MIN_DIMENSION: 253 | self.height += distance 254 | self.translate_side(0, 4, distance) 255 | 256 | def is_inside(self, points: npt.NDArray[np.float32]) -> npt.NDArray[np.bool_]: 257 | vertices = self.get_vertices().copy() 258 | 259 | # .------------. 260 | # /| /| 261 | # / | / | 262 | # (p1).------------. | 263 | # | | | | 264 | # | | | | 265 | # | .(p2)_____|__. 266 | # | / | / 267 | # ./___________./ 268 | # (p0) (p3) 269 | 270 | p0 = vertices[0] 271 | p1 = vertices[3] 272 | p2 = vertices[1] 273 | p3 = vertices[4] 274 | 275 | # p0 as origin 276 | v1 = p1 - p0 277 | v2 = p2 - p0 278 | v3 = p3 - p0 279 | 280 | u = points - p0 281 | u_dot_v1 = u.dot(v1) 282 | u_dot_v2 = u.dot(v2) 283 | u_dot_v3 = u.dot(v3) 284 | 285 | inside_v1 = np.logical_and(np.sum(v1**2) > u_dot_v1, u_dot_v1 > 0) 286 | inside_v2 = np.logical_and(np.sum(v2**2) > u_dot_v2, u_dot_v2 > 0) 287 | inside_v3 = np.logical_and(np.sum(v3**2) > u_dot_v3, u_dot_v3 > 0) 288 | 289 | points_inside: npt.NDArray[np.bool_] = np.logical_and( 290 | np.logical_and(inside_v1, inside_v2), inside_v3 291 | ) 292 | return points_inside 293 | -------------------------------------------------------------------------------- /labelCloud/model/perspective.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, Tuple 3 | 4 | if TYPE_CHECKING: 5 | from . import PointCloud 6 | 7 | 8 | @dataclass 9 | class Perspective(object): 10 | translation: Tuple[float, float, float] 11 | rotation: Tuple[float, float, float] 12 | 13 | @classmethod 14 | def from_point_cloud(cls, pointcloud: "PointCloud") -> "Perspective": 15 | return cls( 16 | translation=pointcloud.get_translation(), 17 | rotation=pointcloud.get_rotations(), 18 | ) 19 | -------------------------------------------------------------------------------- /labelCloud/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/labelCloud/resources/__init__.py -------------------------------------------------------------------------------- /labelCloud/resources/default_classes.json: -------------------------------------------------------------------------------- 1 | { 2 | "classes": [ 3 | { 4 | "name": "unassigned", 5 | "id": 0, 6 | "color": "#9da2ab" 7 | }, 8 | { 9 | "name": "cart", 10 | "id": 1, 11 | "color": "#ffbf35" 12 | }, 13 | { 14 | "name": "box", 15 | "id": 2, 16 | "color": "#f156ff" 17 | } 18 | ], 19 | "default": 0, 20 | "type": "object_detection", 21 | "format": "centroid_abs", 22 | "created_with": { 23 | "name": "labelCloud", 24 | "version": "1.0.1" 25 | } 26 | } -------------------------------------------------------------------------------- /labelCloud/resources/default_config.ini: -------------------------------------------------------------------------------- 1 | [FILE] 2 | ; source of point clouds 3 | pointcloud_folder = pointclouds/ 4 | ; sink for label files 5 | label_folder = labels/ 6 | ; definition of classes and export format 7 | class_definitions = labels/_classes.json 8 | ; only for kitti: calibration file for each point cloud 9 | calib_folder = calib/ 10 | ; sink for segmentation files (*.bin point clouds) [optional] 11 | segmentation_folder = labels/segmentation/ 12 | ; 2d image folder [optional] 13 | image_folder = pointclouds/ 14 | 15 | [POINTCLOUD] 16 | ; drawing size for points in point cloud 17 | point_size = 4.0 18 | ; point color for colorless point clouds (r,g,b) 19 | colorless_color = 0.9, 0.9, 0.9 20 | ; colerize colorless point clouds by height value [optional] 21 | colorless_colorize = True 22 | ; standard step for point cloud translation (for mouse move) 23 | std_translation = 0.03 24 | ; standard step for zooming (for scrolling) 25 | std_zoom = 0.0025 26 | ; blend the color with segmentation labels [optional] 27 | color_with_label = True 28 | ; mix ratio between label colors and rgb colors [optional] 29 | label_color_mix_ratio = 0.3 30 | 31 | [LABEL] 32 | ; number of decimal places for exporting the bounding box parameter. 33 | export_precision = 8 34 | ; default length of the bounding box (for picking mode) 35 | std_boundingbox_length = 0.75 36 | ; default width of the bounding box (for picking mode) 37 | std_boundingbox_width = 0.55 38 | ; default height of the bounding box (for picking mode) 39 | std_boundingbox_height = 0.15 40 | ; standard step for translating the bounding box with button or key (in meter) 41 | std_translation = 0.03 42 | ; standard step for rotating the bounding box with button or key (in degree) 43 | std_rotation = 0.5 44 | ; standard step for scaling the bounding box with button 45 | std_scaling = 0.03 46 | ; minimum value for the length, width and height of a bounding box 47 | min_boundingbox_dimension = 0.01 48 | ; propagate labels to next point cloud if it has no labels yet 49 | propagate_labels = False 50 | 51 | [USER_INTERFACE] 52 | ; only allow z-rotation of bounding boxes. set false to also label x- & y-rotation 53 | z_rotation_only = True 54 | ; visualizes the pointcloud floor (x-y-plane) as a grid 55 | show_floor = True 56 | ; visualizes the object's orientation with an arrow 57 | show_orientation = True 58 | ; background color of the point cloud viewer (rgb) 59 | background_color = 100, 100, 100 60 | ; number of decimal places shown for the parameters of the active bounding box 61 | viewing_precision = 2 62 | ; near and far clipping plane for opengl (where objects are visible, in meter) 63 | near_plane = 0.1 64 | far_plane = 300 65 | ; keep last perspective between point clouds [optional] 66 | keep_perspective = False 67 | ; show button to visualize related images in a separate window [optional] 68 | show_2d_image = False 69 | ; delete the bounding box after assigning the label to the points [optional] 70 | delete_box_after_assign = True 71 | -------------------------------------------------------------------------------- /labelCloud/resources/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/labelCloud/resources/examples/__init__.py -------------------------------------------------------------------------------- /labelCloud/resources/examples/exemplary.json: -------------------------------------------------------------------------------- 1 | { 2 | "folder": "pointclouds", 3 | "filename": "exemplary.ply", 4 | "path": "pointclouds/exemplary.ply", 5 | "objects": [ 6 | { 7 | "name": "cart", 8 | "centroid": { 9 | "x": -0.1908196, 10 | "y": -0.23602801, 11 | "z": 0.08046184 12 | }, 13 | "dimensions": { 14 | "length": 0.75, 15 | "width": 0.55, 16 | "height": 0.15 17 | }, 18 | "rotations": { 19 | "x": 0, 20 | "y": 0, 21 | "z": 235 22 | } 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /labelCloud/resources/examples/exemplary.ply: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/labelCloud/resources/examples/exemplary.ply -------------------------------------------------------------------------------- /labelCloud/resources/icons/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/labelCloud/resources/icons/__init__.py -------------------------------------------------------------------------------- /labelCloud/resources/icons/arrow-down-bold.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/arrow-left-bold.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/arrow-right-bold.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/arrow-up-bold.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/content-save-outline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/cube-outline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/cube-outline_white.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/cursor-default-click.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/delete-outline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/download.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/labelCloud.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/labelCloud/resources/icons/labelCloud.ico -------------------------------------------------------------------------------- /labelCloud/resources/icons/labelCloud_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/labelCloud/resources/icons/labelCloud_icon.png -------------------------------------------------------------------------------- /labelCloud/resources/icons/minus-box-multiple-outline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/panorama.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/plus-box-multiple-outline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/resize.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/select-off.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/icons/upload.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labelCloud/resources/interfaces/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/labelCloud/resources/interfaces/__init__.py -------------------------------------------------------------------------------- /labelCloud/resources/labelCloud_icon.pcd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/labelCloud/resources/labelCloud_icon.pcd -------------------------------------------------------------------------------- /labelCloud/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/labelCloud/tests/__init__.py -------------------------------------------------------------------------------- /labelCloud/tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import time 5 | from pathlib import Path 6 | from typing import Tuple 7 | 8 | import pytest 9 | from PyQt5 import QtCore 10 | 11 | from labelCloud.control.config_manager import config 12 | from labelCloud.control.controller import Controller 13 | from labelCloud.model.bbox import BBox 14 | from labelCloud.view.gui import GUI 15 | from labelCloud.view.startup.dialog import StartupDialog 16 | 17 | 18 | def pytest_configure(config): 19 | os.chdir("../labelCloud") 20 | logging.info(f"Set working directory to {os.getcwd()}.") 21 | 22 | 23 | @pytest.fixture 24 | def startup_pyqt(qtbot, qapp, monkeypatch): 25 | # Backup label 26 | pathToLabel = config.getpath("FILE", "label_folder") / "exemplary.json" 27 | pathToBackup = Path().cwd() / pathToLabel.name 28 | 29 | shutil.copy(pathToLabel, pathToBackup) 30 | 31 | # Setup Model-View-Control structure 32 | control = Controller() 33 | 34 | monkeypatch.setattr(StartupDialog, "exec", lambda self: 1) 35 | 36 | view = GUI(control) 37 | qtbot.addWidget(view) 38 | qtbot.addWidget(view.gl_widget) 39 | 40 | # Install event filter to catch user interventions 41 | qapp.installEventFilter(view) 42 | 43 | # Start GUI 44 | view.show() 45 | yield view, control 46 | 47 | shutil.move(pathToBackup, pathToLabel) 48 | 49 | 50 | @pytest.fixture 51 | def bbox(): 52 | return BBox(cx=0, cy=0, cz=0, length=3, width=2, height=1) 53 | -------------------------------------------------------------------------------- /labelCloud/tests/integration/test_gui.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import Tuple 4 | 5 | from PyQt5 import QtCore 6 | from PyQt5.QtWidgets import QAbstractSlider 7 | 8 | from labelCloud.control.config_manager import config 9 | from labelCloud.control.controller import Controller 10 | from labelCloud.model.bbox import BBox 11 | from labelCloud.view.gui import GUI 12 | 13 | 14 | def test_gui(qtbot, startup_pyqt: Tuple[GUI, Controller]): 15 | view, controller = startup_pyqt 16 | 17 | assert len(controller.pcd_manager.pcds) > 0 18 | os.remove("labels/exemplary.json") 19 | qtbot.mouseClick(view.button_next_pcd, QtCore.Qt.LeftButton, delay=0) 20 | assert "exemplary.json" in os.listdir("labels") 21 | 22 | bbox = controller.bbox_controller.bboxes[0] 23 | bbox.center = (0, 0, 0) 24 | controller.bbox_controller.set_active_bbox(0) 25 | qtbot.mouseClick(view.button_bbox_right, QtCore.Qt.LeftButton, delay=0) 26 | qtbot.mouseClick(view.button_bbox_up, QtCore.Qt.LeftButton, delay=0) 27 | qtbot.mouseClick(view.button_bbox_backward, QtCore.Qt.LeftButton, delay=0) 28 | assert bbox.center == (0.03, 0.03, 0.03) 29 | 30 | view.close() 31 | 32 | 33 | def test_bbox_control_with_buttons( 34 | qtbot, startup_pyqt: Tuple[GUI, Controller], bbox: BBox 35 | ): 36 | view, controller = startup_pyqt 37 | 38 | # Prepare test bounding box 39 | controller.bbox_controller.bboxes = [bbox] 40 | old_length, old_width, old_height = bbox.get_dimensions() 41 | controller.bbox_controller.set_active_bbox(0) 42 | 43 | # Translation 44 | translation_step = config.getfloat("LABEL", "std_translation") 45 | qtbot.mouseClick(view.button_bbox_right, QtCore.Qt.LeftButton, delay=0) 46 | qtbot.mouseClick(view.button_bbox_up, QtCore.Qt.LeftButton, delay=0) 47 | qtbot.mouseClick(view.button_bbox_backward, QtCore.Qt.LeftButton, delay=0) 48 | assert bbox.center == (translation_step, translation_step, translation_step) 49 | qtbot.mouseClick(view.button_bbox_left, QtCore.Qt.LeftButton, delay=0) 50 | qtbot.mouseClick(view.button_bbox_down, QtCore.Qt.LeftButton, delay=0) 51 | qtbot.mouseClick(view.button_bbox_forward, QtCore.Qt.LeftButton) 52 | logging.info("BBOX: %s" % [str(c) for c in bbox.get_center()]) 53 | assert bbox.center == (0.00, 0.00, 0.00) 54 | 55 | # Scaling 56 | scaling_step = config.getfloat("LABEL", "std_scaling") 57 | qtbot.mouseClick(view.button_bbox_increase_dimension, QtCore.Qt.LeftButton) 58 | assert bbox.length == old_length + scaling_step 59 | assert bbox.width == old_width / old_length * bbox.length 60 | assert bbox.height == old_height / old_length * bbox.length 61 | 62 | # Rotation 63 | # TODO: Make dial configureable? 64 | view.dial_bbox_z_rotation.triggerAction(QAbstractSlider.SliderSingleStepAdd) 65 | assert bbox.z_rotation == 1 66 | view.dial_bbox_z_rotation.triggerAction(QAbstractSlider.SliderPageStepAdd) 67 | assert bbox.z_rotation == 11 68 | 69 | view.close() 70 | 71 | 72 | def test_bbox_control_with_keyboard( 73 | qtbot, startup_pyqt: Tuple[GUI, Controller], qapp, bbox: BBox 74 | ): 75 | view, controller = startup_pyqt 76 | 77 | # Prepare test bounding box 78 | controller.bbox_controller.bboxes = [bbox] 79 | controller.bbox_controller.set_active_bbox(0) 80 | 81 | # Translation 82 | translation_step = config.getfloat("LABEL", "std_translation") 83 | for letter in "dqw": 84 | qtbot.keyClick(view, letter) 85 | assert bbox.center == (translation_step, translation_step, translation_step) 86 | translation_step = config.getfloat("LABEL", "std_translation") 87 | for letter in "aes": 88 | qtbot.keyClick(view, letter) 89 | assert bbox.center == (0, 0, 0) 90 | 91 | for key in [QtCore.Qt.Key_D, QtCore.Qt.Key_W, QtCore.Qt.Key_Q]: 92 | qtbot.keyClick(view, key) 93 | assert bbox.center == (translation_step, translation_step, translation_step) 94 | for key in [QtCore.Qt.Key_A, QtCore.Qt.Key_S, QtCore.Qt.Key_E]: 95 | qtbot.keyClick(view, key) 96 | assert bbox.center == (0, 0, 0) 97 | 98 | # Rotation 99 | rotation_step = config.getfloat("LABEL", "std_rotation") 100 | config.set("USER_INTERFACE", "z_rotation_only", "False") 101 | qtbot.keyClick(view, "z") 102 | assert bbox.z_rotation == rotation_step 103 | qtbot.keyClick(view, "x") 104 | assert bbox.z_rotation == 0 105 | # qtbot.keyClick(view, QtCore.Qt.Key_Comma) 106 | # assert bbox.z_rotation == rotation_step 107 | # qtbot.keyClick(view, QtCore.Qt.Key_Period) 108 | # assert bbox.z_rotation == 0 109 | qtbot.keyClick(view, "c") 110 | assert bbox.y_rotation == rotation_step 111 | qtbot.keyClick(view, "v") 112 | assert bbox.y_rotation == 0 113 | qtbot.keyClick(view, "b") 114 | assert bbox.x_rotation == rotation_step 115 | qtbot.keyClick(view, "n") 116 | assert bbox.x_rotation == 0 117 | 118 | # Shortcuts 119 | qtbot.keyClick(view, QtCore.Qt.Key_Delete) 120 | assert len(controller.bbox_controller.bboxes) == 0 121 | assert controller.bbox_controller.get_active_bbox() is None 122 | 123 | view.close() 124 | -------------------------------------------------------------------------------- /labelCloud/tests/integration/test_labeling.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from PyQt5 import QtCore 4 | from PyQt5.QtCore import QPoint 5 | 6 | import pytest 7 | from labelCloud.control.config_manager import config 8 | from labelCloud.control.controller import Controller 9 | from labelCloud.model.bbox import BBox 10 | from labelCloud.view.gui import GUI 11 | 12 | 13 | def test_picking_mode(qtbot, startup_pyqt: Tuple[GUI, Controller]): 14 | view, control = startup_pyqt 15 | control.bbox_controller.bboxes = [] 16 | 17 | qtbot.mouseClick(view.button_pick_bbox, QtCore.Qt.LeftButton, delay=1000) 18 | qtbot.mouseClick( 19 | view.gl_widget, QtCore.Qt.LeftButton, pos=QPoint(500, 500), delay=1000 20 | ) 21 | 22 | assert len(control.bbox_controller.bboxes) == 1 23 | new_bbox = control.bbox_controller.bboxes[0] 24 | assert new_bbox.center == tuple( 25 | pytest.approx(x, abs=0.1) for x in [0.1654, -0.3938, -0.0485] 26 | ) 27 | 28 | assert new_bbox.length == config.getfloat("LABEL", "std_boundingbox_length") 29 | assert new_bbox.width == config.getfloat("LABEL", "std_boundingbox_width") 30 | assert new_bbox.height == config.getfloat("LABEL", "std_boundingbox_height") 31 | assert new_bbox.z_rotation == new_bbox.y_rotation == new_bbox.x_rotation == 0 32 | 33 | 34 | def test_spanning_mode(qtbot, startup_pyqt: Tuple[GUI, Controller]): 35 | view, control = startup_pyqt 36 | control.bbox_controller.bboxes = [] 37 | config.set("USER_INTERFACE", "z_rotation_only", "True") 38 | 39 | qtbot.mouseClick(view.button_span_bbox, QtCore.Qt.LeftButton, delay=10) 40 | qtbot.mouseClick( 41 | view.gl_widget, QtCore.Qt.LeftButton, pos=QPoint(431, 475), delay=20 42 | ) 43 | qtbot.mouseClick( 44 | view.gl_widget, QtCore.Qt.LeftButton, pos=QPoint(506, 367), delay=20 45 | ) 46 | qtbot.mouseClick( 47 | view.gl_widget, QtCore.Qt.LeftButton, pos=QPoint(572, 439), delay=20 48 | ) 49 | qtbot.mouseClick( 50 | view.gl_widget, QtCore.Qt.LeftButton, pos=QPoint(607, 556), delay=20 51 | ) 52 | 53 | assert len(control.bbox_controller.bboxes) == 1 54 | new_bbox: BBox = control.bbox_controller.bboxes[0] 55 | assert new_bbox.center == tuple( 56 | pytest.approx(x, abs=0.1) for x in [0.1967, -0.4569, 0.0262] 57 | ) 58 | assert new_bbox.get_dimensions() == tuple( 59 | pytest.approx(x, abs=0.1) for x in [0.5385, 0.3908, 0.0466] 60 | ) 61 | assert new_bbox.get_rotations() == tuple( 62 | pytest.approx(x % 360, abs=0.5) for x in [0, 0, 55.2205] 63 | ) 64 | -------------------------------------------------------------------------------- /labelCloud/tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | 8 | def pytest_configure(config): 9 | os.chdir("../labelCloud") 10 | logging.info(f"Set working directory to {os.getcwd()}.") 11 | 12 | 13 | @pytest.fixture 14 | def tmppath(tmpdir): 15 | return Path(tmpdir) 16 | -------------------------------------------------------------------------------- /labelCloud/tests/unit/segmentation_handler/test_base_segmentation_handler.py: -------------------------------------------------------------------------------- 1 | from labelCloud.io.segmentations import ( 2 | BaseSegmentationHandler, 3 | NumpySegmentationHandler, 4 | ) 5 | 6 | 7 | def test_get_subclass() -> None: 8 | handler = BaseSegmentationHandler.get_handler(".bin") 9 | assert handler is NumpySegmentationHandler 10 | -------------------------------------------------------------------------------- /labelCloud/tests/unit/segmentation_handler/test_numpy_segmentation_handler.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from contextlib import nullcontext 3 | from pathlib import Path 4 | from typing import Dict 5 | 6 | import numpy as np 7 | import pytest 8 | from labelCloud.io.segmentations import NumpySegmentationHandler 9 | 10 | 11 | @pytest.fixture 12 | def segmentation_path() -> Path: 13 | path = Path("labels/segmentation/") 14 | assert path.exists() 15 | return path 16 | 17 | 18 | @pytest.fixture 19 | def label_definition_path() -> Path: 20 | path = Path("labels/schema/label_definition.json") 21 | assert path.exists() 22 | return path 23 | 24 | 25 | @pytest.fixture 26 | def label_path(segmentation_path) -> Path: 27 | path = segmentation_path / Path("exemplary.bin") 28 | assert path.exists() 29 | return path 30 | 31 | 32 | @pytest.fixture 33 | def not_label_path(segmentation_path) -> Path: 34 | path = segmentation_path / Path("foo.bin") 35 | assert not path.exists() 36 | return path 37 | 38 | 39 | @pytest.fixture 40 | def expected_label_definition() -> Dict[str, int]: 41 | return { 42 | "unassigned": 0, 43 | "person": 1, 44 | "cart": 2, 45 | "wall": 3, 46 | "floor": 4, 47 | } 48 | 49 | 50 | @pytest.fixture 51 | def handler() -> NumpySegmentationHandler: 52 | return NumpySegmentationHandler() 53 | 54 | 55 | def test_read_labels(handler: NumpySegmentationHandler, label_path: Path) -> None: 56 | labels = handler._read_labels(label_path) 57 | assert labels.dtype == np.int8 58 | assert labels.shape == (86357,) 59 | 60 | 61 | def test_create_labels(handler: NumpySegmentationHandler) -> None: 62 | labels = handler._create_labels(num_points=420) 63 | assert labels.dtype == np.int8 64 | assert labels.shape == (420,) 65 | assert (labels == np.zeros((420,))).all() 66 | 67 | 68 | def test_write_labels(handler: NumpySegmentationHandler) -> None: 69 | labels = np.random.randint(low=0, high=4, size=(420,), dtype=np.int8) 70 | with tempfile.TemporaryDirectory() as tempdir: 71 | label_path = Path(tempdir) / Path("foo.bin") 72 | handler._write_labels(label_path=label_path, labels=labels) 73 | 74 | saved_labels = handler._read_labels(label_path) 75 | 76 | assert saved_labels.dtype == np.int8 77 | assert (labels == saved_labels).all() 78 | 79 | 80 | @pytest.mark.parametrize( 81 | ("num_points", "exception"), 82 | ( 83 | [ 84 | (86357, nullcontext()), 85 | (420, pytest.raises(ValueError)), 86 | ] 87 | ), 88 | ) 89 | def test_read_or_create_labels_when_exist( 90 | handler: NumpySegmentationHandler, 91 | label_path: Path, 92 | num_points: int, 93 | exception: BaseException, 94 | ) -> None: 95 | with exception: 96 | labels = handler.read_or_create_labels( 97 | label_path=label_path, num_points=num_points 98 | ) 99 | assert labels.dtype == np.int8 100 | assert labels.shape == (num_points,) 101 | 102 | 103 | def test_read_or_create_labels_when_not_exist( 104 | handler: NumpySegmentationHandler, 105 | not_label_path: Path, 106 | ) -> None: 107 | labels = handler.read_or_create_labels(label_path=not_label_path, num_points=420) 108 | assert labels.dtype == np.int8 109 | assert labels.shape == (420,) 110 | assert (labels == np.zeros((420,))).all() 111 | 112 | 113 | def test_overwrite_labels(handler: NumpySegmentationHandler) -> None: 114 | labels = np.random.randint(low=0, high=4, size=(420,), dtype=np.int8) 115 | with tempfile.TemporaryDirectory() as tempdir: 116 | label_path = Path(tempdir) / Path("foo.bin") 117 | handler.overwrite_labels(label_path=label_path, labels=labels) 118 | saved_labels = handler._read_labels(label_path) 119 | 120 | assert saved_labels.dtype == np.int8 121 | assert (labels == saved_labels).all() 122 | -------------------------------------------------------------------------------- /labelCloud/tests/unit/test_color.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from labelCloud.utils.color import colorize_points_with_height, get_distinct_colors 4 | 5 | 6 | def test_get_distinct_colors() -> None: 7 | num_colors = 17 8 | colors = get_distinct_colors(num_colors) 9 | assert isinstance(colors[0], str) 10 | assert len(colors) == num_colors 11 | 12 | 13 | def test_colorize_points_with_height() -> None: 14 | num_points = 900 15 | points = np.random.uniform(low=0, high=10, size=(num_points, 3)) 16 | z_min = points[:, 2].min() 17 | z_max = points[:, 2].max() 18 | 19 | colors = colorize_points_with_height(points, z_min, z_max) 20 | assert colors.dtype == np.float32 21 | assert colors.shape == (num_points, 3) 22 | assert 0 <= colors.max() <= 1 23 | -------------------------------------------------------------------------------- /labelCloud/tests/unit/test_label_export.py: -------------------------------------------------------------------------------- 1 | # Testing the correct processing of labels for the export in different formats 2 | import json 3 | import os 4 | from pathlib import Path 5 | 6 | import pytest 7 | from labelCloud.control.label_manager import LabelManager 8 | from labelCloud.model.bbox import BBox 9 | 10 | 11 | @pytest.fixture 12 | def bounding_box(): 13 | test_bbox = BBox(0, 0, 0, 1, 1, 1) 14 | test_bbox.set_classname("test_bbox") 15 | test_bbox.set_rotations(90, 180, 270) 16 | return test_bbox 17 | 18 | 19 | def test_vertices_export(bounding_box, tmppath): 20 | label_manager = LabelManager(strategy="vertices", path_to_label_folder=tmppath) 21 | pcd_path = Path("testfolder/testpcd.ply") 22 | label_manager.export_labels(pcd_path, [bounding_box]) 23 | 24 | with tmppath.joinpath("testpcd.json").open("r") as read_file: 25 | data = json.load(read_file) 26 | 27 | assert data["folder"] == "testfolder" 28 | assert data["filename"] == "testpcd.ply" 29 | assert data["path"] == str(pcd_path) 30 | assert data["objects"] == [ 31 | { 32 | "name": "test_bbox", 33 | "vertices": [ 34 | [0.5, -0.5, 0.5], 35 | [0.5, -0.5, -0.5], 36 | [0.5, 0.5, -0.5], 37 | [0.5, 0.5, 0.5], 38 | [-0.5, -0.5, 0.5], 39 | [-0.5, -0.5, -0.5], 40 | [-0.5, 0.5, -0.5], 41 | [-0.5, 0.5, 0.5], 42 | ], 43 | } 44 | ] 45 | 46 | 47 | def test_centroid_rel_export(bounding_box, tmppath): 48 | label_manager = LabelManager(strategy="centroid_rel", path_to_label_folder=tmppath) 49 | pcd_path = Path("testfolder/testpcd.ply") 50 | label_manager.export_labels(pcd_path, [bounding_box]) 51 | 52 | with tmppath.joinpath("testpcd.json").open("r") as read_file: 53 | data = json.load(read_file) 54 | 55 | assert data["folder"] == "testfolder" 56 | assert data["filename"] == "testpcd.ply" 57 | assert data["path"] == str(pcd_path) 58 | assert data["objects"] == [ 59 | { 60 | "name": "test_bbox", 61 | "centroid": {"x": 0, "y": 0, "z": 0}, 62 | "dimensions": {"length": 1, "width": 1, "height": 1}, 63 | "rotations": {"x": 1.57079633, "y": 3.14159265, "z": -1.57079633}, 64 | } 65 | ] 66 | 67 | 68 | def test_centroid_abs_export(bounding_box, tmppath): 69 | label_manager = LabelManager(strategy="centroid_abs", path_to_label_folder=tmppath) 70 | pcd_path = Path("testfolder/testpcd.ply") 71 | label_manager.export_labels(pcd_path, [bounding_box]) 72 | 73 | with tmppath.joinpath("testpcd.json").open("r") as read_file: 74 | data = json.load(read_file) 75 | 76 | assert data["folder"] == "testfolder" 77 | assert data["filename"] == "testpcd.ply" 78 | assert data["path"] == str(pcd_path) 79 | assert data["objects"] == [ 80 | { 81 | "name": "test_bbox", 82 | "centroid": {"x": 0, "y": 0, "z": 0}, 83 | "dimensions": {"length": 1, "width": 1, "height": 1}, 84 | "rotations": {"x": 90, "y": 180, "z": 270}, 85 | } 86 | ] 87 | 88 | 89 | def test_kitti_export(bounding_box, tmppath): 90 | label_manager = LabelManager( 91 | strategy="kitti_untransformed", path_to_label_folder=tmppath 92 | ) 93 | label_manager.export_labels(Path("testfolder/testpcd.ply"), [bounding_box]) 94 | 95 | with tmppath.joinpath("testpcd.txt").open("r") as read_file: 96 | data = read_file.readlines() 97 | 98 | assert data == ["test_bbox 0 0 0 0 0 0 0 1 1 1 0 0 0 -1.57079633\n"] 99 | -------------------------------------------------------------------------------- /labelCloud/tests/unit/test_label_import.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import pytest 5 | from labelCloud.control.label_manager import LabelManager 6 | 7 | 8 | @pytest.fixture 9 | def label_centroid(): # absolute and relative 10 | label = """{"folder": "pointclouds", "filename": "test.ply", "path": "pointclouds/test.ply", 11 | "objects": [{"name": "cart", "centroid": { "x": -0.186338, "y": -0.241696, "z": 0.054818}, 12 | "dimensions": {"length": 0.80014, "width": 0.512493, "height": 0.186055}, 13 | "rotations": {"x": 0, "y": 0, "z": 1.616616} } ] }""" 14 | return label 15 | 16 | 17 | @pytest.mark.parametrize( 18 | "label_format, rotation", 19 | [("centroid_abs", (0, 0, 1.616616)), ("centroid_rel", (0, 0, 92.6252738933211))], 20 | ) 21 | def test_centroid_import(label_centroid, tmppath, label_format, rotation): 22 | # Write label to file 23 | with tmppath.joinpath("test.json").open("w") as write_file: 24 | write_file.write(label_centroid) 25 | 26 | # Import label file 27 | label_manager = LabelManager(strategy=label_format, path_to_label_folder=tmppath) 28 | bounding_boxes = label_manager.import_labels(Path("test.ply")) 29 | bbox = bounding_boxes[0] 30 | 31 | # Check label content 32 | assert bbox.get_classname() == "cart" 33 | assert bbox.get_center() == (-0.186338, -0.241696, 0.054818) 34 | assert bbox.get_dimensions() == (0.80014, 0.512493, 0.186055) 35 | assert bbox.get_rotations() == rotation 36 | 37 | 38 | @pytest.fixture 39 | def label_vertices(): 40 | label = """{"folder": "pointclouds", "filename": "test.ply", "path": "pointclouds/test.ply", "objects": [ 41 | {"name": "cart", "vertices": [[-0.245235,-0.465784,0.548944], [-0.597706,-0.630144,0.160035], 42 | [-0.117064,-0.406017,-0.370295], [0.235407,-0.241657,0.018614], [-0.308628,-0.329838,0.548944], 43 | [-0.661099,-0.494198,0.160035], [-0.180457,-0.270071,-0.370295], [0.172014,-0.105711,0.018614]]}]}""" 44 | return label 45 | 46 | 47 | def test_vertices(label_vertices, tmppath): 48 | # Write label to file 49 | with tmppath.joinpath("test.json").open("w") as write_file: 50 | write_file.write(label_vertices) 51 | 52 | # Import label file 53 | label_manager = LabelManager(strategy="vertices", path_to_label_folder=tmppath) 54 | bounding_boxes = label_manager.import_labels(Path("test.ply")) 55 | bbox = bounding_boxes[0] 56 | 57 | # Check label content 58 | assert bbox.get_classname() == "cart" 59 | assert bbox.get_center() == pytest.approx((-0.212846, -0.3679275, 0.0893245)) 60 | assert bbox.get_dimensions() == pytest.approx((0.75, 0.55, 0.15)) 61 | assert bbox.get_rotations() == pytest.approx( 62 | (270, 45, 25) 63 | ) # apply for rounding errors 64 | 65 | 66 | @pytest.fixture 67 | def label_kitti(): 68 | label = "cart 0 0 0 0 0 0 0 0.75 0.55 0.15 -0.409794 -0.012696 0.076757 0.436332" 69 | return label 70 | 71 | 72 | def test_kitti(label_kitti, tmppath): 73 | # Write label to file 74 | with open(os.path.join(tmppath, "test.txt"), "w") as write_file: 75 | write_file.write(label_kitti) 76 | 77 | # Import label file 78 | label_manager = LabelManager( 79 | strategy="kitti_untransformed", path_to_label_folder=tmppath 80 | ) 81 | bounding_boxes = label_manager.import_labels(Path("test.txt")) 82 | bbox = bounding_boxes[0] 83 | 84 | # Check label content 85 | assert bbox.get_classname() == "cart" 86 | assert bbox.get_center() == (-0.409794, -0.012696, 0.076757) 87 | assert bbox.get_dimensions() == (0.15, 0.55, 0.75) 88 | assert bbox.get_rotations() == pytest.approx( 89 | (0, 0, 25) 90 | ) # apply for rounding errors 91 | -------------------------------------------------------------------------------- /labelCloud/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from . import logger 2 | -------------------------------------------------------------------------------- /labelCloud/utils/color.py: -------------------------------------------------------------------------------- 1 | import colorsys 2 | from typing import List 3 | 4 | import numpy as np 5 | import numpy.typing as npt 6 | import pkg_resources 7 | 8 | from ..definitions.types import Color3f 9 | 10 | 11 | def get_distinct_colors(n: int) -> List[str]: 12 | """generate visualy distinct colors 13 | Args: 14 | n (int): number of colors 15 | Returns: 16 | npt.NDArray[np.float32]: n x 3 (rgb) values between 0 and 1 17 | """ 18 | hue_partition = 1.0 / (n + 1) 19 | colors = np.vstack( 20 | [ 21 | np.array( 22 | colorsys.hsv_to_rgb( 23 | hue_partition * value, 24 | 1.0 - (value % 2) * 0.5, 25 | 1.0 - (value % 3) * 0.1, 26 | ) 27 | ) 28 | for value in range(0, n) 29 | ] 30 | ).astype(np.float32) 31 | 32 | return [rgb_to_hex(color) for color in colors] 33 | 34 | 35 | def colorize_points_with_height( 36 | points: np.ndarray, z_min: float, z_max: float 37 | ) -> npt.NDArray[np.float32]: 38 | palette = np.loadtxt( 39 | pkg_resources.resource_filename("labelCloud.resources", "rocket-palette.txt") 40 | ) 41 | palette_len = len(palette) - 1 42 | 43 | colors = np.zeros(points.shape) 44 | for ind, height in enumerate(points[:, 2]): 45 | colors[ind] = palette[round((height - z_min) / (z_max - z_min) * palette_len)] 46 | return colors.astype(np.float32) 47 | 48 | 49 | def hex_to_rgb(hex: str) -> Color3f: 50 | """Converts a hex color to a list of RGBA values. 51 | 52 | Args: 53 | hex (str): The hex color to convert. 54 | 55 | Returns: 56 | List[float]: The RGB values. 57 | """ 58 | hex = hex.lstrip("#") 59 | return tuple( # type: ignore 60 | [int(hex[i : i + 2], 16) / 255 for i in range(0, 6, 2)] 61 | ) 62 | 63 | 64 | def rgb_to_hex(color: Color3f) -> str: 65 | """Converts a list of RGBA values to a hex color. 66 | 67 | Args: 68 | color (ColorRGB): The RGB values. 69 | 70 | Returns: 71 | str: The hex color. 72 | """ 73 | return "#%02x%02x%02x" % tuple([int(c * 255) for c in color]) 74 | -------------------------------------------------------------------------------- /labelCloud/utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import shutil 4 | from enum import Enum 5 | from functools import lru_cache 6 | from typing import List 7 | 8 | # --------------------------------- FORMATTING -------------------------------- # 9 | 10 | 11 | class Format(Enum): 12 | RESET = "\033[0;0m" 13 | RED = "\033[1;31m" 14 | GREEN = "\033[0;32m" 15 | YELLOW = "\33[93m" # "\033[33m" 16 | BLUE = "\033[1;34m" 17 | CYAN = "\033[1;36m" 18 | BOLD = "\033[;1m" 19 | REVERSE = "\033[;7m" 20 | HEADER = "\033[95m" 21 | OKBLUE = "\033[94m" 22 | OKCYAN = "\033[96m" 23 | OKGREEN = "\033[92m" 24 | WARNING = "\033[93m" 25 | FAIL = "\033[91m" 26 | ENDC = "\033[0m" 27 | UNDERLINE = "\033[4m" 28 | 29 | GREY = "\33[90m" 30 | 31 | 32 | def format(text: str, color: Format) -> str: 33 | return f"{color.value}{text}{Format.ENDC.value}" 34 | 35 | 36 | red = lambda text: format(text, Format.RED) 37 | green = lambda text: format(text, Format.OKGREEN) 38 | yellow = lambda text: format(text, Format.YELLOW) 39 | blue = lambda text: format(text, Format.BLUE) 40 | bold = lambda text: format(text, Format.BOLD) 41 | 42 | 43 | class ColorFormatter(logging.Formatter): 44 | MSG_FORMAT = "%(message)s" 45 | 46 | FORMATS = { 47 | logging.DEBUG: Format.GREY.value + MSG_FORMAT + Format.ENDC.value, 48 | logging.INFO: MSG_FORMAT, 49 | logging.WARNING: Format.YELLOW.value + MSG_FORMAT + Format.ENDC.value, 50 | logging.ERROR: Format.RED.value + MSG_FORMAT + Format.ENDC.value, 51 | logging.CRITICAL: Format.RED.value 52 | + Format.BOLD.value 53 | + MSG_FORMAT 54 | + Format.ENDC.value, 55 | } 56 | 57 | def format(self, record) -> str: 58 | log_fmt = self.FORMATS.get(record.levelno) 59 | formatter = logging.Formatter(log_fmt) 60 | return formatter.format(record) 61 | 62 | 63 | class UncolorFormatter(logging.Formatter): 64 | MSG_FORMAT = "%(asctime)s - %(levelname)-8s: %(message)s" 65 | PATTERN = re.compile("|".join(re.escape(c.value) for c in Format)) 66 | 67 | def format(self, record) -> str: 68 | record.msg = self.PATTERN.sub("", record.msg) 69 | formatter = logging.Formatter(self.MSG_FORMAT) 70 | return formatter.format(record) 71 | 72 | 73 | # ---------------------------------- CONFIG ---------------------------------- # 74 | 75 | # Create handlers 76 | c_handler = logging.StreamHandler() 77 | f_handler = logging.FileHandler(".labelCloud.log", mode="w") 78 | c_handler.setLevel(logging.INFO) # TODO: Automatic coloring 79 | f_handler.setLevel(logging.DEBUG) # TODO: Filter colors 80 | 81 | # Create formatters and add it to handlers 82 | c_handler.setFormatter(ColorFormatter()) 83 | f_handler.setFormatter(UncolorFormatter()) 84 | 85 | 86 | logging.basicConfig( 87 | level=logging.INFO, 88 | format="%(message)s", 89 | handlers=[c_handler, f_handler], 90 | ) 91 | 92 | 93 | # ---------------------------------- HELPERS --------------------------------- # 94 | 95 | TERM_SIZE = shutil.get_terminal_size(fallback=(120, 50)) 96 | 97 | 98 | def start_section(text: str) -> None: 99 | left_pad = (TERM_SIZE.columns - len(text)) // 2 - 1 100 | right_pad = TERM_SIZE.columns - len(text) - left_pad - 2 101 | logging.info(f"{'=' * left_pad} {text} {'=' * right_pad}") 102 | pass 103 | 104 | 105 | def end_section() -> None: 106 | logging.info("=" * TERM_SIZE.columns) 107 | pass 108 | 109 | 110 | ROWS: List[List[str]] = [] 111 | 112 | 113 | def print_column(column_values: List[str], last: bool = False) -> None: 114 | global ROWS 115 | ROWS.append(column_values) 116 | 117 | if last: 118 | col_width = max(len(str(word)) for row in ROWS for word in row) + 2 # padding 119 | for row in ROWS: 120 | logging.info("".join(str(word).ljust(col_width) for word in row)) 121 | ROWS = [] 122 | 123 | 124 | @lru_cache(maxsize=None) 125 | def warn_once(*args, **kwargs): 126 | logging.warning(*args, **kwargs) 127 | -------------------------------------------------------------------------------- /labelCloud/utils/math3d.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import math 3 | from typing import List, Optional, Tuple, Union 4 | 5 | import numpy as np 6 | import numpy.typing as npt 7 | 8 | from ..definitions import Point3D, Rotations3D 9 | 10 | 11 | # LENGTH 12 | def vector_length(point: Union[Point3D, npt.ArrayLike]) -> float: 13 | return float(np.linalg.norm(point)) 14 | 15 | 16 | # TRANSLATION 17 | def translate_point( 18 | point: Union[Point3D, npt.NDArray], 19 | dx: float, 20 | dy: float, 21 | dz: float, 22 | backwards: bool = False, 23 | ) -> Point3D: 24 | if backwards: 25 | dx, dy, dz = (-dx, -dy, -dz) 26 | return tuple(np.add(np.array(point), np.array([dx, dy, dz]))) # type: ignore 27 | 28 | 29 | # ROTATION 30 | 31 | 32 | def degrees_to_radians(degrees: float) -> float: 33 | return degrees * (np.pi / 180) 34 | 35 | 36 | def radians_to_degrees(radians: float) -> float: 37 | return radians * (180 / np.pi) 38 | 39 | 40 | def rotate_around_x(point: Point3D, angle: float, degrees: bool = False) -> npt.NDArray: 41 | if degrees: 42 | angle = degrees_to_radians(angle) 43 | r_matrix = np.array( 44 | [ 45 | [1, 0, 0], 46 | [0, np.cos(angle), -np.sin(angle)], 47 | [0, np.sin(angle), np.cos(angle)], 48 | ] 49 | ) 50 | return r_matrix.dot(point) 51 | 52 | 53 | def rotate_around_y( 54 | point: npt.NDArray, angle: float, degrees: bool = False 55 | ) -> npt.NDArray: 56 | if degrees: 57 | angle = degrees_to_radians(angle) 58 | r_matrix = np.array( 59 | [ 60 | [math.cos(angle), 0, math.sin(angle)], 61 | [0, 1, 0], 62 | [-math.sin(angle), 0, math.cos(angle)], 63 | ] 64 | ) 65 | return r_matrix.dot(point) 66 | 67 | 68 | def rotate_around_z( 69 | point: npt.NDArray, angle: float, degrees: bool = False 70 | ) -> npt.NDArray: 71 | if degrees: 72 | angle = degrees_to_radians(angle) 73 | r_matrix = np.array( 74 | [ 75 | [np.cos(angle), -np.sin(angle), 0], 76 | [np.sin(angle), np.cos(angle), 0], 77 | [0, 0, 1], 78 | ] 79 | ) 80 | return r_matrix.dot(point) 81 | 82 | 83 | def rotate_around_zyx( 84 | point: Point3D, 85 | x_angle: float, 86 | y_angle: float, 87 | z_angle: float, 88 | degrees: bool = False, 89 | ) -> npt.NDArray: # TODO: Return Point3D? 90 | return rotate_around_z( 91 | rotate_around_y(rotate_around_x(point, x_angle, degrees), y_angle, degrees), 92 | z_angle, 93 | degrees, 94 | ) 95 | 96 | 97 | def rotate_bbox_around_center( 98 | vertices: List[Point3D], center: Point3D, rotations: Rotations3D 99 | ) -> List[Point3D]: 100 | rotated_vertices = [] 101 | for vertex in vertices: 102 | centered_vertex = translate_point(vertex, *center, backwards=True) 103 | rotated_vertex = rotate_around_zyx(centered_vertex, *rotations, degrees=True) 104 | rotated_vertices.append(translate_point(rotated_vertex, *center)) 105 | return rotated_vertices 106 | 107 | 108 | # CONVERSION 109 | 110 | 111 | def vertices2rotations( 112 | vertices: List[Point3D], centroid: Point3D 113 | ) -> Tuple[float, float, float]: 114 | x_rotation, y_rotation, z_rotation = (0.0, 0.0, 0.0) 115 | 116 | vertices_trans = np.subtract( 117 | vertices, centroid 118 | ) # translate bbox to origin # TODO: Translation necessary? 119 | 120 | # Calculate z_rotation 121 | x_vec = vertices_trans[3] - vertices_trans[0] # length vector 122 | z_rotation = radians_to_degrees(np.arctan2(x_vec[1], x_vec[0])) % 360 123 | 124 | # Calculate y_rotation 125 | if vertices[3][2] != vertices[0][2]: 126 | logging.info("Bounding box is y-rotated!") 127 | x_vec_rot = rotate_around_z( 128 | x_vec, -z_rotation, degrees=True 129 | ) # apply z-rotation 130 | y_rotation = -radians_to_degrees(np.arctan2(x_vec_rot[2], x_vec_rot[0])) % 360 131 | 132 | # Calculate x_rotation 133 | if vertices[0][2] != vertices[1][2]: 134 | logging.info("Bounding box is x-rotated!") 135 | y_vec = np.subtract(vertices_trans[1], vertices_trans[0]) # width vector 136 | y_vec_rot = rotate_around_z( 137 | y_vec, -z_rotation, degrees=True 138 | ) # apply z- & y-rotation 139 | y_vec_rot = rotate_around_y(y_vec_rot, -y_rotation, degrees=True) 140 | x_rotation = radians_to_degrees(np.arctan2(y_vec_rot[2], y_vec_rot[1])) % 360 141 | logging.info("x-Rotation: %s" % x_rotation) 142 | 143 | logging.info( 144 | "Loaded bounding box has rotation (x, y, z): %s, %s, %s" 145 | % (x_rotation, y_rotation, z_rotation) 146 | ) 147 | return x_rotation, y_rotation, z_rotation 148 | 149 | 150 | # INTERSECTION 151 | 152 | 153 | def get_line_perpendicular( 154 | line_start: Point3D, line_end: Point3D, point: Point3D 155 | ) -> Tuple[Point3D, tuple]: 156 | """Get line perpendicular to point parallel to x-y-plane 157 | 158 | Returns: 159 | List[float]: direction vector, intersection point (x, y) 160 | """ 161 | # Calculate the line equation parameters 162 | m = (line_start[1] - line_end[1]) / (line_start[0] - line_end[0]) 163 | b = m * -line_end[0] + line_end[1] 164 | 165 | # Calculate line perpendicular parallel to x-y-plane 166 | intersection_x = (point[0] + m * (point[1] - b)) / (1 + m**2) 167 | intersection_y = (m * point[0] + m**2 * point[1] + b) / (1 + m**2) 168 | dir_vector = ( 169 | point[0] - intersection_x, 170 | point[1] - intersection_y, 171 | 0, 172 | ) # vector from line to point 173 | return dir_vector, (intersection_x, intersection_y) 174 | 175 | 176 | # Calculates intersection between vector (p0, p1) and plane (p_co, p_no) 177 | def get_line_plane_intersection( 178 | p0: Point3D, p1: Point3D, p_co: Point3D, p_no: Point3D, epsilon=1e-6 179 | ) -> Optional[np.ndarray]: 180 | """Calculate the intersection between a point and a plane. 181 | 182 | :param p0: Point on the line 183 | :param p1: Point on the line 184 | :param p_co: Point on the plane 185 | :param p_no: Normal to the plane 186 | :param epsilon: Threshold for parallelity 187 | :return: Intesection point or None (when the intersection can't be found). 188 | """ 189 | u = np.subtract(p1, p0) 190 | dot = np.dot(p_no, u) 191 | 192 | if abs(dot) > epsilon: 193 | # The factor of the point between p0 -> p1 (0 - 1) 194 | # if 'fac' is between (0 - 1) the point intersects with the segment. 195 | # Otherwise: 196 | # < 0.0: behind p0. 197 | # > 1.0: infront of p1. 198 | w = np.subtract(p0, p_co) 199 | fac = -np.dot(p_no, w) / dot 200 | u = np.array(u) * fac 201 | return np.add(p0, u) 202 | else: 203 | return None # The segment is parallel to plane. 204 | -------------------------------------------------------------------------------- /labelCloud/utils/oglhelper.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, List, Optional, Tuple, Union 2 | 3 | import numpy as np 4 | import numpy.typing as npt 5 | 6 | import OpenGL.GL as GL 7 | from OpenGL import GLU 8 | 9 | from . import math3d 10 | from ..definitions import BBOX_SIDES, Color4f, Point3D 11 | 12 | if TYPE_CHECKING: 13 | from ..model import BBox, PointCloud 14 | 15 | 16 | DEVICE_PIXEL_RATIO: Optional[float] = ( 17 | None # is set once and for every window resize (retina display fix) 18 | ) 19 | 20 | 21 | def draw_points( 22 | points: Union[List[Point3D], npt.NDArray], 23 | color: Color4f = (0, 1, 1, 1), 24 | point_size: int = 10, 25 | ) -> None: 26 | GL.glColor4d(*color) 27 | GL.glPointSize(point_size) 28 | GL.glBegin(GL.GL_POINTS) 29 | for point in points: 30 | GL.glVertex3d(*point) 31 | GL.glEnd() 32 | 33 | 34 | def draw_lines( 35 | points: List[Point3D], 36 | color: Color4f = (0, 1, 1, 1), 37 | line_width: int = 2, 38 | ) -> None: 39 | GL.glColor4d(*color) 40 | GL.glLineWidth(line_width) 41 | GL.glBegin(GL.GL_LINES) 42 | for point in points: 43 | GL.glVertex3d(*point) 44 | GL.glEnd() 45 | 46 | 47 | def draw_triangles(vertices: List[Point3D], color: Color4f = (0, 1, 1, 1)) -> None: 48 | GL.glColor4d(*color) 49 | GL.glBegin(GL.GL_TRIANGLES) 50 | for vertex in vertices: 51 | GL.glVertex3d(*vertex) 52 | GL.glEnd() 53 | 54 | 55 | def draw_rectangles( 56 | vertices: Union[List[Point3D], npt.NDArray], 57 | color: Color4f = (0, 1, 1, 1), 58 | line_width: int = 2, 59 | ) -> None: 60 | GL.glColor4d(*color) 61 | GL.glLineWidth(line_width) 62 | GL.glBegin(GL.GL_QUADS) 63 | for vertex in vertices: 64 | GL.glVertex3d(*vertex) 65 | GL.glEnd() 66 | 67 | 68 | def draw_cuboid( 69 | vertices: Union[List[Point3D], npt.NDArray], 70 | color: Color4f = (1, 1, 0, 0.5), 71 | draw_vertices: bool = False, 72 | vertex_color: Color4f = (0, 1, 1, 1), 73 | ) -> None: 74 | # flatten side vertices 75 | side_vertices = [ 76 | index for side_indices in BBOX_SIDES.values() for index in side_indices 77 | ] 78 | rectangle_vertices = np.array(vertices)[side_vertices] 79 | draw_rectangles(rectangle_vertices, color=color) 80 | if draw_vertices: 81 | draw_points(vertices, color=vertex_color) 82 | 83 | 84 | def draw_crosshair( 85 | cx: float, cy: float, cz: float, color: Color4f = (0, 1, 0, 1) 86 | ) -> None: 87 | GL.glBegin(GL.GL_LINES) 88 | GL.glColor4d(*color) 89 | GL.glVertex3d(cx + 0.1, cy, cz) # x-line 90 | GL.glVertex3d(cx - 0.1, cy, cz) 91 | GL.glVertex3d(cx, cy + 0.1, cz) # y-line 92 | GL.glVertex3d(cx, cy - 0.1, cz) 93 | GL.glVertex3d(cx, cy, cz + 0.1) # z-line 94 | GL.glVertex3d(cx, cy, cz - 0.1) 95 | GL.glEnd() 96 | 97 | 98 | def draw_xy_plane(pcd: "PointCloud") -> None: 99 | mins, maxs = pcd.get_mins_maxs() 100 | x_min, y_min = np.floor(mins[:2]).astype(int) 101 | x_max, y_max = np.ceil(maxs[:2]).astype(int) 102 | GL.glColor3d(0.5, 0.5, 0.5) 103 | GL.glBegin(GL.GL_LINES) 104 | for y in range(y_min, y_max + 1): # x-lines 105 | GL.glVertex3d(x_min, y, 0) 106 | GL.glVertex3d(x_max, y, 0) 107 | 108 | for x in range(x_min, x_max + 1): # y-lines 109 | GL.glVertex3d(x, y_min, 0) 110 | GL.glVertex3d(x, y_max, 0) 111 | GL.glEnd() 112 | 113 | 114 | # RAY PICKING 115 | 116 | 117 | def get_pick_ray(x: float, y: float, modelview, projection) -> Tuple[Point3D, Point3D]: 118 | """ 119 | :param x: rightward screen coordinate 120 | :param y: downward screen coordinate 121 | :param modelview: modelview matrix 122 | :param projection: projection matrix 123 | :return: two points of the pick ray from the closest and furthest frustum 124 | """ 125 | x *= DEVICE_PIXEL_RATIO # type: ignore 126 | y *= DEVICE_PIXEL_RATIO # type: ignore 127 | 128 | viewport = GL.glGetIntegerv(GL.GL_VIEWPORT) 129 | real_y = viewport[3] - y # adjust for down-facing y positions 130 | 131 | # Unproject screen coords into world coordsdd 132 | p_front = GLU.gluUnProject(x, real_y, 0, modelview, projection, viewport) 133 | p_back = GLU.gluUnProject(x, real_y, 1, modelview, projection, viewport) 134 | return p_front, p_back 135 | 136 | 137 | def get_intersected_bboxes( 138 | x: float, y: float, bboxes: List["BBox"], modelview, projection 139 | ) -> Union[int, None]: 140 | """Checks if the picking ray intersects any bounding box from bboxes. 141 | 142 | :param x: x screen coordinate 143 | :param y: y screen coordinate 144 | :param bboxes: list of bounding boxes 145 | :param modelview: modelview matrix 146 | :param projection: projection matrix 147 | :return: Id of the intersected bounding box or None if no bounding box is intersected 148 | """ 149 | intersected_bboxes = {} # bbox_index: bbox 150 | for index, bbox in enumerate(bboxes): 151 | intersection_point, _ = get_intersected_sides(x, y, bbox, modelview, projection) 152 | if intersection_point is not None: 153 | intersected_bboxes[index] = intersection_point[2] 154 | 155 | p0, p1 = get_pick_ray(x, y, modelview, projection) # Calculate picking ray 156 | if intersected_bboxes and ( 157 | p0[2] >= p1[2] 158 | ): # Calculate which intersected bbox is closer to screen 159 | return max(intersected_bboxes, key=intersected_bboxes.get) # type: ignore 160 | elif intersected_bboxes: 161 | return min(intersected_bboxes, key=intersected_bboxes.get) # type: ignore 162 | else: 163 | return None 164 | 165 | 166 | def get_intersected_sides( 167 | x: float, y: float, bbox: "BBox", modelview, projection 168 | ) -> Union[Tuple[List[int], str], Tuple[None, None]]: 169 | """Checks if and with which side of the given bounding box the picking ray intersects. 170 | 171 | :param x: x screen coordinate 172 | :param y: y screen coordinate: 173 | :param bbox: bounding box to check for intersection 174 | :param modelview: modelview matrix 175 | :param projection: projection matrix 176 | :return: intersection point, name of intersected side [top, bottom, right, back, left, front] 177 | """ 178 | p0, p1 = get_pick_ray(x, y, modelview, projection) # Calculate picking ray 179 | vertices = bbox.get_vertices() 180 | 181 | intersections: List[Tuple[list, str]] = ( 182 | list() 183 | ) # (intersection_point, bounding box side) 184 | for side, indices in BBOX_SIDES.items(): 185 | # Calculate plane equation 186 | pl1 = vertices[indices[0]] # point in plane 187 | v1 = np.subtract(vertices[indices[1]], pl1) 188 | v2 = np.subtract(vertices[indices[3]], pl1) 189 | n = np.cross(v1, v2) # plane normal 190 | 191 | intersection = math3d.get_line_plane_intersection(p0, p1, pl1, tuple(n)) # type: ignore 192 | 193 | # Check if intersection is inside rectangle 194 | if intersection is not None: 195 | v = np.subtract(intersection, pl1) 196 | width = np.linalg.norm(v1) 197 | height = np.linalg.norm(v2) 198 | proj1 = np.dot(v, v1) / width 199 | proj2 = np.dot(v, v2) / height 200 | 201 | if (width > proj1 > 0) and (height > proj2 > 0): 202 | intersections.append((intersection.tolist(), side)) 203 | 204 | # Calculate which intersected side is closer 205 | intersections = sorted( 206 | intersections, key=lambda element: element[0][2] 207 | ) # sort by z-value 208 | if intersections and (p0[2] >= p1[2]): 209 | return intersections[-1] # intersection point: list, side: str 210 | elif intersections: 211 | return intersections[0] 212 | else: 213 | return None, None 214 | -------------------------------------------------------------------------------- /labelCloud/utils/singleton.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | from typing import Dict, Type 3 | 4 | 5 | class SingletonABCMeta(ABCMeta): 6 | _instances: Dict[Type, object] = {} 7 | 8 | def __call__(cls, *args, **kwargs): 9 | if cls not in cls._instances: 10 | cls._instances[cls] = super(SingletonABCMeta, cls).__call__(*args, **kwargs) 11 | return cls._instances[cls] 12 | -------------------------------------------------------------------------------- /labelCloud/view/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/labelCloud/view/__init__.py -------------------------------------------------------------------------------- /labelCloud/view/settings_dialog.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | import logging 4 | from pathlib import Path 5 | 6 | import pkg_resources 7 | from PyQt5 import uic 8 | from PyQt5.QtWidgets import QDialog 9 | 10 | from ..control.config_manager import config, config_manager 11 | from ..control.label_manager import LabelManager 12 | from ..io.labels.config import LabelConfig 13 | 14 | 15 | class SettingsDialog(QDialog): 16 | def __init__(self, parent=None) -> None: 17 | super().__init__(parent) 18 | self.parent_gui = parent 19 | uic.loadUi( 20 | pkg_resources.resource_filename( 21 | "labelCloud.resources.interfaces", "settings_interface.ui" 22 | ), 23 | self, 24 | ) 25 | self.fill_with_current_settings() 26 | 27 | self.buttonBox.accepted.connect(self.save) 28 | self.buttonBox.rejected.connect(self.chancel) 29 | self.reset_button.clicked.connect(self.reset) 30 | 31 | def fill_with_current_settings(self) -> None: 32 | # File 33 | self.lineEdit_pointcloudfolder.setText(config.get("FILE", "pointcloud_folder")) 34 | self.lineEdit_labelfolder.setText(config.get("FILE", "label_folder")) 35 | 36 | # Pointcloud 37 | self.doubleSpinBox_pointsize.setValue( 38 | config.getfloat("POINTCLOUD", "POINT_SIZE") 39 | ) 40 | self.lineEdit_pointcolor.setText(config["POINTCLOUD"]["colorless_color"]) 41 | self.checkBox_colorizecolorless.setChecked( 42 | config.getboolean("POINTCLOUD", "colorless_colorize") 43 | ) 44 | self.doubleSpinBox_standardtranslation.setValue( 45 | config.getfloat("POINTCLOUD", "std_translation") 46 | ) 47 | self.doubleSpinBox_standardzoom.setValue( 48 | config.getfloat("POINTCLOUD", "std_zoom") 49 | ) 50 | 51 | # Label 52 | self.comboBox_labelformat.addItems(LabelConfig().type.get_available_formats()) 53 | self.comboBox_labelformat.setCurrentText(LabelConfig().format) 54 | 55 | self.comboBox_defaultobjectclass.addItems(LabelConfig().get_classes().keys()) 56 | self.comboBox_defaultobjectclass.setCurrentText( 57 | LabelConfig().get_default_class_name() 58 | ) 59 | 60 | self.spinBox_exportprecision.setValue( 61 | config.getint("LABEL", "export_precision") 62 | ) 63 | self.doubleSpinBox_minbboxdimensions.setValue( 64 | config.getfloat("LABEL", "min_boundingbox_dimension") 65 | ) 66 | self.doubleSpinBox_stdbboxlength.setValue( 67 | config.getfloat("LABEL", "std_boundingbox_length") 68 | ) 69 | self.doubleSpinBox_stdbboxwidth.setValue( 70 | config.getfloat("LABEL", "std_boundingbox_width") 71 | ) 72 | self.doubleSpinBox_stdbboxheight.setValue( 73 | config.getfloat("LABEL", "std_boundingbox_height") 74 | ) 75 | self.doubleSpinBox_stdbboxtranslation.setValue( 76 | config.getfloat("LABEL", "std_translation") 77 | ) 78 | self.doubleSpinBox_stdbboxrotation.setValue( 79 | config.getfloat("LABEL", "std_rotation") 80 | ) 81 | self.doubleSpinBox_stdbboxscaling.setValue( 82 | config.getfloat("LABEL", "std_scaling") 83 | ) 84 | self.checkBox_propagatelabels.setChecked( 85 | config.getboolean("LABEL", "propagate_labels") 86 | ) 87 | 88 | # User Interface 89 | self.checkBox_zrotationonly.setChecked( 90 | config.getboolean("USER_INTERFACE", "z_rotation_only") 91 | ) 92 | self.checkBox_showfloor.setChecked( 93 | config.getboolean("USER_INTERFACE", "show_floor") 94 | ) 95 | self.checkBox_showbboxorientation.setChecked( 96 | config.getboolean("USER_INTERFACE", "show_orientation") 97 | ) 98 | self.checkBox_keepperspective.setChecked( 99 | config.getboolean("USER_INTERFACE", "keep_perspective") 100 | ) 101 | self.spinBox_viewingprecision.setValue( 102 | config.getint("USER_INTERFACE", "viewing_precision") 103 | ) 104 | self.lineEdit_backgroundcolor.setText( 105 | config.get("USER_INTERFACE", "background_color") 106 | ) 107 | self.checkBox_show2dimage.setChecked( 108 | config.getboolean("USER_INTERFACE", "show_2d_image") 109 | ) 110 | 111 | def save(self) -> None: 112 | # File 113 | config["FILE"]["pointcloud_folder"] = self.lineEdit_pointcloudfolder.text() 114 | config["FILE"]["label_folder"] = self.lineEdit_labelfolder.text() 115 | 116 | # Pointcloud 117 | config["POINTCLOUD"]["point_size"] = str(self.doubleSpinBox_pointsize.value()) 118 | config["POINTCLOUD"]["colorless_color"] = self.lineEdit_pointcolor.text() 119 | config["POINTCLOUD"]["colorless_colorize"] = str( 120 | self.checkBox_colorizecolorless.isChecked() 121 | ) 122 | config["POINTCLOUD"]["std_translation"] = str( 123 | self.doubleSpinBox_standardtranslation.value() 124 | ) 125 | config["POINTCLOUD"]["std_zoom"] = str(self.doubleSpinBox_standardzoom.value()) 126 | 127 | # Label 128 | LabelConfig().set_label_format(self.comboBox_labelformat.currentText()) 129 | LabelConfig().set_default_class(self.comboBox_defaultobjectclass.currentText()) 130 | config["LABEL"]["export_precision"] = str(self.spinBox_exportprecision.value()) 131 | config["LABEL"]["min_boundingbox_dimension"] = str( 132 | self.doubleSpinBox_minbboxdimensions.value() 133 | ) 134 | config["LABEL"]["std_boundingbox_length"] = str( 135 | self.doubleSpinBox_stdbboxlength.value() 136 | ) 137 | config["LABEL"]["std_boundingbox_width"] = str( 138 | self.doubleSpinBox_stdbboxwidth.value() 139 | ) 140 | config["LABEL"]["std_boundingbox_height"] = str( 141 | self.doubleSpinBox_stdbboxheight.value() 142 | ) 143 | config["LABEL"]["std_translation"] = str( 144 | self.doubleSpinBox_stdbboxtranslation.value() 145 | ) 146 | config["LABEL"]["std_rotation"] = str( 147 | self.doubleSpinBox_stdbboxrotation.value() 148 | ) 149 | config["LABEL"]["std_scaling"] = str(self.doubleSpinBox_stdbboxscaling.value()) 150 | config["LABEL"]["propagate_labels"] = str( 151 | self.checkBox_propagatelabels.isChecked() 152 | ) 153 | 154 | # User Interface 155 | config["USER_INTERFACE"]["z_rotation_only"] = str( 156 | self.checkBox_zrotationonly.isChecked() 157 | ) 158 | config["USER_INTERFACE"]["show_floor"] = str( 159 | self.checkBox_showfloor.isChecked() 160 | ) 161 | config["USER_INTERFACE"]["show_orientation"] = str( 162 | self.checkBox_showbboxorientation.isChecked() 163 | ) 164 | config["USER_INTERFACE"]["show_2d_image"] = str( 165 | self.checkBox_show2dimage.isChecked() 166 | ) 167 | config["USER_INTERFACE"]["keep_perspective"] = str( 168 | self.checkBox_keepperspective.isChecked() 169 | ) 170 | config["USER_INTERFACE"][ 171 | "background_color" 172 | ] = self.lineEdit_backgroundcolor.text() 173 | config["USER_INTERFACE"]["viewing_precision"] = str( 174 | self.spinBox_viewingprecision.value() 175 | ) 176 | 177 | config_manager.write_into_file() 178 | self.parent_gui.set_checkbox_states() 179 | self.parent_gui.controller.pcd_manager.label_manager = LabelManager( 180 | strategy=LabelConfig().format, 181 | path_to_label_folder=Path(config["FILE"]["label_folder"]), 182 | ) 183 | logging.info("Saved and activated new configuration!") 184 | 185 | def reset(self) -> None: 186 | config_manager.reset_to_default() 187 | self.fill_with_current_settings() 188 | 189 | def chancel(self) -> None: 190 | logging.info("Settings dialog was chanceled!") 191 | -------------------------------------------------------------------------------- /labelCloud/view/startup/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/labelCloud/view/startup/__init__.py -------------------------------------------------------------------------------- /labelCloud/view/startup/class_list.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import List, Optional 3 | 4 | import pkg_resources 5 | from PyQt5.QtCore import pyqtSignal 6 | from PyQt5.QtGui import QIcon, QPixmap 7 | from PyQt5.QtWidgets import ( 8 | QButtonGroup, 9 | QHBoxLayout, 10 | QLineEdit, 11 | QPushButton, 12 | QSpinBox, 13 | QVBoxLayout, 14 | QWidget, 15 | ) 16 | 17 | from ...io.labels.config import ClassConfig, LabelConfig 18 | from ...utils.color import get_distinct_colors, hex_to_rgb, rgb_to_hex 19 | from .color_button import ColorButton 20 | 21 | 22 | class ClassList(QWidget): 23 | changed = pyqtSignal() 24 | 25 | def __init__(self, *args, **kwargs) -> None: 26 | super().__init__(*args, **kwargs) 27 | 28 | self.colors: List[str] = [] 29 | 30 | self.class_labels = QVBoxLayout() 31 | self.class_labels.addStretch() 32 | 33 | self.setLayout(self.class_labels) 34 | self.delete_buttons = QButtonGroup() 35 | 36 | self.delete_buttons.buttonClicked.connect(self._delete_label) 37 | 38 | for class_label in LabelConfig().classes: 39 | self.add_label( 40 | class_label.id, class_label.name, rgb_to_hex(class_label.color) 41 | ) 42 | 43 | @property 44 | def nb_of_labels(self) -> int: 45 | return len(self.class_labels.children()) 46 | 47 | @property 48 | def next_label_id(self) -> int: 49 | max_class_id = 0 50 | for i in range(self.nb_of_labels): 51 | label_id = int(self.class_labels.itemAt(i).itemAt(0).widget().text()) # type: ignore 52 | max_class_id = max(max_class_id, label_id) 53 | return max_class_id + 1 54 | 55 | def _get_next_distinct_color(self) -> str: 56 | if not self.colors: 57 | self.colors = get_distinct_colors(25) 58 | random.shuffle(self.colors) 59 | return self.colors.pop() 60 | 61 | def add_label( 62 | self, 63 | id: Optional[int] = None, 64 | name: Optional[str] = None, 65 | hex_color: Optional[str] = None, 66 | ) -> None: 67 | if id is None: 68 | id = self.next_label_id 69 | 70 | if name is None: 71 | name = f"label_{id}" 72 | 73 | if hex_color is None: 74 | hex_color = self._get_next_distinct_color() 75 | 76 | row_label = QHBoxLayout() 77 | row_label.setSpacing(15) 78 | 79 | label_id = QSpinBox() 80 | label_id.setMinimum(0) 81 | label_id.setMaximum(255) 82 | label_id.setValue(id) 83 | row_label.addWidget(label_id) 84 | 85 | label_name = QLineEdit(name) 86 | row_label.addWidget(label_name, stretch=2) 87 | 88 | label_name.editingFinished.connect(self.changed.emit) 89 | 90 | label_color = ColorButton(color=hex_color) 91 | row_label.addWidget(label_color) 92 | 93 | label_delete = QPushButton( 94 | icon=QIcon( 95 | QPixmap( 96 | pkg_resources.resource_filename( 97 | "labelCloud.resources.icons", "delete-outline.svg" 98 | ) 99 | ) 100 | ), 101 | text="", 102 | ) 103 | self.delete_buttons.addButton(label_delete) 104 | row_label.addWidget(label_delete) 105 | 106 | self.class_labels.insertLayout(self.nb_of_labels, row_label) 107 | 108 | self.changed.emit() 109 | 110 | def _delete_label(self, delete_button: QPushButton) -> None: 111 | row_label: QHBoxLayout 112 | for row_index, row_label in enumerate(self.class_labels.children()): # type: ignore 113 | if row_label.itemAt(3).widget() == delete_button: 114 | class_name = row_label.itemAt(1).widget().text() # type: ignore 115 | 116 | for _ in range(row_label.count()): 117 | row_label.removeWidget(row_label.itemAt(0).widget()) 118 | break 119 | 120 | self.class_labels.removeItem(self.class_labels.itemAt(row_index)) # type: ignore 121 | 122 | self.changed.emit() 123 | 124 | def _get_class_config(self, row_id: int) -> ClassConfig: 125 | row: QHBoxLayout = self.class_labels.itemAt(row_id) # type: ignore 126 | 127 | class_id = int(row.itemAt(0).widget().text()) # type: ignore 128 | class_name = row.itemAt(1).widget().text() # type: ignore 129 | class_color = hex_to_rgb(row.itemAt(2).widget().color()) # type: ignore 130 | 131 | return ClassConfig(id=class_id, name=class_name, color=class_color) 132 | 133 | def get_class_configs(self) -> List[ClassConfig]: 134 | classes = [] 135 | 136 | for i in range(self.nb_of_labels): 137 | classes.append(self._get_class_config(i)) 138 | 139 | return classes 140 | -------------------------------------------------------------------------------- /labelCloud/view/startup/color_button.py: -------------------------------------------------------------------------------- 1 | from PyQt5 import QtGui, QtWidgets 2 | from PyQt5.QtCore import Qt, pyqtSignal 3 | 4 | 5 | class ColorButton(QtWidgets.QPushButton): 6 | """ 7 | Custom Qt Widget to show a chosen color. 8 | 9 | Left-clicking the button shows the color-chooser, while 10 | right-clicking resets the color to None (no-color). 11 | 12 | Source: https://www.pythonguis.com/widgets/qcolorbutton-a-color-selector-tool-for-pyqt/ 13 | """ 14 | 15 | colorChanged = pyqtSignal(object) 16 | 17 | def __init__(self, *args, color="#FF0000", **kwargs): 18 | super(ColorButton, self).__init__(*args, **kwargs) 19 | 20 | self._color = None 21 | self._default = color 22 | self.pressed.connect(self.onColorPicker) 23 | 24 | # Set the initial/default state. 25 | self.setColor(self._default) 26 | 27 | def setColor(self, color): 28 | if color != self._color: 29 | self._color = color 30 | self.colorChanged.emit(color) 31 | 32 | if self._color: 33 | self.setStyleSheet("background-color: %s;" % self._color) 34 | else: 35 | self.setStyleSheet("") 36 | 37 | def color(self): 38 | return self._color 39 | 40 | def onColorPicker(self): 41 | """ 42 | Show color-picker dialog to select color. 43 | 44 | Qt will use the native dialog by default. 45 | 46 | """ 47 | dlg = QtWidgets.QColorDialog(self) 48 | if self._color: 49 | dlg.setCurrentColor(QtGui.QColor(self._color)) 50 | 51 | if dlg.exec_(): 52 | self.setColor(dlg.currentColor().name()) 53 | 54 | def mousePressEvent(self, e): 55 | if e.button() == Qt.RightButton: 56 | self.setColor(self._default) 57 | 58 | return super(ColorButton, self).mousePressEvent(e) 59 | -------------------------------------------------------------------------------- /labelCloud/view/startup/dialog.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | import pkg_resources 4 | from PyQt5.QtCore import Qt 5 | from PyQt5.QtGui import QIcon 6 | from PyQt5.QtWidgets import ( 7 | QComboBox, 8 | QDesktopWidget, 9 | QDialog, 10 | QDialogButtonBox, 11 | QHBoxLayout, 12 | QLabel, 13 | QMessageBox, 14 | QPushButton, 15 | QScrollArea, 16 | QSizePolicy, 17 | QVBoxLayout, 18 | ) 19 | 20 | from ...io.labels.config import LabelConfig 21 | from ...io.labels.exceptions import ( 22 | DefaultIdMismatchException, 23 | LabelClassNameEmpty, 24 | LabelIdsNotUniqueException, 25 | ZeroLabelException, 26 | ) 27 | from .class_list import ClassList 28 | from .labeling_mode import SelectLabelingMode 29 | 30 | 31 | class StartupDialog(QDialog): 32 | def __init__(self, parent=None) -> None: 33 | super().__init__(parent) 34 | self.parent_gui = parent 35 | 36 | self.setWindowTitle("Welcome to labelCloud") 37 | screen_size = QDesktopWidget().availableGeometry(self).size() 38 | self.resize(screen_size * 0.5) 39 | self.setWindowIcon( 40 | QIcon( 41 | pkg_resources.resource_filename( 42 | "labelCloud.resources.icons", "labelCloud.ico" 43 | ) 44 | ) 45 | ) 46 | self.setContentsMargins(50, 10, 50, 10) 47 | 48 | main_layout = QVBoxLayout() 49 | main_layout.setSpacing(15) 50 | main_layout.setAlignment(Qt.AlignTop) 51 | self.setLayout(main_layout) 52 | 53 | # 1. Row: Selection of labeling mode via checkable buttons 54 | self.button_semantic_segmentation: QPushButton 55 | self.add_labeling_mode_row(main_layout) 56 | 57 | # 2. Row: Definition of class labels 58 | self.add_class_definition_rows(main_layout) 59 | 60 | # 3. Row: Select label export format 61 | self.add_default_and_export_format(main_layout) 62 | 63 | # 4. Row: Buttons to save or cancel 64 | self.buttonBox = QDialogButtonBox(QDialogButtonBox.Save) 65 | self.buttonBox.accepted.connect(self.save) 66 | self.buttonBox.rejected.connect(self.reject) 67 | 68 | main_layout.addWidget(self.buttonBox) 69 | 70 | # ---------------------------------------------------------------------------- # 71 | # SETUP # 72 | # ---------------------------------------------------------------------------- # 73 | 74 | def add_labeling_mode_row(self, parent_layout: QVBoxLayout) -> None: 75 | """ 76 | Add a row to select the labeling mode with two exclusive buttons. 77 | 78 | - the selected mode influences the available label export formats 79 | """ 80 | parent_layout.addWidget(QLabel("Select labeling mode:")) 81 | 82 | self.select_labeling_mode = SelectLabelingMode() 83 | self.select_labeling_mode.changed.connect(self._update_label_formats) 84 | parent_layout.addWidget(self.select_labeling_mode) 85 | 86 | def _update_label_formats(self) -> None: 87 | self.label_export_format.clear() 88 | self.label_export_format.addItems( 89 | self.select_labeling_mode.available_label_formats 90 | ) 91 | 92 | def add_default_and_export_format(self, parent_layout: QVBoxLayout) -> None: 93 | """ 94 | Add a row to select the default class and the label export format. 95 | """ 96 | row = QHBoxLayout() 97 | 98 | row.addWidget(QLabel("Default class:")) 99 | 100 | self.default_label = QComboBox() 101 | self.default_label.addItems( 102 | [class_label.name for class_label in LabelConfig().classes] 103 | ) 104 | self.default_label.setCurrentText(LabelConfig().get_default_class_name()) 105 | row.addWidget(self.default_label, 2) 106 | 107 | row.addSpacing(100) 108 | 109 | row.addWidget(QLabel("Label export format:")) 110 | 111 | self.label_export_format = QComboBox() 112 | self._update_label_formats() 113 | self.label_export_format.setCurrentText(LabelConfig().format) 114 | row.addWidget(self.label_export_format, 2) 115 | 116 | parent_layout.addLayout(row) 117 | 118 | def _on_class_list_changed(self): 119 | old_index = self.default_label.currentIndex() 120 | old_text = self.default_label.currentText() 121 | old_count = self.default_label.count() 122 | 123 | self.default_label.clear() 124 | self.default_label.addItems( 125 | [class_label.name for class_label in self.label_list.get_class_configs()] 126 | ) 127 | 128 | if old_count == self.default_label.count(): # only renaming 129 | self.default_label.setCurrentIndex(old_index) 130 | else: 131 | self.default_label.setCurrentText(old_text) 132 | 133 | def add_class_definition_rows(self, parent_layout: QVBoxLayout) -> None: 134 | scroll_area = QScrollArea() 135 | self.label_list = ClassList(scroll_area) 136 | 137 | self.label_list.changed.connect(self._on_class_list_changed) 138 | 139 | parent_layout.addWidget(QLabel("Change class labels:")) 140 | 141 | scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 142 | scroll_area.setWidgetResizable(True) 143 | scroll_area.setWidget(self.label_list) 144 | scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) 145 | 146 | parent_layout.addWidget(scroll_area) 147 | 148 | button_add_label = QPushButton(text="Add new label") 149 | button_add_label.clicked.connect(lambda: self.label_list.add_label()) 150 | parent_layout.addWidget(button_add_label) 151 | 152 | # ---------------------------------------------------------------------------- # 153 | # LOGIC # 154 | # ---------------------------------------------------------------------------- # 155 | 156 | def _populate_label_config(self) -> None: 157 | LabelConfig().type = self.select_labeling_mode.selected_labeling_mode 158 | 159 | LabelConfig().classes = self.label_list.get_class_configs() 160 | 161 | LabelConfig().set_default_class(self.default_label.currentText()) 162 | LabelConfig().set_label_format(self.label_export_format.currentText()) 163 | 164 | def _save_class_labels(self) -> None: 165 | LabelConfig().validate() 166 | LabelConfig().save_config() 167 | 168 | def save(self): 169 | self._populate_label_config() 170 | 171 | title = "Something went wrong" 172 | text = "" 173 | informative_text = "" 174 | icon = QMessageBox.Critical 175 | buttons = QMessageBox.Cancel 176 | msg = QMessageBox() 177 | 178 | try: 179 | self._save_class_labels() 180 | self.accept() 181 | return 182 | 183 | except DefaultIdMismatchException as e: 184 | text = e.__class__.__name__ 185 | informative_text = ( 186 | str(e) 187 | + f" Do you want to overwrite the default to the first label `{LabelConfig().classes[0].id}`?" 188 | ) 189 | icon = QMessageBox.Question 190 | buttons |= QMessageBox.Ok 191 | msg.accepted.connect(LabelConfig().set_first_as_default) 192 | 193 | except ( 194 | ZeroLabelException, 195 | LabelIdsNotUniqueException, 196 | LabelClassNameEmpty, 197 | ) as e: 198 | text = e.__class__.__name__ 199 | informative_text = str(e) 200 | 201 | except Exception as e: 202 | text = e.__class__.__name__ 203 | informative_text = traceback.format_exc() 204 | 205 | msg.setWindowTitle(title) 206 | msg.setText(text) 207 | msg.setInformativeText(informative_text) 208 | msg.setIcon(icon) 209 | msg.setStandardButtons(buttons) 210 | msg.setDefaultButton(QMessageBox.Cancel) 211 | 212 | msg.exec_() 213 | -------------------------------------------------------------------------------- /labelCloud/view/startup/labeling_mode.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from PyQt5.QtCore import pyqtSignal 4 | from PyQt5.QtWidgets import QHBoxLayout, QPushButton, QWidget 5 | 6 | from ...definitions import LabelingMode 7 | from ...definitions.label_formats.base import BaseLabelFormat 8 | from ...io.labels.config import LabelConfig 9 | 10 | 11 | class SelectLabelingMode(QWidget): 12 | changed = pyqtSignal(LabelingMode) # class_name, was_added 13 | 14 | def __init__(self, *args, **kwargs) -> None: 15 | super().__init__(*args, **kwargs) 16 | 17 | row_buttons = QHBoxLayout() 18 | self.setLayout(row_buttons) 19 | 20 | self._add_object_detection_button(row_buttons) 21 | self._add_semantic_segmentation_button(row_buttons) 22 | 23 | self._initialize_buttons() 24 | 25 | self._connect_clicked_events() 26 | 27 | @property 28 | def selected_labeling_mode(self) -> LabelingMode: 29 | if self.button_object_detection.isChecked(): 30 | return LabelingMode.OBJECT_DETECTION 31 | if self.button_semantic_segmentation.isChecked(): 32 | return LabelingMode.SEMANTIC_SEGMENTATION 33 | raise Exception("No labeling mode selected.") 34 | 35 | @property 36 | def available_label_formats( 37 | self, 38 | ) -> List[BaseLabelFormat]: 39 | return self.selected_labeling_mode.get_available_formats() 40 | 41 | def _add_object_detection_button(self, parent: QHBoxLayout) -> None: 42 | self.button_object_detection = QPushButton( 43 | text=LabelingMode.OBJECT_DETECTION.title().replace("_", " ") 44 | ) 45 | self.button_object_detection.setCheckable(True) 46 | self.button_object_detection.setToolTip( 47 | "This will result in a label file for each point cloud\n" 48 | "with a bounding box for each annotated object." 49 | ) 50 | parent.addWidget(self.button_object_detection) 51 | 52 | def _add_semantic_segmentation_button(self, parent: QHBoxLayout) -> None: 53 | self.button_semantic_segmentation = QPushButton( 54 | text=LabelingMode.SEMANTIC_SEGMENTATION.title().replace("_", " ") 55 | ) 56 | self.button_semantic_segmentation.setCheckable(True) 57 | self.button_semantic_segmentation.setToolTip( 58 | "This will result in a *.bin file for each point cloud\n" 59 | "with a label for each annotated point of an object." 60 | ) 61 | parent.addWidget(self.button_semantic_segmentation) 62 | 63 | def _initialize_buttons(self) -> None: 64 | if LabelConfig().type == LabelingMode.OBJECT_DETECTION: 65 | self.button_object_detection.setChecked(True) 66 | else: 67 | self.button_semantic_segmentation.setChecked(True) 68 | 69 | def _connect_clicked_events(self) -> None: 70 | def select_object_detection(): 71 | self.button_object_detection.setChecked(True) 72 | self.button_semantic_segmentation.setChecked(False) 73 | self.changed.emit(self.selected_labeling_mode) 74 | 75 | self.button_object_detection.clicked.connect(select_object_detection) 76 | 77 | def select_semantic_segmentation(): 78 | self.button_semantic_segmentation.setChecked(True) 79 | self.button_object_detection.setChecked(False) 80 | self.changed.emit(self.selected_labeling_mode) 81 | 82 | self.button_semantic_segmentation.clicked.connect(select_semantic_segmentation) 83 | -------------------------------------------------------------------------------- /labelCloud/view/status_manager.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from PyQt5 import QtCore, QtWidgets 4 | 5 | from ..definitions import Context, Mode 6 | 7 | 8 | class StatusManager: 9 | def __init__(self, status_bar: QtWidgets.QStatusBar) -> None: 10 | self.status_bar = status_bar 11 | 12 | # Add permanent status label 13 | self.mode_label = QtWidgets.QLabel("Navigation Mode") 14 | self.mode_label.setStyleSheet( 15 | "font-weight: bold; font-size: 14px; min-width: 275px;" 16 | ) 17 | self.mode_label.setAlignment(QtCore.Qt.AlignCenter) 18 | self.status_bar.addWidget(self.mode_label, stretch=0) 19 | 20 | # Add temporary status message / tips 21 | self.message_label = QtWidgets.QLabel() 22 | self.message_label.setStyleSheet("font-size: 14px;") 23 | self.message_label.setAlignment(QtCore.Qt.AlignLeft) 24 | self.status_bar.addWidget(self.message_label, stretch=1) 25 | 26 | self.msg_context = Context.DEFAULT 27 | 28 | def set_mode(self, mode: Mode) -> None: 29 | self.mode_label.setText(mode.value) 30 | 31 | def set_message(self, message: str, context: Context = Context.DEFAULT) -> None: 32 | if context >= self.msg_context: 33 | self.message_label.setText(message) 34 | self.msg_context = context 35 | 36 | def clear_message(self, context: Optional[Context] = None): 37 | if context == None or context == self.msg_context: 38 | self.msg_context = Context.DEFAULT 39 | self.set_message("") 40 | 41 | def update_status( 42 | self, 43 | message: str, 44 | mode: Optional[Mode] = None, 45 | context: Context = Context.DEFAULT, 46 | ): 47 | self.set_message(message, context) 48 | 49 | if mode: 50 | self.set_mode(mode) 51 | -------------------------------------------------------------------------------- /labelCloud/view/viewer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from contextlib import contextmanager 3 | from typing import Optional, Tuple, Union 4 | 5 | import numpy as np 6 | import numpy.typing as npt 7 | import OpenGL.GL as GL 8 | from OpenGL import GLU 9 | from PyQt5 import QtGui, QtOpenGL 10 | 11 | from ..control.alignmode import AlignMode 12 | from ..control.bbox_controller import BoundingBoxController 13 | from ..control.config_manager import config 14 | from ..control.drawing_manager import DrawingManager 15 | from ..control.pcd_manager import PointCloudManger 16 | from ..definitions.types import Color4f, Point2D 17 | from ..utils import oglhelper 18 | 19 | 20 | @contextmanager 21 | def ignore_depth_mask(): 22 | GL.glDepthMask(GL.GL_FALSE) 23 | try: 24 | yield 25 | finally: 26 | GL.glDepthMask(GL.GL_TRUE) 27 | 28 | 29 | # Main widget for presenting the point cloud 30 | class GLWidget(QtOpenGL.QGLWidget): 31 | NEAR_PLANE = config.getfloat("USER_INTERFACE", "near_plane") 32 | FAR_PLANE = config.getfloat("USER_INTERFACE", "far_plane") 33 | 34 | def __init__(self, parent=None) -> None: 35 | QtOpenGL.QGLWidget.__init__(self, parent) 36 | self.setMouseTracking( 37 | True 38 | ) # mouseMoveEvent is called also without button pressed 39 | 40 | self.modelview: Optional[npt.NDArray] = None 41 | self.projection: Optional[npt.NDArray] = None 42 | self.DEVICE_PIXEL_RATIO: float = ( 43 | self.devicePixelRatioF() 44 | ) # 1 = normal; 2 = retina display 45 | oglhelper.DEVICE_PIXEL_RATIO = ( 46 | self.DEVICE_PIXEL_RATIO 47 | ) # set for helper functions 48 | 49 | self.pcd_manager: PointCloudManger = None # type: ignore 50 | self.bbox_controller: BoundingBoxController = None # type: ignore 51 | 52 | # Objects to be drawn 53 | self.crosshair_pos: Point2D = (0, 0) 54 | self.crosshair_col: Color4f = (0, 1, 0, 1) 55 | self.selected_side_vertices: npt.NDArray = np.array([]) 56 | self.drawing_mode: DrawingManager = None # type: ignore 57 | self.align_mode: Union[AlignMode, None] = None 58 | 59 | def set_pointcloud_controller(self, pcd_manager: PointCloudManger) -> None: 60 | self.pcd_manager = pcd_manager 61 | 62 | def set_bbox_controller(self, bbox_controller: BoundingBoxController) -> None: 63 | self.bbox_controller = bbox_controller 64 | 65 | # QGLWIDGET METHODS 66 | 67 | def initializeGL(self) -> None: 68 | bg_color = [ 69 | int(fl_color) 70 | for fl_color in config.getlist("USER_INTERFACE", "BACKGROUND_COLOR") 71 | ] # floats to ints 72 | self.qglClearColor(QtGui.QColor(*bg_color)) # screen background color 73 | GL.glEnable(GL.GL_DEPTH_TEST) # for visualization of depth 74 | GL.glEnable(GL.GL_BLEND) # enable transparency 75 | GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) 76 | logging.info("Intialized widget.") 77 | 78 | # Must be written again, due to buffer clearing 79 | self.pcd_manager.pointcloud.create_buffers() # type: ignore 80 | 81 | def resizeGL(self, width, height) -> None: 82 | logging.info("Resized widget.") 83 | GL.glViewport(0, 0, width, height) 84 | GL.glMatrixMode(GL.GL_PROJECTION) 85 | GL.glLoadIdentity() 86 | aspect = width / float(height) 87 | 88 | GLU.gluPerspective(45.0, aspect, GLWidget.NEAR_PLANE, GLWidget.FAR_PLANE) 89 | GL.glMatrixMode(GL.GL_MODELVIEW) 90 | 91 | def paintGL(self) -> None: 92 | GL.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT) 93 | GL.glPushMatrix() # push the current matrix to the current stack 94 | 95 | # Draw point cloud 96 | self.pcd_manager.pointcloud.draw_pointcloud() # type: ignore 97 | 98 | # Get actual matrices for click unprojection 99 | self.modelview = GL.glGetDoublev(GL.GL_MODELVIEW_MATRIX) 100 | self.projection = GL.glGetDoublev(GL.GL_PROJECTION_MATRIX) 101 | 102 | with ignore_depth_mask(): # Do not write decoration and preview elements in depth buffer 103 | if config.getboolean("USER_INTERFACE", "show_floor"): 104 | oglhelper.draw_xy_plane(self.pcd_manager.pointcloud) # type: ignore 105 | 106 | # Draw crosshair/ cursor in 3D world 107 | if self.crosshair_pos: 108 | cx, cy, cz = self.get_world_coords(*self.crosshair_pos, correction=True) 109 | oglhelper.draw_crosshair(cx, cy, cz, color=self.crosshair_col) 110 | 111 | if self.drawing_mode.has_preview(): 112 | self.drawing_mode.draw_preview() 113 | 114 | if self.align_mode is not None: 115 | if self.align_mode.is_active: 116 | self.align_mode.draw_preview() 117 | 118 | # Highlight selected side with filled rectangle 119 | if len(self.selected_side_vertices) == 4: 120 | oglhelper.draw_rectangles( 121 | self.selected_side_vertices, color=(0, 1, 0, 0.3) 122 | ) 123 | 124 | # Draw active bbox 125 | if self.bbox_controller.has_active_bbox(): 126 | self.bbox_controller.get_active_bbox().draw_bbox(highlighted=True) # type: ignore 127 | if config.getboolean("USER_INTERFACE", "show_orientation"): 128 | self.bbox_controller.get_active_bbox().draw_orientation() # type: ignore 129 | 130 | # Draw labeled bboxes 131 | for bbox in self.bbox_controller.bboxes: # type: ignore 132 | bbox.draw_bbox() 133 | 134 | GL.glPopMatrix() # restore the previous modelview matrix 135 | 136 | # Translates the 2D cursor position from screen plane into 3D world space coordinates 137 | def get_world_coords( 138 | self, x: float, y: float, z: Optional[float] = None, correction: bool = False 139 | ) -> Tuple[float, float, float]: 140 | x *= self.DEVICE_PIXEL_RATIO # For fixing mac retina bug 141 | y *= self.DEVICE_PIXEL_RATIO 142 | 143 | # Stored projection matrices are taken from loop 144 | viewport = GL.glGetIntegerv(GL.GL_VIEWPORT) 145 | real_y = viewport[3] - y # adjust for down-facing y positions 146 | 147 | if z is None: 148 | buffer_size = 21 149 | center = buffer_size // 2 + 1 150 | depths = GL.glReadPixels( 151 | x - center + 1, 152 | real_y - center + 1, 153 | buffer_size, 154 | buffer_size, 155 | GL.GL_DEPTH_COMPONENT, 156 | GL.GL_FLOAT, 157 | ) 158 | z = depths[center][center] # Read selected pixel from depth buffer 159 | 160 | if z == 1: 161 | z = depth_smoothing(depths, center) 162 | elif correction: 163 | z = depth_min(depths, center) 164 | 165 | mod_x, mod_y, mod_z = GLU.gluUnProject( 166 | x, real_y, z, self.modelview, self.projection, viewport 167 | ) 168 | return mod_x, mod_y, mod_z 169 | 170 | 171 | # Creates a circular mask with radius around center 172 | def circular_mask(arr_length, center, radius) -> np.ndarray: 173 | dx = np.arange(arr_length) 174 | return (dx[np.newaxis, :] - center) ** 2 + ( 175 | dx[:, np.newaxis] - center 176 | ) ** 2 < radius**2 177 | 178 | 179 | # Returns the minimum (closest) depth for a specified radius around the center 180 | def depth_min(depths, center, r=4) -> float: 181 | selected_depths = depths[circular_mask(len(depths), center, r)] 182 | filtered_depths = selected_depths[(0 < selected_depths) & (selected_depths < 1)] 183 | if 0 in depths: # Check if cursor is at widget border 184 | return 1 185 | elif len(filtered_depths) > 0: 186 | return np.min(filtered_depths) 187 | else: 188 | return 0.5 189 | 190 | 191 | # Returns the mean depth for a specified radius around the center 192 | def depth_smoothing(depths, center, r=15) -> float: 193 | selected_depths = depths[circular_mask(len(depths), center, r)] 194 | if 0 in depths: # Check if cursor is at widget border 195 | return 1 196 | elif np.isnan( 197 | selected_depths[selected_depths < 1] 198 | ).all(): # prevent mean of empty slice 199 | return 1 200 | return np.nanmedian(selected_depths[selected_depths < 1]) 201 | -------------------------------------------------------------------------------- /labels/_classes.json: -------------------------------------------------------------------------------- 1 | { 2 | "classes": [ 3 | { 4 | "name": "unassigned", 5 | "id": 0, 6 | "color": "#9da2ab" 7 | }, 8 | { 9 | "name": "cart", 10 | "id": 1, 11 | "color": "#f156ff" 12 | }, 13 | { 14 | "name": "box", 15 | "id": 2, 16 | "color": "#f57900" 17 | } 18 | ], 19 | "default": 0, 20 | "type": "object_detection", 21 | "format": "centroid_abs", 22 | "created_with": { 23 | "name": "labelCloud", 24 | "version": "1.0.1" 25 | } 26 | } -------------------------------------------------------------------------------- /labels/exemplary.json: -------------------------------------------------------------------------------- 1 | { 2 | "folder": "pointclouds", 3 | "filename": "exemplary.ply", 4 | "path": "pointclouds/exemplary.ply", 5 | "objects": [ 6 | { 7 | "name": "cart", 8 | "centroid": { 9 | "x": -0.1908196, 10 | "y": -0.23602801, 11 | "z": 0.08046184 12 | }, 13 | "dimensions": { 14 | "length": 0.75, 15 | "width": 0.55, 16 | "height": 0.15 17 | }, 18 | "rotations": { 19 | "x": 0, 20 | "y": 0, 21 | "z": 235 22 | } 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: labelCloud 2 | repo_url: https://github.com/ch-sa/labelCloud 3 | copyright: "© Christoph Sager & Contributers" 4 | 5 | nav: 6 | - 'index.md' 7 | - 'setup.md' 8 | - 'configuration.md' 9 | - 'shortcuts.md' 10 | - 'conventions.md' 11 | 12 | theme: 13 | name: material 14 | logo: assets/logo.png 15 | 16 | markdown_extensions: 17 | - pymdownx.highlight: 18 | anchor_linenums: true 19 | - pymdownx.inlinehilite 20 | - pymdownx.snippets 21 | - pymdownx.superfences 22 | - admonition 23 | - pymdownx.details 24 | 25 | plugins: 26 | - search 27 | 28 | extra: 29 | social: 30 | - icon: fontawesome/brands/github 31 | link: https://github.com/ch-sa 32 | - icon: fontawesome/brands/linkedin 33 | link: https://de.linkedin.com/in/christophsager 34 | - icon: fontawesome/brands/twitter 35 | link: https://twitter.com/ch_sager -------------------------------------------------------------------------------- /pointclouds/exemplary.ply: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ch-sa/labelCloud/98c3f81a5cec4a5cd505b625dadd80c73e55e744/pointclouds/exemplary.ply -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [tool.mypy] 9 | plugins = "numpy.typing.mypy_plugin" 10 | exclude = [ 11 | 'tests', 12 | ] 13 | 14 | [[tool.mypy.overrides]] 15 | module = [ 16 | "OpenGL.*", 17 | "open3d.*" 18 | ] 19 | ignore_missing_imports = true 20 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy>=1.21.6,<2.0.0 # Avoids binary incompatibility error. 2 | open3d>=0.15.2 3 | PyOpenGL==3.1.6 # Prevents 'NoneType' object has no attribute 'glGetError'. 4 | PyOpenGL-accelerate~=3.1.5 5 | PyQt5~=5.15.7 6 | 7 | # Testing 8 | pytest~=7.3.1 9 | pytest-qt~=4.2.0 10 | 11 | # Development 12 | black>=23.1.0 13 | mypy~=1.3.0 14 | PyQt5-stubs~=5.15.6 15 | types-setuptools~=71.1.0 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = labelCloud 3 | version = attr: labelCloud.__init__.__version__ 4 | maintainer = Christoph Sager 5 | maintainer_email = christoph.sager@gmail.com 6 | license = GNU General Public License v3.0 7 | description = A lightweight tool for labeling 3D bounding boxes in point clouds. 8 | keywords = 9 | labelCloud 10 | machine learning 11 | computer vision 12 | annotation tool 13 | labeling 14 | point clouds 15 | bounding boxes 16 | 3d object detection 17 | 6d pose estimation 18 | url = https://github.com/ch-sa/labelCloud 19 | long_description = file: README.md 20 | long_description_content_type = text/markdown 21 | classifiers = 22 | Development Status :: 4 - Beta 23 | Natural Language :: English 24 | Programming Language :: Python :: 3 25 | Programming Language :: Python :: 3.7 26 | Programming Language :: Python :: 3.8 27 | Programming Language :: Python :: 3.9 28 | License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) 29 | Topic :: Scientific/Engineering :: Artificial Intelligence 30 | Topic :: Multimedia :: Graphics :: Viewers 31 | 32 | [options] 33 | zip_safe = False 34 | packages = 35 | labelCloud 36 | labelCloud.control 37 | labelCloud.definitions 38 | labelCloud.definitions.label_formats 39 | labelCloud.io 40 | labelCloud.io.labels 41 | labelCloud.io.pointclouds 42 | labelCloud.io.segmentations 43 | labelCloud.labeling_strategies 44 | labelCloud.model 45 | labelCloud.resources 46 | labelCloud.resources.examples 47 | labelCloud.resources.icons 48 | labelCloud.resources.interfaces 49 | labelCloud.tests 50 | labelCloud.utils 51 | labelCloud.view 52 | labelCloud.view.startup 53 | install_requires = 54 | numpy>1.20.0,<2.0.0 55 | open3d 56 | PyOpenGL 57 | PyOpenGL-accelerate 58 | PyQt5>=5.15.7 59 | python_requires = >=3.7 60 | 61 | [options.entry_points] 62 | console_scripts = 63 | labelCloud = labelCloud.__main__:main 64 | 65 | [options.extras_require] 66 | tests = pytest; pytest-qt 67 | 68 | [options.package_data] 69 | labelCloud.resources = *.ini, *.pcd, *.txt, *.json 70 | labelCloud.resources.examples = *.json, *.ply 71 | labelCloud.resources.icons = *.ico, *.png, *.svg, *.txt 72 | labelCloud.resources.interfaces = *.ui 73 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | --------------------------------------------------------------------------------