p4gcc 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- p4gcc-0.0.1/LICENSE.md +7 -0
- p4gcc-0.0.1/PKG-INFO +83 -0
- p4gcc-0.0.1/README.md +62 -0
- p4gcc-0.0.1/pyproject.toml +59 -0
- p4gcc-0.0.1/setup.cfg +4 -0
- p4gcc-0.0.1/src/p4gcc.egg-info/PKG-INFO +83 -0
- p4gcc-0.0.1/src/p4gcc.egg-info/SOURCES.txt +17 -0
- p4gcc-0.0.1/src/p4gcc.egg-info/dependency_links.txt +1 -0
- p4gcc-0.0.1/src/p4gcc.egg-info/entry_points.txt +4 -0
- p4gcc-0.0.1/src/p4gcc.egg-info/requires.txt +10 -0
- p4gcc-0.0.1/src/p4gcc.egg-info/top_level.txt +1 -0
- p4gcc-0.0.1/src/py4dgeo_cloudcompare/__init__.py +13 -0
- p4gcc-0.0.1/src/py4dgeo_cloudcompare/m3c2.py +424 -0
- p4gcc-0.0.1/src/py4dgeo_cloudcompare/plugin.py +18 -0
- p4gcc-0.0.1/src/py4dgeo_cloudcompare/qt_versions.py +20 -0
- p4gcc-0.0.1/tests/test_m3c2.py +241 -0
- p4gcc-0.0.1/tests/test_plugin.py +31 -0
- p4gcc-0.0.1/tests/test_py4dgeo_cloudcompare.py +5 -0
- p4gcc-0.0.1/tests/test_qt_versions.py +38 -0
p4gcc-0.0.1/LICENSE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026, The copyright holders according to COPYING.md
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
p4gcc-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: p4gcc
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Run py4dgeo change analysis from CloudCompare
|
|
5
|
+
Maintainer-email: Dominic Kempf <dominic.kempf@iwr.uni-heidelberg.de>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE.md
|
|
12
|
+
Requires-Dist: py4dgeo>=1.2.1
|
|
13
|
+
Provides-Extra: tests
|
|
14
|
+
Requires-Dist: pytest; extra == "tests"
|
|
15
|
+
Requires-Dist: pytest-cov; extra == "tests"
|
|
16
|
+
Provides-Extra: docs
|
|
17
|
+
Requires-Dist: sphinx; extra == "docs"
|
|
18
|
+
Requires-Dist: sphinx_mdinclude; extra == "docs"
|
|
19
|
+
Requires-Dist: sphinx_rtd_theme; extra == "docs"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# Welcome to py4dgeo-cloudcompare
|
|
23
|
+
|
|
24
|
+
[](https://opensource.org/licenses/MIT)
|
|
25
|
+
[](https://github.com/dokempf/py4dgeo-cloudcompare/actions/workflows/ci.yml)
|
|
26
|
+
[](https://py4dgeo-cloudcompare.readthedocs.io/)
|
|
27
|
+
[](https://codecov.io/gh/dokempf/py4dgeo-cloudcompare)
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
The Python package `py4dgeo_cloudcompare` can be installed from PyPI:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
python -m pip install py4dgeo_cloudcompare
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Development installation
|
|
38
|
+
|
|
39
|
+
If you want to contribute to the development of `py4dgeo_cloudcompare`, we recommend
|
|
40
|
+
the following editable installation from this repository:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
git clone git@github.com:dokempf/py4dgeo-cloudcompare.git
|
|
44
|
+
cd py4dgeo-cloudcompare
|
|
45
|
+
python -m pip install --editable .[tests]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Having done so, the test suite can be run using `pytest`:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
python -m pytest
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## CloudCompare plugin
|
|
55
|
+
|
|
56
|
+
This package provides a Python M3C2 plugin for
|
|
57
|
+
[CloudCompare-PythonRuntime](https://tmontaigu.github.io/CloudCompare-PythonRuntime/getting_started.html#plugin).
|
|
58
|
+
Install the package into the Python environment used by CloudCompare:
|
|
59
|
+
|
|
60
|
+
```console
|
|
61
|
+
python -m pip install .
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
After restarting CloudCompare, run
|
|
65
|
+
**Compute M3C2 (py4dgeo)** from the Python action launcher or the Python
|
|
66
|
+
plugins menu. The modeless dialog can be opened before any point clouds are
|
|
67
|
+
loaded. Its pickers update while clouds are loaded into CloudCompare and let
|
|
68
|
+
you assign the reference, compared, and core point clouds. An existing
|
|
69
|
+
selection is used only to choose convenient defaults. The dialog also
|
|
70
|
+
configures normal radii, projection radius and depth,
|
|
71
|
+
registration error, normal orientation, and robust aggregation.
|
|
72
|
+
|
|
73
|
+
The result is added to CloudCompare as a new point cloud at the core-point
|
|
74
|
+
locations. It displays the signed M3C2 distance and also contains scalar
|
|
75
|
+
fields for the level of detection, cloud spreads and sample counts, computed
|
|
76
|
+
normal components, and selected normal radius. CloudCompare global shifts and
|
|
77
|
+
scales are respected when clouds use different local coordinate systems.
|
|
78
|
+
CloudCompare discovers the plugin through the `cloudcompare.plugins` package
|
|
79
|
+
entry point declared in `pyproject.toml`.
|
|
80
|
+
|
|
81
|
+
## Acknowledgments
|
|
82
|
+
|
|
83
|
+
This repository was set up using the [SSC Cookiecutter for Python Packages](https://github.com/ssciwr/cookiecutter-python-package).
|
p4gcc-0.0.1/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Welcome to py4dgeo-cloudcompare
|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
[](https://github.com/dokempf/py4dgeo-cloudcompare/actions/workflows/ci.yml)
|
|
5
|
+
[](https://py4dgeo-cloudcompare.readthedocs.io/)
|
|
6
|
+
[](https://codecov.io/gh/dokempf/py4dgeo-cloudcompare)
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
The Python package `py4dgeo_cloudcompare` can be installed from PyPI:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
python -m pip install py4dgeo_cloudcompare
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Development installation
|
|
17
|
+
|
|
18
|
+
If you want to contribute to the development of `py4dgeo_cloudcompare`, we recommend
|
|
19
|
+
the following editable installation from this repository:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
git clone git@github.com:dokempf/py4dgeo-cloudcompare.git
|
|
23
|
+
cd py4dgeo-cloudcompare
|
|
24
|
+
python -m pip install --editable .[tests]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Having done so, the test suite can be run using `pytest`:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
python -m pytest
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## CloudCompare plugin
|
|
34
|
+
|
|
35
|
+
This package provides a Python M3C2 plugin for
|
|
36
|
+
[CloudCompare-PythonRuntime](https://tmontaigu.github.io/CloudCompare-PythonRuntime/getting_started.html#plugin).
|
|
37
|
+
Install the package into the Python environment used by CloudCompare:
|
|
38
|
+
|
|
39
|
+
```console
|
|
40
|
+
python -m pip install .
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
After restarting CloudCompare, run
|
|
44
|
+
**Compute M3C2 (py4dgeo)** from the Python action launcher or the Python
|
|
45
|
+
plugins menu. The modeless dialog can be opened before any point clouds are
|
|
46
|
+
loaded. Its pickers update while clouds are loaded into CloudCompare and let
|
|
47
|
+
you assign the reference, compared, and core point clouds. An existing
|
|
48
|
+
selection is used only to choose convenient defaults. The dialog also
|
|
49
|
+
configures normal radii, projection radius and depth,
|
|
50
|
+
registration error, normal orientation, and robust aggregation.
|
|
51
|
+
|
|
52
|
+
The result is added to CloudCompare as a new point cloud at the core-point
|
|
53
|
+
locations. It displays the signed M3C2 distance and also contains scalar
|
|
54
|
+
fields for the level of detection, cloud spreads and sample counts, computed
|
|
55
|
+
normal components, and selected normal radius. CloudCompare global shifts and
|
|
56
|
+
scales are respected when clouds use different local coordinate systems.
|
|
57
|
+
CloudCompare discovers the plugin through the `cloudcompare.plugins` package
|
|
58
|
+
entry point declared in `pyproject.toml`.
|
|
59
|
+
|
|
60
|
+
## Acknowledgments
|
|
61
|
+
|
|
62
|
+
This repository was set up using the [SSC Cookiecutter for Python Packages](https://github.com/ssciwr/cookiecutter-python-package).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# This section describes the requirements of the build/installation
|
|
2
|
+
# process itself. Being able to do this was the original reason to
|
|
3
|
+
# introduce pyproject.toml
|
|
4
|
+
[build-system]
|
|
5
|
+
requires = [
|
|
6
|
+
"setuptools >=61",
|
|
7
|
+
]
|
|
8
|
+
build-backend = "setuptools.build_meta"
|
|
9
|
+
|
|
10
|
+
# This section provides general project metadata that is used across
|
|
11
|
+
# a variety of build tools. Notably, the version specified here is the
|
|
12
|
+
# single source of truth for py4dgeo_cloudcompare's version
|
|
13
|
+
[project]
|
|
14
|
+
name = "p4gcc"
|
|
15
|
+
description = "Run py4dgeo change analysis from CloudCompare"
|
|
16
|
+
readme = "README.md"
|
|
17
|
+
maintainers = [
|
|
18
|
+
{ name = "Dominic Kempf", email = "dominic.kempf@iwr.uni-heidelberg.de" },
|
|
19
|
+
]
|
|
20
|
+
version = "0.0.1"
|
|
21
|
+
requires-python = ">=3.10"
|
|
22
|
+
license-files = ["LICEN[CS]E*"]
|
|
23
|
+
license = "MIT"
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"py4dgeo>=1.2.1",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.entry-points."cloudcompare.plugins"]
|
|
33
|
+
hello = "py4dgeo_cloudcompare.plugin:HelloWorldPlugin"
|
|
34
|
+
m3c2 = "py4dgeo_cloudcompare.m3c2:M3C2Plugin"
|
|
35
|
+
qt_versions = "py4dgeo_cloudcompare.qt_versions:QtVersionsPlugin"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
tests = [
|
|
39
|
+
"pytest",
|
|
40
|
+
"pytest-cov",
|
|
41
|
+
]
|
|
42
|
+
docs = [
|
|
43
|
+
"sphinx",
|
|
44
|
+
"sphinx_mdinclude",
|
|
45
|
+
"sphinx_rtd_theme",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# The following section contains setuptools-specific configuration
|
|
50
|
+
# options. For a full reference of available options, check the overview
|
|
51
|
+
# at https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
|
|
52
|
+
[tool.setuptools.packages.find]
|
|
53
|
+
where = ["src"]
|
|
54
|
+
|
|
55
|
+
# The following is the configuration for the pytest test suite
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
testpaths = [
|
|
58
|
+
"tests",
|
|
59
|
+
]
|
p4gcc-0.0.1/setup.cfg
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: p4gcc
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Run py4dgeo change analysis from CloudCompare
|
|
5
|
+
Maintainer-email: Dominic Kempf <dominic.kempf@iwr.uni-heidelberg.de>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE.md
|
|
12
|
+
Requires-Dist: py4dgeo>=1.2.1
|
|
13
|
+
Provides-Extra: tests
|
|
14
|
+
Requires-Dist: pytest; extra == "tests"
|
|
15
|
+
Requires-Dist: pytest-cov; extra == "tests"
|
|
16
|
+
Provides-Extra: docs
|
|
17
|
+
Requires-Dist: sphinx; extra == "docs"
|
|
18
|
+
Requires-Dist: sphinx_mdinclude; extra == "docs"
|
|
19
|
+
Requires-Dist: sphinx_rtd_theme; extra == "docs"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# Welcome to py4dgeo-cloudcompare
|
|
23
|
+
|
|
24
|
+
[](https://opensource.org/licenses/MIT)
|
|
25
|
+
[](https://github.com/dokempf/py4dgeo-cloudcompare/actions/workflows/ci.yml)
|
|
26
|
+
[](https://py4dgeo-cloudcompare.readthedocs.io/)
|
|
27
|
+
[](https://codecov.io/gh/dokempf/py4dgeo-cloudcompare)
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
The Python package `py4dgeo_cloudcompare` can be installed from PyPI:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
python -m pip install py4dgeo_cloudcompare
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Development installation
|
|
38
|
+
|
|
39
|
+
If you want to contribute to the development of `py4dgeo_cloudcompare`, we recommend
|
|
40
|
+
the following editable installation from this repository:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
git clone git@github.com:dokempf/py4dgeo-cloudcompare.git
|
|
44
|
+
cd py4dgeo-cloudcompare
|
|
45
|
+
python -m pip install --editable .[tests]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Having done so, the test suite can be run using `pytest`:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
python -m pytest
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## CloudCompare plugin
|
|
55
|
+
|
|
56
|
+
This package provides a Python M3C2 plugin for
|
|
57
|
+
[CloudCompare-PythonRuntime](https://tmontaigu.github.io/CloudCompare-PythonRuntime/getting_started.html#plugin).
|
|
58
|
+
Install the package into the Python environment used by CloudCompare:
|
|
59
|
+
|
|
60
|
+
```console
|
|
61
|
+
python -m pip install .
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
After restarting CloudCompare, run
|
|
65
|
+
**Compute M3C2 (py4dgeo)** from the Python action launcher or the Python
|
|
66
|
+
plugins menu. The modeless dialog can be opened before any point clouds are
|
|
67
|
+
loaded. Its pickers update while clouds are loaded into CloudCompare and let
|
|
68
|
+
you assign the reference, compared, and core point clouds. An existing
|
|
69
|
+
selection is used only to choose convenient defaults. The dialog also
|
|
70
|
+
configures normal radii, projection radius and depth,
|
|
71
|
+
registration error, normal orientation, and robust aggregation.
|
|
72
|
+
|
|
73
|
+
The result is added to CloudCompare as a new point cloud at the core-point
|
|
74
|
+
locations. It displays the signed M3C2 distance and also contains scalar
|
|
75
|
+
fields for the level of detection, cloud spreads and sample counts, computed
|
|
76
|
+
normal components, and selected normal radius. CloudCompare global shifts and
|
|
77
|
+
scales are respected when clouds use different local coordinate systems.
|
|
78
|
+
CloudCompare discovers the plugin through the `cloudcompare.plugins` package
|
|
79
|
+
entry point declared in `pyproject.toml`.
|
|
80
|
+
|
|
81
|
+
## Acknowledgments
|
|
82
|
+
|
|
83
|
+
This repository was set up using the [SSC Cookiecutter for Python Packages](https://github.com/ssciwr/cookiecutter-python-package).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE.md
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/p4gcc.egg-info/PKG-INFO
|
|
5
|
+
src/p4gcc.egg-info/SOURCES.txt
|
|
6
|
+
src/p4gcc.egg-info/dependency_links.txt
|
|
7
|
+
src/p4gcc.egg-info/entry_points.txt
|
|
8
|
+
src/p4gcc.egg-info/requires.txt
|
|
9
|
+
src/p4gcc.egg-info/top_level.txt
|
|
10
|
+
src/py4dgeo_cloudcompare/__init__.py
|
|
11
|
+
src/py4dgeo_cloudcompare/m3c2.py
|
|
12
|
+
src/py4dgeo_cloudcompare/plugin.py
|
|
13
|
+
src/py4dgeo_cloudcompare/qt_versions.py
|
|
14
|
+
tests/test_m3c2.py
|
|
15
|
+
tests/test_plugin.py
|
|
16
|
+
tests/test_py4dgeo_cloudcompare.py
|
|
17
|
+
tests/test_qt_versions.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
py4dgeo_cloudcompare
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from importlib import metadata
|
|
2
|
+
|
|
3
|
+
__version__ = metadata.version(__package__)
|
|
4
|
+
del metadata
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def add_one(x: int):
|
|
8
|
+
"""An example function that increases a number
|
|
9
|
+
|
|
10
|
+
:param x: The input parameter to increase
|
|
11
|
+
:return: The successor of the given number
|
|
12
|
+
"""
|
|
13
|
+
return x + 1
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""CloudCompare front end for :class:`py4dgeo.M3C2`.
|
|
2
|
+
|
|
3
|
+
The imports of py4dgeo and PySide6 deliberately happen when the action is
|
|
4
|
+
executed. CloudCompare can therefore discover the plugin even while its
|
|
5
|
+
Python environment is still being configured, and the numerical parts of
|
|
6
|
+
this module can be tested without a Qt application.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Sequence
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pycc
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class M3C2Parameters:
|
|
20
|
+
"""Parameters exposed by the M3C2 dialog, in py4dgeo units."""
|
|
21
|
+
|
|
22
|
+
normal_radii: tuple[float, ...] = (0.5, 1.0, 2.0)
|
|
23
|
+
cylinder_radius: float = 1.0
|
|
24
|
+
max_distance: float = 0.0
|
|
25
|
+
registration_error: float = 0.0
|
|
26
|
+
orientation_vector: tuple[float, float, float] = (0.0, 0.0, 1.0)
|
|
27
|
+
robust_aggregation: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class M3C2Result:
|
|
32
|
+
"""Numerical result used to construct a CloudCompare point cloud."""
|
|
33
|
+
|
|
34
|
+
distances: np.ndarray
|
|
35
|
+
uncertainties: np.ndarray
|
|
36
|
+
normals: np.ndarray
|
|
37
|
+
normal_radii: np.ndarray
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_normal_radii(value: str) -> tuple[float, ...]:
|
|
41
|
+
"""Parse a comma/semicolon/whitespace separated list of positive radii."""
|
|
42
|
+
|
|
43
|
+
tokens = value.replace(";", " ").replace(",", " ").split()
|
|
44
|
+
if not tokens:
|
|
45
|
+
raise ValueError("Specify at least one normal radius.")
|
|
46
|
+
radii = tuple(float(token) for token in tokens)
|
|
47
|
+
if any(not np.isfinite(radius) or radius <= 0.0 for radius in radii):
|
|
48
|
+
raise ValueError("Normal radii must be finite and greater than zero.")
|
|
49
|
+
return radii
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def calculate_m3c2(
|
|
53
|
+
reference_points: np.ndarray,
|
|
54
|
+
compared_points: np.ndarray,
|
|
55
|
+
core_points: np.ndarray,
|
|
56
|
+
parameters: M3C2Parameters,
|
|
57
|
+
) -> M3C2Result:
|
|
58
|
+
"""Run py4dgeo M3C2 on three ``(n, 3)`` coordinate arrays."""
|
|
59
|
+
|
|
60
|
+
from py4dgeo import Epoch, M3C2
|
|
61
|
+
|
|
62
|
+
reference = Epoch(_points_array(reference_points, "reference cloud"))
|
|
63
|
+
compared = Epoch(_points_array(compared_points, "compared cloud"))
|
|
64
|
+
core = _points_array(core_points, "core points")
|
|
65
|
+
|
|
66
|
+
orientation = np.asarray(parameters.orientation_vector, dtype=np.float64)
|
|
67
|
+
if orientation.shape != (3,) or not np.isfinite(orientation).all():
|
|
68
|
+
raise ValueError("The orientation vector must contain three finite values.")
|
|
69
|
+
if np.linalg.norm(orientation) == 0.0:
|
|
70
|
+
raise ValueError("The orientation vector must not be zero.")
|
|
71
|
+
if parameters.cylinder_radius <= 0.0:
|
|
72
|
+
raise ValueError("The projection cylinder radius must be greater than zero.")
|
|
73
|
+
if not parameters.normal_radii or any(
|
|
74
|
+
not np.isfinite(radius) or radius <= 0.0 for radius in parameters.normal_radii
|
|
75
|
+
):
|
|
76
|
+
raise ValueError("Normal radii must be finite and greater than zero.")
|
|
77
|
+
if parameters.max_distance < 0.0 or parameters.registration_error < 0.0:
|
|
78
|
+
raise ValueError("Maximum depth and registration error cannot be negative.")
|
|
79
|
+
|
|
80
|
+
algorithm = M3C2(
|
|
81
|
+
epochs=(reference, compared),
|
|
82
|
+
corepoints=core,
|
|
83
|
+
cyl_radius=float(parameters.cylinder_radius),
|
|
84
|
+
normal_radii=list(parameters.normal_radii),
|
|
85
|
+
max_distance=float(parameters.max_distance),
|
|
86
|
+
registration_error=float(parameters.registration_error),
|
|
87
|
+
orientation_vector=orientation,
|
|
88
|
+
robust_aggr=parameters.robust_aggregation,
|
|
89
|
+
)
|
|
90
|
+
distances, uncertainties = algorithm.run()
|
|
91
|
+
return M3C2Result(
|
|
92
|
+
distances=np.asarray(distances),
|
|
93
|
+
uncertainties=uncertainties,
|
|
94
|
+
normals=np.asarray(algorithm.directions()),
|
|
95
|
+
normal_radii=np.asarray(algorithm.directions_radii()).reshape(-1),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _points_array(points: np.ndarray, description: str) -> np.ndarray:
|
|
100
|
+
array = np.ascontiguousarray(points, dtype=np.float64)
|
|
101
|
+
if array.ndim != 2 or array.shape[1] != 3 or array.shape[0] == 0:
|
|
102
|
+
raise ValueError(f"The {description} must be a non-empty (n, 3) array.")
|
|
103
|
+
if not np.isfinite(array).all():
|
|
104
|
+
raise ValueError(f"The {description} contains non-finite coordinates.")
|
|
105
|
+
return array
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _cloud_name(cloud: Any) -> str:
|
|
109
|
+
return str(cloud.getName())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _global_points(cloud: Any) -> np.ndarray:
|
|
113
|
+
"""Return CloudCompare local points in their global coordinate system."""
|
|
114
|
+
|
|
115
|
+
points = np.asarray(cloud.points(), dtype=np.float64).copy()
|
|
116
|
+
shift_value = cloud.getGlobalShift()
|
|
117
|
+
shift = np.array([shift_value.x, shift_value.y, shift_value.z])
|
|
118
|
+
scale = float(cloud.getGlobalScale())
|
|
119
|
+
if scale == 0.0:
|
|
120
|
+
raise ValueError(f"Cloud '{_cloud_name(cloud)}' has a zero global scale.")
|
|
121
|
+
# CloudCompare stores local = (global + shift) * scale.
|
|
122
|
+
return points / scale - shift
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _loaded_point_clouds(app: Any) -> list[Any]:
|
|
126
|
+
"""Collect point clouds recursively from CloudCompare's database tree."""
|
|
127
|
+
|
|
128
|
+
root = app.dbRootObject()
|
|
129
|
+
if root is None:
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
clouds: list[Any] = []
|
|
133
|
+
pending = [root]
|
|
134
|
+
while pending:
|
|
135
|
+
entity = pending.pop()
|
|
136
|
+
if isinstance(entity, pycc.ccPointCloud):
|
|
137
|
+
clouds.append(entity)
|
|
138
|
+
pending.extend(
|
|
139
|
+
entity.getChild(index)
|
|
140
|
+
for index in reversed(range(entity.getChildrenNumber()))
|
|
141
|
+
)
|
|
142
|
+
return clouds
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _preferred_cloud_indices(app: Any, clouds: Sequence[Any]) -> tuple[int, int]:
|
|
146
|
+
"""Use the current selection as defaults without requiring a selection."""
|
|
147
|
+
|
|
148
|
+
selected_ids = {
|
|
149
|
+
entity.getUniqueID()
|
|
150
|
+
for entity in app.getSelectedEntities()
|
|
151
|
+
if isinstance(entity, pycc.ccPointCloud)
|
|
152
|
+
}
|
|
153
|
+
selected_indices = [
|
|
154
|
+
index
|
|
155
|
+
for index, cloud in enumerate(clouds)
|
|
156
|
+
if cloud.getUniqueID() in selected_ids
|
|
157
|
+
]
|
|
158
|
+
if len(selected_indices) >= 2:
|
|
159
|
+
return selected_indices[0], selected_indices[1]
|
|
160
|
+
if len(selected_indices) == 1:
|
|
161
|
+
other = 0 if selected_indices[0] != 0 else 1
|
|
162
|
+
return selected_indices[0], other
|
|
163
|
+
return 0, 1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def create_result_cloud(
|
|
167
|
+
core_cloud: Any,
|
|
168
|
+
result: M3C2Result,
|
|
169
|
+
name: str,
|
|
170
|
+
) -> Any:
|
|
171
|
+
"""Create a display-ready CloudCompare cloud with M3C2 scalar fields."""
|
|
172
|
+
|
|
173
|
+
local_core_points = np.asarray(core_cloud.points(), dtype=pycc.PointCoordinateType)
|
|
174
|
+
output = pycc.ccPointCloud(
|
|
175
|
+
np.ascontiguousarray(local_core_points[:, 0]),
|
|
176
|
+
np.ascontiguousarray(local_core_points[:, 1]),
|
|
177
|
+
np.ascontiguousarray(local_core_points[:, 2]),
|
|
178
|
+
)
|
|
179
|
+
output.setName(name)
|
|
180
|
+
|
|
181
|
+
shift = core_cloud.getGlobalShift()
|
|
182
|
+
output.setGlobalShift(shift.x, shift.y, shift.z)
|
|
183
|
+
output.setGlobalScale(core_cloud.getGlobalScale())
|
|
184
|
+
|
|
185
|
+
fields = (
|
|
186
|
+
("M3C2 distance", result.distances),
|
|
187
|
+
("M3C2 LoD", result.uncertainties["lodetection"]),
|
|
188
|
+
("M3C2 spread cloud 1", result.uncertainties["spread1"]),
|
|
189
|
+
("M3C2 spread cloud 2", result.uncertainties["spread2"]),
|
|
190
|
+
("M3C2 samples cloud 1", result.uncertainties["num_samples1"]),
|
|
191
|
+
("M3C2 samples cloud 2", result.uncertainties["num_samples2"]),
|
|
192
|
+
("M3C2 normal X", result.normals[:, 0]),
|
|
193
|
+
("M3C2 normal Y", result.normals[:, 1]),
|
|
194
|
+
("M3C2 normal Z", result.normals[:, 2]),
|
|
195
|
+
("M3C2 normal radius", result.normal_radii),
|
|
196
|
+
)
|
|
197
|
+
distance_index = -1
|
|
198
|
+
for field_name, values in fields:
|
|
199
|
+
values = np.asarray(values, dtype=pycc.ScalarType).reshape(-1)
|
|
200
|
+
if values.size != local_core_points.shape[0]:
|
|
201
|
+
raise ValueError(f"Result field '{field_name}' has an unexpected size.")
|
|
202
|
+
index = output.addScalarField(field_name, values)
|
|
203
|
+
output.getScalarField(index).computeMinAndMax()
|
|
204
|
+
if field_name == "M3C2 distance":
|
|
205
|
+
distance_index = index
|
|
206
|
+
|
|
207
|
+
output.setCurrentDisplayedScalarField(distance_index)
|
|
208
|
+
output.showSF(True)
|
|
209
|
+
output.showSFColorsScale(True)
|
|
210
|
+
return output
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class _M3C2Dialog:
|
|
214
|
+
"""Small wrapper that keeps Qt imports local to GUI execution."""
|
|
215
|
+
|
|
216
|
+
def __init__(self, app: Any):
|
|
217
|
+
from PySide6 import QtCore, QtWidgets
|
|
218
|
+
|
|
219
|
+
self.QtWidgets = QtWidgets
|
|
220
|
+
self.app = app
|
|
221
|
+
self.clouds: list[Any] = []
|
|
222
|
+
self.dialog = QtWidgets.QDialog(QtWidgets.QApplication.activeWindow())
|
|
223
|
+
self.dialog.setWindowTitle("py4dgeo M3C2")
|
|
224
|
+
self.dialog.setMinimumWidth(480)
|
|
225
|
+
layout = QtWidgets.QVBoxLayout(self.dialog)
|
|
226
|
+
description = QtWidgets.QLabel(
|
|
227
|
+
"Compute signed M3C2 distances for selected CloudCompare point clouds."
|
|
228
|
+
)
|
|
229
|
+
description.setWordWrap(True)
|
|
230
|
+
layout.addWidget(description)
|
|
231
|
+
form = QtWidgets.QFormLayout()
|
|
232
|
+
layout.addLayout(form)
|
|
233
|
+
|
|
234
|
+
self.reference = QtWidgets.QComboBox()
|
|
235
|
+
self.compared = QtWidgets.QComboBox()
|
|
236
|
+
self.core = QtWidgets.QComboBox()
|
|
237
|
+
form.addRow("Reference cloud", self.reference)
|
|
238
|
+
form.addRow("Compared cloud", self.compared)
|
|
239
|
+
form.addRow("Core points", self.core)
|
|
240
|
+
self.cloud_status = QtWidgets.QLabel()
|
|
241
|
+
self.refresh_button = QtWidgets.QPushButton("Refresh point clouds")
|
|
242
|
+
self.refresh_button.clicked.connect(self.refresh_clouds)
|
|
243
|
+
cloud_controls = QtWidgets.QHBoxLayout()
|
|
244
|
+
cloud_controls.addWidget(self.cloud_status)
|
|
245
|
+
cloud_controls.addStretch()
|
|
246
|
+
cloud_controls.addWidget(self.refresh_button)
|
|
247
|
+
form.addRow("", cloud_controls)
|
|
248
|
+
|
|
249
|
+
self.normal_radii = QtWidgets.QLineEdit("0.5, 1.0, 2.0")
|
|
250
|
+
self.normal_radii.setToolTip("One or more py4dgeo search radii, separated by commas")
|
|
251
|
+
form.addRow("Normal radii", self.normal_radii)
|
|
252
|
+
self.cylinder_radius = self._spin(1.0, 1.0e-9)
|
|
253
|
+
form.addRow("Projection cylinder radius", self.cylinder_radius)
|
|
254
|
+
self.max_distance = self._spin(0.0, 0.0)
|
|
255
|
+
self.max_distance.setSpecialValueText("Automatic")
|
|
256
|
+
form.addRow("Maximum projection depth", self.max_distance)
|
|
257
|
+
self.registration_error = self._spin(0.0, 0.0)
|
|
258
|
+
form.addRow("Registration error", self.registration_error)
|
|
259
|
+
|
|
260
|
+
orientation = QtWidgets.QHBoxLayout()
|
|
261
|
+
self.orientation = []
|
|
262
|
+
for label, initial in zip("XYZ", (0.0, 0.0, 1.0)):
|
|
263
|
+
orientation.addWidget(QtWidgets.QLabel(label))
|
|
264
|
+
widget = self._spin(initial, -1.0e12)
|
|
265
|
+
widget.setMaximum(1.0e12)
|
|
266
|
+
self.orientation.append(widget)
|
|
267
|
+
orientation.addWidget(widget)
|
|
268
|
+
form.addRow("Normal orientation vector", orientation)
|
|
269
|
+
self.robust = QtWidgets.QCheckBox("Use median and IQR")
|
|
270
|
+
form.addRow("Robust aggregation", self.robust)
|
|
271
|
+
self.output_name = QtWidgets.QLineEdit("M3C2 result")
|
|
272
|
+
form.addRow("Output cloud name", self.output_name)
|
|
273
|
+
|
|
274
|
+
buttons = QtWidgets.QDialogButtonBox(
|
|
275
|
+
QtWidgets.QDialogButtonBox.StandardButton.Ok
|
|
276
|
+
| QtWidgets.QDialogButtonBox.StandardButton.Cancel
|
|
277
|
+
)
|
|
278
|
+
buttons.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setText("Compute")
|
|
279
|
+
buttons.accepted.connect(self._validate_and_accept)
|
|
280
|
+
buttons.rejected.connect(self.dialog.reject)
|
|
281
|
+
layout.addWidget(buttons)
|
|
282
|
+
self._align_center = QtCore.Qt.AlignmentFlag.AlignCenter
|
|
283
|
+
description.setAlignment(self._align_center)
|
|
284
|
+
|
|
285
|
+
# Keep the picker synchronized while the modeless dialog is open. This
|
|
286
|
+
# lets users load point clouds after launching the plugin.
|
|
287
|
+
self._refresh_timer = QtCore.QTimer(self.dialog)
|
|
288
|
+
self._refresh_timer.setInterval(1000)
|
|
289
|
+
self._refresh_timer.timeout.connect(self.refresh_clouds)
|
|
290
|
+
self._refresh_timer.start()
|
|
291
|
+
self.refresh_clouds(use_selection_defaults=True)
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _cloud_label(cloud: Any) -> str:
|
|
295
|
+
return f"{_cloud_name(cloud)} [ID {cloud.getUniqueID()}]"
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _cloud_id(cloud: Any) -> Any:
|
|
299
|
+
return cloud.getUniqueID()
|
|
300
|
+
|
|
301
|
+
def refresh_clouds(self, use_selection_defaults: bool = False) -> None:
|
|
302
|
+
"""Refresh the pickers while preserving the current assignments."""
|
|
303
|
+
|
|
304
|
+
combos = (self.reference, self.compared, self.core)
|
|
305
|
+
selected_ids = []
|
|
306
|
+
for combo in combos:
|
|
307
|
+
index = combo.currentIndex()
|
|
308
|
+
selected_ids.append(
|
|
309
|
+
self._cloud_id(self.clouds[index])
|
|
310
|
+
if 0 <= index < len(self.clouds)
|
|
311
|
+
else None
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
clouds = _loaded_point_clouds(self.app)
|
|
315
|
+
cloud_ids = [self._cloud_id(cloud) for cloud in clouds]
|
|
316
|
+
if cloud_ids == [self._cloud_id(cloud) for cloud in self.clouds]:
|
|
317
|
+
if len(clouds) == 0:
|
|
318
|
+
self.cloud_status.setText("No point clouds loaded")
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
self.clouds = clouds
|
|
322
|
+
labels = [self._cloud_label(cloud) for cloud in clouds]
|
|
323
|
+
if use_selection_defaults and len(clouds) >= 2:
|
|
324
|
+
reference_index, compared_index = _preferred_cloud_indices(self.app, clouds)
|
|
325
|
+
default_indices = (reference_index, compared_index, reference_index)
|
|
326
|
+
else:
|
|
327
|
+
default_indices = (0, 1, 0)
|
|
328
|
+
|
|
329
|
+
for combo, selected_id, default_index in zip(
|
|
330
|
+
combos, selected_ids, default_indices
|
|
331
|
+
):
|
|
332
|
+
combo.clear()
|
|
333
|
+
combo.addItems(labels)
|
|
334
|
+
if selected_id in cloud_ids:
|
|
335
|
+
combo.setCurrentIndex(cloud_ids.index(selected_id))
|
|
336
|
+
elif default_index < len(clouds):
|
|
337
|
+
combo.setCurrentIndex(default_index)
|
|
338
|
+
|
|
339
|
+
if len(clouds) == 0:
|
|
340
|
+
self.cloud_status.setText("No point clouds loaded")
|
|
341
|
+
elif len(clouds) == 1:
|
|
342
|
+
self.cloud_status.setText("Load one more point cloud")
|
|
343
|
+
else:
|
|
344
|
+
self.cloud_status.setText(f"{len(clouds)} point clouds available")
|
|
345
|
+
|
|
346
|
+
def _spin(self, value: float, minimum: float):
|
|
347
|
+
widget = self.QtWidgets.QDoubleSpinBox()
|
|
348
|
+
widget.setDecimals(6)
|
|
349
|
+
widget.setRange(minimum, 1.0e12)
|
|
350
|
+
widget.setValue(value)
|
|
351
|
+
return widget
|
|
352
|
+
|
|
353
|
+
def _validate_and_accept(self) -> None:
|
|
354
|
+
self.refresh_clouds()
|
|
355
|
+
if len(self.clouds) < 2:
|
|
356
|
+
raise ValueError(
|
|
357
|
+
"Load at least two point clouds in CloudCompare before computing."
|
|
358
|
+
)
|
|
359
|
+
parameters = self.parameters()
|
|
360
|
+
if np.linalg.norm(parameters.orientation_vector) == 0.0:
|
|
361
|
+
raise ValueError("The normal orientation vector must not be zero.")
|
|
362
|
+
if self.reference.currentIndex() == self.compared.currentIndex():
|
|
363
|
+
raise ValueError("Reference and compared clouds must be different.")
|
|
364
|
+
if not self.output_name.text().strip():
|
|
365
|
+
raise ValueError("Specify a name for the output cloud.")
|
|
366
|
+
self.dialog.accept()
|
|
367
|
+
|
|
368
|
+
def show(self) -> None:
|
|
369
|
+
self.refresh_clouds()
|
|
370
|
+
self.dialog.show()
|
|
371
|
+
self.dialog.raise_()
|
|
372
|
+
self.dialog.activateWindow()
|
|
373
|
+
|
|
374
|
+
def parameters(self) -> M3C2Parameters:
|
|
375
|
+
return M3C2Parameters(
|
|
376
|
+
normal_radii=parse_normal_radii(self.normal_radii.text()),
|
|
377
|
+
cylinder_radius=self.cylinder_radius.value(),
|
|
378
|
+
max_distance=self.max_distance.value(),
|
|
379
|
+
registration_error=self.registration_error.value(),
|
|
380
|
+
orientation_vector=tuple(widget.value() for widget in self.orientation),
|
|
381
|
+
robust_aggregation=self.robust.isChecked(),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class M3C2Plugin(pycc.PythonPluginInterface):
|
|
386
|
+
"""CloudCompare PythonRuntime plugin exposing py4dgeo M3C2."""
|
|
387
|
+
|
|
388
|
+
def __init__(self) -> None:
|
|
389
|
+
super().__init__()
|
|
390
|
+
self.app = pycc.GetInstance()
|
|
391
|
+
self._dialog: _M3C2Dialog | None = None
|
|
392
|
+
|
|
393
|
+
def getActions(self):
|
|
394
|
+
return [pycc.Action(name="Compute M3C2 (py4dgeo)", target=self.run)]
|
|
395
|
+
|
|
396
|
+
def run(self) -> None:
|
|
397
|
+
if self._dialog is None:
|
|
398
|
+
self._dialog = _M3C2Dialog(self.app)
|
|
399
|
+
self._dialog.dialog.accepted.connect(self._compute)
|
|
400
|
+
self._dialog.show()
|
|
401
|
+
|
|
402
|
+
def _compute(self) -> None:
|
|
403
|
+
"""Compute from the assignments captured by the modeless dialog."""
|
|
404
|
+
|
|
405
|
+
assert self._dialog is not None
|
|
406
|
+
dialog = self._dialog
|
|
407
|
+
clouds = dialog.clouds
|
|
408
|
+
reference = clouds[dialog.reference.currentIndex()]
|
|
409
|
+
compared = clouds[dialog.compared.currentIndex()]
|
|
410
|
+
core = clouds[dialog.core.currentIndex()]
|
|
411
|
+
result = calculate_m3c2(
|
|
412
|
+
_global_points(reference),
|
|
413
|
+
_global_points(compared),
|
|
414
|
+
_global_points(core),
|
|
415
|
+
dialog.parameters(),
|
|
416
|
+
)
|
|
417
|
+
output = create_result_cloud(core, result, dialog.output_name.text().strip())
|
|
418
|
+
self.app.addToDB(output, updateZoom=False)
|
|
419
|
+
self.app.redrawAll()
|
|
420
|
+
print(
|
|
421
|
+
f"py4dgeo M3C2: added '{dialog.output_name.text().strip()}' "
|
|
422
|
+
f"with {output.size()} core points"
|
|
423
|
+
)
|
|
424
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Minimal CloudCompare Python plugin."""
|
|
2
|
+
|
|
3
|
+
import pycc
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def hello_world() -> None:
|
|
7
|
+
"""Print a greeting in CloudCompare's Python console."""
|
|
8
|
+
print("Hello from Python")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HelloWorldPlugin(pycc.PythonPluginInterface):
|
|
12
|
+
"""Expose a Hello World action in CloudCompare."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
super().__init__()
|
|
16
|
+
|
|
17
|
+
def getActions(self):
|
|
18
|
+
return [pycc.Action(name="Hello World", target=hello_world)]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""CloudCompare plugin for reporting the compile-time and runtime Qt versions."""
|
|
2
|
+
|
|
3
|
+
import pycc
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def report_qt_versions() -> None:
|
|
7
|
+
"""Print the Qt versions used to build and run CloudCompare."""
|
|
8
|
+
|
|
9
|
+
print(f"Qt compile-time version: {pycc.QT_VERSION_STR}")
|
|
10
|
+
print(f"Qt runtime version: {pycc.qtVersion()}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QtVersionsPlugin(pycc.PythonPluginInterface):
|
|
14
|
+
"""Expose Qt version information as a separate CloudCompare action."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
super().__init__()
|
|
18
|
+
|
|
19
|
+
def getActions(self):
|
|
20
|
+
return [pycc.Action(name="Report Qt Versions", target=report_qt_versions)]
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import sys
|
|
3
|
+
from types import ModuleType
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PythonPluginInterface:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Action:
|
|
14
|
+
def __init__(self, *, name, target, icon=None):
|
|
15
|
+
self.name = name
|
|
16
|
+
self.target = target
|
|
17
|
+
self.icon = icon
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PointCloud:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def m3c2_module(monkeypatch):
|
|
26
|
+
pycc = ModuleType("pycc")
|
|
27
|
+
pycc.PythonPluginInterface = PythonPluginInterface
|
|
28
|
+
pycc.Action = Action
|
|
29
|
+
pycc.ccPointCloud = PointCloud
|
|
30
|
+
pycc.PointCoordinateType = np.float32
|
|
31
|
+
pycc.ScalarType = np.float32
|
|
32
|
+
pycc.GetInstance = lambda: None
|
|
33
|
+
monkeypatch.setitem(sys.modules, "pycc", pycc)
|
|
34
|
+
sys.modules.pop("py4dgeo_cloudcompare.m3c2", None)
|
|
35
|
+
return importlib.import_module("py4dgeo_cloudcompare.m3c2")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_parse_normal_radii(m3c2_module):
|
|
39
|
+
assert m3c2_module.parse_normal_radii("0.5, 1; 2") == (0.5, 1.0, 2.0)
|
|
40
|
+
|
|
41
|
+
for invalid in ("", "one", "0", "-1", "nan"):
|
|
42
|
+
with pytest.raises(ValueError):
|
|
43
|
+
m3c2_module.parse_normal_radii(invalid)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_global_points_applies_cloudcompare_shift_and_scale(m3c2_module):
|
|
47
|
+
class Shift:
|
|
48
|
+
x, y, z = (-100.0, 50.0, -2.0)
|
|
49
|
+
|
|
50
|
+
class Cloud:
|
|
51
|
+
def points(self):
|
|
52
|
+
return np.array([[2.0, 4.0, 6.0]], dtype=np.float32)
|
|
53
|
+
|
|
54
|
+
def getGlobalShift(self):
|
|
55
|
+
return Shift()
|
|
56
|
+
|
|
57
|
+
def getGlobalScale(self):
|
|
58
|
+
return 2.0
|
|
59
|
+
|
|
60
|
+
# global = local / scale - shift
|
|
61
|
+
np.testing.assert_allclose(
|
|
62
|
+
m3c2_module._global_points(Cloud()), [[101.0, -48.0, 5.0]]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_loaded_point_clouds_walks_database_tree(m3c2_module):
|
|
67
|
+
class Group:
|
|
68
|
+
def __init__(self, *children):
|
|
69
|
+
self.children = children
|
|
70
|
+
|
|
71
|
+
def getChildrenNumber(self):
|
|
72
|
+
return len(self.children)
|
|
73
|
+
|
|
74
|
+
def getChild(self, index):
|
|
75
|
+
return self.children[index]
|
|
76
|
+
|
|
77
|
+
cloud1 = PointCloud()
|
|
78
|
+
cloud1.getChildrenNumber = lambda: 0
|
|
79
|
+
cloud2 = PointCloud()
|
|
80
|
+
cloud2.getChildrenNumber = lambda: 0
|
|
81
|
+
root = Group(Group(cloud1), cloud2)
|
|
82
|
+
app = type("App", (), {"dbRootObject": lambda self: root})()
|
|
83
|
+
|
|
84
|
+
assert m3c2_module._loaded_point_clouds(app) == [cloud1, cloud2]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_selection_only_controls_cloud_defaults(m3c2_module):
|
|
88
|
+
clouds = [PointCloud(), PointCloud(), PointCloud()]
|
|
89
|
+
for identifier, cloud in enumerate(clouds, start=10):
|
|
90
|
+
cloud.getUniqueID = lambda identifier=identifier: identifier
|
|
91
|
+
|
|
92
|
+
app = type(
|
|
93
|
+
"App",
|
|
94
|
+
(),
|
|
95
|
+
{"getSelectedEntities": lambda self: [clouds[2], clouds[0]]},
|
|
96
|
+
)()
|
|
97
|
+
assert m3c2_module._preferred_cloud_indices(app, clouds) == (0, 2)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_calculate_m3c2_maps_parameters(monkeypatch, m3c2_module):
|
|
101
|
+
py4dgeo = ModuleType("py4dgeo")
|
|
102
|
+
|
|
103
|
+
class Epoch:
|
|
104
|
+
def __init__(self, points):
|
|
105
|
+
self.points = points
|
|
106
|
+
|
|
107
|
+
class M3C2:
|
|
108
|
+
last_kwargs = None
|
|
109
|
+
|
|
110
|
+
def __init__(self, **kwargs):
|
|
111
|
+
M3C2.last_kwargs = kwargs
|
|
112
|
+
self.count = len(kwargs["corepoints"])
|
|
113
|
+
|
|
114
|
+
def run(self):
|
|
115
|
+
dtype = [
|
|
116
|
+
("lodetection", float),
|
|
117
|
+
("spread1", float),
|
|
118
|
+
("spread2", float),
|
|
119
|
+
("num_samples1", int),
|
|
120
|
+
("num_samples2", int),
|
|
121
|
+
]
|
|
122
|
+
return np.arange(self.count), np.zeros(self.count, dtype=dtype)
|
|
123
|
+
|
|
124
|
+
def directions(self):
|
|
125
|
+
return np.tile([0.0, 0.0, 1.0], (self.count, 1))
|
|
126
|
+
|
|
127
|
+
def directions_radii(self):
|
|
128
|
+
return np.full((self.count, 1), 2.0)
|
|
129
|
+
|
|
130
|
+
py4dgeo.Epoch = Epoch
|
|
131
|
+
py4dgeo.M3C2 = M3C2
|
|
132
|
+
monkeypatch.setitem(sys.modules, "py4dgeo", py4dgeo)
|
|
133
|
+
|
|
134
|
+
points = np.array([[0, 0, 0], [1, 1, 1]], dtype=np.float32)
|
|
135
|
+
parameters = m3c2_module.M3C2Parameters(
|
|
136
|
+
normal_radii=(1.0, 2.0),
|
|
137
|
+
cylinder_radius=3.0,
|
|
138
|
+
max_distance=4.0,
|
|
139
|
+
registration_error=0.2,
|
|
140
|
+
orientation_vector=(0.0, 1.0, 0.0),
|
|
141
|
+
robust_aggregation=True,
|
|
142
|
+
)
|
|
143
|
+
result = m3c2_module.calculate_m3c2(points, points, points, parameters)
|
|
144
|
+
|
|
145
|
+
assert M3C2.last_kwargs["cyl_radius"] == 3.0
|
|
146
|
+
assert M3C2.last_kwargs["normal_radii"] == [1.0, 2.0]
|
|
147
|
+
assert M3C2.last_kwargs["max_distance"] == 4.0
|
|
148
|
+
assert M3C2.last_kwargs["registration_error"] == 0.2
|
|
149
|
+
assert M3C2.last_kwargs["robust_aggr"] is True
|
|
150
|
+
assert result.distances.tolist() == [0, 1]
|
|
151
|
+
assert result.normal_radii.tolist() == [2.0, 2.0]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_plugin_action(m3c2_module):
|
|
155
|
+
plugin = m3c2_module.M3C2Plugin()
|
|
156
|
+
actions = plugin.getActions()
|
|
157
|
+
assert len(actions) == 1
|
|
158
|
+
assert actions[0].name == "Compute M3C2 (py4dgeo)"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_plugin_opens_dialog_without_loaded_clouds(monkeypatch, m3c2_module):
|
|
162
|
+
shown = []
|
|
163
|
+
|
|
164
|
+
class Signal:
|
|
165
|
+
def connect(self, callback):
|
|
166
|
+
self.callback = callback
|
|
167
|
+
|
|
168
|
+
class Dialog:
|
|
169
|
+
def __init__(self, app):
|
|
170
|
+
self.app = app
|
|
171
|
+
self.dialog = type("Widget", (), {"accepted": Signal()})()
|
|
172
|
+
|
|
173
|
+
def show(self):
|
|
174
|
+
shown.append(True)
|
|
175
|
+
|
|
176
|
+
monkeypatch.setattr(m3c2_module, "_M3C2Dialog", Dialog)
|
|
177
|
+
plugin = m3c2_module.M3C2Plugin()
|
|
178
|
+
plugin.run()
|
|
179
|
+
assert shown == [True]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_plugin_run_propagates_dialog_exception(monkeypatch, m3c2_module):
|
|
183
|
+
error = RuntimeError("dialog failed")
|
|
184
|
+
|
|
185
|
+
class Dialog:
|
|
186
|
+
def __init__(self, app):
|
|
187
|
+
raise error
|
|
188
|
+
|
|
189
|
+
monkeypatch.setattr(m3c2_module, "_M3C2Dialog", Dialog)
|
|
190
|
+
plugin = m3c2_module.M3C2Plugin()
|
|
191
|
+
|
|
192
|
+
with pytest.raises(RuntimeError) as caught:
|
|
193
|
+
plugin.run()
|
|
194
|
+
|
|
195
|
+
assert caught.value is error
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_dialog_validation_propagates_exception(m3c2_module):
|
|
199
|
+
dialog = type(
|
|
200
|
+
"Dialog",
|
|
201
|
+
(),
|
|
202
|
+
{
|
|
203
|
+
"clouds": [],
|
|
204
|
+
"refresh_clouds": lambda self: None,
|
|
205
|
+
},
|
|
206
|
+
)()
|
|
207
|
+
|
|
208
|
+
with pytest.raises(ValueError, match="Load at least two point clouds"):
|
|
209
|
+
m3c2_module._M3C2Dialog._validate_and_accept(dialog)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_plugin_compute_propagates_raw_exception(monkeypatch, m3c2_module):
|
|
213
|
+
error = RuntimeError("M3C2 failed")
|
|
214
|
+
cloud = object()
|
|
215
|
+
combo = type("Combo", (), {"currentIndex": lambda self: 0})()
|
|
216
|
+
output_name = type("Name", (), {"text": lambda self: "result"})()
|
|
217
|
+
dialog = type(
|
|
218
|
+
"Dialog",
|
|
219
|
+
(),
|
|
220
|
+
{
|
|
221
|
+
"clouds": [cloud],
|
|
222
|
+
"reference": combo,
|
|
223
|
+
"compared": combo,
|
|
224
|
+
"core": combo,
|
|
225
|
+
"output_name": output_name,
|
|
226
|
+
"parameters": lambda self: m3c2_module.M3C2Parameters(),
|
|
227
|
+
},
|
|
228
|
+
)()
|
|
229
|
+
monkeypatch.setattr(m3c2_module, "_global_points", lambda cloud: np.empty((1, 3)))
|
|
230
|
+
|
|
231
|
+
def fail(*args):
|
|
232
|
+
raise error
|
|
233
|
+
|
|
234
|
+
monkeypatch.setattr(m3c2_module, "calculate_m3c2", fail)
|
|
235
|
+
plugin = m3c2_module.M3C2Plugin()
|
|
236
|
+
plugin._dialog = dialog
|
|
237
|
+
|
|
238
|
+
with pytest.raises(RuntimeError) as caught:
|
|
239
|
+
plugin._compute()
|
|
240
|
+
|
|
241
|
+
assert caught.value is error
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import sys
|
|
3
|
+
from types import ModuleType
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PythonPluginInterface:
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Action:
|
|
11
|
+
def __init__(self, *, name, target, icon=None):
|
|
12
|
+
self.name = name
|
|
13
|
+
self.target = target
|
|
14
|
+
self.icon = icon
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_hello_world_plugin(monkeypatch, capsys):
|
|
18
|
+
pycc = ModuleType("pycc")
|
|
19
|
+
pycc.PythonPluginInterface = PythonPluginInterface
|
|
20
|
+
pycc.Action = Action
|
|
21
|
+
monkeypatch.setitem(sys.modules, "pycc", pycc)
|
|
22
|
+
|
|
23
|
+
plugin_module = importlib.import_module("py4dgeo_cloudcompare.plugin")
|
|
24
|
+
plugin = plugin_module.HelloWorldPlugin()
|
|
25
|
+
actions = plugin.getActions()
|
|
26
|
+
|
|
27
|
+
assert len(actions) == 1
|
|
28
|
+
assert actions[0].name == "Hello World"
|
|
29
|
+
|
|
30
|
+
actions[0].target()
|
|
31
|
+
assert capsys.readouterr().out == "Hello from Python\n"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import sys
|
|
3
|
+
from types import ModuleType
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PythonPluginInterface:
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Action:
|
|
11
|
+
def __init__(self, *, name, target, icon=None):
|
|
12
|
+
self.name = name
|
|
13
|
+
self.target = target
|
|
14
|
+
self.icon = icon
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_qt_versions_plugin_reports_compile_time_and_runtime_versions(
|
|
18
|
+
monkeypatch, capsys
|
|
19
|
+
):
|
|
20
|
+
pycc = ModuleType("pycc")
|
|
21
|
+
pycc.PythonPluginInterface = PythonPluginInterface
|
|
22
|
+
pycc.Action = Action
|
|
23
|
+
pycc.QT_VERSION_STR = "6.8.3"
|
|
24
|
+
pycc.qtVersion = lambda: "6.8.2"
|
|
25
|
+
monkeypatch.setitem(sys.modules, "pycc", pycc)
|
|
26
|
+
sys.modules.pop("py4dgeo_cloudcompare.qt_versions", None)
|
|
27
|
+
|
|
28
|
+
plugin_module = importlib.import_module("py4dgeo_cloudcompare.qt_versions")
|
|
29
|
+
actions = plugin_module.QtVersionsPlugin().getActions()
|
|
30
|
+
|
|
31
|
+
assert len(actions) == 1
|
|
32
|
+
assert actions[0].name == "Report Qt Versions"
|
|
33
|
+
|
|
34
|
+
actions[0].target()
|
|
35
|
+
assert capsys.readouterr().out == (
|
|
36
|
+
"Qt compile-time version: 6.8.3\n"
|
|
37
|
+
"Qt runtime version: 6.8.2\n"
|
|
38
|
+
)
|