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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """CloudCompare plugin adapter for PyTLidar TreeQSM."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,2 @@
1
+ [cloudcompare.plugins]
2
+ PyTLidar = pytlidar_cc.plugin:PyTLidarPlugin
@@ -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