viseda 1.0.0__py3-none-any.whl
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.
- viseda/__init__.py +20 -0
- viseda/cli.py +144 -0
- viseda/core/__init__.py +3 -0
- viseda/core/base.py +69 -0
- viseda/hyperspectral/__init__.py +1 -0
- viseda/hyperspectral/eda.py +1849 -0
- viseda/image/__init__.py +1 -0
- viseda/image/eda.py +1840 -0
- viseda/pointcloud/__init__.py +3 -0
- viseda/pointcloud/eda.py +1167 -0
- viseda/report/__init__.py +3 -0
- viseda/report/html_report.py +213 -0
- viseda/text/__init__.py +3 -0
- viseda/text/eda.py +1613 -0
- viseda/utils/__init__.py +27 -0
- viseda/utils/helpers.py +120 -0
- viseda/video/__init__.py +3 -0
- viseda/video/eda.py +587 -0
- viseda-1.0.0.dist-info/METADATA +266 -0
- viseda-1.0.0.dist-info/RECORD +24 -0
- viseda-1.0.0.dist-info/WHEEL +5 -0
- viseda-1.0.0.dist-info/entry_points.txt +2 -0
- viseda-1.0.0.dist-info/licenses/LICENSE +21 -0
- viseda-1.0.0.dist-info/top_level.txt +1 -0
viseda/pointcloud/eda.py
ADDED
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
viseda.pointcloud.eda
|
|
3
|
+
=====================
|
|
4
|
+
Comprehensive exploratory data analysis for point cloud datasets.
|
|
5
|
+
|
|
6
|
+
The module is designed around one unified class: ``PointCloudEDA``.
|
|
7
|
+
It supports analysing a single point cloud, a list of point clouds, or a full
|
|
8
|
+
folder of point cloud files. The emphasis is dataset-level EDA, similar to the
|
|
9
|
+
HyperspectralEDA design used elsewhere in VisEDA.
|
|
10
|
+
|
|
11
|
+
Supported file formats
|
|
12
|
+
----------------------
|
|
13
|
+
* ``.npy`` / ``.npz`` — NumPy arrays with shape (N, D), D >= 3
|
|
14
|
+
* ``.txt`` / ``.csv`` / ``.xyz`` / ``.pts`` — text point clouds
|
|
15
|
+
* ``.ply`` — ASCII PLY point clouds
|
|
16
|
+
* ``.las`` / ``.laz`` — requires ``pip install laspy``
|
|
17
|
+
|
|
18
|
+
Expected data layout
|
|
19
|
+
--------------------
|
|
20
|
+
The first three columns must be X, Y, Z coordinates. Additional columns are
|
|
21
|
+
kept as attributes where possible, especially RGB/intensity-like channels.
|
|
22
|
+
|
|
23
|
+
Examples
|
|
24
|
+
--------
|
|
25
|
+
Dataset from directory
|
|
26
|
+
>>> eda = PointCloudEDA(max_points_per_cloud=200_000)
|
|
27
|
+
>>> eda.load("path/to/pointclouds", label_from_parent=True)
|
|
28
|
+
>>> eda.summary()
|
|
29
|
+
>>> eda.plot_dataset()
|
|
30
|
+
>>> eda.plot_clouds_grid()
|
|
31
|
+
|
|
32
|
+
Arrays directly
|
|
33
|
+
>>> eda = PointCloudEDA()
|
|
34
|
+
>>> eda.load_arrays([cloud1, cloud2], labels=["bridge", "road"])
|
|
35
|
+
>>> eda.summary()
|
|
36
|
+
>>> eda.plot_dataset()
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import html
|
|
42
|
+
import json
|
|
43
|
+
import math
|
|
44
|
+
import warnings
|
|
45
|
+
from collections import Counter
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
|
48
|
+
|
|
49
|
+
import numpy as np
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _plt():
|
|
53
|
+
import matplotlib.pyplot as plt
|
|
54
|
+
return plt
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _mpl():
|
|
58
|
+
import matplotlib as mpl
|
|
59
|
+
return mpl
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PointCloudRecord:
|
|
63
|
+
"""Container for per-cloud statistics."""
|
|
64
|
+
|
|
65
|
+
__slots__ = (
|
|
66
|
+
"path", "label", "file_ext", "file_size_kb", "n_points", "n_dims",
|
|
67
|
+
"dtype", "has_color", "has_intensity", "is_corrupt", "error",
|
|
68
|
+
"xyz_min", "xyz_max", "xyz_mean", "xyz_std", "bbox_size",
|
|
69
|
+
"bbox_volume", "centroid", "span_x", "span_y", "span_z",
|
|
70
|
+
"density", "height_min", "height_max", "height_mean", "height_std",
|
|
71
|
+
"z_percentiles", "radial_distance_mean", "radial_distance_std",
|
|
72
|
+
"nearest_neighbor_mean", "nearest_neighbor_median", "nearest_neighbor_std",
|
|
73
|
+
"outlier_fraction", "duplicate_fraction", "finite_fraction",
|
|
74
|
+
"planarity", "linearity", "scattering", "curvature",
|
|
75
|
+
"normal_entropy", "intensity_mean", "intensity_std",
|
|
76
|
+
"rgb_mean", "rgb_std", "sample_points",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
for field in self.__slots__:
|
|
81
|
+
setattr(self, field, None)
|
|
82
|
+
self.is_corrupt = False
|
|
83
|
+
self.error = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PointCloudEDA:
|
|
87
|
+
"""
|
|
88
|
+
Comprehensive EDA for point cloud datasets.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
verbose:
|
|
93
|
+
Print progress information.
|
|
94
|
+
max_clouds:
|
|
95
|
+
Analyse at most this number of clouds when loading from disk.
|
|
96
|
+
max_points_per_cloud:
|
|
97
|
+
Downsample each cloud to at most this number of points for statistics
|
|
98
|
+
and plotting. Use ``None`` to keep all points.
|
|
99
|
+
sample_seed:
|
|
100
|
+
Random seed used for reproducible downsampling.
|
|
101
|
+
compute_neighbors:
|
|
102
|
+
Compute nearest-neighbour spacing metrics. Requires scikit-learn.
|
|
103
|
+
compute_geometry:
|
|
104
|
+
Compute PCA-based geometry descriptors: linearity, planarity,
|
|
105
|
+
scattering and curvature. Requires scikit-learn.
|
|
106
|
+
neighbor_sample_size:
|
|
107
|
+
Maximum number of points used for nearest-neighbour computations.
|
|
108
|
+
duplicate_decimals:
|
|
109
|
+
Decimal places used when estimating duplicate points.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
SUPPORTED_EXTS = {".npy", ".npz", ".txt", ".csv", ".xyz", ".pts", ".ply", ".las", ".laz"}
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
verbose: bool = True,
|
|
117
|
+
max_clouds: Optional[int] = None,
|
|
118
|
+
max_points_per_cloud: Optional[int] = 200_000,
|
|
119
|
+
sample_seed: int = 0,
|
|
120
|
+
compute_neighbors: bool = True,
|
|
121
|
+
compute_geometry: bool = True,
|
|
122
|
+
neighbor_sample_size: int = 10_000,
|
|
123
|
+
duplicate_decimals: int = 5,
|
|
124
|
+
) -> None:
|
|
125
|
+
self.verbose = verbose
|
|
126
|
+
self.max_clouds = max_clouds
|
|
127
|
+
self.max_points_per_cloud = max_points_per_cloud
|
|
128
|
+
self.sample_seed = sample_seed
|
|
129
|
+
self.compute_neighbors = compute_neighbors
|
|
130
|
+
self.compute_geometry = compute_geometry
|
|
131
|
+
self.neighbor_sample_size = neighbor_sample_size
|
|
132
|
+
self.duplicate_decimals = duplicate_decimals
|
|
133
|
+
|
|
134
|
+
self._records: List[PointCloudRecord] = []
|
|
135
|
+
self._arrays: Dict[str, np.ndarray] = {}
|
|
136
|
+
self._label_map: Dict[str, str] = {}
|
|
137
|
+
self._loaded = False
|
|
138
|
+
self._results: Dict[str, Any] = {}
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
# Loading
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
def load(
|
|
144
|
+
self,
|
|
145
|
+
source: Union[str, Path, Sequence[Union[str, Path]]],
|
|
146
|
+
labels: Optional[Dict[str, str]] = None,
|
|
147
|
+
label_from_parent: bool = False,
|
|
148
|
+
recursive: bool = True,
|
|
149
|
+
) -> "PointCloudEDA":
|
|
150
|
+
"""Load one file, a list of files, or a directory of point clouds."""
|
|
151
|
+
paths = self._resolve_paths(source, recursive=recursive)
|
|
152
|
+
if self.max_clouds is not None:
|
|
153
|
+
paths = paths[: self.max_clouds]
|
|
154
|
+
|
|
155
|
+
if labels:
|
|
156
|
+
self._label_map = {str(Path(k).resolve()): v for k, v in labels.items()}
|
|
157
|
+
|
|
158
|
+
self._records = []
|
|
159
|
+
self._arrays = {}
|
|
160
|
+
self._log(f"Found {len(paths)} point cloud file(s) — computing statistics …")
|
|
161
|
+
|
|
162
|
+
for i, path in enumerate(paths):
|
|
163
|
+
if self.verbose:
|
|
164
|
+
self._log(f" [{i + 1}/{len(paths)}] {path.name}")
|
|
165
|
+
rec = self._analyse_file(path, label_from_parent=label_from_parent)
|
|
166
|
+
self._records.append(rec)
|
|
167
|
+
|
|
168
|
+
self._loaded = True
|
|
169
|
+
bad = sum(r.is_corrupt for r in self._records)
|
|
170
|
+
self._log(f"Done. {len(self._records)} cloud(s) loaded ({bad} corrupt).")
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
def load_arrays(
|
|
174
|
+
self,
|
|
175
|
+
arrays: Sequence[np.ndarray],
|
|
176
|
+
labels: Optional[Sequence[str]] = None,
|
|
177
|
+
names: Optional[Sequence[str]] = None,
|
|
178
|
+
) -> "PointCloudEDA":
|
|
179
|
+
"""Load point clouds directly as arrays with shape (N, D), D >= 3."""
|
|
180
|
+
self._records = []
|
|
181
|
+
self._arrays = {}
|
|
182
|
+
self._log(f"Loading {len(arrays)} point cloud array(s) …")
|
|
183
|
+
|
|
184
|
+
if self.max_clouds is not None:
|
|
185
|
+
arrays = arrays[: self.max_clouds]
|
|
186
|
+
if labels is not None:
|
|
187
|
+
labels = labels[: self.max_clouds]
|
|
188
|
+
if names is not None:
|
|
189
|
+
names = names[: self.max_clouds]
|
|
190
|
+
|
|
191
|
+
for i, arr in enumerate(arrays):
|
|
192
|
+
rec = PointCloudRecord()
|
|
193
|
+
rec.path = names[i] if names and i < len(names) else f"<array_{i}>"
|
|
194
|
+
rec.file_ext = "array"
|
|
195
|
+
rec.label = labels[i] if labels and i < len(labels) else None
|
|
196
|
+
try:
|
|
197
|
+
cloud = self._normalise_cloud_array(arr)
|
|
198
|
+
cloud = self._downsample(cloud)
|
|
199
|
+
self._arrays[rec.path] = cloud
|
|
200
|
+
self._fill_stats(rec, cloud)
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
rec.is_corrupt = True
|
|
203
|
+
rec.error = str(exc)
|
|
204
|
+
self._log(f" ✗ {rec.path}: {exc}")
|
|
205
|
+
self._records.append(rec)
|
|
206
|
+
|
|
207
|
+
self._loaded = True
|
|
208
|
+
return self
|
|
209
|
+
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
# Public analysis methods
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
def summary(self) -> Dict[str, Any]:
|
|
214
|
+
"""Return a nested summary dictionary for all loaded clouds."""
|
|
215
|
+
self._check_loaded()
|
|
216
|
+
valid = [r for r in self._records if not r.is_corrupt]
|
|
217
|
+
corrupt = [r for r in self._records if r.is_corrupt]
|
|
218
|
+
if not valid:
|
|
219
|
+
result = {
|
|
220
|
+
"inventory": {
|
|
221
|
+
"total_clouds": len(self._records),
|
|
222
|
+
"valid_clouds": 0,
|
|
223
|
+
"corrupt_clouds": len(corrupt),
|
|
224
|
+
"corrupt_paths": [r.path for r in corrupt],
|
|
225
|
+
"format_distribution": {},
|
|
226
|
+
"label_distribution": None,
|
|
227
|
+
},
|
|
228
|
+
"spatial_extent": {},
|
|
229
|
+
"point_counts": {},
|
|
230
|
+
"density": {},
|
|
231
|
+
"quality": {},
|
|
232
|
+
"geometry": {},
|
|
233
|
+
"attributes": {},
|
|
234
|
+
"labels": {"label_distribution": None, "class_imbalance_ratio": None},
|
|
235
|
+
"error": "No valid point clouds found.",
|
|
236
|
+
}
|
|
237
|
+
self._results["summary"] = result
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
def arr(attr: str) -> np.ndarray:
|
|
241
|
+
vals = [getattr(r, attr) for r in valid if getattr(r, attr) is not None]
|
|
242
|
+
return np.asarray(vals, dtype=float) if vals else np.asarray([], dtype=float)
|
|
243
|
+
|
|
244
|
+
labels = [r.label for r in valid if r.label]
|
|
245
|
+
label_dist = dict(Counter(labels)) if labels else None
|
|
246
|
+
format_dist = dict(Counter(r.file_ext for r in valid))
|
|
247
|
+
|
|
248
|
+
bbox_sizes = np.vstack([r.bbox_size for r in valid if r.bbox_size is not None])
|
|
249
|
+
xyz_means = np.vstack([r.xyz_mean for r in valid if r.xyz_mean is not None])
|
|
250
|
+
xyz_stds = np.vstack([r.xyz_std for r in valid if r.xyz_std is not None])
|
|
251
|
+
|
|
252
|
+
summary = {
|
|
253
|
+
"inventory": {
|
|
254
|
+
"total_clouds": len(self._records),
|
|
255
|
+
"valid_clouds": len(valid),
|
|
256
|
+
"corrupt_clouds": len(corrupt),
|
|
257
|
+
"corrupt_paths": [r.path for r in corrupt],
|
|
258
|
+
"format_distribution": format_dist,
|
|
259
|
+
"label_distribution": label_dist,
|
|
260
|
+
"point_count": _stat_dict(arr("n_points")),
|
|
261
|
+
"dimension_count": _stat_dict(arr("n_dims")),
|
|
262
|
+
"has_color_count": int(sum(bool(r.has_color) for r in valid)),
|
|
263
|
+
"has_intensity_count": int(sum(bool(r.has_intensity) for r in valid)),
|
|
264
|
+
},
|
|
265
|
+
"geometry": {
|
|
266
|
+
"bbox_volume": _stat_dict(arr("bbox_volume")),
|
|
267
|
+
"density": _stat_dict(arr("density")),
|
|
268
|
+
"span_x": _stat_dict(arr("span_x")),
|
|
269
|
+
"span_y": _stat_dict(arr("span_y")),
|
|
270
|
+
"span_z": _stat_dict(arr("span_z")),
|
|
271
|
+
"bbox_size_mean": bbox_sizes.mean(axis=0).tolist() if len(bbox_sizes) else None,
|
|
272
|
+
"xyz_mean_mean": xyz_means.mean(axis=0).tolist() if len(xyz_means) else None,
|
|
273
|
+
"xyz_std_mean": xyz_stds.mean(axis=0).tolist() if len(xyz_stds) else None,
|
|
274
|
+
},
|
|
275
|
+
"height": {
|
|
276
|
+
"height_min": _stat_dict(arr("height_min")),
|
|
277
|
+
"height_max": _stat_dict(arr("height_max")),
|
|
278
|
+
"height_mean": _stat_dict(arr("height_mean")),
|
|
279
|
+
"height_std": _stat_dict(arr("height_std")),
|
|
280
|
+
},
|
|
281
|
+
"quality": {
|
|
282
|
+
"finite_fraction": _stat_dict(arr("finite_fraction")),
|
|
283
|
+
"duplicate_fraction": _stat_dict(arr("duplicate_fraction")),
|
|
284
|
+
"outlier_fraction": _stat_dict(arr("outlier_fraction")),
|
|
285
|
+
"nearest_neighbor_mean": _stat_dict(arr("nearest_neighbor_mean")),
|
|
286
|
+
"nearest_neighbor_median": _stat_dict(arr("nearest_neighbor_median")),
|
|
287
|
+
"nearest_neighbor_std": _stat_dict(arr("nearest_neighbor_std")),
|
|
288
|
+
},
|
|
289
|
+
"shape_descriptors": {
|
|
290
|
+
"linearity": _stat_dict(arr("linearity")),
|
|
291
|
+
"planarity": _stat_dict(arr("planarity")),
|
|
292
|
+
"scattering": _stat_dict(arr("scattering")),
|
|
293
|
+
"curvature": _stat_dict(arr("curvature")),
|
|
294
|
+
},
|
|
295
|
+
"attributes": {
|
|
296
|
+
"intensity_mean": _stat_dict(arr("intensity_mean")),
|
|
297
|
+
"intensity_std": _stat_dict(arr("intensity_std")),
|
|
298
|
+
"rgb_mean_mean": _mean_array([r.rgb_mean for r in valid if r.rgb_mean is not None]),
|
|
299
|
+
"rgb_std_mean": _mean_array([r.rgb_std for r in valid if r.rgb_std is not None]),
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
self._results["summary"] = summary
|
|
303
|
+
return summary
|
|
304
|
+
|
|
305
|
+
def get_record(self, index: int = 0) -> PointCloudRecord:
|
|
306
|
+
"""Return the record at *index*."""
|
|
307
|
+
self._check_loaded()
|
|
308
|
+
return self._records[index]
|
|
309
|
+
|
|
310
|
+
def get_cloud(self, index: int = 0) -> np.ndarray:
|
|
311
|
+
"""Return the loaded/downsampled point cloud array at *index*."""
|
|
312
|
+
self._check_loaded()
|
|
313
|
+
rec = self._records[index]
|
|
314
|
+
return self._load_cloud_array(rec)
|
|
315
|
+
|
|
316
|
+
def pairwise_cloud_distances(self, max_clouds: int = 50) -> Tuple[np.ndarray, List[str]]:
|
|
317
|
+
"""
|
|
318
|
+
Compute a pairwise dataset-level distance matrix between clouds.
|
|
319
|
+
|
|
320
|
+
The distance is computed from normalised summary vectors, not from raw
|
|
321
|
+
point-to-point Chamfer distance, so it remains fast for many clouds.
|
|
322
|
+
"""
|
|
323
|
+
self._check_loaded()
|
|
324
|
+
valid = [r for r in self._records if not r.is_corrupt][:max_clouds]
|
|
325
|
+
if len(valid) < 2:
|
|
326
|
+
raise ValueError("Need at least two valid clouds.")
|
|
327
|
+
features = []
|
|
328
|
+
names = []
|
|
329
|
+
for rec in valid:
|
|
330
|
+
vec = [
|
|
331
|
+
rec.n_points, rec.span_x, rec.span_y, rec.span_z,
|
|
332
|
+
rec.bbox_volume, rec.density, rec.height_mean, rec.height_std,
|
|
333
|
+
rec.nearest_neighbor_mean or 0.0, rec.duplicate_fraction or 0.0,
|
|
334
|
+
rec.outlier_fraction or 0.0, rec.linearity or 0.0,
|
|
335
|
+
rec.planarity or 0.0, rec.scattering or 0.0, rec.curvature or 0.0,
|
|
336
|
+
]
|
|
337
|
+
features.append(vec)
|
|
338
|
+
names.append(rec.label or Path(str(rec.path)).stem)
|
|
339
|
+
X = np.asarray(features, dtype=float)
|
|
340
|
+
X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0)
|
|
341
|
+
std = X.std(axis=0)
|
|
342
|
+
std[std == 0] = 1.0
|
|
343
|
+
Xn = (X - X.mean(axis=0)) / std
|
|
344
|
+
diff = Xn[:, None, :] - Xn[None, :, :]
|
|
345
|
+
dist = np.sqrt((diff ** 2).sum(axis=2))
|
|
346
|
+
return dist, names
|
|
347
|
+
|
|
348
|
+
# ------------------------------------------------------------------
|
|
349
|
+
# Plotting
|
|
350
|
+
# ------------------------------------------------------------------
|
|
351
|
+
def plot_dataset(
|
|
352
|
+
self,
|
|
353
|
+
figsize: Tuple[int, int] = (22, 18),
|
|
354
|
+
save_path: Optional[str] = None,
|
|
355
|
+
dpi: int = 150,
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Dataset-level dashboard summarising all loaded point clouds."""
|
|
358
|
+
self._check_loaded()
|
|
359
|
+
plt = _plt()
|
|
360
|
+
mpl = _mpl()
|
|
361
|
+
valid = [r for r in self._records if not r.is_corrupt]
|
|
362
|
+
if not valid:
|
|
363
|
+
raise RuntimeError("No valid point clouds to plot.")
|
|
364
|
+
|
|
365
|
+
s = self.summary()
|
|
366
|
+
fig = plt.figure(figsize=figsize, facecolor="white")
|
|
367
|
+
fig.suptitle("PointCloudEDA — Dataset Analysis", fontsize=18, fontweight="bold")
|
|
368
|
+
gs = mpl.gridspec.GridSpec(4, 4, figure=fig, hspace=0.45, wspace=0.35)
|
|
369
|
+
|
|
370
|
+
self._plot_dataset_card(fig.add_subplot(gs[0, :2]), s)
|
|
371
|
+
self._plot_label_dist(fig.add_subplot(gs[0, 2:]), valid)
|
|
372
|
+
self._plot_hist(fig.add_subplot(gs[1, 0]), [r.n_points for r in valid], "Point Count")
|
|
373
|
+
self._plot_hist(fig.add_subplot(gs[1, 1]), [r.bbox_volume for r in valid], "Bounding Box Volume")
|
|
374
|
+
self._plot_hist(fig.add_subplot(gs[1, 2]), [r.density for r in valid], "Point Density")
|
|
375
|
+
self._plot_hist(fig.add_subplot(gs[1, 3]), [r.height_std for r in valid], "Height Std")
|
|
376
|
+
self._plot_xyz_spans(fig.add_subplot(gs[2, :2]), valid)
|
|
377
|
+
self._plot_quality_bars(fig.add_subplot(gs[2, 2:]), valid)
|
|
378
|
+
self._plot_shape_descriptors(fig.add_subplot(gs[3, :2]), valid)
|
|
379
|
+
self._plot_pairwise_distance(fig.add_subplot(gs[3, 2:]), valid)
|
|
380
|
+
self._finalise(fig, save_path, dpi)
|
|
381
|
+
|
|
382
|
+
def plot(
|
|
383
|
+
self,
|
|
384
|
+
cloud_index: int = 0,
|
|
385
|
+
max_points: int = 20_000,
|
|
386
|
+
figsize: Tuple[int, int] = (18, 14),
|
|
387
|
+
save_path: Optional[str] = None,
|
|
388
|
+
dpi: int = 150,
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Single-cloud dashboard for one selected point cloud."""
|
|
391
|
+
self._check_loaded()
|
|
392
|
+
plt = _plt()
|
|
393
|
+
mpl = _mpl()
|
|
394
|
+
rec = self._records[cloud_index]
|
|
395
|
+
cloud = self._load_cloud_array(rec)
|
|
396
|
+
xyz = self._sample_xyz(cloud[:, :3], max_points=max_points)
|
|
397
|
+
|
|
398
|
+
fig = plt.figure(figsize=figsize, facecolor="white")
|
|
399
|
+
fig.suptitle(f"PointCloudEDA — {rec.label or rec.path}", fontsize=16, fontweight="bold")
|
|
400
|
+
gs = mpl.gridspec.GridSpec(3, 3, figure=fig, hspace=0.45, wspace=0.35)
|
|
401
|
+
|
|
402
|
+
self._plot_cloud_card(fig.add_subplot(gs[0, 0]), rec)
|
|
403
|
+
ax3d = fig.add_subplot(gs[0, 1:], projection="3d")
|
|
404
|
+
self._plot_3d_scatter(ax3d, xyz, rec)
|
|
405
|
+
self._plot_2d_projection(fig.add_subplot(gs[1, 0]), xyz, "X", "Y", 0, 1)
|
|
406
|
+
self._plot_2d_projection(fig.add_subplot(gs[1, 1]), xyz, "X", "Z", 0, 2)
|
|
407
|
+
self._plot_2d_projection(fig.add_subplot(gs[1, 2]), xyz, "Y", "Z", 1, 2)
|
|
408
|
+
self._plot_height_hist(fig.add_subplot(gs[2, 0]), xyz)
|
|
409
|
+
self._plot_density_map(fig.add_subplot(gs[2, 1]), xyz)
|
|
410
|
+
self._plot_local_spacing(fig.add_subplot(gs[2, 2]), rec)
|
|
411
|
+
self._finalise(fig, save_path, dpi)
|
|
412
|
+
|
|
413
|
+
def plot_clouds_grid(
|
|
414
|
+
self,
|
|
415
|
+
n: int = 12,
|
|
416
|
+
cols: int = 4,
|
|
417
|
+
max_points: int = 8_000,
|
|
418
|
+
figsize: Optional[Tuple[int, int]] = None,
|
|
419
|
+
save_path: Optional[str] = None,
|
|
420
|
+
dpi: int = 150,
|
|
421
|
+
) -> None:
|
|
422
|
+
"""Grid of 3D previews for multiple loaded clouds."""
|
|
423
|
+
self._check_loaded()
|
|
424
|
+
plt = _plt()
|
|
425
|
+
valid_indices = [i for i, r in enumerate(self._records) if not r.is_corrupt][:n]
|
|
426
|
+
rows = int(math.ceil(len(valid_indices) / cols))
|
|
427
|
+
figsize = figsize or (cols * 4, rows * 4)
|
|
428
|
+
fig = plt.figure(figsize=figsize, facecolor="white")
|
|
429
|
+
|
|
430
|
+
for panel, idx in enumerate(valid_indices):
|
|
431
|
+
rec = self._records[idx]
|
|
432
|
+
cloud = self._load_cloud_array(rec)
|
|
433
|
+
xyz = self._sample_xyz(cloud[:, :3], max_points=max_points)
|
|
434
|
+
ax = fig.add_subplot(rows, cols, panel + 1, projection="3d")
|
|
435
|
+
self._plot_3d_scatter(ax, xyz, rec, compact=True)
|
|
436
|
+
|
|
437
|
+
fig.suptitle("PointCloudEDA — Point Cloud Preview Grid", fontsize=14, fontweight="bold")
|
|
438
|
+
self._finalise(fig, save_path, dpi)
|
|
439
|
+
|
|
440
|
+
def plot_height_distribution(
|
|
441
|
+
self,
|
|
442
|
+
figsize: Tuple[int, int] = (12, 6),
|
|
443
|
+
save_path: Optional[str] = None,
|
|
444
|
+
dpi: int = 150,
|
|
445
|
+
) -> None:
|
|
446
|
+
"""Overlay height/Z distributions across loaded clouds."""
|
|
447
|
+
self._check_loaded()
|
|
448
|
+
plt = _plt()
|
|
449
|
+
fig, ax = plt.subplots(figsize=figsize, facecolor="white")
|
|
450
|
+
valid = [r for r in self._records if not r.is_corrupt]
|
|
451
|
+
for rec in valid[:30]:
|
|
452
|
+
cloud = self._load_cloud_array(rec)
|
|
453
|
+
z = self._sample_xyz(cloud[:, :3], max_points=20_000)[:, 2]
|
|
454
|
+
ax.hist(z, bins=40, histtype="step", density=True, alpha=0.7, label=(rec.label or Path(str(rec.path)).stem)[:18])
|
|
455
|
+
if len(valid) <= 10:
|
|
456
|
+
ax.legend(fontsize=7)
|
|
457
|
+
ax.set_title("Height/Z Distribution Across Clouds")
|
|
458
|
+
ax.set_xlabel("Z")
|
|
459
|
+
ax.set_ylabel("Density")
|
|
460
|
+
self._finalise(fig, save_path, dpi)
|
|
461
|
+
|
|
462
|
+
def plot_pairwise_cloud_distances(
|
|
463
|
+
self,
|
|
464
|
+
max_clouds: int = 50,
|
|
465
|
+
figsize: Tuple[int, int] = (10, 8),
|
|
466
|
+
save_path: Optional[str] = None,
|
|
467
|
+
dpi: int = 150,
|
|
468
|
+
) -> None:
|
|
469
|
+
"""Heatmap of pairwise cloud distances based on summary descriptors."""
|
|
470
|
+
self._check_loaded()
|
|
471
|
+
plt = _plt()
|
|
472
|
+
dist, names = self.pairwise_cloud_distances(max_clouds=max_clouds)
|
|
473
|
+
fig, ax = plt.subplots(figsize=figsize, facecolor="white")
|
|
474
|
+
im = ax.imshow(dist, aspect="auto")
|
|
475
|
+
plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04).ax.tick_params(labelsize=7)
|
|
476
|
+
if len(names) <= 30:
|
|
477
|
+
ax.set_xticks(range(len(names)))
|
|
478
|
+
ax.set_yticks(range(len(names)))
|
|
479
|
+
ax.set_xticklabels(names, rotation=45, ha="right", fontsize=7)
|
|
480
|
+
ax.set_yticklabels(names, fontsize=7)
|
|
481
|
+
ax.set_title("Pairwise Cloud Distance Matrix")
|
|
482
|
+
self._finalise(fig, save_path, dpi)
|
|
483
|
+
|
|
484
|
+
# ------------------------------------------------------------------
|
|
485
|
+
# Report
|
|
486
|
+
# ------------------------------------------------------------------
|
|
487
|
+
def report(self, output_path: str = "viseda_pointcloud_report.html") -> str:
|
|
488
|
+
"""Generate a self-contained HTML report."""
|
|
489
|
+
self._check_loaded()
|
|
490
|
+
summary = self.summary()
|
|
491
|
+
_generate_html_report(summary, output_path)
|
|
492
|
+
self._log(f"Report saved → {output_path}")
|
|
493
|
+
return output_path
|
|
494
|
+
|
|
495
|
+
# ------------------------------------------------------------------
|
|
496
|
+
# Internal reading and statistics
|
|
497
|
+
# ------------------------------------------------------------------
|
|
498
|
+
def _analyse_file(self, path: Path, label_from_parent: bool) -> PointCloudRecord:
|
|
499
|
+
rec = PointCloudRecord()
|
|
500
|
+
rec.path = str(path)
|
|
501
|
+
rec.file_ext = path.suffix.lower()
|
|
502
|
+
rec.file_size_kb = path.stat().st_size / 1024 if path.exists() else None
|
|
503
|
+
rec.label = path.parent.name if label_from_parent else self._label_map.get(str(path.resolve()))
|
|
504
|
+
try:
|
|
505
|
+
cloud = self._read_cloud(path)
|
|
506
|
+
cloud = self._downsample(cloud)
|
|
507
|
+
self._arrays[rec.path] = cloud
|
|
508
|
+
self._fill_stats(rec, cloud)
|
|
509
|
+
except Exception as exc:
|
|
510
|
+
rec.is_corrupt = True
|
|
511
|
+
rec.error = str(exc)
|
|
512
|
+
self._log(f" ✗ {path.name}: {exc}")
|
|
513
|
+
return rec
|
|
514
|
+
|
|
515
|
+
def _fill_stats(self, rec: PointCloudRecord, cloud: np.ndarray) -> None:
|
|
516
|
+
cloud = self._normalise_cloud_array(cloud)
|
|
517
|
+
original_rows = len(cloud)
|
|
518
|
+
finite_mask = np.isfinite(cloud[:, :3]).all(axis=1)
|
|
519
|
+
rec.finite_fraction = float(finite_mask.mean()) if original_rows else 0.0
|
|
520
|
+
cloud = cloud[finite_mask]
|
|
521
|
+
if len(cloud) == 0:
|
|
522
|
+
raise ValueError("Point cloud contains no finite XYZ points.")
|
|
523
|
+
|
|
524
|
+
xyz = cloud[:, :3].astype(np.float64)
|
|
525
|
+
rec.n_points = int(len(xyz))
|
|
526
|
+
rec.n_dims = int(cloud.shape[1])
|
|
527
|
+
rec.dtype = str(cloud.dtype)
|
|
528
|
+
rec.has_color = self._detect_color(cloud)
|
|
529
|
+
rec.has_intensity = cloud.shape[1] >= 4
|
|
530
|
+
|
|
531
|
+
rec.xyz_min = xyz.min(axis=0)
|
|
532
|
+
rec.xyz_max = xyz.max(axis=0)
|
|
533
|
+
rec.xyz_mean = xyz.mean(axis=0)
|
|
534
|
+
rec.xyz_std = xyz.std(axis=0)
|
|
535
|
+
rec.centroid = rec.xyz_mean.copy()
|
|
536
|
+
rec.bbox_size = rec.xyz_max - rec.xyz_min
|
|
537
|
+
rec.span_x, rec.span_y, rec.span_z = [float(v) for v in rec.bbox_size]
|
|
538
|
+
rec.bbox_volume = float(np.prod(np.maximum(rec.bbox_size, 1e-12)))
|
|
539
|
+
rec.density = float(rec.n_points / rec.bbox_volume) if rec.bbox_volume > 0 else None
|
|
540
|
+
|
|
541
|
+
z = xyz[:, 2]
|
|
542
|
+
rec.height_min = float(z.min())
|
|
543
|
+
rec.height_max = float(z.max())
|
|
544
|
+
rec.height_mean = float(z.mean())
|
|
545
|
+
rec.height_std = float(z.std())
|
|
546
|
+
rec.z_percentiles = np.percentile(z, [0, 5, 25, 50, 75, 95, 100]).tolist()
|
|
547
|
+
|
|
548
|
+
radial = np.linalg.norm(xyz - rec.centroid, axis=1)
|
|
549
|
+
rec.radial_distance_mean = float(radial.mean())
|
|
550
|
+
rec.radial_distance_std = float(radial.std())
|
|
551
|
+
if radial.std() > 0:
|
|
552
|
+
rec.outlier_fraction = float(np.mean(radial > radial.mean() + 3.0 * radial.std()))
|
|
553
|
+
else:
|
|
554
|
+
rec.outlier_fraction = 0.0
|
|
555
|
+
|
|
556
|
+
rounded = np.round(xyz, decimals=self.duplicate_decimals)
|
|
557
|
+
unique_n = len(np.unique(rounded, axis=0))
|
|
558
|
+
rec.duplicate_fraction = float(1.0 - unique_n / len(xyz))
|
|
559
|
+
|
|
560
|
+
if cloud.shape[1] >= 4:
|
|
561
|
+
intensity = cloud[:, 3].astype(float)
|
|
562
|
+
intensity = intensity[np.isfinite(intensity)]
|
|
563
|
+
if len(intensity):
|
|
564
|
+
rec.intensity_mean = float(intensity.mean())
|
|
565
|
+
rec.intensity_std = float(intensity.std())
|
|
566
|
+
|
|
567
|
+
if rec.has_color:
|
|
568
|
+
rgb = self._extract_rgb(cloud)
|
|
569
|
+
if rgb is not None and len(rgb):
|
|
570
|
+
rec.rgb_mean = rgb.mean(axis=0).tolist()
|
|
571
|
+
rec.rgb_std = rgb.std(axis=0).tolist()
|
|
572
|
+
|
|
573
|
+
rec.sample_points = self._sample_xyz(xyz, max_points=min(5000, len(xyz)))
|
|
574
|
+
|
|
575
|
+
if self.compute_neighbors:
|
|
576
|
+
self._fill_neighbor_stats(rec, xyz)
|
|
577
|
+
else:
|
|
578
|
+
rec.nearest_neighbor_mean = None
|
|
579
|
+
rec.nearest_neighbor_median = None
|
|
580
|
+
rec.nearest_neighbor_std = None
|
|
581
|
+
|
|
582
|
+
if self.compute_geometry:
|
|
583
|
+
self._fill_geometry_stats(rec, xyz)
|
|
584
|
+
else:
|
|
585
|
+
rec.linearity = rec.planarity = rec.scattering = rec.curvature = None
|
|
586
|
+
rec.normal_entropy = None
|
|
587
|
+
|
|
588
|
+
def _fill_neighbor_stats(self, rec: PointCloudRecord, xyz: np.ndarray) -> None:
|
|
589
|
+
if len(xyz) < 2:
|
|
590
|
+
rec.nearest_neighbor_mean = 0.0
|
|
591
|
+
rec.nearest_neighbor_median = 0.0
|
|
592
|
+
rec.nearest_neighbor_std = 0.0
|
|
593
|
+
return
|
|
594
|
+
try:
|
|
595
|
+
from sklearn.neighbors import NearestNeighbors
|
|
596
|
+
pts = self._sample_xyz(xyz, max_points=min(self.neighbor_sample_size, len(xyz)))
|
|
597
|
+
nn = NearestNeighbors(n_neighbors=2)
|
|
598
|
+
nn.fit(pts)
|
|
599
|
+
dists, _ = nn.kneighbors(pts)
|
|
600
|
+
nearest = dists[:, 1]
|
|
601
|
+
rec.nearest_neighbor_mean = float(nearest.mean())
|
|
602
|
+
rec.nearest_neighbor_median = float(np.median(nearest))
|
|
603
|
+
rec.nearest_neighbor_std = float(nearest.std())
|
|
604
|
+
except Exception:
|
|
605
|
+
rec.nearest_neighbor_mean = None
|
|
606
|
+
rec.nearest_neighbor_median = None
|
|
607
|
+
rec.nearest_neighbor_std = None
|
|
608
|
+
|
|
609
|
+
def _fill_geometry_stats(self, rec: PointCloudRecord, xyz: np.ndarray) -> None:
|
|
610
|
+
if len(xyz) < 3:
|
|
611
|
+
rec.linearity = rec.planarity = rec.scattering = rec.curvature = 0.0
|
|
612
|
+
rec.normal_entropy = 0.0
|
|
613
|
+
return
|
|
614
|
+
pts = self._sample_xyz(xyz, max_points=min(50_000, len(xyz)))
|
|
615
|
+
centered = pts - pts.mean(axis=0)
|
|
616
|
+
cov = np.cov(centered.T)
|
|
617
|
+
eig = np.linalg.eigvalsh(cov)
|
|
618
|
+
eig = np.sort(np.maximum(eig, 0))[::-1]
|
|
619
|
+
l1, l2, l3 = eig + 1e-12
|
|
620
|
+
rec.linearity = float((l1 - l2) / l1)
|
|
621
|
+
rec.planarity = float((l2 - l3) / l1)
|
|
622
|
+
rec.scattering = float(l3 / l1)
|
|
623
|
+
rec.curvature = float(l3 / (l1 + l2 + l3))
|
|
624
|
+
probs = eig / eig.sum()
|
|
625
|
+
rec.normal_entropy = float(-(probs * np.log(probs + 1e-12)).sum())
|
|
626
|
+
|
|
627
|
+
def _resolve_paths(self, source: Union[str, Path, Sequence[Union[str, Path]]], recursive: bool) -> List[Path]:
|
|
628
|
+
if isinstance(source, (str, Path)):
|
|
629
|
+
p = Path(source)
|
|
630
|
+
if p.is_dir():
|
|
631
|
+
globber = p.rglob if recursive else p.glob
|
|
632
|
+
paths = [x for x in globber("*") if x.is_file() and x.suffix.lower() in self.SUPPORTED_EXTS]
|
|
633
|
+
return sorted(paths)
|
|
634
|
+
if p.is_file():
|
|
635
|
+
return [p]
|
|
636
|
+
raise FileNotFoundError(f"Source not found: {source}")
|
|
637
|
+
paths = [Path(x) for x in source]
|
|
638
|
+
return [p for p in paths if p.suffix.lower() in self.SUPPORTED_EXTS]
|
|
639
|
+
|
|
640
|
+
def _read_cloud(self, path: Path) -> np.ndarray:
|
|
641
|
+
suffix = path.suffix.lower()
|
|
642
|
+
if suffix == ".npy":
|
|
643
|
+
return self._normalise_cloud_array(np.load(str(path)))
|
|
644
|
+
if suffix == ".npz":
|
|
645
|
+
data = np.load(str(path))
|
|
646
|
+
key = list(data.keys())[0]
|
|
647
|
+
return self._normalise_cloud_array(data[key])
|
|
648
|
+
if suffix in {".txt", ".csv", ".xyz", ".pts"}:
|
|
649
|
+
delimiter = "," if suffix == ".csv" else None
|
|
650
|
+
return self._read_text_cloud(path, delimiter=delimiter)
|
|
651
|
+
if suffix == ".ply":
|
|
652
|
+
return self._read_ascii_ply(path)
|
|
653
|
+
if suffix in {".las", ".laz"}:
|
|
654
|
+
return self._read_las(path)
|
|
655
|
+
raise ValueError(f"Unsupported point cloud format: {suffix}")
|
|
656
|
+
|
|
657
|
+
def _read_text_cloud(self, path: Path, delimiter: Optional[str]) -> np.ndarray:
|
|
658
|
+
try:
|
|
659
|
+
arr = np.loadtxt(str(path), delimiter=delimiter, comments="#")
|
|
660
|
+
except ValueError:
|
|
661
|
+
arr = np.genfromtxt(str(path), delimiter=delimiter, comments="#", names=None)
|
|
662
|
+
return self._normalise_cloud_array(arr)
|
|
663
|
+
|
|
664
|
+
def _read_ascii_ply(self, path: Path) -> np.ndarray:
|
|
665
|
+
with path.open("r", encoding="utf-8", errors="ignore") as f:
|
|
666
|
+
header = []
|
|
667
|
+
vertex_count = None
|
|
668
|
+
properties = []
|
|
669
|
+
while True:
|
|
670
|
+
line = f.readline()
|
|
671
|
+
if not line:
|
|
672
|
+
raise ValueError("Invalid PLY file: missing end_header.")
|
|
673
|
+
stripped = line.strip()
|
|
674
|
+
header.append(stripped)
|
|
675
|
+
if stripped.startswith("format") and "ascii" not in stripped:
|
|
676
|
+
raise ValueError("Only ASCII .ply is supported without extra dependencies.")
|
|
677
|
+
if stripped.startswith("element vertex"):
|
|
678
|
+
vertex_count = int(stripped.split()[-1])
|
|
679
|
+
if stripped.startswith("property") and vertex_count is not None:
|
|
680
|
+
properties.append(stripped.split()[-1])
|
|
681
|
+
if stripped == "end_header":
|
|
682
|
+
break
|
|
683
|
+
if vertex_count is None:
|
|
684
|
+
raise ValueError("Invalid PLY file: no vertex count.")
|
|
685
|
+
rows = []
|
|
686
|
+
for _ in range(vertex_count):
|
|
687
|
+
line = f.readline()
|
|
688
|
+
if not line:
|
|
689
|
+
break
|
|
690
|
+
vals = [float(x) for x in line.strip().split()]
|
|
691
|
+
rows.append(vals)
|
|
692
|
+
arr = np.asarray(rows, dtype=np.float32)
|
|
693
|
+
if arr.shape[1] >= 3 and properties[:3] != ["x", "y", "z"]:
|
|
694
|
+
lower = [p.lower() for p in properties]
|
|
695
|
+
if all(k in lower for k in ["x", "y", "z"]):
|
|
696
|
+
idx = [lower.index("x"), lower.index("y"), lower.index("z")]
|
|
697
|
+
rest = [i for i in range(arr.shape[1]) if i not in idx]
|
|
698
|
+
arr = arr[:, idx + rest]
|
|
699
|
+
return self._normalise_cloud_array(arr)
|
|
700
|
+
|
|
701
|
+
def _read_las(self, path: Path) -> np.ndarray:
|
|
702
|
+
try:
|
|
703
|
+
import laspy
|
|
704
|
+
except ImportError as exc:
|
|
705
|
+
raise ImportError("LAS/LAZ support requires: pip install laspy") from exc
|
|
706
|
+
las = laspy.read(str(path))
|
|
707
|
+
cols = [np.asarray(las.x), np.asarray(las.y), np.asarray(las.z)]
|
|
708
|
+
if hasattr(las, "intensity"):
|
|
709
|
+
cols.append(np.asarray(las.intensity))
|
|
710
|
+
for color_name in ("red", "green", "blue"):
|
|
711
|
+
if hasattr(las, color_name):
|
|
712
|
+
cols.append(np.asarray(getattr(las, color_name)))
|
|
713
|
+
return self._normalise_cloud_array(np.column_stack(cols))
|
|
714
|
+
|
|
715
|
+
def _normalise_cloud_array(self, arr: np.ndarray) -> np.ndarray:
|
|
716
|
+
arr = np.asarray(arr)
|
|
717
|
+
if arr.ndim == 1:
|
|
718
|
+
if arr.size < 3:
|
|
719
|
+
raise ValueError("Point cloud array must contain at least XYZ columns.")
|
|
720
|
+
arr = arr.reshape(1, -1)
|
|
721
|
+
if arr.ndim != 2 or arr.shape[1] < 3:
|
|
722
|
+
raise ValueError("Point cloud array must have shape (N, D), with D >= 3.")
|
|
723
|
+
return arr.astype(np.float32, copy=False)
|
|
724
|
+
|
|
725
|
+
def _downsample(self, cloud: np.ndarray) -> np.ndarray:
|
|
726
|
+
if self.max_points_per_cloud is None or len(cloud) <= self.max_points_per_cloud:
|
|
727
|
+
return cloud
|
|
728
|
+
rng = np.random.default_rng(self.sample_seed)
|
|
729
|
+
idx = rng.choice(len(cloud), size=self.max_points_per_cloud, replace=False)
|
|
730
|
+
return cloud[np.sort(idx)]
|
|
731
|
+
|
|
732
|
+
def _load_cloud_array(self, rec: PointCloudRecord) -> np.ndarray:
|
|
733
|
+
if rec.path in self._arrays:
|
|
734
|
+
return self._arrays[rec.path]
|
|
735
|
+
if rec.path and not str(rec.path).startswith("<array"):
|
|
736
|
+
cloud = self._downsample(self._read_cloud(Path(str(rec.path))))
|
|
737
|
+
self._arrays[rec.path] = cloud
|
|
738
|
+
return cloud
|
|
739
|
+
raise ValueError("Raw cloud array is unavailable for this record.")
|
|
740
|
+
|
|
741
|
+
def _detect_color(self, cloud: np.ndarray) -> bool:
|
|
742
|
+
if cloud.shape[1] < 6:
|
|
743
|
+
return False
|
|
744
|
+
rgb = cloud[:, -3:]
|
|
745
|
+
return bool(np.nanmax(rgb) > 1.0 or np.nanmax(rgb) <= 1.0)
|
|
746
|
+
|
|
747
|
+
def _extract_rgb(self, cloud: np.ndarray) -> Optional[np.ndarray]:
|
|
748
|
+
if cloud.shape[1] < 6:
|
|
749
|
+
return None
|
|
750
|
+
rgb = cloud[:, -3:].astype(float)
|
|
751
|
+
finite = np.isfinite(rgb).all(axis=1)
|
|
752
|
+
rgb = rgb[finite]
|
|
753
|
+
if len(rgb) == 0:
|
|
754
|
+
return None
|
|
755
|
+
if rgb.max() > 1.0:
|
|
756
|
+
rgb = rgb / max(255.0, rgb.max())
|
|
757
|
+
return np.clip(rgb, 0, 1)
|
|
758
|
+
|
|
759
|
+
def _sample_xyz(self, xyz: np.ndarray, max_points: int = 10_000) -> np.ndarray:
|
|
760
|
+
xyz = np.asarray(xyz)
|
|
761
|
+
if len(xyz) <= max_points:
|
|
762
|
+
return xyz
|
|
763
|
+
rng = np.random.default_rng(self.sample_seed)
|
|
764
|
+
idx = rng.choice(len(xyz), size=max_points, replace=False)
|
|
765
|
+
return xyz[idx]
|
|
766
|
+
|
|
767
|
+
def _check_loaded(self) -> None:
|
|
768
|
+
if not self._loaded:
|
|
769
|
+
raise RuntimeError("No point clouds loaded. Call load() or load_arrays() first.")
|
|
770
|
+
|
|
771
|
+
def _log(self, message: str) -> None:
|
|
772
|
+
if self.verbose:
|
|
773
|
+
print(f"[viseda] {message}")
|
|
774
|
+
|
|
775
|
+
# ------------------------------------------------------------------
|
|
776
|
+
# Plot helpers
|
|
777
|
+
# ------------------------------------------------------------------
|
|
778
|
+
def _plot_dataset_card(self, ax, s: Dict[str, Any]) -> None:
|
|
779
|
+
ax.axis("off")
|
|
780
|
+
inv = s["inventory"]
|
|
781
|
+
geo = s["geometry"]
|
|
782
|
+
quality = s["quality"]
|
|
783
|
+
lines = [
|
|
784
|
+
f"Total clouds: {inv['total_clouds']}",
|
|
785
|
+
f"Valid clouds: {inv['valid_clouds']}",
|
|
786
|
+
f"Corrupt clouds: {inv['corrupt_clouds']}",
|
|
787
|
+
f"Formats: {inv['format_distribution']}",
|
|
788
|
+
f"Labels: {inv['label_distribution']}",
|
|
789
|
+
f"Mean points: {inv['point_count'].get('mean', 'N/A')}",
|
|
790
|
+
f"Mean bbox volume: {geo['bbox_volume'].get('mean', 'N/A')}",
|
|
791
|
+
f"Mean density: {geo['density'].get('mean', 'N/A')}",
|
|
792
|
+
f"Duplicate frac: {quality['duplicate_fraction'].get('mean', 'N/A')}",
|
|
793
|
+
f"Outlier frac: {quality['outlier_fraction'].get('mean', 'N/A')}",
|
|
794
|
+
]
|
|
795
|
+
ax.text(0.03, 0.97, "\n".join(lines), va="top", ha="left", transform=ax.transAxes,
|
|
796
|
+
fontsize=9, family="monospace", bbox=dict(boxstyle="round,pad=0.5", facecolor="#f2f2f2"))
|
|
797
|
+
ax.set_title("Dataset Overview")
|
|
798
|
+
|
|
799
|
+
def _plot_cloud_card(self, ax, rec: PointCloudRecord) -> None:
|
|
800
|
+
ax.axis("off")
|
|
801
|
+
lines = [
|
|
802
|
+
f"Path: {rec.path}",
|
|
803
|
+
f"Label: {rec.label or 'N/A'}",
|
|
804
|
+
f"Points: {rec.n_points:,}",
|
|
805
|
+
f"Dimensions: {rec.n_dims}",
|
|
806
|
+
f"BBox: {np.round(rec.bbox_size, 3).tolist()}",
|
|
807
|
+
f"Volume: {rec.bbox_volume:.4f}",
|
|
808
|
+
f"Density: {rec.density:.4f}",
|
|
809
|
+
f"Height mean: {rec.height_mean:.4f}",
|
|
810
|
+
f"NN mean: {rec.nearest_neighbor_mean if rec.nearest_neighbor_mean is not None else 'N/A'}",
|
|
811
|
+
f"Duplicates: {rec.duplicate_fraction:.4f}",
|
|
812
|
+
f"Outliers: {rec.outlier_fraction:.4f}",
|
|
813
|
+
]
|
|
814
|
+
ax.text(0.03, 0.97, "\n".join(lines), va="top", ha="left", transform=ax.transAxes,
|
|
815
|
+
fontsize=8, family="monospace", bbox=dict(boxstyle="round,pad=0.5", facecolor="#f2f2f2"))
|
|
816
|
+
ax.set_title("Cloud Overview")
|
|
817
|
+
|
|
818
|
+
def _style_ax(self, ax):
|
|
819
|
+
ax.set_facecolor('#f6f8fa')
|
|
820
|
+
for sp in ax.spines.values():
|
|
821
|
+
sp.set_edgecolor('#d0d7de')
|
|
822
|
+
ax.tick_params(colors='#57606a', labelsize=8)
|
|
823
|
+
|
|
824
|
+
def _plot_label_dist(self, ax, valid: List[PointCloudRecord]) -> None:
|
|
825
|
+
labels = [r.label for r in valid if r.label]
|
|
826
|
+
if not labels:
|
|
827
|
+
ax.text(0.5, 0.5, "No labels provided", ha="center", va="center", transform=ax.transAxes)
|
|
828
|
+
ax.set_title("Label Distribution")
|
|
829
|
+
return
|
|
830
|
+
names, counts = zip(*Counter(labels).most_common())
|
|
831
|
+
ax.barh(range(len(names)), counts)
|
|
832
|
+
ax.set_yticks(range(len(names)))
|
|
833
|
+
ax.set_yticklabels(names)
|
|
834
|
+
ax.set_title("Label Distribution")
|
|
835
|
+
ax.set_xlabel("Cloud count")
|
|
836
|
+
|
|
837
|
+
def _plot_hist(self, ax, values: Sequence[Any], title: str, bins: int = 20) -> None:
|
|
838
|
+
vals = np.asarray([v for v in values if v is not None and np.isfinite(v)], dtype=float)
|
|
839
|
+
if len(vals) == 0:
|
|
840
|
+
ax.set_title(title)
|
|
841
|
+
ax.text(0.5, 0.5, "No data", ha="center", va="center", transform=ax.transAxes)
|
|
842
|
+
return
|
|
843
|
+
ax.hist(vals, bins=min(bins, max(5, len(vals))))
|
|
844
|
+
ax.axvline(vals.mean(), linestyle="--", linewidth=1, label=f"mean={vals.mean():.3g}")
|
|
845
|
+
ax.legend(fontsize=7)
|
|
846
|
+
ax.set_title(title)
|
|
847
|
+
|
|
848
|
+
def _plot_xyz_spans(self, ax, valid: List[PointCloudRecord]) -> None:
|
|
849
|
+
spans = np.asarray([r.bbox_size for r in valid if r.bbox_size is not None], dtype=float)
|
|
850
|
+
if len(spans) == 0:
|
|
851
|
+
ax.set_title("XYZ Spans")
|
|
852
|
+
return
|
|
853
|
+
x = np.arange(len(spans))
|
|
854
|
+
ax.plot(x, spans[:, 0], marker=".", label="X span")
|
|
855
|
+
ax.plot(x, spans[:, 1], marker=".", label="Y span")
|
|
856
|
+
ax.plot(x, spans[:, 2], marker=".", label="Z span")
|
|
857
|
+
ax.set_title("XYZ Bounding Box Spans")
|
|
858
|
+
ax.set_xlabel("Cloud index")
|
|
859
|
+
ax.legend(fontsize=8)
|
|
860
|
+
|
|
861
|
+
def _plot_quality_bars(self, ax, valid: List[PointCloudRecord]) -> None:
|
|
862
|
+
metrics = {
|
|
863
|
+
"Finite": [r.finite_fraction for r in valid],
|
|
864
|
+
"Duplicate": [r.duplicate_fraction for r in valid],
|
|
865
|
+
"Outlier": [r.outlier_fraction for r in valid],
|
|
866
|
+
"NN mean": [r.nearest_neighbor_mean for r in valid if r.nearest_neighbor_mean is not None],
|
|
867
|
+
}
|
|
868
|
+
labels, means = [], []
|
|
869
|
+
for name, values in metrics.items():
|
|
870
|
+
vals = np.asarray([v for v in values if v is not None and np.isfinite(v)], dtype=float)
|
|
871
|
+
if len(vals):
|
|
872
|
+
labels.append(name)
|
|
873
|
+
means.append(float(vals.mean()))
|
|
874
|
+
ax.bar(labels, means)
|
|
875
|
+
ax.set_title("Quality Metrics")
|
|
876
|
+
ax.tick_params(axis="x", rotation=30)
|
|
877
|
+
|
|
878
|
+
def _plot_shape_descriptors(self, ax, valid: List[PointCloudRecord]) -> None:
|
|
879
|
+
names = ["linearity", "planarity", "scattering", "curvature"]
|
|
880
|
+
means = []
|
|
881
|
+
for name in names:
|
|
882
|
+
vals = np.asarray([getattr(r, name) for r in valid if getattr(r, name) is not None], dtype=float)
|
|
883
|
+
means.append(vals.mean() if len(vals) else 0)
|
|
884
|
+
ax.bar([n.title() for n in names], means)
|
|
885
|
+
ax.set_title("PCA Shape Descriptors")
|
|
886
|
+
ax.tick_params(axis="x", rotation=20)
|
|
887
|
+
|
|
888
|
+
def _plot_pairwise_distance(self, ax, valid: List[PointCloudRecord]) -> None:
|
|
889
|
+
if len(valid) < 2:
|
|
890
|
+
ax.set_title("Pairwise Distances")
|
|
891
|
+
return
|
|
892
|
+
try:
|
|
893
|
+
dist, names = self.pairwise_cloud_distances(max_clouds=min(30, len(valid)))
|
|
894
|
+
im = ax.imshow(dist, aspect="auto")
|
|
895
|
+
ax.figure.colorbar(im, ax=ax, fraction=0.046, pad=0.04).ax.tick_params(labelsize=6)
|
|
896
|
+
ax.set_title("Cloud Distance Heatmap")
|
|
897
|
+
if len(names) <= 15:
|
|
898
|
+
ax.set_xticks(range(len(names)))
|
|
899
|
+
ax.set_yticks(range(len(names)))
|
|
900
|
+
ax.set_xticklabels(names, rotation=45, ha="right", fontsize=6)
|
|
901
|
+
ax.set_yticklabels(names, fontsize=6)
|
|
902
|
+
except Exception as exc:
|
|
903
|
+
ax.text(0.5, 0.5, str(exc), ha="center", va="center", transform=ax.transAxes)
|
|
904
|
+
ax.set_title("Cloud Distance Heatmap")
|
|
905
|
+
|
|
906
|
+
def _plot_3d_scatter(self, ax, xyz: np.ndarray, rec: PointCloudRecord, compact: bool = False) -> None:
|
|
907
|
+
colors = xyz[:, 2]
|
|
908
|
+
ax.scatter(xyz[:, 0], xyz[:, 1], xyz[:, 2], c=colors, s=1, alpha=0.6)
|
|
909
|
+
ax.set_title((rec.label or Path(str(rec.path)).stem)[:30], fontsize=8 if compact else 10)
|
|
910
|
+
if not compact:
|
|
911
|
+
ax.set_xlabel("X")
|
|
912
|
+
ax.set_ylabel("Y")
|
|
913
|
+
ax.set_zlabel("Z")
|
|
914
|
+
else:
|
|
915
|
+
ax.set_xticks([])
|
|
916
|
+
ax.set_yticks([])
|
|
917
|
+
ax.set_zticks([])
|
|
918
|
+
|
|
919
|
+
def _plot_2d_projection(self, ax, xyz: np.ndarray, xlab: str, ylab: str, xi: int, yi: int) -> None:
|
|
920
|
+
ax.scatter(xyz[:, xi], xyz[:, yi], s=1, alpha=0.5)
|
|
921
|
+
ax.set_xlabel(xlab)
|
|
922
|
+
ax.set_ylabel(ylab)
|
|
923
|
+
ax.set_title(f"{xlab}-{ylab} Projection")
|
|
924
|
+
|
|
925
|
+
def _plot_height_hist(self, ax, xyz: np.ndarray) -> None:
|
|
926
|
+
ax.hist(xyz[:, 2], bins=40)
|
|
927
|
+
ax.set_title("Height/Z Distribution")
|
|
928
|
+
ax.set_xlabel("Z")
|
|
929
|
+
|
|
930
|
+
def _plot_density_map(self, ax, xyz: np.ndarray) -> None:
|
|
931
|
+
h = ax.hist2d(xyz[:, 0], xyz[:, 1], bins=80)
|
|
932
|
+
ax.figure.colorbar(h[3], ax=ax, fraction=0.046, pad=0.04).ax.tick_params(labelsize=6)
|
|
933
|
+
ax.set_title("XY Density Map")
|
|
934
|
+
ax.set_xlabel("X")
|
|
935
|
+
ax.set_ylabel("Y")
|
|
936
|
+
|
|
937
|
+
def _plot_local_spacing(self, ax, rec: PointCloudRecord) -> None:
|
|
938
|
+
vals = [rec.nearest_neighbor_mean, rec.nearest_neighbor_median, rec.nearest_neighbor_std]
|
|
939
|
+
vals = [0 if v is None else v for v in vals]
|
|
940
|
+
ax.bar(["NN mean", "NN median", "NN std"], vals)
|
|
941
|
+
ax.set_title("Nearest-Neighbour Spacing")
|
|
942
|
+
ax.tick_params(axis="x", rotation=20)
|
|
943
|
+
|
|
944
|
+
def _finalise(self, fig, save_path: Optional[str], dpi: int) -> None:
|
|
945
|
+
plt = _plt()
|
|
946
|
+
if save_path:
|
|
947
|
+
Path(save_path).parent.mkdir(parents=True, exist_ok=True)
|
|
948
|
+
fig.savefig(save_path, dpi=dpi, bbox_inches="tight")
|
|
949
|
+
plt.close(fig)
|
|
950
|
+
else:
|
|
951
|
+
plt.tight_layout()
|
|
952
|
+
plt.show()
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _stat_dict(values: np.ndarray) -> Dict[str, Optional[float]]:
|
|
956
|
+
values = np.asarray(values, dtype=float)
|
|
957
|
+
values = values[np.isfinite(values)]
|
|
958
|
+
if len(values) == 0:
|
|
959
|
+
return {"count": 0, "mean": None, "std": None, "min": None, "q25": None, "median": None, "q75": None, "max": None}
|
|
960
|
+
return {
|
|
961
|
+
"count": int(len(values)),
|
|
962
|
+
"mean": round(float(values.mean()), 6),
|
|
963
|
+
"std": round(float(values.std()), 6),
|
|
964
|
+
"min": round(float(values.min()), 6),
|
|
965
|
+
"q25": round(float(np.percentile(values, 25)), 6),
|
|
966
|
+
"median": round(float(np.median(values)), 6),
|
|
967
|
+
"q75": round(float(np.percentile(values, 75)), 6),
|
|
968
|
+
"max": round(float(values.max()), 6),
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _mean_array(arrays: List[Any]) -> Optional[List[float]]:
|
|
973
|
+
if not arrays:
|
|
974
|
+
return None
|
|
975
|
+
vals = np.asarray(arrays, dtype=float)
|
|
976
|
+
if vals.ndim == 1:
|
|
977
|
+
return vals.tolist()
|
|
978
|
+
return vals.mean(axis=0).round(6).tolist()
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _generate_html_report(summary: Dict[str, Any], output_path: str) -> None:
|
|
982
|
+
# Styled HTML report matching the HyperspectralEDA report design.
|
|
983
|
+
# It uses cards, grids, statistic rows, and compact distribution bars
|
|
984
|
+
# instead of raw JSON <pre> blocks.
|
|
985
|
+
|
|
986
|
+
def fmt(value: Any) -> str:
|
|
987
|
+
if value is None:
|
|
988
|
+
return "N/A"
|
|
989
|
+
if isinstance(value, (int, np.integer)):
|
|
990
|
+
return f"{int(value):,}"
|
|
991
|
+
if isinstance(value, (float, np.floating)):
|
|
992
|
+
if not np.isfinite(value):
|
|
993
|
+
return "N/A"
|
|
994
|
+
return f"{float(value):.4f}"
|
|
995
|
+
return html.escape(str(value))
|
|
996
|
+
|
|
997
|
+
def stat_rows(stats: Dict[str, Any], order: Optional[List[str]] = None) -> str:
|
|
998
|
+
if not isinstance(stats, dict) or not stats:
|
|
999
|
+
return '<div class="stat"><span>No data</span><span class="val">N/A</span></div>'
|
|
1000
|
+
order = order or ["min", "max", "mean", "median", "std", "q25", "q75", "count"]
|
|
1001
|
+
labels = {"q25": "p25", "q75": "p75", "std": "std", "min": "min", "max": "max", "mean": "mean", "median": "median", "count": "count"}
|
|
1002
|
+
rows = []
|
|
1003
|
+
for key in order:
|
|
1004
|
+
if key in stats:
|
|
1005
|
+
rows.append(
|
|
1006
|
+
f'<div class="stat"><span>{labels.get(key, html.escape(str(key)))}</span>'
|
|
1007
|
+
f'<span class="val">{fmt(stats.get(key))}</span></div>'
|
|
1008
|
+
)
|
|
1009
|
+
if not rows:
|
|
1010
|
+
for key, value in stats.items():
|
|
1011
|
+
rows.append(
|
|
1012
|
+
f'<div class="stat"><span>{html.escape(str(key))}</span>'
|
|
1013
|
+
f'<span class="val">{fmt(value)}</span></div>'
|
|
1014
|
+
)
|
|
1015
|
+
return "".join(rows)
|
|
1016
|
+
|
|
1017
|
+
def stat_card(title: str, stats: Dict[str, Any]) -> str:
|
|
1018
|
+
return f'<div class="card"><h3>{html.escape(title)}</h3>{stat_rows(stats)}</div>'
|
|
1019
|
+
|
|
1020
|
+
def simple_card(title: str, items: Dict[str, Any]) -> str:
|
|
1021
|
+
rows = []
|
|
1022
|
+
for key, value in items.items():
|
|
1023
|
+
rows.append(
|
|
1024
|
+
f'<div class="stat"><span>{html.escape(str(key))}</span>'
|
|
1025
|
+
f'<span class="val">{fmt(value)}</span></div>'
|
|
1026
|
+
)
|
|
1027
|
+
body = "".join(rows) or '<div class="stat"><span>No data</span><span class="val">N/A</span></div>'
|
|
1028
|
+
return f'<div class="card"><h3>{html.escape(title)}</h3>{body}</div>'
|
|
1029
|
+
|
|
1030
|
+
def vector_card(title: str, values: Any, labels: Optional[List[str]] = None) -> str:
|
|
1031
|
+
if values is None:
|
|
1032
|
+
return simple_card(title, {"value": None})
|
|
1033
|
+
labels = labels or [f"Value {i+1}" for i in range(len(values))]
|
|
1034
|
+
return simple_card(title, {label: val for label, val in zip(labels, values)})
|
|
1035
|
+
|
|
1036
|
+
def bar_chart(title: str, data: Optional[Dict[Any, Any]], span: int = 1) -> str:
|
|
1037
|
+
data = data or {}
|
|
1038
|
+
if not data:
|
|
1039
|
+
body = '<div class="stat"><span>No data</span><span class="val">N/A</span></div>'
|
|
1040
|
+
else:
|
|
1041
|
+
vals = [float(v) for v in data.values() if v is not None]
|
|
1042
|
+
max_val = max(vals) if vals else 1.0
|
|
1043
|
+
if max_val <= 0:
|
|
1044
|
+
max_val = 1.0
|
|
1045
|
+
rows = []
|
|
1046
|
+
for key, value in data.items():
|
|
1047
|
+
value = 0 if value is None else value
|
|
1048
|
+
width = max(0.0, min(100.0, (float(value) / max_val) * 100.0))
|
|
1049
|
+
rows.append(
|
|
1050
|
+
'<div class="bar-row">'
|
|
1051
|
+
f'<span class="bar-label">{html.escape(str(key))}</span>'
|
|
1052
|
+
f'<div class="bar"><div class="bar-fill" style="width:{width:.1f}%"></div></div>'
|
|
1053
|
+
f'<span class="bar-count">{fmt(value)}</span>'
|
|
1054
|
+
'</div>'
|
|
1055
|
+
)
|
|
1056
|
+
body = f'<div class="bar-wrap">{"".join(rows)}</div>'
|
|
1057
|
+
style = f' style="grid-column:span {span};"' if span > 1 else ""
|
|
1058
|
+
return f'<div class="card"{style}><h3>{html.escape(title)}</h3>{body}</div>'
|
|
1059
|
+
|
|
1060
|
+
inv = summary.get("inventory", {}) or {}
|
|
1061
|
+
geom = summary.get("geometry", {}) or {}
|
|
1062
|
+
height = summary.get("height", {}) or {}
|
|
1063
|
+
quality = summary.get("quality", {}) or {}
|
|
1064
|
+
shape = summary.get("shape_descriptors", {}) or {}
|
|
1065
|
+
attrs = summary.get("attributes", {}) or {}
|
|
1066
|
+
|
|
1067
|
+
html_text = f'''<!DOCTYPE html>
|
|
1068
|
+
<html lang="en"><head><meta charset="UTF-8"/>
|
|
1069
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
1070
|
+
<title>VisEDA — PointCloud Report</title>
|
|
1071
|
+
<style>
|
|
1072
|
+
:root{{--bg:white;--surface:#f6f8fa;--border:#d0d7de;--text:#1f2328;
|
|
1073
|
+
--muted:#57606a;--accent:#58a6ff;--green:#3fb950;--red:#f78166;
|
|
1074
|
+
--yellow:#e3b341;}}
|
|
1075
|
+
*{{box-sizing:border-box;margin:0;padding:0}}
|
|
1076
|
+
body{{background:var(--bg);color:var(--text);
|
|
1077
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
|
1078
|
+
padding:2rem;}}
|
|
1079
|
+
h1{{font-size:1.9rem;margin-bottom:.25rem}}
|
|
1080
|
+
h2{{font-size:1.05rem;color:var(--accent);margin:1.8rem 0 .6rem}}
|
|
1081
|
+
h3{{font-size:.78rem;color:var(--muted);text-transform:uppercase;
|
|
1082
|
+
letter-spacing:.05em;margin-bottom:.5rem}}
|
|
1083
|
+
.sub{{color:var(--muted);font-size:.85rem;margin-bottom:1.5rem}}
|
|
1084
|
+
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.9rem}}
|
|
1085
|
+
.card{{background:var(--surface);border:1px solid var(--border);
|
|
1086
|
+
border-radius:8px;padding:1rem}}
|
|
1087
|
+
.stat{{display:flex;justify-content:space-between;font-size:.82rem;
|
|
1088
|
+
padding:.18rem 0;border-bottom:1px solid var(--border);gap:.75rem}}
|
|
1089
|
+
.stat:last-child{{border-bottom:none}}
|
|
1090
|
+
.val{{color:var(--accent);font-variant-numeric:tabular-nums;text-align:right}}
|
|
1091
|
+
.badge{{display:inline-block;padding:.15rem .5rem;border-radius:12px;
|
|
1092
|
+
font-size:.72rem;font-weight:600;margin:.15rem}}
|
|
1093
|
+
.badge-blue{{background:rgba(88,166,255,.15);color:var(--accent)}}
|
|
1094
|
+
.badge-green{{background:rgba(63,185,80,.15);color:var(--green)}}
|
|
1095
|
+
.badge-red{{background:rgba(247,129,102,.15);color:var(--red)}}
|
|
1096
|
+
.bar-wrap{{margin-top:.4rem}}
|
|
1097
|
+
.bar-row{{display:flex;align-items:center;gap:.4rem;margin:.18rem 0;font-size:.76rem}}
|
|
1098
|
+
.bar-label{{width:120px;overflow:hidden;text-overflow:ellipsis;
|
|
1099
|
+
white-space:nowrap;color:var(--muted)}}
|
|
1100
|
+
.bar{{flex:1;background:var(--border);border-radius:3px;height:9px}}
|
|
1101
|
+
.bar-fill{{height:100%;border-radius:3px;background:var(--accent)}}
|
|
1102
|
+
.bar-count{{width:55px;text-align:right;color:var(--accent)}}
|
|
1103
|
+
footer{{margin-top:3rem;color:var(--muted);font-size:.72rem;
|
|
1104
|
+
border-top:1px solid var(--border);padding-top:1rem}}
|
|
1105
|
+
</style></head><body>
|
|
1106
|
+
<h1>☁️ VisEDA — PointCloud EDA Report</h1>
|
|
1107
|
+
<p class="sub">Generated by <strong>VisEDA</strong></p>
|
|
1108
|
+
<p style="margin-bottom:1rem"><span class="badge badge-blue">{fmt(inv.get('total_clouds'))} clouds</span><span class="badge badge-green">{fmt(inv.get('valid_clouds'))} valid</span><span class="badge badge-red">{fmt(inv.get('corrupt_clouds'))} corrupt</span></p>
|
|
1109
|
+
|
|
1110
|
+
<h2>📦 Inventory</h2>
|
|
1111
|
+
<div class="grid">
|
|
1112
|
+
{simple_card('Counts', {'Total clouds': inv.get('total_clouds'), 'Valid clouds': inv.get('valid_clouds'), 'Corrupt clouds': inv.get('corrupt_clouds'), 'Clouds with colour': inv.get('has_color_count'), 'Clouds with intensity': inv.get('has_intensity_count')})}
|
|
1113
|
+
{bar_chart('Label Distribution', inv.get('label_distribution'), span=2)}
|
|
1114
|
+
{bar_chart('Format Distribution', inv.get('format_distribution'))}
|
|
1115
|
+
{stat_card('Point Count', inv.get('point_count', {}))}
|
|
1116
|
+
{stat_card('Dimension Count', inv.get('dimension_count', {}))}
|
|
1117
|
+
</div>
|
|
1118
|
+
|
|
1119
|
+
<h2>📐 Geometry</h2>
|
|
1120
|
+
<div class="grid">
|
|
1121
|
+
{stat_card('Bounding Box Volume', geom.get('bbox_volume', {}))}
|
|
1122
|
+
{stat_card('Point Density', geom.get('density', {}))}
|
|
1123
|
+
{stat_card('Span X', geom.get('span_x', {}))}
|
|
1124
|
+
{stat_card('Span Y', geom.get('span_y', {}))}
|
|
1125
|
+
{stat_card('Span Z', geom.get('span_z', {}))}
|
|
1126
|
+
{vector_card('Mean Bounding Box Size', geom.get('bbox_size_mean'), ['X span', 'Y span', 'Z span'])}
|
|
1127
|
+
{vector_card('Mean XYZ Position', geom.get('xyz_mean_mean'), ['X mean', 'Y mean', 'Z mean'])}
|
|
1128
|
+
{vector_card('Mean XYZ Std', geom.get('xyz_std_mean'), ['X std', 'Y std', 'Z std'])}
|
|
1129
|
+
</div>
|
|
1130
|
+
|
|
1131
|
+
<h2>↕️ Height</h2>
|
|
1132
|
+
<div class="grid">
|
|
1133
|
+
{stat_card('Height Minimum', height.get('height_min', {}))}
|
|
1134
|
+
{stat_card('Height Maximum', height.get('height_max', {}))}
|
|
1135
|
+
{stat_card('Height Mean', height.get('height_mean', {}))}
|
|
1136
|
+
{stat_card('Height Std', height.get('height_std', {}))}
|
|
1137
|
+
</div>
|
|
1138
|
+
|
|
1139
|
+
<h2>✅ Quality</h2>
|
|
1140
|
+
<div class="grid">
|
|
1141
|
+
{stat_card('Finite Fraction', quality.get('finite_fraction', {}))}
|
|
1142
|
+
{stat_card('Duplicate Fraction', quality.get('duplicate_fraction', {}))}
|
|
1143
|
+
{stat_card('Outlier Fraction', quality.get('outlier_fraction', {}))}
|
|
1144
|
+
{stat_card('Nearest-Neighbour Mean', quality.get('nearest_neighbor_mean', {}))}
|
|
1145
|
+
{stat_card('Nearest-Neighbour Median', quality.get('nearest_neighbor_median', {}))}
|
|
1146
|
+
{stat_card('Nearest-Neighbour Std', quality.get('nearest_neighbor_std', {}))}
|
|
1147
|
+
</div>
|
|
1148
|
+
|
|
1149
|
+
<h2>🧊 Shape Descriptors</h2>
|
|
1150
|
+
<div class="grid">
|
|
1151
|
+
{stat_card('Linearity', shape.get('linearity', {}))}
|
|
1152
|
+
{stat_card('Planarity', shape.get('planarity', {}))}
|
|
1153
|
+
{stat_card('Scattering', shape.get('scattering', {}))}
|
|
1154
|
+
{stat_card('Curvature', shape.get('curvature', {}))}
|
|
1155
|
+
</div>
|
|
1156
|
+
|
|
1157
|
+
<h2>🎨 Attributes</h2>
|
|
1158
|
+
<div class="grid">
|
|
1159
|
+
{stat_card('Intensity Mean', attrs.get('intensity_mean', {}))}
|
|
1160
|
+
{stat_card('Intensity Std', attrs.get('intensity_std', {}))}
|
|
1161
|
+
{vector_card('RGB Mean', attrs.get('rgb_mean_mean'), ['R mean', 'G mean', 'B mean'])}
|
|
1162
|
+
{vector_card('RGB Std', attrs.get('rgb_std_mean'), ['R std', 'G std', 'B std'])}
|
|
1163
|
+
</div>
|
|
1164
|
+
|
|
1165
|
+
<footer>Generated by VisEDA — Visual Exploratory Data Analysis</footer>
|
|
1166
|
+
</body></html>'''
|
|
1167
|
+
Path(output_path).write_text(html_text, encoding="utf-8")
|