├── .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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------