pytlidar-cc 0.1.0__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.
- pytlidar_cc-0.1.0/PKG-INFO +77 -0
- pytlidar_cc-0.1.0/README.md +64 -0
- pytlidar_cc-0.1.0/pyproject.toml +35 -0
- pytlidar_cc-0.1.0/setup.cfg +4 -0
- pytlidar_cc-0.1.0/src/pytlidar_cc/__init__.py +3 -0
- pytlidar_cc-0.1.0/src/pytlidar_cc/adapter.py +137 -0
- pytlidar_cc-0.1.0/src/pytlidar_cc/dialog.py +313 -0
- pytlidar_cc-0.1.0/src/pytlidar_cc/mesh.py +57 -0
- pytlidar_cc-0.1.0/src/pytlidar_cc/plugin.py +366 -0
- pytlidar_cc-0.1.0/src/pytlidar_cc/worker.py +95 -0
- pytlidar_cc-0.1.0/src/pytlidar_cc.egg-info/PKG-INFO +77 -0
- pytlidar_cc-0.1.0/src/pytlidar_cc.egg-info/SOURCES.txt +15 -0
- pytlidar_cc-0.1.0/src/pytlidar_cc.egg-info/dependency_links.txt +1 -0
- pytlidar_cc-0.1.0/src/pytlidar_cc.egg-info/entry_points.txt +2 -0
- pytlidar_cc-0.1.0/src/pytlidar_cc.egg-info/requires.txt +1 -0
- pytlidar_cc-0.1.0/src/pytlidar_cc.egg-info/top_level.txt +1 -0
- pytlidar_cc-0.1.0/tests/test_mesh.py +66 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytlidar-cc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CloudCompare plugin that runs PyTLidar TreeQSM on a selected point cloud
|
|
5
|
+
Author: John Hagood
|
|
6
|
+
License-Expression: GPL-3.0-only
|
|
7
|
+
Project-URL: Homepage, https://github.com/Landscape-CV/PyTLidar
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: <3.13,>=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: PyTLidar>=1.0.6
|
|
13
|
+
|
|
14
|
+
# pytlidar-cc
|
|
15
|
+
|
|
16
|
+
CloudCompare plugin that runs [PyTLidar](https://github.com/Landscape-CV/PyTLidar)
|
|
17
|
+
TreeQSM on selected point clouds and puts the models back into the scene.
|
|
18
|
+
|
|
19
|
+
## What you get
|
|
20
|
+
|
|
21
|
+
One group per fitted model, named after the source cloud:
|
|
22
|
+
|
|
23
|
+
- `cylinders`: the cylinder start points with `radius`, `length`,
|
|
24
|
+
`branch_order`, `branch_id` and `surface_coverage` scalar fields.
|
|
25
|
+
- `cylinder mesh`: the cylinders as one triangle mesh, `branch_order` on its
|
|
26
|
+
vertices for colouring.
|
|
27
|
+
- `stem mesh` when stem triangulation is on.
|
|
28
|
+
- The tree metrics printed to the console.
|
|
29
|
+
|
|
30
|
+
Everything is in the source cloud's frame and carries its global shift.
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
- CloudCompare 2.13 or newer with the PythonRuntime plugin. It ships in the
|
|
35
|
+
Windows installer and in the OpenFields macOS builds
|
|
36
|
+
(simulation.openfields.fr); otherwise build it from source.
|
|
37
|
+
- PyTLidar 1.0.6 or newer in the plugin's Python environment.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
With the Python environment CloudCompare uses:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
python -m pip install PyTLidar ./pytlidar_cc
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
1. Select one or more point clouds (one segmented tree each).
|
|
50
|
+
2. Plugins, PyTLidar, Run PyTLidar.
|
|
51
|
+
3. Adjust the settings, OK.
|
|
52
|
+
|
|
53
|
+
Settings: generated or custom PatchDiam values (several values per parameter
|
|
54
|
+
runs every combination and the metric picks the best model), an optional
|
|
55
|
+
scalar field filter, stem triangulation, repeat runs and parallel workers,
|
|
56
|
+
and where the model files save.
|
|
57
|
+
|
|
58
|
+
## Notes
|
|
59
|
+
|
|
60
|
+
- Model files (the QSM npz, the cylinder, branch and treedata tables and a
|
|
61
|
+
run_info.json per run) save into one folder per CloudCompare session,
|
|
62
|
+
under Documents/PyTLidar/results by default; run numbers continue across
|
|
63
|
+
jobs in a session.
|
|
64
|
+
- TreeQSM is stochastic. Repeat runs puts each model in the scene as its own
|
|
65
|
+
group with a console summary that flags suspect fits; keep the tree you
|
|
66
|
+
like.
|
|
67
|
+
- Fits run in worker processes: progress streams to the console and the job
|
|
68
|
+
can be cancelled from the progress dialog. Parallel runs each use their
|
|
69
|
+
own memory, so keep workers low for very large clouds.
|
|
70
|
+
- Segment the tree from the plot first (for example with the Treeiso plugin)
|
|
71
|
+
and clean obvious noise.
|
|
72
|
+
- A running job holds off system idle sleep so fits continue with the
|
|
73
|
+
screen locked; closing the lid still sleeps as normal.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
GPL-3.0-only, like PyTLidar.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# pytlidar-cc
|
|
2
|
+
|
|
3
|
+
CloudCompare plugin that runs [PyTLidar](https://github.com/Landscape-CV/PyTLidar)
|
|
4
|
+
TreeQSM on selected point clouds and puts the models back into the scene.
|
|
5
|
+
|
|
6
|
+
## What you get
|
|
7
|
+
|
|
8
|
+
One group per fitted model, named after the source cloud:
|
|
9
|
+
|
|
10
|
+
- `cylinders`: the cylinder start points with `radius`, `length`,
|
|
11
|
+
`branch_order`, `branch_id` and `surface_coverage` scalar fields.
|
|
12
|
+
- `cylinder mesh`: the cylinders as one triangle mesh, `branch_order` on its
|
|
13
|
+
vertices for colouring.
|
|
14
|
+
- `stem mesh` when stem triangulation is on.
|
|
15
|
+
- The tree metrics printed to the console.
|
|
16
|
+
|
|
17
|
+
Everything is in the source cloud's frame and carries its global shift.
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- CloudCompare 2.13 or newer with the PythonRuntime plugin. It ships in the
|
|
22
|
+
Windows installer and in the OpenFields macOS builds
|
|
23
|
+
(simulation.openfields.fr); otherwise build it from source.
|
|
24
|
+
- PyTLidar 1.0.6 or newer in the plugin's Python environment.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
With the Python environment CloudCompare uses:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
python -m pip install PyTLidar ./pytlidar_cc
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
1. Select one or more point clouds (one segmented tree each).
|
|
37
|
+
2. Plugins, PyTLidar, Run PyTLidar.
|
|
38
|
+
3. Adjust the settings, OK.
|
|
39
|
+
|
|
40
|
+
Settings: generated or custom PatchDiam values (several values per parameter
|
|
41
|
+
runs every combination and the metric picks the best model), an optional
|
|
42
|
+
scalar field filter, stem triangulation, repeat runs and parallel workers,
|
|
43
|
+
and where the model files save.
|
|
44
|
+
|
|
45
|
+
## Notes
|
|
46
|
+
|
|
47
|
+
- Model files (the QSM npz, the cylinder, branch and treedata tables and a
|
|
48
|
+
run_info.json per run) save into one folder per CloudCompare session,
|
|
49
|
+
under Documents/PyTLidar/results by default; run numbers continue across
|
|
50
|
+
jobs in a session.
|
|
51
|
+
- TreeQSM is stochastic. Repeat runs puts each model in the scene as its own
|
|
52
|
+
group with a console summary that flags suspect fits; keep the tree you
|
|
53
|
+
like.
|
|
54
|
+
- Fits run in worker processes: progress streams to the console and the job
|
|
55
|
+
can be cancelled from the progress dialog. Parallel runs each use their
|
|
56
|
+
own memory, so keep workers low for very large clouds.
|
|
57
|
+
- Segment the tree from the plot first (for example with the Treeiso plugin)
|
|
58
|
+
and clean obvious noise.
|
|
59
|
+
- A running job holds off system idle sleep so fits continue with the
|
|
60
|
+
screen locked; closing the lid still sleeps as normal.
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
GPL-3.0-only, like PyTLidar.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pytlidar-cc"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CloudCompare plugin that runs PyTLidar TreeQSM on a selected point cloud"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "GPL-3.0-only"
|
|
11
|
+
authors = [{ name = "John Hagood" }]
|
|
12
|
+
requires-python = ">=3.10, <3.13"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"PyTLidar>=1.0.6",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.entry-points."cloudcompare.plugins"]
|
|
22
|
+
PyTLidar = "pytlidar_cc.plugin:PyTLidarPlugin"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/Landscape-CV/PyTLidar"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
pytlidar_cc = ["assets/*.png"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Conversions from PyTLidar QSM results to CloudCompare objects."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pycc
|
|
7
|
+
|
|
8
|
+
from .mesh import cylinder_mesh
|
|
9
|
+
|
|
10
|
+
FACETS = 8
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cloud_from_cc(cc_cloud):
|
|
14
|
+
"""Nx3 float64 array from a ccPointCloud."""
|
|
15
|
+
return cc_cloud.points().astype(np.float64)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def results_to_cc(cc, source, qsm, offset, label=None):
|
|
19
|
+
"""Add one QSM to the scene and print the treedata summary.
|
|
20
|
+
|
|
21
|
+
offset undoes the plugin's mean centring, added in float64 before the
|
|
22
|
+
float32 cast. label distinguishes the groups of a multi-run job.
|
|
23
|
+
"""
|
|
24
|
+
cylinder = qsm["cylinder"]
|
|
25
|
+
starts = np.asarray(cylinder["start"], dtype=np.float64) + offset
|
|
26
|
+
|
|
27
|
+
name = f"PyTLidar - {source.getName()}"
|
|
28
|
+
if label:
|
|
29
|
+
name = f"{name} ({label})"
|
|
30
|
+
group = pycc.ccHObject(name)
|
|
31
|
+
group.addChild(_cylinder_cloud(cylinder, starts, source))
|
|
32
|
+
if starts.shape[0] > 0:
|
|
33
|
+
group.addChild(_cylinder_ccmesh(cylinder, starts, source))
|
|
34
|
+
tri = qsm.get("triangulation")
|
|
35
|
+
if isinstance(tri, dict) and np.size(tri.get("vert", [])) > 0:
|
|
36
|
+
group.addChild(_stem_ccmesh(tri, offset, source))
|
|
37
|
+
cc.addToDB(group)
|
|
38
|
+
cc.updateUI()
|
|
39
|
+
_print_tree_data(qsm["treedata"], name)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _make_cloud(name, xyz, source):
|
|
43
|
+
cloud = pycc.ccPointCloud(np.ascontiguousarray(xyz[:, 0], dtype=np.float32),
|
|
44
|
+
np.ascontiguousarray(xyz[:, 1], dtype=np.float32),
|
|
45
|
+
np.ascontiguousarray(xyz[:, 2], dtype=np.float32))
|
|
46
|
+
cloud.setName(name)
|
|
47
|
+
cloud.copyGlobalShiftAndScale(source)
|
|
48
|
+
return cloud
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _add_scalar_field(cloud, name, values):
|
|
52
|
+
idx = cloud.addScalarField(name)
|
|
53
|
+
sf = cloud.getScalarField(idx)
|
|
54
|
+
sf.asArray()[:] = np.asarray(values, dtype=np.float32).reshape(-1)
|
|
55
|
+
sf.computeMinAndMax()
|
|
56
|
+
return idx
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _cylinder_cloud(cylinder, starts, source):
|
|
60
|
+
"""Cylinder start points with the per-cylinder attributes as scalar
|
|
61
|
+
fields, the filterable view of the model."""
|
|
62
|
+
cloud = _make_cloud("cylinders", starts, source)
|
|
63
|
+
n = starts.shape[0]
|
|
64
|
+
if n == 0:
|
|
65
|
+
return cloud
|
|
66
|
+
|
|
67
|
+
for name, key in (("radius", "radius"), ("length", "length"),
|
|
68
|
+
("branch_order", "BranchOrder"), ("branch_id", "branch"),
|
|
69
|
+
("surface_coverage", "SurfCov")):
|
|
70
|
+
idx = _add_scalar_field(cloud, name, cylinder[key])
|
|
71
|
+
if name == "radius":
|
|
72
|
+
cloud.setCurrentDisplayedScalarField(idx)
|
|
73
|
+
cloud.showSF(True)
|
|
74
|
+
return cloud
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _cylinder_ccmesh(cylinder, starts, source):
|
|
78
|
+
"""Merged triangle mesh of all cylinders, the visual view of the model.
|
|
79
|
+
branch_order rides on the vertices so the mesh can be coloured by it."""
|
|
80
|
+
vertices, triangles, vertex_cylinder = cylinder_mesh(
|
|
81
|
+
starts,
|
|
82
|
+
np.asarray(cylinder["axis"], dtype=np.float64),
|
|
83
|
+
np.asarray(cylinder["length"], dtype=np.float64),
|
|
84
|
+
np.asarray(cylinder["radius"], dtype=np.float64),
|
|
85
|
+
facets=FACETS,
|
|
86
|
+
)
|
|
87
|
+
vcloud = _make_cloud("vertices", vertices, source)
|
|
88
|
+
idx = _add_scalar_field(vcloud, "branch_order",
|
|
89
|
+
np.asarray(cylinder["BranchOrder"])[vertex_cylinder])
|
|
90
|
+
vcloud.setCurrentDisplayedScalarField(idx)
|
|
91
|
+
|
|
92
|
+
mesh = pycc.ccMesh(vcloud)
|
|
93
|
+
mesh.setName("cylinder mesh")
|
|
94
|
+
if hasattr(mesh, "reserve"):
|
|
95
|
+
# Some pycc builds require the triangle capacity up front.
|
|
96
|
+
mesh.reserve(triangles.shape[0])
|
|
97
|
+
for a, b, c in triangles:
|
|
98
|
+
mesh.addTriangle(int(a), int(b), int(c))
|
|
99
|
+
mesh.addChild(vcloud)
|
|
100
|
+
return mesh
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _stem_ccmesh(tri, offset, source):
|
|
104
|
+
"""The stem triangulation (inputs Tria) as its own mesh, in the same frame
|
|
105
|
+
as the cylinders."""
|
|
106
|
+
vertices = np.asarray(tri["vert"], dtype=np.float64) + offset
|
|
107
|
+
facets = np.asarray(tri["facet"], dtype=np.int64)
|
|
108
|
+
vcloud = _make_cloud("stem vertices", vertices, source)
|
|
109
|
+
mesh = pycc.ccMesh(vcloud)
|
|
110
|
+
mesh.setName("stem mesh")
|
|
111
|
+
if hasattr(mesh, "reserve"):
|
|
112
|
+
mesh.reserve(facets.shape[0])
|
|
113
|
+
for a, b, c in facets:
|
|
114
|
+
mesh.addTriangle(int(a), int(b), int(c))
|
|
115
|
+
mesh.addChild(vcloud)
|
|
116
|
+
return mesh
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _print_tree_data(treedata, name):
|
|
120
|
+
"""Tree metrics to the CloudCompare console. tree_data casts every value
|
|
121
|
+
to float32, so the counts need an explicit int."""
|
|
122
|
+
lines = [
|
|
123
|
+
f"--- {name} ---",
|
|
124
|
+
f" Total volume: {float(treedata.get('TotalVolume', 0)):.3f} L",
|
|
125
|
+
f" Trunk volume: {float(treedata.get('TrunkVolume', 0)):.3f} L",
|
|
126
|
+
f" Branch volume: {float(treedata.get('BranchVolume', 0)):.3f} L",
|
|
127
|
+
f" Tree height: {float(treedata.get('TreeHeight', 0)):.2f} m",
|
|
128
|
+
f" Trunk length: {float(treedata.get('TrunkLength', 0)):.2f} m",
|
|
129
|
+
f" Branch length: {float(treedata.get('BranchLength', 0)):.2f} m",
|
|
130
|
+
f" DBH (QSM): {float(treedata.get('DBHqsm', 0)):.4f} m",
|
|
131
|
+
f" DBH (cylinder): {float(treedata.get('DBHcyl', 0)):.4f} m",
|
|
132
|
+
f" Branches: {int(treedata.get('NumberBranches', 0))}",
|
|
133
|
+
f" Max branch order: {int(treedata.get('MaxBranchOrder', 0))}",
|
|
134
|
+
"---",
|
|
135
|
+
]
|
|
136
|
+
sys.stdout.write("\n".join(lines) + "\n")
|
|
137
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Settings dialog for the TreeQSM action.
|
|
2
|
+
|
|
3
|
+
CloudCompare's PythonRuntime ships its own Qt binding (PyQt5 on current
|
|
4
|
+
builds); importing a second Qt into CloudCompare's process can crash it, so
|
|
5
|
+
that binding is preferred and PySide6 is only a fallback (the combination
|
|
6
|
+
3DFin ships with).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from PyQt5.QtCore import QSettings
|
|
11
|
+
from PyQt5.QtWidgets import (
|
|
12
|
+
QApplication, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
|
13
|
+
QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout,
|
|
14
|
+
QLabel, QLineEdit, QMessageBox, QProgressDialog, QPushButton,
|
|
15
|
+
QSpinBox, QVBoxLayout, QWidget,
|
|
16
|
+
)
|
|
17
|
+
except ImportError:
|
|
18
|
+
from PySide6.QtCore import QSettings
|
|
19
|
+
from PySide6.QtWidgets import (
|
|
20
|
+
QApplication, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
|
21
|
+
QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout,
|
|
22
|
+
QLabel, QLineEdit, QMessageBox, QProgressDialog, QPushButton,
|
|
23
|
+
QSpinBox, QVBoxLayout, QWidget,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
WARNING = (
|
|
29
|
+
"TreeQSM uses randomised cover sets: every run gives a slightly "
|
|
30
|
+
"different model.\nProgress appears in the console; the first run also "
|
|
31
|
+
"compiles the numba kernels."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _JobProgressDialog(QProgressDialog):
|
|
36
|
+
"""Qt emits canceled() on any close of the dialog, screen lock included.
|
|
37
|
+
Closes are ignored until allow_close() so only the button cancels."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, *args):
|
|
40
|
+
super().__init__(*args)
|
|
41
|
+
self._closable = False
|
|
42
|
+
|
|
43
|
+
def allow_close(self):
|
|
44
|
+
self._closable = True
|
|
45
|
+
|
|
46
|
+
def closeEvent(self, event):
|
|
47
|
+
if self._closable:
|
|
48
|
+
event.accept()
|
|
49
|
+
else:
|
|
50
|
+
event.ignore()
|
|
51
|
+
|
|
52
|
+
def keyPressEvent(self, event):
|
|
53
|
+
event.ignore()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def make_progress_dialog(title):
|
|
57
|
+
"""Non-modal indeterminate progress dialog with a working Cancel."""
|
|
58
|
+
dlg = _JobProgressDialog(title, "Cancel", 0, 0)
|
|
59
|
+
dlg.setWindowTitle("PyTLidar")
|
|
60
|
+
dlg.setMinimumDuration(0)
|
|
61
|
+
dlg.show()
|
|
62
|
+
return dlg
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_diams(text):
|
|
66
|
+
"""Comma-separated PatchDiam values as a list of floats, each in (0, 1].
|
|
67
|
+
|
|
68
|
+
Raises ValueError on anything else, including an empty string.
|
|
69
|
+
"""
|
|
70
|
+
values = [float(v) for v in text.split(",") if v.strip()]
|
|
71
|
+
if not values or any(not 0.0 < v <= 1.0 for v in values):
|
|
72
|
+
raise ValueError(f"PatchDiam values must be in (0, 1]: {text!r}")
|
|
73
|
+
return values
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _scalar_field_names(source):
|
|
77
|
+
return [source.getScalarFieldName(i)
|
|
78
|
+
for i in range(source.getNumberOfScalarFields())]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _SettingsDialog(QDialog):
|
|
82
|
+
def __init__(self, clouds, metrics):
|
|
83
|
+
super().__init__()
|
|
84
|
+
self.n_clouds = len(clouds)
|
|
85
|
+
title = (clouds[0].getName() if self.n_clouds == 1
|
|
86
|
+
else f"{self.n_clouds} clouds")
|
|
87
|
+
self.setWindowTitle(f"PyTLidar (TreeQSM): {title}")
|
|
88
|
+
self.settings = None
|
|
89
|
+
|
|
90
|
+
layout = QVBoxLayout(self)
|
|
91
|
+
|
|
92
|
+
patch = QGroupBox("PatchDiam")
|
|
93
|
+
patch_layout = QVBoxLayout(patch)
|
|
94
|
+
self.custom_check = QCheckBox("Use custom values")
|
|
95
|
+
self.custom_check.setToolTip(
|
|
96
|
+
"Off: generate candidate PatchDiam values automatically. "
|
|
97
|
+
"On: give the values to test, comma separated.")
|
|
98
|
+
patch_layout.addWidget(self.custom_check)
|
|
99
|
+
|
|
100
|
+
self.auto_box = QWidget()
|
|
101
|
+
auto_form = QFormLayout(self.auto_box)
|
|
102
|
+
self.n_pd1 = QSpinBox()
|
|
103
|
+
self.n_pd2min = QSpinBox()
|
|
104
|
+
self.n_pd2max = QSpinBox()
|
|
105
|
+
for spin in (self.n_pd1, self.n_pd2min, self.n_pd2max):
|
|
106
|
+
spin.setRange(1, 3)
|
|
107
|
+
spin.setValue(1)
|
|
108
|
+
auto_form.addRow("PatchDiam1 candidates", self.n_pd1)
|
|
109
|
+
auto_form.addRow("PatchDiam2Min candidates", self.n_pd2min)
|
|
110
|
+
auto_form.addRow("PatchDiam2Max candidates", self.n_pd2max)
|
|
111
|
+
patch_layout.addWidget(self.auto_box)
|
|
112
|
+
|
|
113
|
+
self.custom_box = QWidget()
|
|
114
|
+
custom_form = QFormLayout(self.custom_box)
|
|
115
|
+
self.pd1 = QLineEdit()
|
|
116
|
+
self.pd2min = QLineEdit()
|
|
117
|
+
self.pd2max = QLineEdit()
|
|
118
|
+
for edit, hint in ((self.pd1, "e.g. 0.1"), (self.pd2min, "e.g. 0.02"),
|
|
119
|
+
(self.pd2max, "e.g. 0.06")):
|
|
120
|
+
edit.setPlaceholderText(hint)
|
|
121
|
+
custom_form.addRow("PatchDiam1", self.pd1)
|
|
122
|
+
custom_form.addRow("PatchDiam2Min", self.pd2min)
|
|
123
|
+
custom_form.addRow("PatchDiam2Max", self.pd2max)
|
|
124
|
+
self.custom_box.hide()
|
|
125
|
+
patch_layout.addWidget(self.custom_box)
|
|
126
|
+
|
|
127
|
+
self.count_label = QLabel("")
|
|
128
|
+
patch_layout.addWidget(self.count_label)
|
|
129
|
+
|
|
130
|
+
self.custom_check.toggled.connect(self.auto_box.setHidden)
|
|
131
|
+
self.custom_check.toggled.connect(self.custom_box.setVisible)
|
|
132
|
+
self.custom_check.toggled.connect(self._update_count)
|
|
133
|
+
for spin in (self.n_pd1, self.n_pd2min, self.n_pd2max):
|
|
134
|
+
spin.valueChanged.connect(self._update_count)
|
|
135
|
+
for edit in (self.pd1, self.pd2min, self.pd2max):
|
|
136
|
+
edit.textChanged.connect(self._update_count)
|
|
137
|
+
self._update_count()
|
|
138
|
+
layout.addWidget(patch)
|
|
139
|
+
|
|
140
|
+
# Union across the selection; clouds without the chosen field are
|
|
141
|
+
# not filtered.
|
|
142
|
+
self.sf_names = []
|
|
143
|
+
for cloud in clouds:
|
|
144
|
+
for name in _scalar_field_names(cloud):
|
|
145
|
+
if name not in self.sf_names:
|
|
146
|
+
self.sf_names.append(name)
|
|
147
|
+
self.sf_check = None
|
|
148
|
+
if self.sf_names:
|
|
149
|
+
sf_box = QGroupBox("Point filter")
|
|
150
|
+
sf_form = QFormLayout(sf_box)
|
|
151
|
+
self.sf_check = QCheckBox("Filter by scalar field")
|
|
152
|
+
self.sf_combo = QComboBox()
|
|
153
|
+
self.sf_combo.addItems(self.sf_names)
|
|
154
|
+
for i, name in enumerate(self.sf_names):
|
|
155
|
+
if "intensity" in name.lower():
|
|
156
|
+
self.sf_combo.setCurrentIndex(i)
|
|
157
|
+
break
|
|
158
|
+
self.sf_threshold = QDoubleSpinBox()
|
|
159
|
+
self.sf_threshold.setRange(-1e9, 1e9)
|
|
160
|
+
self.sf_threshold.setDecimals(3)
|
|
161
|
+
self.sf_combo.setEnabled(False)
|
|
162
|
+
self.sf_threshold.setEnabled(False)
|
|
163
|
+
self.sf_check.toggled.connect(self.sf_combo.setEnabled)
|
|
164
|
+
self.sf_check.toggled.connect(self.sf_threshold.setEnabled)
|
|
165
|
+
sf_form.addRow(self.sf_check)
|
|
166
|
+
sf_form.addRow("Field", self.sf_combo)
|
|
167
|
+
sf_form.addRow("Keep values at least", self.sf_threshold)
|
|
168
|
+
layout.addWidget(sf_box)
|
|
169
|
+
|
|
170
|
+
metric_form = QFormLayout()
|
|
171
|
+
self.metric_combo = QComboBox()
|
|
172
|
+
self.metric_combo.addItems(list(metrics))
|
|
173
|
+
if "all_mean_dis" in metrics:
|
|
174
|
+
self.metric_combo.setCurrentText("all_mean_dis")
|
|
175
|
+
self.metric_combo.setToolTip(
|
|
176
|
+
"Picks the best model when several PatchDiam combinations run. "
|
|
177
|
+
"Ignored for a single combination.")
|
|
178
|
+
metric_form.addRow("Optimal model metric", self.metric_combo)
|
|
179
|
+
self.tria_check = QCheckBox("Triangulate the stem")
|
|
180
|
+
self.tria_check.setToolTip(
|
|
181
|
+
"Model the bottom of the trunk as a triangle mesh, better volume for "
|
|
182
|
+
"large or buttressed stems. Adds a stem mesh to the output.")
|
|
183
|
+
metric_form.addRow(self.tria_check)
|
|
184
|
+
self.runs_spin = QSpinBox()
|
|
185
|
+
self.runs_spin.setRange(1, 100)
|
|
186
|
+
self.runs_spin.setValue(1)
|
|
187
|
+
self.runs_spin.setToolTip(
|
|
188
|
+
"TreeQSM is stochastic, so repeated runs give different models. "
|
|
189
|
+
"Each run is added to the scene as its own group; keep the tree "
|
|
190
|
+
"you like and delete the rest.")
|
|
191
|
+
metric_form.addRow("Repeat runs", self.runs_spin)
|
|
192
|
+
self.workers_spin = QSpinBox()
|
|
193
|
+
self.workers_spin.setRange(1, 8)
|
|
194
|
+
self.workers_spin.setValue(1)
|
|
195
|
+
self.workers_spin.setToolTip(
|
|
196
|
+
"How many runs fit at the same time. Each parallel run uses its "
|
|
197
|
+
"own memory, so keep this low for very large clouds.")
|
|
198
|
+
metric_form.addRow("Parallel workers", self.workers_spin)
|
|
199
|
+
self.runs_spin.valueChanged.connect(self._update_count)
|
|
200
|
+
self.save_check = QCheckBox("Save model files")
|
|
201
|
+
self.save_check.setChecked(True)
|
|
202
|
+
self.save_check.setToolTip(
|
|
203
|
+
"Writes the QSM archive (npz), the cylinder, branch and treedata "
|
|
204
|
+
"tables and a run_info.json for each run.")
|
|
205
|
+
metric_form.addRow(self.save_check)
|
|
206
|
+
default_dir = QSettings("PyTLidar", "pytlidar-cc").value(
|
|
207
|
+
"results_dir", str(Path.home() / "Documents" / "PyTLidar" / "results"))
|
|
208
|
+
self.dir_edit = QLineEdit(str(default_dir))
|
|
209
|
+
browse = QPushButton("Browse")
|
|
210
|
+
browse.clicked.connect(self._pick_dir)
|
|
211
|
+
dir_row = QWidget()
|
|
212
|
+
dir_lay = QHBoxLayout(dir_row)
|
|
213
|
+
dir_lay.setContentsMargins(0, 0, 0, 0)
|
|
214
|
+
dir_lay.addWidget(self.dir_edit)
|
|
215
|
+
dir_lay.addWidget(browse)
|
|
216
|
+
self.save_check.toggled.connect(dir_row.setEnabled)
|
|
217
|
+
metric_form.addRow("Results folder", dir_row)
|
|
218
|
+
layout.addLayout(metric_form)
|
|
219
|
+
|
|
220
|
+
warning = QLabel(WARNING)
|
|
221
|
+
warning.setWordWrap(True)
|
|
222
|
+
layout.addWidget(warning)
|
|
223
|
+
|
|
224
|
+
self.error_label = QLabel("")
|
|
225
|
+
self.error_label.setStyleSheet("color: red")
|
|
226
|
+
self.error_label.setWordWrap(True)
|
|
227
|
+
layout.addWidget(self.error_label)
|
|
228
|
+
|
|
229
|
+
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|
230
|
+
buttons.accepted.connect(self._validate_and_accept)
|
|
231
|
+
buttons.rejected.connect(self.reject)
|
|
232
|
+
layout.addWidget(buttons)
|
|
233
|
+
|
|
234
|
+
def _update_count(self):
|
|
235
|
+
if self.custom_check.isChecked():
|
|
236
|
+
try:
|
|
237
|
+
n = (len(parse_diams(self.pd1.text()))
|
|
238
|
+
* len(parse_diams(self.pd2min.text()))
|
|
239
|
+
* len(parse_diams(self.pd2max.text())))
|
|
240
|
+
except ValueError:
|
|
241
|
+
self.count_label.setText("")
|
|
242
|
+
return
|
|
243
|
+
else:
|
|
244
|
+
n = (self.n_pd1.value() * self.n_pd2min.value()
|
|
245
|
+
* self.n_pd2max.value())
|
|
246
|
+
runs = self.runs_spin.value() if hasattr(self, "runs_spin") else 1
|
|
247
|
+
total = n * runs * self.n_clouds
|
|
248
|
+
if total == 1:
|
|
249
|
+
self.count_label.setText("1 model will be fitted.")
|
|
250
|
+
else:
|
|
251
|
+
parts = [f"{n} PatchDiam combination(s)"]
|
|
252
|
+
if runs > 1:
|
|
253
|
+
parts.append(f"{runs} runs")
|
|
254
|
+
if self.n_clouds > 1:
|
|
255
|
+
parts.append(f"{self.n_clouds} clouds")
|
|
256
|
+
self.count_label.setText(
|
|
257
|
+
f"{total} models will be fitted ({' x '.join(parts)}); "
|
|
258
|
+
"the runtime multiplies accordingly.")
|
|
259
|
+
|
|
260
|
+
def _pick_dir(self):
|
|
261
|
+
d = QFileDialog.getExistingDirectory(self, "Results folder",
|
|
262
|
+
self.dir_edit.text())
|
|
263
|
+
if d:
|
|
264
|
+
self.dir_edit.setText(d)
|
|
265
|
+
|
|
266
|
+
def _validate_and_accept(self):
|
|
267
|
+
if self.runs_spin.value() > 20:
|
|
268
|
+
reply = QMessageBox.question(
|
|
269
|
+
self, "PyTLidar",
|
|
270
|
+
f"{self.runs_spin.value()} repeat runs, times the PatchDiam "
|
|
271
|
+
"combinations, is a lot of fitting. Are you sure?")
|
|
272
|
+
if reply != QMessageBox.StandardButton.Yes:
|
|
273
|
+
return
|
|
274
|
+
custom = None
|
|
275
|
+
if self.custom_check.isChecked():
|
|
276
|
+
try:
|
|
277
|
+
custom = (parse_diams(self.pd1.text()),
|
|
278
|
+
parse_diams(self.pd2min.text()),
|
|
279
|
+
parse_diams(self.pd2max.text()))
|
|
280
|
+
except ValueError as exc:
|
|
281
|
+
self.error_label.setText(str(exc))
|
|
282
|
+
return
|
|
283
|
+
intensity_sf = None
|
|
284
|
+
threshold = 0.0
|
|
285
|
+
if self.sf_check is not None and self.sf_check.isChecked():
|
|
286
|
+
intensity_sf = self.sf_combo.currentText()
|
|
287
|
+
threshold = float(self.sf_threshold.value())
|
|
288
|
+
QSettings("PyTLidar", "pytlidar-cc").setValue(
|
|
289
|
+
"results_dir", self.dir_edit.text())
|
|
290
|
+
self.settings = {
|
|
291
|
+
"custom": custom,
|
|
292
|
+
"n_patchdiam": (self.n_pd1.value(), self.n_pd2min.value(),
|
|
293
|
+
self.n_pd2max.value()),
|
|
294
|
+
"metric": self.metric_combo.currentText(),
|
|
295
|
+
"tria": self.tria_check.isChecked(),
|
|
296
|
+
"runs": self.runs_spin.value(),
|
|
297
|
+
"workers": self.workers_spin.value(),
|
|
298
|
+
"save_files": self.save_check.isChecked(),
|
|
299
|
+
"results_dir": self.dir_edit.text(),
|
|
300
|
+
"intensity_sf": intensity_sf,
|
|
301
|
+
"intensity_threshold": threshold,
|
|
302
|
+
}
|
|
303
|
+
self.accept()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def show_settings_dialog(clouds, metrics):
|
|
307
|
+
"""Modal settings dialog for one job over the selected clouds. Returns
|
|
308
|
+
the settings dict, or None when the user cancels. CloudCompare's Qt event
|
|
309
|
+
loop is already running, so QDialog.exec() is enough; no QApplication or
|
|
310
|
+
QEventLoop needed."""
|
|
311
|
+
dialog = _SettingsDialog(clouds, metrics)
|
|
312
|
+
dialog.exec()
|
|
313
|
+
return dialog.settings
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Triangle mesh of a QSM's cylinders.
|
|
2
|
+
|
|
3
|
+
Pure numpy so it can be tested without CloudCompare. Each cylinder becomes a
|
|
4
|
+
ring of `facets` vertices at both ends and 2 * facets side triangles; the
|
|
5
|
+
rings are oriented by an orthonormal frame built from the cylinder axis.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cylinder_mesh(starts, axes, lengths, radii, facets=8):
|
|
12
|
+
"""Mesh the cylinders as one vertex/triangle soup.
|
|
13
|
+
|
|
14
|
+
starts, axes: (n, 3). lengths, radii: (n,). Returns (vertices, triangles,
|
|
15
|
+
vertex_cylinder): vertices is (n * 2 * facets, 3) float64, triangles is
|
|
16
|
+
(n * 2 * facets, 3) int 0-based indices into vertices, vertex_cylinder
|
|
17
|
+
maps every vertex to the index of the cylinder it belongs to, for
|
|
18
|
+
carrying per-cylinder values onto the mesh as scalar fields.
|
|
19
|
+
"""
|
|
20
|
+
starts = np.asarray(starts, dtype=np.float64).reshape(-1, 3)
|
|
21
|
+
axes = np.asarray(axes, dtype=np.float64).reshape(-1, 3)
|
|
22
|
+
lengths = np.asarray(lengths, dtype=np.float64).reshape(-1)
|
|
23
|
+
radii = np.asarray(radii, dtype=np.float64).reshape(-1)
|
|
24
|
+
n = starts.shape[0]
|
|
25
|
+
if n == 0:
|
|
26
|
+
return (np.zeros((0, 3)), np.zeros((0, 3), dtype=np.int64),
|
|
27
|
+
np.zeros(0, dtype=np.int64))
|
|
28
|
+
|
|
29
|
+
norms = np.linalg.norm(axes, axis=1, keepdims=True)
|
|
30
|
+
axes = np.where(norms > 1e-12, axes / np.where(norms == 0, 1.0, norms),
|
|
31
|
+
[[0.0, 0.0, 1.0]])
|
|
32
|
+
|
|
33
|
+
# Frame per cylinder: u, v span the plane of the end rings.
|
|
34
|
+
ref = np.where(np.abs(axes[:, 2:3]) < 0.9, [[0.0, 0.0, 1.0]],
|
|
35
|
+
[[1.0, 0.0, 0.0]])
|
|
36
|
+
u = np.cross(axes, ref)
|
|
37
|
+
u /= np.linalg.norm(u, axis=1, keepdims=True)
|
|
38
|
+
v = np.cross(axes, u)
|
|
39
|
+
|
|
40
|
+
theta = 2.0 * np.pi * np.arange(facets) / facets
|
|
41
|
+
ring = (u[:, None, :] * np.cos(theta)[None, :, None]
|
|
42
|
+
+ v[:, None, :] * np.sin(theta)[None, :, None]) * radii[:, None, None]
|
|
43
|
+
base = starts[:, None, :] + ring
|
|
44
|
+
top = (starts + axes * lengths[:, None])[:, None, :] + ring
|
|
45
|
+
vertices = np.concatenate([base, top], axis=1).reshape(-1, 3)
|
|
46
|
+
|
|
47
|
+
# Two triangles per side quad, indices local to one cylinder's block of
|
|
48
|
+
# 2 * facets vertices (base ring first, then top ring).
|
|
49
|
+
k = np.arange(facets)
|
|
50
|
+
kn = (k + 1) % facets
|
|
51
|
+
quad = np.concatenate([np.stack([k, kn, k + facets], axis=1),
|
|
52
|
+
np.stack([kn, kn + facets, k + facets], axis=1)])
|
|
53
|
+
triangles = (quad[None, :, :]
|
|
54
|
+
+ (np.arange(n) * 2 * facets)[:, None, None]).reshape(-1, 3)
|
|
55
|
+
|
|
56
|
+
vertex_cylinder = np.repeat(np.arange(n), 2 * facets)
|
|
57
|
+
return vertices, triangles, vertex_cylinder
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""CloudCompare PythonRuntime plugin entry point for PyTLidar TreeQSM.
|
|
2
|
+
|
|
3
|
+
Only pycc, numpy and the standard library import at module scope: an import
|
|
4
|
+
error here silently drops the plugin from the Plugins menu. Fits run in
|
|
5
|
+
worker processes using the selected environment's own python, because
|
|
6
|
+
sys.executable is CloudCompare itself in the embedded interpreter.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import pickle
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pycc
|
|
22
|
+
|
|
23
|
+
_active_run = {}
|
|
24
|
+
# one results subfolder per CloudCompare session; run numbers continue across jobs
|
|
25
|
+
_session_stamp = None
|
|
26
|
+
_session_runs = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _venv_python():
|
|
30
|
+
for rel in ("bin/python", "bin/python3", "Scripts/python.exe"):
|
|
31
|
+
candidate = Path(sys.prefix) / rel
|
|
32
|
+
if candidate.exists():
|
|
33
|
+
return str(candidate)
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
f"No python executable found under {sys.prefix}. Select a virtual "
|
|
36
|
+
"environment in the Python plugin settings.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _worker_env(workers):
|
|
40
|
+
"""Split the machine's threads between parallel workers so they do not
|
|
41
|
+
fight over cores."""
|
|
42
|
+
env = os.environ.copy()
|
|
43
|
+
per = max(1, (os.cpu_count() or 4) // max(1, workers))
|
|
44
|
+
for var in ("NUMBA_NUM_THREADS", "OMP_NUM_THREADS",
|
|
45
|
+
"OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
|
|
46
|
+
env[var] = str(per)
|
|
47
|
+
return env
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _hold_idle_sleep(reason):
|
|
51
|
+
"""Keep the system from idle sleeping until the returned callable runs.
|
|
52
|
+
|
|
53
|
+
Every path releases by itself if the process dies: macOS uses a named
|
|
54
|
+
IOKit power assertion (caffeinate as fallback), Windows the thread
|
|
55
|
+
execution state, Linux a systemd-inhibit tied to this pid. Best effort,
|
|
56
|
+
a no-op where none of that works."""
|
|
57
|
+
if sys.platform == "win32":
|
|
58
|
+
try:
|
|
59
|
+
import ctypes
|
|
60
|
+
ES_CONTINUOUS = 0x80000000
|
|
61
|
+
ES_SYSTEM_REQUIRED = 0x00000001
|
|
62
|
+
k32 = ctypes.windll.kernel32
|
|
63
|
+
if k32.SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED):
|
|
64
|
+
return lambda: k32.SetThreadExecutionState(ES_CONTINUOUS)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
return lambda: None
|
|
68
|
+
if sys.platform.startswith("linux"):
|
|
69
|
+
try:
|
|
70
|
+
proc = subprocess.Popen(
|
|
71
|
+
["systemd-inhibit", "--what=idle:sleep", "--who=PyTLidar",
|
|
72
|
+
f"--why={reason}", "--mode=block",
|
|
73
|
+
"tail", f"--pid={os.getpid()}", "-f", "/dev/null"],
|
|
74
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
75
|
+
return proc.terminate
|
|
76
|
+
except OSError:
|
|
77
|
+
return lambda: None
|
|
78
|
+
if sys.platform != "darwin":
|
|
79
|
+
return lambda: None
|
|
80
|
+
try:
|
|
81
|
+
import ctypes
|
|
82
|
+
import ctypes.util
|
|
83
|
+
|
|
84
|
+
iokit = ctypes.CDLL(ctypes.util.find_library("IOKit"))
|
|
85
|
+
cf = ctypes.CDLL(ctypes.util.find_library("CoreFoundation"))
|
|
86
|
+
cf.CFStringCreateWithCString.restype = ctypes.c_void_p
|
|
87
|
+
cf.CFStringCreateWithCString.argtypes = [
|
|
88
|
+
ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint32]
|
|
89
|
+
cf.CFRelease.argtypes = [ctypes.c_void_p]
|
|
90
|
+
iokit.IOPMAssertionCreateWithName.argtypes = [
|
|
91
|
+
ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p,
|
|
92
|
+
ctypes.POINTER(ctypes.c_uint32)]
|
|
93
|
+
utf8 = 0x08000100
|
|
94
|
+
a_type = cf.CFStringCreateWithCString(
|
|
95
|
+
None, b"PreventUserIdleSystemSleep", utf8)
|
|
96
|
+
a_name = cf.CFStringCreateWithCString(None, reason.encode(), utf8)
|
|
97
|
+
assertion = ctypes.c_uint32(0)
|
|
98
|
+
err = iokit.IOPMAssertionCreateWithName(
|
|
99
|
+
a_type, 255, a_name, ctypes.byref(assertion))
|
|
100
|
+
cf.CFRelease(a_type)
|
|
101
|
+
cf.CFRelease(a_name)
|
|
102
|
+
if err == 0:
|
|
103
|
+
return lambda: iokit.IOPMAssertionRelease(assertion)
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
try:
|
|
107
|
+
proc = subprocess.Popen(
|
|
108
|
+
["/usr/bin/caffeinate", "-i", "-w", str(os.getpid())])
|
|
109
|
+
return proc.terminate
|
|
110
|
+
except OSError:
|
|
111
|
+
return lambda: None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _run_stats(model):
|
|
115
|
+
"""(mean dist mm, trunk dist mm, mean surface coverage %) of one model."""
|
|
116
|
+
pm = model.get("pmdistance")
|
|
117
|
+
dist = trunk = float("nan")
|
|
118
|
+
if isinstance(pm, dict):
|
|
119
|
+
if pm.get("mean") is not None:
|
|
120
|
+
dist = float(np.ravel(pm["mean"])[0]) * 1000.0
|
|
121
|
+
if pm.get("TrunkMean") is not None:
|
|
122
|
+
trunk = float(np.ravel(pm["TrunkMean"])[0]) * 1000.0
|
|
123
|
+
cov = float("nan")
|
|
124
|
+
sc = model.get("cylinder", {}).get("SurfCov")
|
|
125
|
+
if sc is not None and np.size(sc) > 0:
|
|
126
|
+
cov = float(np.mean(sc)) * 100.0
|
|
127
|
+
return dist, trunk, cov
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _run_treeqsm():
|
|
131
|
+
cc = pycc.GetInstance()
|
|
132
|
+
if _active_run:
|
|
133
|
+
raise RuntimeError("A PyTLidar job is already running; cancel it or "
|
|
134
|
+
"wait for it to finish.")
|
|
135
|
+
clouds = [e for e in cc.getSelectedEntities()
|
|
136
|
+
if isinstance(e, pycc.ccPointCloud)]
|
|
137
|
+
if not clouds:
|
|
138
|
+
raise RuntimeError("Select at least one point cloud.")
|
|
139
|
+
|
|
140
|
+
from PyTLidar.pipeline import centre
|
|
141
|
+
from PyTLidar.Utils import Utils as pt_utils
|
|
142
|
+
|
|
143
|
+
from .adapter import cloud_from_cc, results_to_cc
|
|
144
|
+
from .dialog import make_progress_dialog, QApplication, show_settings_dialog
|
|
145
|
+
|
|
146
|
+
settings = show_settings_dialog(clouds, pt_utils.get_all_metrics())
|
|
147
|
+
if settings is None:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
runs = max(1, int(settings.get("runs", 1)))
|
|
151
|
+
workers = max(1, int(settings.get("workers", 1)))
|
|
152
|
+
global _session_stamp
|
|
153
|
+
save = bool(settings.get("save_files"))
|
|
154
|
+
dest = None
|
|
155
|
+
if save:
|
|
156
|
+
if _session_stamp is None:
|
|
157
|
+
_session_stamp = time.strftime("%Y-%m-%d_%H%M")
|
|
158
|
+
dest = Path(settings["results_dir"]) / _session_stamp
|
|
159
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
|
|
161
|
+
names = []
|
|
162
|
+
for i, c in enumerate(clouds):
|
|
163
|
+
n = re.sub(r"[^\w.-]+", "_", Path(c.getName()).stem) or f"cloud{i}"
|
|
164
|
+
if n in names:
|
|
165
|
+
n = f"{n}_{i}"
|
|
166
|
+
names.append(n)
|
|
167
|
+
|
|
168
|
+
job = Path(tempfile.mkdtemp(prefix="pytlidar_cc_"))
|
|
169
|
+
(job / "settings.json").write_text(json.dumps({
|
|
170
|
+
"custom": settings["custom"],
|
|
171
|
+
"n_patchdiam": list(settings["n_patchdiam"]),
|
|
172
|
+
"metric": settings["metric"],
|
|
173
|
+
"tria": bool(settings.get("tria")),
|
|
174
|
+
"save": save,
|
|
175
|
+
"names": names,
|
|
176
|
+
}))
|
|
177
|
+
|
|
178
|
+
# A cloud that does not look like a tree is skipped, not fatal
|
|
179
|
+
prepared = {}
|
|
180
|
+
for ci, source in enumerate(clouds):
|
|
181
|
+
P = cloud_from_cc(source)
|
|
182
|
+
if settings["intensity_sf"] is not None:
|
|
183
|
+
idx = source.getScalarFieldIndexByName(settings["intensity_sf"])
|
|
184
|
+
if idx >= 0:
|
|
185
|
+
keep = (source.getScalarField(idx).asArray()
|
|
186
|
+
>= settings["intensity_threshold"])
|
|
187
|
+
P = P[keep]
|
|
188
|
+
# define_input's stem estimation misbehaves on selections that are
|
|
189
|
+
# not a tree.
|
|
190
|
+
if P.shape[0] < 1000 or P[:, 2].max() - P[:, 2].min() < 1.5:
|
|
191
|
+
sys.stdout.write(f"Skipping {source.getName()}: needs at least 1000 "
|
|
192
|
+
"points and 1.5 m of height.\n")
|
|
193
|
+
continue
|
|
194
|
+
# Centre on the mean to protect the float32 cylinder arrays from
|
|
195
|
+
# precision loss; treeqsm returns z in the frame it is given.
|
|
196
|
+
offset = P.mean(axis=0)
|
|
197
|
+
P = centre(P, z=False)
|
|
198
|
+
np.save(job / f"cloud_{ci}.npy", P)
|
|
199
|
+
prepared[ci] = {"source": source, "offset": offset}
|
|
200
|
+
if not prepared:
|
|
201
|
+
shutil.rmtree(job, ignore_errors=True)
|
|
202
|
+
raise RuntimeError("No usable point cloud in the selection.")
|
|
203
|
+
|
|
204
|
+
python = _venv_python()
|
|
205
|
+
env = _worker_env(workers)
|
|
206
|
+
tasks = []
|
|
207
|
+
for ci in prepared:
|
|
208
|
+
start = _session_runs.get(names[ci], 0)
|
|
209
|
+
tasks += [(ci, rid) for rid in range(start + 1, start + runs + 1)]
|
|
210
|
+
_session_runs[names[ci]] = start + runs
|
|
211
|
+
total = len(tasks)
|
|
212
|
+
sys.stdout.write(f"Running PyTLidar: {len(prepared)} tree(s) x {runs} run(s), "
|
|
213
|
+
f"up to {min(workers, total)} at a time.\n")
|
|
214
|
+
|
|
215
|
+
title = (clouds[0].getName() if len(clouds) == 1
|
|
216
|
+
else f"{len(prepared)} trees")
|
|
217
|
+
progress = make_progress_dialog(f"PyTLidar (TreeQSM): {title}")
|
|
218
|
+
state = {"queue": tasks, "procs": {}, "buffers": {}, "done": 0,
|
|
219
|
+
"failed": [], "summaries": {}, "cancelled": False,
|
|
220
|
+
"progress": progress,
|
|
221
|
+
"release_sleep": _hold_idle_sleep("PyTLidar TreeQSM fits")}
|
|
222
|
+
_active_run.update(state)
|
|
223
|
+
|
|
224
|
+
def tag(ci, rid):
|
|
225
|
+
name = prepared[ci]["source"].getName()
|
|
226
|
+
return f"{name} run {rid}" if (runs > 1 or rid > 1) else name
|
|
227
|
+
|
|
228
|
+
def launch_next():
|
|
229
|
+
while state["queue"] and len(state["procs"]) < workers:
|
|
230
|
+
ci, rid = state["queue"].pop(0)
|
|
231
|
+
proc = subprocess.Popen(
|
|
232
|
+
[python, "-u", "-m", "pytlidar_cc.worker",
|
|
233
|
+
str(job), str(ci), str(rid)],
|
|
234
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env)
|
|
235
|
+
os.set_blocking(proc.stdout.fileno(), False)
|
|
236
|
+
state["procs"][(ci, rid)] = proc
|
|
237
|
+
sys.stdout.write(f"[{tag(ci, rid)}] started\n")
|
|
238
|
+
|
|
239
|
+
def finish():
|
|
240
|
+
# closing the dialog would emit canceled(), disconnect first
|
|
241
|
+
try:
|
|
242
|
+
progress.canceled.disconnect(cancel)
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
progress.allow_close()
|
|
246
|
+
progress.close()
|
|
247
|
+
try:
|
|
248
|
+
if state["cancelled"]:
|
|
249
|
+
for proc in state["procs"].values():
|
|
250
|
+
try:
|
|
251
|
+
proc.wait(timeout=2)
|
|
252
|
+
except Exception:
|
|
253
|
+
pass
|
|
254
|
+
sys.stdout.write("PyTLidar job cancelled.\n")
|
|
255
|
+
return
|
|
256
|
+
if state["failed"]:
|
|
257
|
+
names = ", ".join(tag(ci, rid) for ci, rid in state["failed"])
|
|
258
|
+
sys.stdout.write(f"Failed fits: {names}, see the lines above.\n")
|
|
259
|
+
if runs > 1:
|
|
260
|
+
for ci, rows in sorted(state["summaries"].items()):
|
|
261
|
+
if len(rows) < 2:
|
|
262
|
+
continue
|
|
263
|
+
med_dist = float(np.nanmedian([r[3] for r in rows]))
|
|
264
|
+
med_trunk = float(np.nanmedian([r[4] for r in rows]))
|
|
265
|
+
sys.stdout.write(f"--- {prepared[ci]['source'].getName()} run summary ---\n")
|
|
266
|
+
for rid, vol, branches, dist, trunk, cov in sorted(rows):
|
|
267
|
+
mark = ""
|
|
268
|
+
if dist > 1.5 * med_dist or trunk > 1.5 * med_trunk:
|
|
269
|
+
mark = " <- fit looks off, check before using"
|
|
270
|
+
sys.stdout.write(f" run {rid}: volume {vol:.0f} L, branches "
|
|
271
|
+
f"{branches}, mean dist {dist:.1f} mm, "
|
|
272
|
+
f"coverage {cov:.0f}%{mark}\n")
|
|
273
|
+
if dest is not None:
|
|
274
|
+
sys.stdout.write(f"Model files saved to {dest}\n")
|
|
275
|
+
finally:
|
|
276
|
+
state["release_sleep"]()
|
|
277
|
+
shutil.rmtree(job, ignore_errors=True)
|
|
278
|
+
_active_run.clear()
|
|
279
|
+
|
|
280
|
+
def drain(key, proc):
|
|
281
|
+
# unbuffered workers can hand over partial lines; emit only whole ones
|
|
282
|
+
ci, rid = key
|
|
283
|
+
try:
|
|
284
|
+
data = os.read(proc.stdout.fileno(), 65536)
|
|
285
|
+
except (BlockingIOError, OSError):
|
|
286
|
+
return
|
|
287
|
+
if not data:
|
|
288
|
+
return
|
|
289
|
+
buf = state["buffers"].get(key, "") + data.decode("utf-8", errors="replace")
|
|
290
|
+
*lines, state["buffers"][key] = buf.split("\n")
|
|
291
|
+
for ln in lines:
|
|
292
|
+
if ln.strip():
|
|
293
|
+
sys.stdout.write(f"[{tag(ci, rid)}] {ln}\n")
|
|
294
|
+
|
|
295
|
+
def poll_once():
|
|
296
|
+
for key, proc in list(state["procs"].items()):
|
|
297
|
+
ci, rid = key
|
|
298
|
+
drain(key, proc)
|
|
299
|
+
rc = proc.poll()
|
|
300
|
+
if rc is not None:
|
|
301
|
+
drain(key, proc)
|
|
302
|
+
leftover = state["buffers"].pop(key, "")
|
|
303
|
+
if leftover.strip():
|
|
304
|
+
sys.stdout.write(f"[{tag(ci, rid)}] {leftover}\n")
|
|
305
|
+
del state["procs"][key]
|
|
306
|
+
result = job / f"result_{ci}_{rid}.pkl"
|
|
307
|
+
if state["cancelled"]:
|
|
308
|
+
pass
|
|
309
|
+
elif rc == 0 and result.exists():
|
|
310
|
+
state["done"] += 1
|
|
311
|
+
# scene insertion must happen here on the main thread
|
|
312
|
+
with open(result, "rb") as f:
|
|
313
|
+
model = pickle.load(f)
|
|
314
|
+
label = f"run {rid}" if (runs > 1 or rid > 1) else None
|
|
315
|
+
results_to_cc(cc, prepared[ci]["source"], model,
|
|
316
|
+
prepared[ci]["offset"], label=label)
|
|
317
|
+
td = model["treedata"]
|
|
318
|
+
dist, trunk, cov = _run_stats(model)
|
|
319
|
+
state["summaries"].setdefault(ci, []).append(
|
|
320
|
+
(rid, float(td.get("TotalVolume", 0)),
|
|
321
|
+
int(td.get("NumberBranches", 0)), dist, trunk, cov))
|
|
322
|
+
del model
|
|
323
|
+
if dest is not None:
|
|
324
|
+
outdir = job / f"out_{ci}_{rid}"
|
|
325
|
+
if outdir.is_dir():
|
|
326
|
+
for f in outdir.iterdir():
|
|
327
|
+
shutil.move(str(f), dest / f.name)
|
|
328
|
+
else:
|
|
329
|
+
state["failed"].append(key)
|
|
330
|
+
sys.stdout.write(f"[{tag(ci, rid)}] failed (exit {rc})\n")
|
|
331
|
+
if not state["cancelled"]:
|
|
332
|
+
launch_next()
|
|
333
|
+
finished = state["done"] + len(state["failed"])
|
|
334
|
+
progress.setLabelText(
|
|
335
|
+
f"PyTLidar (TreeQSM): {title}\n{finished}/{total} fits finished, "
|
|
336
|
+
f"{len(state['procs'])} running")
|
|
337
|
+
|
|
338
|
+
def cancel():
|
|
339
|
+
state["cancelled"] = True
|
|
340
|
+
state["queue"].clear()
|
|
341
|
+
for proc in state["procs"].values():
|
|
342
|
+
proc.kill()
|
|
343
|
+
|
|
344
|
+
progress.canceled.connect(cancel)
|
|
345
|
+
launch_next()
|
|
346
|
+
# the runtime does not call Python back once this action returns, so
|
|
347
|
+
# pump the event loop here until the job ends
|
|
348
|
+
while (state["procs"] or state["queue"]) and not state["cancelled"]:
|
|
349
|
+
poll_once()
|
|
350
|
+
QApplication.processEvents()
|
|
351
|
+
time.sleep(0.05)
|
|
352
|
+
poll_once()
|
|
353
|
+
finish()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class PyTLidarPlugin(pycc.PythonPluginInterface):
|
|
357
|
+
def __init__(self):
|
|
358
|
+
pycc.PythonPluginInterface.__init__(self)
|
|
359
|
+
|
|
360
|
+
def getIcon(self):
|
|
361
|
+
icon = Path(__file__).parent / "assets" / "icon.png"
|
|
362
|
+
return str(icon.resolve()) if icon.exists() else ""
|
|
363
|
+
|
|
364
|
+
def getActions(self):
|
|
365
|
+
return [pycc.Action(name="Run PyTLidar", icon=self.getIcon(),
|
|
366
|
+
target=_run_treeqsm)]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""One TreeQSM fit per process, driven by the plugin.
|
|
2
|
+
|
|
3
|
+
python -m pytlidar_cc.worker <job_dir> <cloud_idx> <run_id>, with
|
|
4
|
+
cloud_<idx>.npy and settings.json in the job directory. Writes
|
|
5
|
+
result_<idx>_<run_id>.pkl minus the bulky point/cover/segment data; stdout
|
|
6
|
+
is relayed to CloudCompare's console. With saving on, the classic model
|
|
7
|
+
files and a run_info.json land in out_<idx>_<run_id>/.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import pickle
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(job_dir, cloud_idx, run_id):
|
|
19
|
+
job = Path(job_dir)
|
|
20
|
+
|
|
21
|
+
# The fit builds matplotlib figures; keep the worker headless.
|
|
22
|
+
import matplotlib
|
|
23
|
+
matplotlib.use("Agg")
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from PyTLidar.pipeline import build_inputs, calculate_optimal, run_qsm
|
|
27
|
+
|
|
28
|
+
settings = json.loads((job / "settings.json").read_text())
|
|
29
|
+
P = np.load(job / f"cloud_{cloud_idx}.npy")
|
|
30
|
+
print(f"fitting {P.shape[0]} points", flush=True)
|
|
31
|
+
|
|
32
|
+
save = bool(settings.get("save"))
|
|
33
|
+
name = f"{settings['names'][int(cloud_idx)]}_run{run_id}"
|
|
34
|
+
common = dict(names=[name], savemat=1 if save else 0,
|
|
35
|
+
savetxt=1 if save else 0, plot=0, disp=2, savepdf=0)
|
|
36
|
+
if settings["custom"] is not None:
|
|
37
|
+
inputs = build_inputs(P, custom=tuple(settings["custom"]), **common)
|
|
38
|
+
else:
|
|
39
|
+
inputs = build_inputs(P, n_patchdiam=tuple(settings["n_patchdiam"]), **common)
|
|
40
|
+
if not inputs:
|
|
41
|
+
raise RuntimeError("The cloud is empty.")
|
|
42
|
+
inputs[0]["Tria"] = 1 if settings.get("tria") else 0
|
|
43
|
+
|
|
44
|
+
outdir = job / f"out_{cloud_idx}_{run_id}"
|
|
45
|
+
outdir.mkdir(exist_ok=True)
|
|
46
|
+
t0 = time.time()
|
|
47
|
+
models, _ = run_qsm(P, inputs[0], results_dir=str(outdir))
|
|
48
|
+
elapsed = time.time() - t0
|
|
49
|
+
if not models:
|
|
50
|
+
raise RuntimeError("TreeQSM fitted no cylinders to this cloud.")
|
|
51
|
+
|
|
52
|
+
best = 0
|
|
53
|
+
if len(models) > 1:
|
|
54
|
+
best, _, _ = calculate_optimal(models, settings["metric"])
|
|
55
|
+
best = int(best)
|
|
56
|
+
model = models[best]
|
|
57
|
+
|
|
58
|
+
if save:
|
|
59
|
+
# flatten the results/ subfolder treeqsm creates
|
|
60
|
+
sub = outdir / "results"
|
|
61
|
+
if sub.is_dir():
|
|
62
|
+
for f in sub.iterdir():
|
|
63
|
+
shutil.move(str(f), outdir / f.name)
|
|
64
|
+
sub.rmdir()
|
|
65
|
+
inp = model["rundata"]["inputs"]
|
|
66
|
+
td = model["treedata"]
|
|
67
|
+
info = {
|
|
68
|
+
"source": settings["names"][int(cloud_idx)],
|
|
69
|
+
"run": int(run_id),
|
|
70
|
+
"points": int(P.shape[0]),
|
|
71
|
+
"PatchDiam1": float(np.ravel(inp["PatchDiam1"])[0]),
|
|
72
|
+
"PatchDiam2Min": float(np.ravel(inp["PatchDiam2Min"])[0]),
|
|
73
|
+
"PatchDiam2Max": float(np.ravel(inp["PatchDiam2Max"])[0]),
|
|
74
|
+
"Tria": int(inp.get("Tria", 0)),
|
|
75
|
+
"metric": settings["metric"],
|
|
76
|
+
"TotalVolume_L": float(np.ravel(td.get("TotalVolume", 0))[0]),
|
|
77
|
+
"elapsed_s": round(elapsed, 1),
|
|
78
|
+
"date": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
79
|
+
}
|
|
80
|
+
try:
|
|
81
|
+
from importlib.metadata import version
|
|
82
|
+
info["PyTLidar"] = version("PyTLidar")
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
(outdir / f"run_info_{name}.json").write_text(json.dumps(info, indent=1))
|
|
86
|
+
|
|
87
|
+
for key in ("points", "cover", "segment"):
|
|
88
|
+
model.pop(key, None)
|
|
89
|
+
with open(job / f"result_{cloud_idx}_{run_id}.pkl", "wb") as f:
|
|
90
|
+
pickle.dump(model, f, protocol=4)
|
|
91
|
+
print("done", flush=True)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
main(sys.argv[1], sys.argv[2], sys.argv[3])
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytlidar-cc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CloudCompare plugin that runs PyTLidar TreeQSM on a selected point cloud
|
|
5
|
+
Author: John Hagood
|
|
6
|
+
License-Expression: GPL-3.0-only
|
|
7
|
+
Project-URL: Homepage, https://github.com/Landscape-CV/PyTLidar
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: <3.13,>=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: PyTLidar>=1.0.6
|
|
13
|
+
|
|
14
|
+
# pytlidar-cc
|
|
15
|
+
|
|
16
|
+
CloudCompare plugin that runs [PyTLidar](https://github.com/Landscape-CV/PyTLidar)
|
|
17
|
+
TreeQSM on selected point clouds and puts the models back into the scene.
|
|
18
|
+
|
|
19
|
+
## What you get
|
|
20
|
+
|
|
21
|
+
One group per fitted model, named after the source cloud:
|
|
22
|
+
|
|
23
|
+
- `cylinders`: the cylinder start points with `radius`, `length`,
|
|
24
|
+
`branch_order`, `branch_id` and `surface_coverage` scalar fields.
|
|
25
|
+
- `cylinder mesh`: the cylinders as one triangle mesh, `branch_order` on its
|
|
26
|
+
vertices for colouring.
|
|
27
|
+
- `stem mesh` when stem triangulation is on.
|
|
28
|
+
- The tree metrics printed to the console.
|
|
29
|
+
|
|
30
|
+
Everything is in the source cloud's frame and carries its global shift.
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
- CloudCompare 2.13 or newer with the PythonRuntime plugin. It ships in the
|
|
35
|
+
Windows installer and in the OpenFields macOS builds
|
|
36
|
+
(simulation.openfields.fr); otherwise build it from source.
|
|
37
|
+
- PyTLidar 1.0.6 or newer in the plugin's Python environment.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
With the Python environment CloudCompare uses:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
python -m pip install PyTLidar ./pytlidar_cc
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
1. Select one or more point clouds (one segmented tree each).
|
|
50
|
+
2. Plugins, PyTLidar, Run PyTLidar.
|
|
51
|
+
3. Adjust the settings, OK.
|
|
52
|
+
|
|
53
|
+
Settings: generated or custom PatchDiam values (several values per parameter
|
|
54
|
+
runs every combination and the metric picks the best model), an optional
|
|
55
|
+
scalar field filter, stem triangulation, repeat runs and parallel workers,
|
|
56
|
+
and where the model files save.
|
|
57
|
+
|
|
58
|
+
## Notes
|
|
59
|
+
|
|
60
|
+
- Model files (the QSM npz, the cylinder, branch and treedata tables and a
|
|
61
|
+
run_info.json per run) save into one folder per CloudCompare session,
|
|
62
|
+
under Documents/PyTLidar/results by default; run numbers continue across
|
|
63
|
+
jobs in a session.
|
|
64
|
+
- TreeQSM is stochastic. Repeat runs puts each model in the scene as its own
|
|
65
|
+
group with a console summary that flags suspect fits; keep the tree you
|
|
66
|
+
like.
|
|
67
|
+
- Fits run in worker processes: progress streams to the console and the job
|
|
68
|
+
can be cancelled from the progress dialog. Parallel runs each use their
|
|
69
|
+
own memory, so keep workers low for very large clouds.
|
|
70
|
+
- Segment the tree from the plot first (for example with the Treeiso plugin)
|
|
71
|
+
and clean obvious noise.
|
|
72
|
+
- A running job holds off system idle sleep so fits continue with the
|
|
73
|
+
screen locked; closing the lid still sleeps as normal.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
GPL-3.0-only, like PyTLidar.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/pytlidar_cc/__init__.py
|
|
4
|
+
src/pytlidar_cc/adapter.py
|
|
5
|
+
src/pytlidar_cc/dialog.py
|
|
6
|
+
src/pytlidar_cc/mesh.py
|
|
7
|
+
src/pytlidar_cc/plugin.py
|
|
8
|
+
src/pytlidar_cc/worker.py
|
|
9
|
+
src/pytlidar_cc.egg-info/PKG-INFO
|
|
10
|
+
src/pytlidar_cc.egg-info/SOURCES.txt
|
|
11
|
+
src/pytlidar_cc.egg-info/dependency_links.txt
|
|
12
|
+
src/pytlidar_cc.egg-info/entry_points.txt
|
|
13
|
+
src/pytlidar_cc.egg-info/requires.txt
|
|
14
|
+
src/pytlidar_cc.egg-info/top_level.txt
|
|
15
|
+
tests/test_mesh.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
PyTLidar>=1.0.6
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pytlidar_cc
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from pytlidar_cc.mesh import cylinder_mesh
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_empty_input():
|
|
8
|
+
vertices, triangles, vertex_cylinder = cylinder_mesh(
|
|
9
|
+
np.zeros((0, 3)), np.zeros((0, 3)), np.zeros(0), np.zeros(0))
|
|
10
|
+
assert vertices.shape == (0, 3)
|
|
11
|
+
assert triangles.shape == (0, 3)
|
|
12
|
+
assert vertex_cylinder.shape == (0,)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_counts_and_index_ranges():
|
|
16
|
+
n, facets = 3, 8
|
|
17
|
+
rng = np.random.default_rng(0)
|
|
18
|
+
starts = rng.normal(size=(n, 3))
|
|
19
|
+
axes = rng.normal(size=(n, 3))
|
|
20
|
+
lengths = rng.uniform(0.1, 2.0, n)
|
|
21
|
+
radii = rng.uniform(0.01, 0.5, n)
|
|
22
|
+
|
|
23
|
+
vertices, triangles, vertex_cylinder = cylinder_mesh(
|
|
24
|
+
starts, axes, lengths, radii, facets=facets)
|
|
25
|
+
|
|
26
|
+
assert vertices.shape == (n * 2 * facets, 3)
|
|
27
|
+
assert triangles.shape == (n * 2 * facets, 3)
|
|
28
|
+
assert vertex_cylinder.shape == (n * 2 * facets,)
|
|
29
|
+
assert triangles.min() >= 0
|
|
30
|
+
assert triangles.max() < len(vertices)
|
|
31
|
+
# Every triangle stays within its own cylinder's vertex block.
|
|
32
|
+
tri_cyl = vertex_cylinder[triangles]
|
|
33
|
+
assert (tri_cyl == tri_cyl[:, :1]).all()
|
|
34
|
+
assert np.array_equal(np.unique(vertex_cylinder), np.arange(n))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_vertical_cylinder_geometry():
|
|
38
|
+
length, radius, facets = 2.0, 0.25, 12
|
|
39
|
+
vertices, _, _ = cylinder_mesh(
|
|
40
|
+
np.array([[1.0, -2.0, 5.0]]), np.array([[0.0, 0.0, 1.0]]),
|
|
41
|
+
np.array([length]), np.array([radius]), facets=facets)
|
|
42
|
+
|
|
43
|
+
base, top = vertices[:facets], vertices[facets:]
|
|
44
|
+
assert np.allclose(base[:, 2], 5.0)
|
|
45
|
+
assert np.allclose(top[:, 2], 5.0 + length)
|
|
46
|
+
for ring in (base, top):
|
|
47
|
+
assert np.allclose(np.hypot(ring[:, 0] - 1.0, ring[:, 1] + 2.0), radius)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_tilted_cylinder_ring_radius():
|
|
51
|
+
axis = np.array([[1.0, 1.0, 1.0]]) / np.sqrt(3.0)
|
|
52
|
+
start = np.array([[0.0, 0.0, 0.0]])
|
|
53
|
+
radius = 0.1
|
|
54
|
+
vertices, _, _ = cylinder_mesh(start, axis, np.array([1.0]),
|
|
55
|
+
np.array([radius]), facets=8)
|
|
56
|
+
# Perpendicular distance of every vertex from the axis line is the radius.
|
|
57
|
+
along = vertices @ axis[0]
|
|
58
|
+
perp = vertices - np.outer(along, axis[0])
|
|
59
|
+
assert np.allclose(np.linalg.norm(perp, axis=1), radius)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_degenerate_axis_no_nan():
|
|
63
|
+
vertices, triangles, _ = cylinder_mesh(
|
|
64
|
+
np.zeros((1, 3)), np.zeros((1, 3)), np.array([1.0]), np.array([0.1]))
|
|
65
|
+
assert np.isfinite(vertices).all()
|
|
66
|
+
assert len(triangles) == 16
|