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.
@@ -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")