Commit 74cbbe0
committed
[1.3.74] 2026-06-10
- Added two read-only shoot-topology accessors: `PlantArchitecture::getAllShootIDs()` returns the contiguous, 0-based list of shoot IDs for a plant (shoot 0 is always the base stem), and `PlantArchitecture::getPlantShoot()` returns a const reference to the `shared_ptr<Shoot>` for a given shoot, exposing its topology (rank, parent/child IDs, parent node index) and woody internode geometry for inspecting a plant's ground-truth structure. Both fail fast with `helios_runtime_error()` on a nonexistent plant ID, and `getPlantShoot()` also on an out-of-range shoot ID.
- Fixed terminal fruiting buds being created at full size and then abruptly snapping to 25% scale on the first fruit-growth step: `appendPhytomerToShoot()` now initializes a terminal bud entering the `BUD_FRUITING` state at a 0.25 inflorescence scale fraction (matching the existing `setFloralBudState()` behavior), so the fruit/panicle grows in gradually instead of regrowing after a sudden shrink.
- Fixed `syntheticScan()` producing NaN ray directions for a single-row or single-column scan: the inclusive-endpoint angular step now guards its `N-1` denominator so a scan with `Nphi==1` or `Ntheta==1` samples once at the minimum angle instead of dividing by zero.
- Added an optional global scanner tilt for synthetic scans, configured per scan via a new `<scanTilt>` XML element (`roll pitch` in degrees; default `0 0` = perfectly level) and exposed through new `scanTilt_roll`/`scanTilt_pitch` fields on `ScanMetadata`, two trailing constructor arguments, and `LiDARcloud::getScanTiltRoll()` / `getScanTiltPitch()` accessors. This models the residual tilt of the scanner spin axis away from plumb that a real terrestrial scanner's dual-axis inclinometer reports. During `syntheticScan()` the entire fan of ray directions is rotated about the scanner origin using right-hand rotations in a right-handed, Z-up body frame (matching commercial scanners such as RIEGL SOCS): the forward axis is the horizontal projection of the azimuth-zero (`phiMin`) heading, the lateral axis completes the frame, roll rotates about the lateral axis and pitch about the forward axis, with roll applied first. The new field round-trips through `exportScans()`/`loadXML()`.
- Reworked leaf-area inversion onto a single miss-aware, beam-based equal-weighting algorithm that handles both single- and multi-return data, replacing the previous auto-detected split between a single-return "standard" path and a multi-return "equal weighting" path (the now-unused `filterRaysByBoundingBox()`, `calculateVoxelPathLengths()`, and `findRayIndexByDirection()` helpers and the entire single-return path were removed). Misses (fired pulses that returned nothing — the transmitted beams that form the denominator of the per-voxel transmission probability) are now the canonical input: they are flagged with a per-hit `is_miss` data field (set by `syntheticScan()` and `gapfillMisses()`, carried through imported data when present), queryable via new `LiDARcloud::isHitMiss()` / `hasMisses()` methods and the `LIDAR_MISS_DISTANCE` constant. `calculateLeafArea()` now fails fast with an explicit error if the cloud contains no misses, rather than silently producing biased LAD. Also fixed `invertLAD()` to no longer report a stalled secant solve (which left the estimate near the 0.1 initial guess) as converged — convergence now requires the achieved error to fall below tolerance, so a stalled solve correctly falls back to the average-`dr` formulation.
- Made the leaf-area inversion's per-beam voxel classification independent of how far out a miss point is placed along its beam. Previously a transmitted beam was counted toward the transmission probability only when its hit point fell within a hard-coded 5000 m `scanner_range` (so a miss placed at the synthetic-scan distance and a miss placed at `LIDAR_MISS_DISTANCE` by `gapfillMisses()` were classified differently, and moving the placement distance silently doubled the inverted LAD). A beam is now classified purely geometrically: any beam that passes through the voxel exit is "after voxel" (transmitted) regardless of placement distance, folding misses into the same `E_after` count as any other transmitted return. The `scanner_range` threshold and the separate all-miss-only probability branch were removed. Results are unchanged for existing data; only the previously fragile coupling between the miss placement distance and the inversion is eliminated.
- Documented and made explicit the synthetic-scan return-intensity radiometric model: the `intensity` recorded by `syntheticScan()` is the *range-normalized* return amplitude \f$\rho\cos\theta\f$ (per-primitive `reflectivity_lidar` reflectivity times the incidence-angle cosine) with the \f$1/R^2\f$ range loss of the LiDAR range equation divided back out, so a surface returns the same intensity regardless of scanner-to-target range. The normalization is now routed through a new `LiDARcloud::applyRangeIntensityCorrection()` helper (identity on the value, since the synthetic intensity is generated directly as \f$\rho\cos\theta\f$) that is the single place to switch to a raw range-dependent convention. Full-waveform partial-footprint attenuation is deliberately preserved (it is carried by the fraction of beam sub-rays striking the target — a target property, not a range-geometry loss).
- Added an optional `reflectance` synthetic-scan output column: when `reflectance` is listed in a scan's `ASCII_format`, `syntheticScan()` records the return reflectance in decibels, \f$10\log_{10}|I|\f$ of the range-normalized intensity, following the terrestrial-laser-scanner convention (e.g. RIEGL) in which a perfect Lambertian reflector at normal incidence is 0 dB and all real returns are negative. Returns with no detectable signal (misses, fully grazing, or back-facing hits) are floored at -999 dB rather than \f$-\infty\f$. The `reflectance` token is treated as a computed output rather than a primitive-data field, so it is not overwritten by same-named primitive data.
- Added a robust row/column miss gap-filling path and made `gapfillMisses()` auto-select between it and the existing timestamp path. When returns carry native scan-grid `row`/`column` hit data the new `gapfillMisses_rowcolumn()` fits a per-row generative model (median zenith per row; Theil-Sen azimuth line `intercept + slope·column`, with cross-row Theil-Sen extrapolation of the per-row parameters so blank near-zenith rows are extrapolated rather than only interpolated), which is robust to scanner tilt, azimuth shear, and angular noise without requiring a level or regular scan; the timestamp path is renamed `gapfillMisses_timestamp()` and used as the fallback. Row/column is preferred when both are present; a scan with returns carrying neither now fails fast, while a scan with no returns at all is skipped gracefully. The ASCII loader now retains `row`/`column` columns as hit data (previously discarded) so imported clouds can use this path.
- Made exported ASCII point clouds self-describing: `exportPointCloud()` now writes a leading `#`-prefixed comment-line header listing the column field names (matching the `ASCII_format` columns, including user-defined fields), following the convention recognized by tools such as CloudCompare. A new optional `write_header` argument (default true) suppresses it, and the loader now skips any `#`-prefixed line, so headered files round-trip through `loadXML()` unchanged.
- Added read-only triangulation diagnostics to `triangulateHitPoints()`: the run now tallies the number of candidate triangles the Delaunay pass produced and how many were dropped by each filter, exposed through new `LiDARcloud::getTriangulationCandidateCount()`, `getTriangulationDroppedByLmax()`, `getTriangulationDroppedByAspect()`, and `getTriangulationDroppedByDegenerate()` accessors. The filtering logic was refactored so each dropped triangle is attributed to exactly one primary reason in priority order (`Lmax`, then aspect/separation-ratio, then degenerate area), so the counts reconcile as `candidates == kept + dropped_lmax + dropped_aspect + dropped_degenerate`. No change to which triangles are kept.
- Added per-voxel statistical sampling uncertainty for the leaf-area inversion, following Pimont et al. (2018, RSE 215:343-370). A new `calculateLeafArea()` overload taking a characteristic vegetation `element_width` computes, alongside the unchanged leaf-area point estimate, the sampling variance of LAD via a Beer-Lambert delta-method propagation with two terms: a finite-beam term that decays as 1/N (the binomial variance of the relative density index, guarded against the larger empirical spread of multi-return per-beam transmittance fractions), and an N-independent element-position-variability term derived from the single-element optical depth (omitted, leaving a sampling-only variance, when `element_width <= 0`); the existing two-argument overload delegates to it with a default 5 cm width. The per-voxel sufficient statistics and variance are stored on each `GridCell` (beam count N, relative density index, mean/variance of beam path length, single-element optical depth, LAD variance, and a precomputed 95% CI-validity flag) and exposed through new `getCellBeamCount()`, `getCellRelativeDensityIndex()`, `getCellMeanPathLength()`, `getCellLADVariance()`, single-voxel `getCellLeafAreaConfidenceInterval()`, and group-scale `getGroupLADConfidenceInterval()` (the recommended path; Eq. 39, assuming voxel independence) accessors. Confidence intervals are gated by the Pimont Table-3 validity envelope (`ciValidPimont()`) so untrustworthy single-voxel intervals are refused rather than emitted, with the two-sided z-multiplier obtained from an Acklam normal-quantile approximation. A new `exportLeafAreaUncertainty()` writes a self-describing per-cell file (`cell_index leaf_area beam_count I_rdi LAD_std_error ci_valid`). The uncertainty is conditional on the beams that entered each voxel and does not capture occlusion/coverage bias.
- Added a spinning multibeam scan pattern modeling a rotating multi-channel sensor (e.g. Velodyne, Ouster, Hesai), in addition to the existing uniform-angular-grid raster pattern. A new `ScanPattern` enum (`SCAN_PATTERN_RASTER`/`SCAN_PATTERN_SPINNING_MULTIBEAM`) and `scanPattern`/`beamZenithAngles` fields on `ScanMetadata` are set through a new `ScanMetadata` constructor that takes a vector of per-channel zenith angles in place of `Ntheta` and the zenith range. Each scan-table row is a laser channel fired at its own fixed (generally non-uniformly spaced) zenith angle while each column is a uniform azimuth step, so the pattern reuses the same `Ntheta`×`Nphi` table storage and all downstream processing (ray tracing, hit tables, leaf-area/leaf-angle inversion) is shared with raster scans; `rc2direction()`/`direction2rc()` map a multibeam row to the nearest channel rather than interpolating, and `syntheticScan()` fires each row at its channel angle and records the firing channel index as a `channel` hit-data column. The pattern round-trips through `exportScans()`/`loadXML()` via a new `<scanPattern>` element plus `<beamElevationAngles>` (space-separated per-channel elevation angles in degrees above the horizon, converted internally to zenith) and `<Nphi>` (azimuth-step count), and is queryable through new `LiDARcloud::getScanPattern()` / `getScanBeamZenithAngles()` accessors.1 parent cfd8127 commit 74cbbe0
30 files changed
Lines changed: 10704 additions & 3270 deletions
File tree
- doc
- plugins
- lidar
- doc
- images
- include
- lib
- CDT
- include
- s_hull_pro
- src
- tests
- plantarchitecture
- doc
- include
- src
- tests
- visualizer/doc
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
3 | 22 | | |
4 | 23 | | |
5 | 24 | | |
| |||
31 | 50 | | |
32 | 51 | | |
33 | 52 | | |
| 53 | + | |
34 | 54 | | |
35 | 55 | | |
36 | 56 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
962 | 962 | | |
963 | 963 | | |
964 | 964 | | |
965 | | - | |
| 965 | + | |
966 | 966 | | |
967 | 967 | | |
968 | 968 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
23 | | - | |
| 23 | + | |
24 | 24 | | |
25 | 25 | | |
26 | | - | |
| 26 | + | |
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| |||
0 commit comments