1.3.77
 
Loading...
Searching...
No Matches
LiDAR.cpp
Go to the documentation of this file.
1
16#include "LiDAR.h"
17
18#include <random> // per-beam range-noise RNG in the parallelized syntheticScan post-processing
19#include <set>
20
21#ifdef _OPENMP
22#include <omp.h>
23#endif
24
25using namespace std;
26using namespace helios;
27
28namespace {
31 bool isStandardColumnToken(const std::string &label) {
32 static const std::set<std::string> standard_tokens = {"x", "y", "z", "r", "g", "b", "r255", "g255", "b255", "row", "column", "zenith", "azimuth", "zenith_rad", "azimuth_rad",
33 // "raydir" is reserved here so it is never treated as a primitive-data label,
34 // even though the ASCII file reader treats it as a generic scalar column.
35 "raydir"};
36 return standard_tokens.find(label) != standard_tokens.end();
37 }
38
40
46 bool resolveScalarHitData(helios::Context *context, uint UUID, const std::string &label, double &value) {
47 if (context->doesPrimitiveDataExist(UUID, label.c_str())) {
48 switch (context->getPrimitiveDataType(label.c_str())) {
50 float v;
51 context->getPrimitiveData(UUID, label.c_str(), v);
52 value = double(v);
53 return true;
54 }
56 context->getPrimitiveData(UUID, label.c_str(), value);
57 return true;
58 }
60 int v;
61 context->getPrimitiveData(UUID, label.c_str(), v);
62 value = double(v);
63 return true;
64 }
66 uint v;
67 context->getPrimitiveData(UUID, label.c_str(), v);
68 value = double(v);
69 return true;
70 }
71 default:
72 return false; // non-scalar primitive-data types are not transferable to hits
73 }
74 }
75
76 // Fall back to the hit primitive's parent object data. Primitives with no parent object have object id 0;
77 // guard with doesObjectExist() because doesObjectDataExist() only checks object existence under HELIOS_DEBUG.
78 uint objID = context->getPrimitiveParentObjectID(UUID);
79 if (context->doesObjectExist(objID) && context->doesObjectDataExist(objID, label.c_str())) {
80 switch (context->getObjectDataType(label.c_str())) {
82 float v;
83 context->getObjectData(objID, label.c_str(), v);
84 value = double(v);
85 return true;
86 }
88 context->getObjectData(objID, label.c_str(), value);
89 return true;
90 }
92 int v;
93 context->getObjectData(objID, label.c_str(), v);
94 value = double(v);
95 return true;
96 }
98 uint v;
99 context->getObjectData(objID, label.c_str(), v);
100 value = double(v);
101 return true;
102 }
103 default:
104 return false; // non-scalar object-data types are not transferable to hits
105 }
106 }
107
108 return false;
109 }
110
114 double normalQuantile(double p) {
115 if (p <= 0.0 || p >= 1.0) {
116 helios_runtime_error("ERROR (normalQuantile): probability must be strictly between 0 and 1.");
117 }
118 // Coefficients for Acklam's algorithm
119 static const double a[] = {-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00};
120 static const double b[] = {-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, 6.680131188771972e+01, -1.328068155288572e+01};
121 static const double c[] = {-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00};
122 static const double d[] = {7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, 3.754408661907416e+00};
123 const double p_low = 0.02425;
124 const double p_high = 1.0 - p_low;
125 double q, r;
126 if (p < p_low) { // lower tail
127 q = std::sqrt(-2.0 * std::log(p));
128 return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0);
129 } else if (p <= p_high) { // central region
130 q = p - 0.5;
131 r = q * q;
132 return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0);
133 } else { // upper tail
134 q = std::sqrt(-2.0 * std::log(1.0 - p));
135 return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0);
136 }
137 }
138
139 // ---- Quaternion math for moving-platform LiDAR ----
140 // Convention (pinned): Hamilton quaternions, body->world, stored as helios::vec4 components (x,y,z,w) = (qx,qy,qz,qw).
141 // quat_rotate(q, v) rotates a vector v expressed in the body frame into the world frame. quat_from_rpy builds a
142 // quaternion from intrinsic Z-Y-X (yaw-pitch-roll) Tait-Bryan angles: q = qz(yaw) * qy(pitch) * qx(roll), so the
143 // resulting rotation applies roll first, then pitch, then yaw (the standard aerospace convention). Helios stores
144 // geometry in single precision, so these operate on float vec4/vec3 to match the rest of the pipeline.
145
147 helios::vec3 quat_rotate(const helios::vec4 &q, const helios::vec3 &v) {
148 // v' = v + 2*qw*(qv x v) + 2*(qv x (qv x v)), with qv = (qx,qy,qz). Assumes q is unit-norm.
149 const helios::vec3 qv = helios::make_vec3(q.x, q.y, q.z);
150 const helios::vec3 t = helios::cross(qv, v) * 2.f;
151 return v + t * q.w + helios::cross(qv, t);
152 }
153
155 helios::vec4 quat_slerp(helios::vec4 q0, helios::vec4 q1, double u) {
156 q0.normalize();
157 q1.normalize();
158 double dot = double(q0.x) * q1.x + double(q0.y) * q1.y + double(q0.z) * q1.z + double(q0.w) * q1.w;
159 // Quaternions q and -q represent the same rotation; choose the shorter arc.
160 if (dot < 0.0) {
161 q1 = helios::make_vec4(-q1.x, -q1.y, -q1.z, -q1.w);
162 dot = -dot;
163 }
164 if (dot > 0.9995) {
165 // Nearly parallel: normalized linear interpolation avoids division by ~zero sin(theta).
166 helios::vec4 result = helios::make_vec4(q0.x + float(u) * (q1.x - q0.x), q0.y + float(u) * (q1.y - q0.y), q0.z + float(u) * (q1.z - q0.z), q0.w + float(u) * (q1.w - q0.w));
167 result.normalize();
168 return result;
169 }
170 const double theta_0 = std::acos(dot);
171 const double theta = theta_0 * u;
172 const double sin_theta_0 = std::sin(theta_0);
173 const double s0 = std::sin(theta_0 - theta) / sin_theta_0;
174 const double s1 = std::sin(theta) / sin_theta_0;
175 helios::vec4 result = helios::make_vec4(float(s0 * q0.x + s1 * q1.x), float(s0 * q0.y + s1 * q1.y), float(s0 * q0.z + s1 * q1.z), float(s0 * q0.w + s1 * q1.w));
176 result.normalize();
177 return result;
178 }
179
181 helios::vec4 quat_from_rpy(float roll, float pitch, float yaw) {
182 const float cr = std::cos(roll * 0.5f), sr = std::sin(roll * 0.5f);
183 const float cp = std::cos(pitch * 0.5f), sp = std::sin(pitch * 0.5f);
184 const float cy = std::cos(yaw * 0.5f), sy = std::sin(yaw * 0.5f);
185 // q = qz(yaw) * qy(pitch) * qx(roll) (Hamilton product), components (x,y,z,w).
186 helios::vec4 q;
187 q.w = cr * cp * cy + sr * sp * sy;
188 q.x = sr * cp * cy - cr * sp * sy;
189 q.y = cr * sp * cy + sr * cp * sy;
190 q.z = cr * cp * sy - sr * sp * cy;
191 return q;
192 }
193
198 inline bool triangulationCancelled(volatile int *cancel_flag) {
199 return cancel_flag != nullptr && *cancel_flag != 0;
200 }
201} // namespace
202
204
205 origin = make_vec3(0, 0, 0);
206 Ntheta = 100;
207 thetaMin = 0;
208 thetaMax = M_PI;
209 Nphi = 200;
210 phiMin = 0;
211 phiMax = 2.f * M_PI;
212 exitDiameter = 0;
213 beamDivergence = 0;
216 scanTilt_roll = 0;
217 scanTilt_pitch = 0;
219 columnFormat = {"x", "y", "z"};
221
222 data_file = "";
223}
224
225ScanMetadata::ScanMetadata(const vec3 &a_origin, uint a_Ntheta, float a_thetaMin, float a_thetaMax, uint a_Nphi, float a_phiMin, float a_phiMax, float a_exitDiameter, float a_beamDivergence, float a_rangeNoiseStdDev, float a_angleNoiseStdDev,
226 const vector<string> &a_columnFormat, float a_scanTiltRoll, float a_scanTiltPitch, float a_scanAzimuthOffset) {
227
228 // Copy arguments into structure variables
229 origin = a_origin;
230 Ntheta = a_Ntheta;
231 thetaMin = a_thetaMin;
232 thetaMax = a_thetaMax;
233 Nphi = a_Nphi;
234 phiMin = a_phiMin;
235 phiMax = a_phiMax;
236 exitDiameter = a_exitDiameter;
237 beamDivergence = a_beamDivergence;
238 rangeNoiseStdDev = a_rangeNoiseStdDev;
239 angleNoiseStdDev = a_angleNoiseStdDev;
240 scanTilt_roll = a_scanTiltRoll;
241 scanTilt_pitch = a_scanTiltPitch;
242 scanTilt_azimuth = a_scanAzimuthOffset;
243 columnFormat = a_columnFormat;
245
246 data_file = "";
247}
248
249ScanMetadata::ScanMetadata(const vec3 &a_origin, const std::vector<float> &a_beamZenithAngles, uint a_Nphi, float a_phiMin, float a_phiMax, float a_exitDiameter, float a_beamDivergence, float a_rangeNoiseStdDev, float a_angleNoiseStdDev,
250 const vector<string> &a_columnFormat, float a_scanTiltRoll, float a_scanTiltPitch, float a_scanAzimuthOffset) {
251
252 if (a_beamZenithAngles.empty()) {
253 helios_runtime_error("ERROR (ScanMetadata): A spinning multibeam scan requires at least one beam (channel) zenith angle, but the provided beamZenithAngles vector is empty.");
254 }
255
256 origin = a_origin;
258 beamZenithAngles = a_beamZenithAngles;
259 Ntheta = uint(a_beamZenithAngles.size()); // one row per laser channel
260 // thetaMin/thetaMax bracket the channel angles so range queries and bounding logic remain valid for non-uniform spacing.
261 thetaMin = *std::min_element(a_beamZenithAngles.begin(), a_beamZenithAngles.end());
262 thetaMax = *std::max_element(a_beamZenithAngles.begin(), a_beamZenithAngles.end());
263 Nphi = a_Nphi;
264 phiMin = a_phiMin;
265 phiMax = a_phiMax;
266 exitDiameter = a_exitDiameter;
267 beamDivergence = a_beamDivergence;
268 rangeNoiseStdDev = a_rangeNoiseStdDev;
269 angleNoiseStdDev = a_angleNoiseStdDev;
270 scanTilt_roll = a_scanTiltRoll;
271 scanTilt_pitch = a_scanTiltPitch;
272 scanTilt_azimuth = a_scanAzimuthOffset;
273 columnFormat = a_columnFormat;
274
275 data_file = "";
276}
277
278namespace {
279
281 struct dvec3 {
282 double x, y, z;
283 };
284 inline dvec3 operator-(const dvec3 &v) {
285 return {-v.x, -v.y, -v.z};
286 }
287 inline double dot(const dvec3 &a, const dvec3 &b) {
288 return a.x * b.x + a.y * b.y + a.z * b.z;
289 }
290 inline dvec3 normalize(const dvec3 &v) {
291 double m = std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
292 return {v.x / m, v.y / m, v.z / m};
293 }
294
296
310 bool refractRay(const dvec3 &incident, const dvec3 &normal, double n_from, double n_to, dvec3 &refracted) {
311 const double r = n_from / n_to;
312 const double c = -dot(normal, incident); // cosine of the incidence angle
313 const double radicand = 1.0 - r * r * (1.0 - c * c);
314 if (radicand < 0.0) {
315 return false; // total internal reflection
316 }
317 const double k = r * c - std::sqrt(radicand);
318 refracted = normalize({r * incident.x + k * normal.x, r * incident.y + k * normal.y, r * incident.z + k * normal.z});
319 return true;
320 }
321
323
347 helios::vec3 risleyBodyDirection(const ScanMetadata &scan, size_t pulse_index) {
348
349 const double t = double(pulse_index) * scan.pulse_period;
350 const double n_air = scan.risley_refractive_index_air;
351
352 // Incident beam along the optical axis (+y, the Helios body-forward axis). The flat face is perpendicular to this axis.
353 dvec3 beam = {0.0, 1.0, 0.0};
354 const dvec3 flat_normal = {0.0, -1.0, 0.0}; // entry face normal, oriented back toward the incoming (+y) beam
355
356 for (const RisleyPrism &prism : scan.risley_prisms) {
357 const double phi = prism.phase + prism.rotor_rate * t; // clocking angle of this prism's wedge at pulse time t
358 const double sinW = std::sin(prism.wedge_angle);
359 const double cosW = std::cos(prism.wedge_angle);
360
361 // Tilted (exit) face normal: the face is inclined by the wedge angle from the plane perpendicular to the optical
362 // axis, so its outward normal sits at angle wedge off the +y axis and rolls around it with the clocking angle phi.
363 // Built directly from the rotation geometry (no external code): axial component cos(wedge) along +y, transverse
364 // component sin(wedge) in the x-z plane at angle phi.
365 const dvec3 tilted_normal = normalize({sinW * std::cos(phi), cosW, sinW * std::sin(phi)});
366
367 // Refract through the flat entry face (air -> glass), then the tilted exit face (glass -> air). The normal handed to
368 // refractRay points back toward the medium the ray is leaving, so the exit-face normal is negated.
369 dvec3 in_glass;
370 if (!refractRay(beam, flat_normal, n_air, prism.refractive_index, in_glass)) {
371 return helios::make_vec3(0.f, 1.f, 0.f); // total internal reflection: return the optical axis (+y)
372 }
373 dvec3 out_glass;
374 if (!refractRay(in_glass, -tilted_normal, prism.refractive_index, n_air, out_glass)) {
375 return helios::make_vec3(0.f, 1.f, 0.f);
376 }
377 beam = out_glass;
378 }
379
380 beam = normalize(beam);
381 return helios::make_vec3(float(beam.x), float(beam.y), float(beam.z));
382 }
383
384} // namespace
385
387
389 // A Risley-prism scan is non-separable: each column is one pulse whose body-frame direction comes from the rotating
390 // prism optics at that pulse's time, not from a row x column angular grid. The single source of truth is
391 // risleyBodyDirection(); rc2direction returns its spherical form so callers that reason in (zenith,azimuth) stay
392 // consistent with the ray generator.
393 return cart2sphere(risleyBodyDirection(*this, column));
394 }
395
396 float zenith;
398 // Each row is a laser channel fired at its own fixed (generally non-uniform) zenith angle.
399 uint clamped_row = (row < beamZenithAngles.size()) ? row : uint(beamZenithAngles.size()) - 1;
400 zenith = beamZenithAngles.at(clamped_row);
401 } else {
402 zenith = thetaMin + (thetaMax - thetaMin) / float(Ntheta) * float(row);
403 }
404 float elevation = 0.5f * M_PI - zenith;
405 float phi = phiMin - (phiMax - phiMin) / float(Nphi) * float(column);
406 return make_SphericalCoord(1, elevation, phi);
407};
408
410
412 // A Risley-prism scan has no row x column angular grid (it is stored as a single row, one pulse per column), and it is
413 // always trajectory-driven so the direction passed here is in world coordinates and cannot be inverted back to a pulse
414 // index. Row/column is meaningless for this pattern; return (0,0). Downstream, moving scans identify points by
415 // timestamp / pulse_id / origin rather than (row,column), and triangulation / gap-filling reject moving scans outright.
416 return helios::make_int2(0, 0);
417 }
418
419 float theta = direction.zenith;
420 float phi = direction.azimuth;
421
422 int row;
424 // Channel zenith angles are not uniformly spaced, so map to the nearest channel rather than interpolating linearly.
425 int nearest = 0;
426 float best = std::fabs(theta - beamZenithAngles.at(0));
427 for (uint k = 1; k < beamZenithAngles.size(); k++) {
428 float d = std::fabs(theta - beamZenithAngles.at(k));
429 if (d < best) {
430 best = d;
431 nearest = int(k);
432 }
433 }
434 row = nearest;
435 } else {
436 row = std::round((theta - thetaMin) / (thetaMax - thetaMin) * float(Ntheta));
437 }
438 int column = std::round(fabs(phi - phiMin) / (phiMax - phiMin) * float(Nphi));
439
440 if (row <= -1) {
441 row = 0;
442 } else if (row >= Ntheta) {
443 row = Ntheta - 1;
444 }
445 if (column <= -1) {
446 column = 0;
447 } else if (column >= Nphi) {
448 column = Nphi - 1;
449 }
450
451 return helios::make_int2(row, column);
452};
453
454void ScanMetadata::poseAt(double t, helios::vec3 &pos, helios::vec4 &quat) const {
455
456 const size_t M = traj_t.size();
457 if (M == 0) {
458 helios_runtime_error("ERROR (ScanMetadata::poseAt): the scan has no trajectory samples. This scan was not created as a moving-platform scan (see LiDARcloud::addScanMoving).");
459 }
460 if (traj_pos.size() != M || traj_quat.size() != M) {
461 helios_runtime_error("ERROR (ScanMetadata::poseAt): trajectory arrays have inconsistent lengths (traj_t=" + std::to_string(M) + ", traj_pos=" + std::to_string(traj_pos.size()) + ", traj_quat=" + std::to_string(traj_quat.size()) +
462 "). All three must have the same number of samples.");
463 }
464
465 // Single sample: constant pose (also covers a degenerate zero-velocity trajectory).
466 if (M == 1) {
467 pos = traj_pos.at(0);
468 quat = traj_quat.at(0);
469 return;
470 }
471
472 // Clamp to the trajectory endpoints rather than extrapolating.
473 if (t <= traj_t.front()) {
474 pos = traj_pos.front();
475 quat = traj_quat.front();
476 return;
477 }
478 if (t >= traj_t.back()) {
479 pos = traj_pos.back();
480 quat = traj_quat.back();
481 return;
482 }
483
484 // Binary search for the bracketing interval [traj_t[i], traj_t[i+1]] containing t.
485 // upper_bound returns the first sample strictly greater than t, so the lower index is one before it.
486 const auto upper = std::upper_bound(traj_t.begin(), traj_t.end(), t);
487 const size_t i1 = size_t(upper - traj_t.begin());
488 const size_t i0 = i1 - 1;
489
490 const double t0_s = traj_t.at(i0);
491 const double t1_s = traj_t.at(i1);
492 const double denom = t1_s - t0_s;
493 if (denom <= 0.0) {
494 helios_runtime_error("ERROR (ScanMetadata::poseAt): trajectory times are not strictly increasing (traj_t[" + std::to_string(i0) + "]=" + std::to_string(t0_s) + " >= traj_t[" + std::to_string(i1) + "]=" + std::to_string(t1_s) + ").");
495 }
496 const double u = (t - t0_s) / denom;
497
498 // Linear interpolation of position; SLERP of orientation.
499 pos = traj_pos.at(i0) + (traj_pos.at(i1) - traj_pos.at(i0)) * float(u);
500 quat = quat_slerp(traj_quat.at(i0), traj_quat.at(i1), u);
501};
502
504
505 Nhits = 0;
506 hitgridcellcomputed = false;
507 triangulationcomputed = false;
508 triangulation_candidate_count = 0;
509 triangulation_dropped_lmax = 0;
510 triangulation_dropped_aspect = 0;
511 triangulation_dropped_degenerate = 0;
512 printmessages = true;
513 collision_detection = nullptr;
514}
515
517 delete collision_detection;
518}
519
521 printmessages = false;
522}
523
525 if (collision_detection == nullptr) {
526 collision_detection = new CollisionDetection(context);
527 collision_detection->disableMessages();
528 }
529 // Forward any registered cancellation flag so an in-flight syntheticScan can
530 // be aborted mid-trace (the ray loop in castRaysSoA polls it). Re-applied
531 // here because the CD object is created lazily, after setCancelFlag().
532 collision_detection->setCancelFlag(cancel_flag);
533}
534
535void LiDARcloud::setCancelFlag(volatile int *flag) {
536 cancel_flag = flag;
537 if (collision_detection != nullptr) {
538 collision_detection->setCancelFlag(flag);
539 }
540}
541
543 synthetic_scan_progress = ptr;
544}
545
546void LiDARcloud::prepareUnifiedRayTracing(helios::Context *context) {
548 // Disable automatic BVH rebuilds for the whole batch (geometry is static during a scan) and build the BVH once. When
549 // the per-scan beam fan-out is traced in chunks, this prevents a full O(P log P) BVH rebuild on every chunk.
550 collision_detection->disableAutomaticBVHRebuilds();
551 collision_detection->buildBVH();
552}
553
554void LiDARcloud::finishUnifiedRayTracing() {
555 collision_detection->enableAutomaticBVHRebuilds();
556}
557
558void LiDARcloud::castRaysUnified(size_t total_rays, helios::vec3 *ray_origins, helios::vec3 *direction, float *hit_t, float *hit_fnorm, int *hit_ID, size_t packet_size) {
559 const float miss_distance = LIDAR_RAYTRACE_MISS_T;
560 constexpr uint MISS_UUID = 0xFFFFFFFFu; // sentinel written by castRaysSoA for a miss
561
562 if (total_rays == 0) {
563 return;
564 }
565
566 // Low-memory SoA cast: results are written directly into per-ray scratch arrays rather than a full-length
567 // RayQuery input vector plus a HitResult output vector (which together cost ~96 bytes/ray of transient storage).
568 // The primitive UUID doubles as the hit/miss flag (MISS_UUID == miss); distance/normal are reused below.
569 std::vector<uint> uuid(total_rays);
570 std::vector<helios::vec3> normal(total_rays);
571 // hit_t is reused as the SoA distance output array (float, length total_rays) to avoid a separate allocation.
572 // When the rays are grouped into coherent pulses (packet_size > 1) the packet traversal amortizes node/primitive
573 // fetches across each pulse's sub-rays; results are identical to the per-ray path.
574 if (packet_size > 1) {
575 collision_detection->castRaysSoA_packets(ray_origins, direction, total_rays, packet_size, miss_distance, hit_t, normal.data(), uuid.data());
576 } else {
577 collision_detection->castRaysSoA(ray_origins, direction, total_rays, miss_distance, hit_t, normal.data(), uuid.data());
578 }
579
580 // Convert the SoA results to the LiDAR per-ray format expected by the waveform reduction.
581 for (size_t i = 0; i < total_rays; i++) {
582 if (uuid[i] != MISS_UUID) {
583 hit_ID[i] = static_cast<int>(uuid[i]);
584 // hit_t[i] already holds the hit distance (written in place by castRaysSoA).
585 const helios::vec3 &ray_dir = direction[i];
586 hit_fnorm[i] = ray_dir.x * normal[i].x + ray_dir.y * normal[i].y + ray_dir.z * normal[i].z;
587 } else {
588 hit_t[i] = miss_distance;
589 hit_ID[i] = -1;
590 hit_fnorm[i] = 1e6;
591 }
592 }
593}
594
595void LiDARcloud::performUnifiedRayTracing(helios::Context *context, size_t N, int Npulse, helios::vec3 *ray_origins, helios::vec3 *direction, float *hit_t, float *hit_fnorm, int *hit_ID) {
596 // Standalone single-batch entry point (used directly by tests): prepare the BVH, cast all N*Npulse rays, then restore
597 // automatic rebuilds. The chunked syntheticScan path instead calls prepareUnifiedRayTracing() once and
598 // castRaysUnified() per chunk to build the BVH only once across the whole scan.
599 prepareUnifiedRayTracing(context);
600 castRaysUnified(N * size_t(Npulse), ray_origins, direction, hit_t, hit_fnorm, hit_ID, size_t(Npulse));
601 finishUnifiedRayTracing();
602}
603
604float LiDARcloud::applyRangeIntensityCorrection(float intensity, float distance) {
605 // Helios reports RANGE-NORMALIZED intensity: the range-independent return amplitude rho*cos(theta), as if the
606 // geometric 1/R^2 loss of the LiDAR range equation had been measured and then divided back out. Equivalently,
607 // the physical raw return rho*cos(theta)/R^2 is multiplied by R^2 to remove the range dependence:
608 //
609 // I_norm = (rho*cos(theta) / R^2) * R^2 = rho*cos(theta)
610 //
611 // Because the synthetic intensity is generated directly as rho*cos(theta) (no 1/R^2 loss is ever applied), the
612 // two operations cancel and the normalization is the identity on the value. This helper makes that contract
613 // explicit and is the single place to change if the raw (range-dependent) convention is ever desired instead.
614 // The partial-footprint (point-target) attenuation of sub-footprint returns (in multi-return mode) is carried by the
615 // fraction of beam sub-rays that strike the target and is intentionally retained (it is a target property, not
616 // a range-geometry loss). The distance argument is accepted for interface symmetry and future raw-mode use.
617 (void) distance;
618 return intensity;
619}
620
622 printmessages = true;
623}
624
625void LiDARcloud::setProgressCallback(std::function<void(float, const std::string &)> callback) {
626 progress_callback = std::move(callback);
627}
628
630 if (bytes == 0) {
631 helios_runtime_error("ERROR (LiDARcloud::setSyntheticScanMemoryBudget): the memory budget must be greater than zero.");
632 }
633 synthetic_scan_memory_budget_bytes = bytes;
634}
635
637 return synthetic_scan_memory_budget_bytes;
638}
639
640bool LiDARcloud::anyScanMoving() const {
641 for (const auto &scan: scans) {
642 if (scan.isMoving) {
643 return true;
644 }
645 }
646 return false;
647}
648
649void LiDARcloud::validateRayDirections() {
650
651 // This validation reconstructs each hit's direction from the single scan origin, which is not meaningful for a
652 // moving-platform scan (each pulse has its own origin). Fail fast rather than report spurious mismatches.
653 if (anyScanMoving()) {
654 helios_runtime_error("ERROR (LiDARcloud::validateRayDirections): ray-direction validation is not supported for moving-platform scans (see addScanMoving), because the per-pulse origins make a single-origin direction check meaningless.");
655 }
656
657 for (uint s = 0; s < getScanCount(); s++) {
658
659 for (int j = 0; j < getScanSizePhi(s); j++) {
660 for (int i = 0; i < getScanSizeTheta(s); i++) {
661 if (getHitIndex(s, i, j) >= 0) {
662 SphericalCoord direction1 = scans.at(s).rc2direction(i, j);
663 SphericalCoord direction2 = cart2sphere(getHitXYZ(getHitIndex(s, i, j)) - getScanOrigin(s));
664 SphericalCoord direction3 = getHitRaydir(getHitIndex(s, i, j));
665
666
667 float err_theta = max(fabs(direction1.zenith - direction2.zenith), fabs(direction1.zenith - direction3.zenith));
668
669 float err_phi = max(fabs(direction1.azimuth - direction2.azimuth), fabs(direction1.azimuth - direction3.azimuth));
670
671 if (err_theta > 1e-6 || err_phi > 1e-6) {
672 helios_runtime_error("ERROR (LiDARcloud::validateRayDirections): validation of ray directions failed.");
673 }
674 }
675 }
676 }
677 }
678}
679
681 return scans.size();
682}
683
685
686 float epsilon = 1e-5;
687
688 if (newscan.thetaMin < 0) {
689 std::cerr << "WARNING (LiDARcloud::addScan): Specified scan minimum zenith angle of " << newscan.thetaMin << " is less than 0. Truncating to 0." << std::endl;
690 newscan.thetaMin = 0;
691 }
692 if (newscan.phiMin < 0) {
693 std::cerr << "WARNING (LiDARcloud::addScan): Specified scan minimum azimuth angle of " << newscan.phiMin << " is less than 0. Truncating to 0." << std::endl;
694 newscan.phiMin = 0;
695 }
696 if (newscan.thetaMax > M_PI + epsilon) {
697 std::cerr << "WARNING (LiDARcloud::addScan): Specified scan maximum zenith angle of " << newscan.thetaMax << " is greater than pi. Setting thetaMin to 0 and truncating thetaMax to pi. Did you mistakenly use degrees instead of radians?"
698 << std::endl;
699 newscan.thetaMax = M_PI;
700 newscan.thetaMin = 0;
701 }
702 // Only the static raster path has a physically-bounded azimuth sweep where phiMax >> 2pi signals a degrees/radians
703 // mistake. A spinning multibeam scan legitimately encodes phiMax = n_revolutions*2pi (many multiples of 2pi), and a
704 // moving raster scan may sweep an arbitrary azimuth, so the warning is gated to SCAN_MODE_STATIC_RASTER.
705 if (newscan.scanMode == SCAN_MODE_STATIC_RASTER && newscan.phiMax > 4.f * M_PI + epsilon) {
706 std::cerr << "WARNING (LiDARcloud::addScan): Specified scan maximum azimuth angle of " << newscan.phiMax << " is greater than 2pi. Did you mistakenly use degrees instead of radians?" << std::endl;
707 }
708
709 // initialize the hit table to `-1' (all misses)
710 HitTable<int> table;
711 table.resize(newscan.Ntheta, newscan.Nphi, -1);
712 hit_tables.push_back(table);
713
714 scans.emplace_back(newscan);
715
716 return scans.size() - 1;
717}
718
719uint LiDARcloud::addScanMoving(ScanMetadata scan, const std::vector<double> &traj_t, const std::vector<vec3> &traj_pos, const std::vector<vec4> &traj_quat, const vec3 &lever_arm, const vec3 &boresight_rpy, float pulse_rate_hz, double t0) {
720
721 const size_t M = traj_t.size();
722 if (M == 0) {
723 helios_runtime_error("ERROR (LiDARcloud::addScanMoving): the trajectory is empty. At least one pose sample is required.");
724 }
725 if (traj_pos.size() != M || traj_quat.size() != M) {
726 helios_runtime_error("ERROR (LiDARcloud::addScanMoving): trajectory arrays have inconsistent lengths (traj_t=" + std::to_string(M) + ", traj_pos=" + std::to_string(traj_pos.size()) + ", traj_quat=" + std::to_string(traj_quat.size()) +
727 "). All three must have the same number of samples.");
728 }
729 for (size_t k = 1; k < M; k++) {
730 if (traj_t.at(k) <= traj_t.at(k - 1)) {
731 helios_runtime_error("ERROR (LiDARcloud::addScanMoving): trajectory times must be strictly increasing, but traj_t[" + std::to_string(k - 1) + "]=" + std::to_string(traj_t.at(k - 1)) + " >= traj_t[" + std::to_string(k) + "]=" +
732 std::to_string(traj_t.at(k)) + ".");
733 }
734 }
735 // Reject non-finite trajectory data up front: a single NaN/inf would otherwise propagate silently through the SLERP
736 // interpolation and produce NaN origins/directions for a whole range of pulses. Quaternions must also be non-zero so
737 // they can be normalized.
738 for (size_t k = 0; k < M; k++) {
739 if (!std::isfinite(traj_t.at(k))) {
740 helios_runtime_error("ERROR (LiDARcloud::addScanMoving): trajectory time traj_t[" + std::to_string(k) + "] is not finite (NaN or infinity).");
741 }
742 const helios::vec3 &p = traj_pos.at(k);
743 if (!std::isfinite(p.x) || !std::isfinite(p.y) || !std::isfinite(p.z)) {
744 helios_runtime_error("ERROR (LiDARcloud::addScanMoving): trajectory position traj_pos[" + std::to_string(k) + "] is not finite (NaN or infinity).");
745 }
746 const helios::vec4 &q = traj_quat.at(k);
747 if (!std::isfinite(q.x) || !std::isfinite(q.y) || !std::isfinite(q.z) || !std::isfinite(q.w)) {
748 helios_runtime_error("ERROR (LiDARcloud::addScanMoving): trajectory quaternion traj_quat[" + std::to_string(k) + "] is not finite (NaN or infinity).");
749 }
750 if (q.magnitude() < 1e-6f) {
751 helios_runtime_error("ERROR (LiDARcloud::addScanMoving): trajectory quaternion traj_quat[" + std::to_string(k) + "] has near-zero magnitude and cannot be normalized to a valid rotation.");
752 }
753 }
754 if (!std::isfinite(t0)) {
755 helios_runtime_error("ERROR (LiDARcloud::addScanMoving): t0 must be finite, but a non-finite value was provided.");
756 }
757 if (pulse_rate_hz <= 0.f) {
758 helios_runtime_error("ERROR (LiDARcloud::addScanMoving): pulse_rate_hz must be greater than 0, but " + std::to_string(pulse_rate_hz) + " was provided.");
759 }
760 // Trajectory replaces scanTilt: the static roll/pitch/yaw tilt is incompatible with a trajectory-driven attitude.
761 if (scan.scanTilt_roll != 0.f || scan.scanTilt_pitch != 0.f || scan.scanTilt_azimuth != 0.f) {
762 helios_runtime_error("ERROR (LiDARcloud::addScanMoving): the scan specifies a non-zero static tilt (scanTilt_roll/pitch/azimuth), which is not applied for moving-platform scans. Platform attitude must be supplied entirely "
763 "through the trajectory quaternions and the boresight; leave the static tilt at zero.");
764 }
765
766 scan.isMoving = true;
767 scan.traj_t = traj_t;
768 scan.traj_pos = traj_pos;
769 scan.traj_quat = traj_quat;
770 scan.lever_arm = lever_arm;
771 scan.boresight_rpy = boresight_rpy;
772 scan.pulse_period = 1.0 / double(pulse_rate_hz);
773 scan.t0 = t0;
774 // The trajectory supplies position; the static origin field is unused for moving scans.
775 scan.origin = traj_pos.front();
776
777 return addScan(scan);
778}
779
780uint LiDARcloud::addScanMoving(ScanMetadata scan, const std::vector<double> &traj_t, const std::vector<vec3> &traj_pos, const std::vector<vec3> &traj_rpy, const vec3 &lever_arm, const vec3 &boresight_rpy, float pulse_rate_hz, double t0) {
781
782 // Convert the per-sample roll/pitch/yaw Euler angles to Hamilton body->world quaternions (intrinsic Z-Y-X, the same
783 // convention used for the boresight), then delegate to the quaternion overload so all validation lives in one place.
784 // The length check is duplicated here only so the error message names traj_rpy rather than the converted traj_quat.
785 if (traj_rpy.size() != traj_t.size()) {
786 helios_runtime_error("ERROR (LiDARcloud::addScanMoving): trajectory arrays have inconsistent lengths (traj_t=" + std::to_string(traj_t.size()) + ", traj_rpy=" + std::to_string(traj_rpy.size()) + "). All trajectory arrays must have the same number of samples.");
787 }
788
789 std::vector<vec4> traj_quat;
790 traj_quat.reserve(traj_rpy.size());
791 for (const vec3 &rpy: traj_rpy) {
792 traj_quat.push_back(quat_from_rpy(rpy.x, rpy.y, rpy.z));
793 }
794
795 return addScanMoving(scan, traj_t, traj_pos, traj_quat, lever_arm, boresight_rpy, pulse_rate_hz, t0);
796}
797
798uint LiDARcloud::addScanSpinning(const std::vector<float> &beamElevationAngles, float azimuthStep_rad, float pulse_rate_hz, const std::vector<double> &traj_t, const std::vector<vec3> &traj_pos, const std::vector<vec4> &traj_quat,
799 const vec3 &lever_arm, const vec3 &boresight_rpy, float exitDiameter, float beamDivergence, float rangeNoiseStdDev, float angleNoiseStdDev, const std::vector<std::string> &columnFormat, double t0) {
800
801 // Physical-parameter setup for a continuously-spinning multibeam sensor. The caller supplies the instrument's
802 // channel elevations, azimuth resolution, and PRF; the internal Ntheta x Nphi grid, rotation rate, and revolution
803 // count are derived here so the caller never hand-flattens the instrument into a raster grid. The heavy lifting
804 // (per-pulse time/pose, validation) is delegated to addScanMoving once the grid is built.
805
806 if (beamElevationAngles.empty()) {
807 helios_runtime_error("ERROR (LiDARcloud::addScanSpinning): beamElevationAngles is empty. A spinning multibeam sensor requires at least one channel.");
808 }
809 if (azimuthStep_rad <= 0.f) {
810 helios_runtime_error("ERROR (LiDARcloud::addScanSpinning): azimuthStep_rad must be greater than 0, but " + std::to_string(azimuthStep_rad) + " was provided.");
811 }
812 if (pulse_rate_hz <= 0.f) {
813 helios_runtime_error("ERROR (LiDARcloud::addScanSpinning): pulse_rate_hz must be greater than 0, but " + std::to_string(pulse_rate_hz) + " was provided.");
814 }
815 const size_t M = traj_t.size();
816 if (M == 0) {
817 helios_runtime_error("ERROR (LiDARcloud::addScanSpinning): the trajectory is empty. At least one pose sample is required (a stationary capture is expressed as two coincident poses separated by the acquisition duration).");
818 }
819 // Validate the trajectory array lengths here, before traj_pos.front() is used to seed the ScanMetadata origin below.
820 // addScanMoving() also validates these, but it runs only after that dereference, so an empty/short traj_pos would be
821 // undefined behavior rather than a clear error. Fail fast with an actionable message instead.
822 if (traj_pos.size() != M || traj_quat.size() != M) {
823 helios_runtime_error("ERROR (LiDARcloud::addScanSpinning): trajectory arrays have inconsistent lengths (traj_t=" + std::to_string(M) + ", traj_pos=" + std::to_string(traj_pos.size()) + ", traj_quat=" + std::to_string(traj_quat.size()) +
824 "). All trajectory arrays must have the same number of samples.");
825 }
826
827 // Convert per-channel elevation (above horizon) to the zenith convention used internally (0 = up, pi/2 = horizontal).
828 std::vector<float> beamZenithAngles;
829 beamZenithAngles.reserve(beamElevationAngles.size());
830 for (float elevation: beamElevationAngles) {
831 beamZenithAngles.push_back(0.5f * float(M_PI) - elevation);
832 }
833
834 const uint channels = uint(beamZenithAngles.size());
835 // Round to an integer number of azimuth steps per revolution and use the exact dphi = 2pi/steps_per_rev so the
836 // sampling closes the circle perfectly regardless of the requested step.
837 const uint steps_per_rev = uint(std::lround(2.0 * M_PI / double(azimuthStep_rad)));
838 if (steps_per_rev == 0) {
839 helios_runtime_error("ERROR (LiDARcloud::addScanSpinning): azimuthStep_rad=" + std::to_string(azimuthStep_rad) + " is larger than 2pi, which yields zero azimuth steps per revolution. Use a finer azimuth resolution.");
840 }
841
842 const double duration = traj_t.back() - traj_t.front();
843 if (duration <= 0.0) {
844 helios_runtime_error("ERROR (LiDARcloud::addScanSpinning): the trajectory duration (traj_t.back() - traj_t.front() = " + std::to_string(duration) + ") must be greater than 0.");
845 }
846
847 const double rotation_rate = double(pulse_rate_hz) / (double(channels) * double(steps_per_rev)); // revolutions per second
848 const double n_revolutions = rotation_rate * duration;
849 const uint Nphi = uint(std::lround(double(steps_per_rev) * n_revolutions));
850 if (Nphi == 0) {
851 helios_runtime_error("ERROR (LiDARcloud::addScanSpinning): the derived azimuth-step count is zero (PRF=" + std::to_string(pulse_rate_hz) + " Hz over a " + std::to_string(duration) +
852 " s trajectory yields less than one azimuth step). Increase the PRF, the trajectory duration, or the azimuth resolution.");
853 }
854
855 // Build a spinning-multibeam ScanMetadata. phiMax encodes the full multi-revolution sweep (n_revolutions*2pi); the
856 // ray generator and rc2direction/direction2rc use the periodic convention (dphi = phiMax/Nphi) so this closes correctly.
857 ScanMetadata scan(traj_pos.front(), beamZenithAngles, Nphi, 0.f, float(n_revolutions * 2.0 * M_PI), exitDiameter, beamDivergence, rangeNoiseStdDev, angleNoiseStdDev, columnFormat);
859 scan.steps_per_rev = steps_per_rev;
860 scan.rotation_rate = rotation_rate;
861 scan.n_revolutions = n_revolutions;
862
863 return addScanMoving(scan, traj_t, traj_pos, traj_quat, lever_arm, boresight_rpy, pulse_rate_hz, t0);
864}
865
866uint LiDARcloud::addScanSpinning(const std::vector<float> &beamElevationAngles, float azimuthStep_rad, float pulse_rate_hz, const std::vector<double> &traj_t, const std::vector<vec3> &traj_pos, const std::vector<vec3> &traj_rpy,
867 const vec3 &lever_arm, const vec3 &boresight_rpy, float exitDiameter, float beamDivergence, float rangeNoiseStdDev, float angleNoiseStdDev, const std::vector<std::string> &columnFormat, double t0) {
868
869 // Convert the per-sample roll/pitch/yaw Euler angles to quaternions, then delegate to the quaternion overload.
870 if (traj_rpy.size() != traj_t.size()) {
871 helios_runtime_error("ERROR (LiDARcloud::addScanSpinning): trajectory arrays have inconsistent lengths (traj_t=" + std::to_string(traj_t.size()) + ", traj_rpy=" + std::to_string(traj_rpy.size()) + "). All trajectory arrays must have the same number of samples.");
872 }
873
874 std::vector<vec4> traj_quat;
875 traj_quat.reserve(traj_rpy.size());
876 for (const vec3 &rpy: traj_rpy) {
877 traj_quat.push_back(quat_from_rpy(rpy.x, rpy.y, rpy.z));
878 }
879
880 return addScanSpinning(beamElevationAngles, azimuthStep_rad, pulse_rate_hz, traj_t, traj_pos, traj_quat, lever_arm, boresight_rpy, exitDiameter, beamDivergence, rangeNoiseStdDev, angleNoiseStdDev, columnFormat, t0);
881}
882
883uint LiDARcloud::addScanMovingRaster(uint Ntheta, float thetaMin, float thetaMax, uint Nphi, float phiMin, float phiMax, float pulse_rate_hz, const std::vector<double> &traj_t, const std::vector<vec3> &traj_pos,
884 const std::vector<vec4> &traj_quat, const vec3 &lever_arm, const vec3 &boresight_rpy, float exitDiameter, float beamDivergence, float rangeNoiseStdDev, float angleNoiseStdDev,
885 const std::vector<std::string> &columnFormat, double t0) {
886
887 // Non-spinning sensor on a moving platform: the caller specifies the per-frame angular fan and the trajectory, and
888 // addScanMoving derives the per-pulse time sampling. This is a thin convenience over the low-level addScanMoving so
889 // the caller does not pre-build a ScanMetadata; it additionally stamps the SCAN_MODE_MOVING_RASTER descriptor.
890 ScanMetadata scan(traj_pos.empty() ? make_vec3(0, 0, 0) : traj_pos.front(), Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, rangeNoiseStdDev, angleNoiseStdDev, columnFormat);
892
893 return addScanMoving(scan, traj_t, traj_pos, traj_quat, lever_arm, boresight_rpy, pulse_rate_hz, t0);
894}
895
896uint LiDARcloud::addScanRisley(const std::vector<RisleyPrism> &prisms, double refractive_index_air, float pulse_rate_hz, const std::vector<double> &traj_t, const std::vector<vec3> &traj_pos, const std::vector<vec4> &traj_quat,
897 const vec3 &lever_arm, const vec3 &boresight_rpy, float exitDiameter, float beamDivergence, float rangeNoiseStdDev, float angleNoiseStdDev, const std::vector<std::string> &columnFormat, double t0) {
898
899 // Physical-parameter setup for a rotating-Risley-prism (Livox-style rosette) sensor. The caller supplies the prism stack
900 // and PRF; the pulse count Npulses is derived from the PRF and the trajectory duration, and the scan is stored as an
901 // Ntheta=1, Nphi=Npulses table (one beam direction per pulse). The heavy lifting (per-pulse time/pose, trajectory
902 // validation) is delegated to addScanMoving once the single-row grid is built.
903
904 if (prisms.empty()) {
905 helios_runtime_error("ERROR (LiDARcloud::addScanRisley): the prism stack is empty. A Risley-prism scanner requires at least one rotating wedge prism (a Livox-style sensor uses two counter-rotating prisms).");
906 }
907 if (pulse_rate_hz <= 0.f) {
908 helios_runtime_error("ERROR (LiDARcloud::addScanRisley): pulse_rate_hz must be greater than 0, but " + std::to_string(pulse_rate_hz) + " was provided.");
909 }
910 if (refractive_index_air <= 0.0) {
911 helios_runtime_error("ERROR (LiDARcloud::addScanRisley): refractive_index_air must be greater than 0, but " + std::to_string(refractive_index_air) + " was provided.");
912 }
913 for (size_t k = 0; k < prisms.size(); k++) {
914 if (prisms.at(k).refractive_index <= 0.0) {
915 helios_runtime_error("ERROR (LiDARcloud::addScanRisley): prism " + std::to_string(k) + " has a non-positive refractive index (" + std::to_string(prisms.at(k).refractive_index) + ").");
916 }
917 }
918 const size_t M = traj_t.size();
919 if (M == 0) {
920 helios_runtime_error("ERROR (LiDARcloud::addScanRisley): the trajectory is empty. At least one pose sample is required (a stationary capture is expressed as two coincident poses separated by the acquisition duration).");
921 }
922 // Validate the trajectory array lengths here, before traj_pos.front() is used to seed the ScanMetadata origin below.
923 // addScanMoving() also validates these, but it runs only after that dereference, so an empty/short traj_pos would be
924 // undefined behavior rather than a clear error. Fail fast with an actionable message instead.
925 if (traj_pos.size() != M || traj_quat.size() != M) {
926 helios_runtime_error("ERROR (LiDARcloud::addScanRisley): trajectory arrays have inconsistent lengths (traj_t=" + std::to_string(M) + ", traj_pos=" + std::to_string(traj_pos.size()) + ", traj_quat=" + std::to_string(traj_quat.size()) +
927 "). All trajectory arrays must have the same number of samples.");
928 }
929
930 const double duration = traj_t.back() - traj_t.front();
931 if (duration <= 0.0) {
932 helios_runtime_error("ERROR (LiDARcloud::addScanRisley): the trajectory duration (traj_t.back() - traj_t.front() = " + std::to_string(duration) + ") must be greater than 0.");
933 }
934
935 // One pulse per firing of the PRF over the acquisition. Stored as a single-row (Ntheta=1) table so the existing pulse
936 // ordinal k = Ntheta*column + row collapses to k = column = pulse index, and every per-pulse quantity (time, origin) is
937 // derived exactly as for any other moving scan.
938 const uint Npulses = uint(std::lround(double(pulse_rate_hz) * duration));
939 if (Npulses == 0) {
940 helios_runtime_error("ERROR (LiDARcloud::addScanRisley): the derived pulse count is zero (PRF=" + std::to_string(pulse_rate_hz) + " Hz over a " + std::to_string(duration) +
941 " s trajectory yields less than one pulse). Increase the PRF or the trajectory duration.");
942 }
943
944 // Build the single-row Risley-prism ScanMetadata, then attach the prism stack. The angular bounds are not used for ray
945 // generation (the Risley branch of the ray generator computes each direction from the prism optics), but are populated
946 // with the emergent circular field of view so range queries and bounding logic stay meaningful. The maximum half-angle is
947 // estimated by sampling the rosette - the field of view is an emergent property of the optics, not an input.
948 ScanMetadata scan(traj_pos.front(), 1u, 0.f, float(M_PI), Npulses, 0.f, float(2.0 * M_PI), exitDiameter, beamDivergence, rangeNoiseStdDev, angleNoiseStdDev, columnFormat);
951 scan.risley_prisms = prisms;
952 scan.risley_refractive_index_air = refractive_index_air;
953 scan.pulse_period = 1.0 / double(pulse_rate_hz); // set early so risleyBodyDirection() below uses the real per-pulse time
954
955 // Estimate the emergent circular field of view by sampling the rosette over a bounded number of pulses, then set the
956 // zenith bounds to a cone of that half-angle about the optical axis (+y, zenith pi/2). Clamped to [0, pi].
957 const size_t Nsample = std::min<size_t>(Npulses, 20000);
958 float max_halfangle = 0.f;
959 for (size_t k = 0; k < Nsample; k++) {
960 helios::vec3 dir = risleyBodyDirection(scan, k);
961 float halfangle = std::acos(std::max(-1.f, std::min(1.f, dir.y))); // angle from the +y optical axis
962 if (halfangle > max_halfangle) {
963 max_halfangle = halfangle;
964 }
965 }
966 scan.thetaMin = std::max(0.f, 0.5f * float(M_PI) - max_halfangle);
967 scan.thetaMax = std::min(float(M_PI), 0.5f * float(M_PI) + max_halfangle);
968
969 return addScanMoving(scan, traj_t, traj_pos, traj_quat, lever_arm, boresight_rpy, pulse_rate_hz, t0);
970}
971
972uint LiDARcloud::addScanRisley(const std::vector<RisleyPrism> &prisms, double refractive_index_air, float pulse_rate_hz, const std::vector<double> &traj_t, const std::vector<vec3> &traj_pos, const std::vector<vec3> &traj_rpy,
973 const vec3 &lever_arm, const vec3 &boresight_rpy, float exitDiameter, float beamDivergence, float rangeNoiseStdDev, float angleNoiseStdDev, const std::vector<std::string> &columnFormat, double t0) {
974
975 // Convert the per-sample roll/pitch/yaw Euler angles to quaternions, then delegate to the quaternion overload.
976 if (traj_rpy.size() != traj_t.size()) {
977 helios_runtime_error("ERROR (LiDARcloud::addScanRisley): trajectory arrays have inconsistent lengths (traj_t=" + std::to_string(traj_t.size()) + ", traj_rpy=" + std::to_string(traj_rpy.size()) + "). All trajectory arrays must have the same number of samples.");
978 }
979
980 std::vector<vec4> traj_quat;
981 traj_quat.reserve(traj_rpy.size());
982 for (const vec3 &rpy: traj_rpy) {
983 traj_quat.push_back(quat_from_rpy(rpy.x, rpy.y, rpy.z));
984 }
985
986 return addScanRisley(prisms, refractive_index_air, pulse_rate_hz, traj_t, traj_pos, traj_quat, lever_arm, boresight_rpy, exitDiameter, beamDivergence, rangeNoiseStdDev, angleNoiseStdDev, columnFormat, t0);
987}
988
989void LiDARcloud::addHitPoint(uint scanID, const vec3 &xyz, const SphericalCoord &direction) {
990
991 // default color
992 RGBcolor color = make_RGBcolor(1, 0, 0);
993
994 // empty data
995 std::map<std::string, double> data;
996
997 addHitPoint(scanID, xyz, direction, color, data);
998}
999
1000void LiDARcloud::addHitPoint(uint scanID, const vec3 &xyz, const SphericalCoord &direction, const map<string, double> &data) {
1001
1002 // default color
1003 RGBcolor color = make_RGBcolor(1, 0, 0);
1004
1005 addHitPoint(scanID, xyz, direction, color, data);
1006}
1007
1008void LiDARcloud::addHitPoint(uint scanID, const vec3 &xyz, const SphericalCoord &direction, const RGBcolor &color) {
1009
1010 // empty data
1011 std::map<std::string, double> data;
1012
1013 addHitPoint(scanID, xyz, direction, color, data);
1014}
1015
1016size_t LiDARcloud::getOrCreateHitDataColumn(const std::string &label) {
1017 auto it = hit_data_label_index.find(label);
1018 if (it != hit_data_label_index.end()) {
1019 return it->second;
1020 }
1021 // New label: create a column back-filled "absent" for all hits that already exist, so the column
1022 // stays length-aligned with `hits`. In practice the synthetic scan inserts every standard label on
1023 // the first hit, so this back-fill is empty; only labels introduced mid-cloud (e.g. gapfill codes)
1024 // pay an O(N) fill, and there are only a handful of those.
1025 const size_t slot = hit_data_labels.size();
1026 hit_data_labels.push_back(label);
1027 hit_data_label_index[label] = slot;
1028 hit_data_columns.emplace_back(hits.size(), 0.0);
1029 hit_data_present.emplace_back(hits.size(), char(0));
1030 return slot;
1031}
1032
1035void LiDARcloud::appendHitData(const std::map<std::string, double> &data) {
1036 const size_t i = hits.size() - 1;
1037
1038 // Extend every existing column by one absent slot for this new hit.
1039 for (size_t s = 0; s < hit_data_columns.size(); s++) {
1040 hit_data_columns[s].push_back(0.0);
1041 hit_data_present[s].push_back(char(0));
1042 }
1043
1044 // Fill in the values this hit actually carries (creating new columns as needed; a column created
1045 // here is back-filled absent for prior hits AND already extended for this hit by emplace_back above
1046 // via getOrCreateHitDataColumn, which sizes to hits.size()).
1047 for (const auto &kv: data) {
1048 const size_t s = getOrCreateHitDataColumn(kv.first);
1049 hit_data_columns[s][i] = kv.second;
1050 hit_data_present[s][i] = char(1);
1051 }
1052}
1053
1054void LiDARcloud::addHitPoint(uint scanID, const vec3 &xyz, const SphericalCoord &direction, const RGBcolor &color, const map<string, double> &data) {
1055
1056 // error checking
1057 if (scanID >= scans.size()) {
1058 helios_runtime_error("ERROR (LiDARcloud::addHitPoint): Hit point cannot be added to scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1059 }
1060
1061 const ScanMetadata &scan = scans.at(scanID); // reference, not a per-hit copy (ScanMetadata holds vectors)
1062 int2 row_column = scan.direction2rc(direction);
1063
1064 HitPoint hit(scanID, xyz, direction, row_column, color);
1065
1066 hits.push_back(hit);
1067 appendHitData(data);
1068}
1069
1070void LiDARcloud::addHitPoint(uint scanID, const vec3 &xyz, const int2 &row_column, const RGBcolor &color, const map<string, double> &data) {
1071
1072 const ScanMetadata &scan = scans.at(scanID); // reference, not a per-hit copy (ScanMetadata holds vectors)
1073 SphericalCoord direction = scan.rc2direction(row_column.x, row_column.y);
1074
1075 HitPoint hit(scanID, xyz, direction, row_column, color);
1076
1077 hits.push_back(hit);
1078 appendHitData(data);
1079}
1080
1082
1083 if (index >= hits.size()) {
1084 cerr << "WARNING (deleteHitPoint): Hit point #" << index << " cannot be deleted from the scan because there have only been " << hits.size() << " hit points added." << endl;
1085 return;
1086 }
1087
1088 // erase from vector of hits (use swap-and-pop method). The columnar scalar-data store is indexed by
1089 // hit position, so it must be swapped-and-popped in exact lockstep or the columns desync from the
1090 // surviving hits' positions/colors and silently corrupt every subsequent read/export.
1091 const size_t last = hits.size() - 1;
1092 for (size_t s = 0; s < hit_data_columns.size(); s++) {
1093 std::swap(hit_data_columns[s][index], hit_data_columns[s][last]);
1094 std::swap(hit_data_present[s][index], hit_data_present[s][last]);
1095 hit_data_columns[s].pop_back();
1096 hit_data_present[s].pop_back();
1097 }
1098
1099 std::swap(hits.at(index), hits.back());
1100 hits.pop_back();
1101}
1102
1104 return hits.size();
1105}
1106
1108 if (scanID >= scans.size()) {
1109 helios_runtime_error("ERROR (LiDARcloud::getScanOrigin): Cannot get origin of scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1110 }
1111 return scans.at(scanID).origin;
1112}
1113
1115 if (scanID >= scans.size()) {
1116 helios_runtime_error("ERROR (LiDARcloud::getScanSizeTheta): Cannot get theta size for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1117 }
1118 return scans.at(scanID).Ntheta;
1119}
1120
1122 if (scanID >= scans.size()) {
1123 helios_runtime_error("ERROR (LiDARcloud::getScanSizePhi): Cannot get phi size for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1124 }
1125 return scans.at(scanID).Nphi;
1126}
1127
1129 if (scanID >= scans.size()) {
1130 helios_runtime_error("ERROR (LiDARcloud::getScanRangeTheta): Cannot get theta range for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1131 }
1132 return helios::make_vec2(scans.at(scanID).thetaMin, scans.at(scanID).thetaMax);
1133}
1134
1136 if (scanID >= scans.size()) {
1137 helios_runtime_error("ERROR (LiDARcloud::getScanRangePhi): Cannot get phi range for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1138 }
1139 return helios::make_vec2(scans.at(scanID).phiMin, scans.at(scanID).phiMax);
1140}
1141
1143 if (scanID >= scans.size()) {
1144 helios_runtime_error("ERROR (LiDARcloud::getScanBeamExitDiameter): Cannot get exit diameter for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1145 }
1146 return scans.at(scanID).exitDiameter;
1147}
1148
1150 if (scanID >= scans.size()) {
1151 helios_runtime_error("ERROR (LiDARcloud::getScanBeamDivergence): Cannot get beam divergence for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1152 }
1153 return scans.at(scanID).beamDivergence;
1154}
1155
1157 if (scanID >= scans.size()) {
1158 helios_runtime_error("ERROR (LiDARcloud::getScanRangeNoiseStdDev): Cannot get range noise standard deviation for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1159 }
1160 return scans.at(scanID).rangeNoiseStdDev;
1161}
1162
1164 if (scanID >= scans.size()) {
1165 helios_runtime_error("ERROR (LiDARcloud::getScanAngleNoiseStdDev): Cannot get angular noise standard deviation for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1166 }
1167 return scans.at(scanID).angleNoiseStdDev;
1168}
1169
1171 if (scanID >= scans.size()) {
1172 helios_runtime_error("ERROR (LiDARcloud::getScanReturnMode): Cannot get return mode for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1173 }
1174 return scans.at(scanID).returnMode;
1175}
1176
1178 if (scanID >= scans.size()) {
1179 helios_runtime_error("ERROR (LiDARcloud::setScanReturnMode): Cannot set return mode for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1180 }
1181 scans.at(scanID).returnMode = returnMode;
1182}
1183
1185 if (scanID >= scans.size()) {
1186 helios_runtime_error("ERROR (LiDARcloud::getScanSingleReturnSelection): Cannot get single-return selection for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1187 }
1188 return scans.at(scanID).singleReturnSelection;
1189}
1190
1192 if (scanID >= scans.size()) {
1193 helios_runtime_error("ERROR (LiDARcloud::setScanSingleReturnSelection): Cannot set single-return selection for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1194 }
1195 scans.at(scanID).singleReturnSelection = selection;
1196}
1197
1199 if (scanID >= scans.size()) {
1200 helios_runtime_error("ERROR (LiDARcloud::getScanMaxReturns): Cannot get maximum returns for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1201 }
1202 return scans.at(scanID).maxReturns;
1203}
1204
1205void LiDARcloud::setScanMaxReturns(uint scanID, int maxReturns) {
1206 if (scanID >= scans.size()) {
1207 helios_runtime_error("ERROR (LiDARcloud::setScanMaxReturns): Cannot set maximum returns for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1208 }
1209 if (maxReturns < 1) {
1210 helios_runtime_error("ERROR (LiDARcloud::setScanMaxReturns): Maximum returns must be at least 1, but " + std::to_string(maxReturns) + " was given.");
1211 }
1212 scans.at(scanID).maxReturns = maxReturns;
1213}
1214
1216 if (scanID >= scans.size()) {
1217 helios_runtime_error("ERROR (LiDARcloud::getScanPulseWidth): Cannot get pulse width for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1218 }
1219 return scans.at(scanID).pulseWidth;
1220}
1221
1222void LiDARcloud::setScanPulseWidth(uint scanID, float pulseWidth) {
1223 if (scanID >= scans.size()) {
1224 helios_runtime_error("ERROR (LiDARcloud::setScanPulseWidth): Cannot set pulse width for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1225 }
1226 if (pulseWidth < 0.f) {
1227 helios_runtime_error("ERROR (LiDARcloud::setScanPulseWidth): Pulse width must be non-negative, but " + std::to_string(pulseWidth) + " was given.");
1228 }
1229 scans.at(scanID).pulseWidth = pulseWidth;
1230}
1231
1233 if (scanID >= scans.size()) {
1234 helios_runtime_error("ERROR (LiDARcloud::getScanDetectionThreshold): Cannot get detection threshold for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1235 }
1236 return scans.at(scanID).detectionThreshold;
1237}
1238
1239void LiDARcloud::setScanDetectionThreshold(uint scanID, float detectionThreshold) {
1240 if (scanID >= scans.size()) {
1241 helios_runtime_error("ERROR (LiDARcloud::setScanDetectionThreshold): Cannot set detection threshold for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1242 }
1243 if (detectionThreshold < 0.f) {
1244 helios_runtime_error("ERROR (LiDARcloud::setScanDetectionThreshold): Detection threshold must be non-negative, but " + std::to_string(detectionThreshold) + " was given.");
1245 }
1246 scans.at(scanID).detectionThreshold = detectionThreshold;
1247}
1248
1250 if (scanID >= scans.size()) {
1251 helios_runtime_error("ERROR (LiDARcloud::getScanTiltRoll): Cannot get scanner tilt roll for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1252 }
1253 return scans.at(scanID).scanTilt_roll;
1254}
1255
1257 if (scanID >= scans.size()) {
1258 helios_runtime_error("ERROR (LiDARcloud::getScanTiltPitch): Cannot get scanner tilt pitch for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1259 }
1260 return scans.at(scanID).scanTilt_pitch;
1261}
1262
1264 if (scanID >= scans.size()) {
1265 helios_runtime_error("ERROR (LiDARcloud::getScanAzimuthOffset): Cannot get scanner azimuth offset for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1266 }
1267 return scans.at(scanID).scanTilt_azimuth;
1268}
1269
1270std::vector<std::string> LiDARcloud::getScanColumnFormat(uint scanID) const {
1271 if (scanID >= scans.size()) {
1272 helios_runtime_error("ERROR (LiDARcloud::getScanColumnFormat): Cannot get column format for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1273 }
1274 return scans.at(scanID).columnFormat;
1275}
1276
1278 if (scanID >= scans.size()) {
1279 helios_runtime_error("ERROR (LiDARcloud::getScanPattern): Cannot get scan pattern for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1280 }
1281 return scans.at(scanID).scanPattern;
1282}
1283
1284std::vector<float> LiDARcloud::getScanBeamZenithAngles(uint scanID) const {
1285 if (scanID >= scans.size()) {
1286 helios_runtime_error("ERROR (LiDARcloud::getScanBeamZenithAngles): Cannot get beam zenith angles for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1287 }
1288 return scans.at(scanID).beamZenithAngles;
1289}
1290
1292 if (scanID >= scans.size()) {
1293 helios_runtime_error("ERROR (LiDARcloud::getScanMode): Cannot get scan mode for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1294 }
1295 return scans.at(scanID).scanMode;
1296}
1297
1299 if (scanID >= scans.size()) {
1300 helios_runtime_error("ERROR (LiDARcloud::getScanStepsPerRev): Cannot get steps per revolution for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1301 }
1302 return scans.at(scanID).steps_per_rev;
1303}
1304
1306 if (scanID >= scans.size()) {
1307 helios_runtime_error("ERROR (LiDARcloud::getScanRotationRate): Cannot get rotation rate for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1308 }
1309 return scans.at(scanID).rotation_rate;
1310}
1311
1313 if (scanID >= scans.size()) {
1314 helios_runtime_error("ERROR (LiDARcloud::getScanRevolutions): Cannot get revolution count for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1315 }
1316 return scans.at(scanID).n_revolutions;
1317}
1318
1319std::vector<RisleyPrism> LiDARcloud::getScanRisleyPrisms(uint scanID) const {
1320 if (scanID >= scans.size()) {
1321 helios_runtime_error("ERROR (LiDARcloud::getScanRisleyPrisms): Cannot get Risley prisms for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1322 }
1323 return scans.at(scanID).risley_prisms;
1324}
1325
1327 if (scanID >= scans.size()) {
1328 helios_runtime_error("ERROR (LiDARcloud::getScanRisleyRefractiveIndexAir): Cannot get the Risley air refractive index for scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1329 }
1330 return scans.at(scanID).risley_refractive_index_air;
1331}
1332
1334
1335 if (index >= hits.size()) {
1336 helios_runtime_error("ERROR (LiDARcloud::getHitXYZ): Hit point index out of bounds. Requesting hit #" + std::to_string(index) + " but scan only has " + std::to_string(hits.size()) + " hits.");
1337 }
1338
1339 return hits.at(index).position;
1340}
1341
1343
1344 if (index >= hits.size()) {
1345 helios_runtime_error("ERROR (LiDARcloud::getHitOrigin): Hit point index out of bounds. Requesting hit #" + std::to_string(index) + " but scan only has " + std::to_string(hits.size()) + " hits.");
1346 }
1347
1348 // Moving-platform scans store the per-pulse emission origin on each hit. Static scans do not, so fall back to the
1349 // single scan origin.
1350 if (doesHitDataExist(index, "origin_x") && doesHitDataExist(index, "origin_y") && doesHitDataExist(index, "origin_z")) {
1351 return helios::make_vec3(float(getHitData(index, "origin_x")), float(getHitData(index, "origin_y")), float(getHitData(index, "origin_z")));
1352 }
1353
1354 return getScanOrigin(uint(getHitScanID(index)));
1355}
1356
1358
1359 if (index >= hits.size()) {
1360 helios_runtime_error("ERROR (LiDARcloud::getHitRaydir): Hit point index out of bounds. Requesting hit #" + std::to_string(index) + " but scan only has " + std::to_string(hits.size()) + " hits.");
1361 }
1362
1363 // Use the beam's own emission origin (per-pulse for moving-platform scans; the scan origin for static scans) so the
1364 // recovered ray direction is correct regardless of platform motion.
1365 vec3 direction_cart = getHitXYZ(index) - getHitOrigin(index);
1366 return cart2sphere(direction_cart);
1367}
1368
1369void LiDARcloud::setHitData(uint index, const char *label, double value) {
1370
1371 if (index >= hits.size()) {
1372 helios_runtime_error("ERROR (LiDARcloud::setHitScalarData): Hit point index out of bounds. Tried to set hit #" + std::to_string(index) + " but scan only has " + std::to_string(hits.size()) + " hits.");
1373 }
1374
1375 // Columnar store: resolve (or create, back-filled absent) the label's column, then set this hit's
1376 // value and mark it present. Observably identical to the old per-hit map[label]=value.
1377 const size_t slot = getOrCreateHitDataColumn(label);
1378 hit_data_columns[slot][index] = value;
1379 hit_data_present[slot][index] = char(1);
1380}
1381
1382double LiDARcloud::getHitData(uint index, const char *label) const {
1383
1384 if (index >= hits.size()) {
1385 helios_runtime_error("ERROR (LiDARcloud::getHitData): Hit point index out of bounds. Requesting hit #" + std::to_string(index) + " but scan only has " + std::to_string(hits.size()) + " hits.");
1386 }
1387
1388 // O(1): one small label->slot hash lookup + one contiguous indexed read, instead of a per-hit
1389 // red-black-tree descent. Absent (label never set, or not set on this hit) throws the same error.
1390 auto it = hit_data_label_index.find(label);
1391 if (it == hit_data_label_index.end() || hit_data_present[it->second][index] == char(0)) {
1392 helios_runtime_error("ERROR (LiDARcloud::getHitData): Data value ``" + std::string(label) + "'' does not exist.");
1393 }
1394
1395 return hit_data_columns[it->second][index];
1396}
1397
1398bool LiDARcloud::doesHitDataExist(uint index, const char *label) const {
1399
1400 if (index >= hits.size()) {
1401 return false;
1402 }
1403
1404 auto it = hit_data_label_index.find(label);
1405 return it != hit_data_label_index.end() && hit_data_present[it->second][index] != char(0);
1406}
1407
1408int LiDARcloud::getHitDataColumnIndex(const char *label) const {
1409 auto it = hit_data_label_index.find(label);
1410 if (it == hit_data_label_index.end()) {
1411 return -1;
1412 }
1413 return int(it->second);
1414}
1415
1416void LiDARcloud::getHitDataColumn(const char *label, std::vector<double> &data, double absent_value) const {
1417 const size_t N = hits.size();
1418 data.resize(N);
1419
1420 auto it = hit_data_label_index.find(label);
1421 if (it == hit_data_label_index.end()) {
1422 // Label never set on any hit: every entry is absent.
1423 std::fill(data.begin(), data.end(), absent_value);
1424 return;
1425 }
1426
1427 // Single cache-linear pass over the contiguous value and presence columns.
1428 const std::vector<double> &column = hit_data_columns[it->second];
1429 const std::vector<char> &present = hit_data_present[it->second];
1430 for (size_t i = 0; i < N; i++) {
1431 data[i] = present[i] != char(0) ? column[i] : absent_value;
1432 }
1433}
1434
1435void LiDARcloud::clearHits() {
1436 hits.clear();
1437 hit_data_labels.clear();
1438 hit_data_label_index.clear();
1439 hit_data_columns.clear();
1440 hit_data_present.clear();
1441}
1442
1444
1445 if (index >= hits.size()) {
1446 helios_runtime_error("ERROR (LiDARcloud::getHitColor): Hit point index out of bounds. Requesting hit #" + std::to_string(index) + " but scan only has " + std::to_string(hits.size()) + " hits.");
1447 }
1448
1449 return hits.at(index).color;
1450}
1451
1453
1454 if (index >= hits.size()) {
1455 helios_runtime_error("ERROR (LiDARcloud::getHitColor): Hit point index out of bounds. Requesting hit #" + std::to_string(index) + " but scan only has " + std::to_string(hits.size()) + " hits.");
1456 }
1457
1458 return hits.at(index).scanID;
1459}
1460
1461int LiDARcloud::getHitIndex(uint scanID, uint row, uint column) const {
1462
1463 if (scanID >= scans.size()) {
1464 helios_runtime_error("ERROR (LiDARcloud::deleteHitPoint): Hit point cannot be deleted from scan #" + std::to_string(scanID) + " because there have only been " + std::to_string(scans.size()) + " scans added.");
1465 }
1466 if (row >= getScanSizeTheta(scanID)) {
1467 helios_runtime_error("ERROR (LiDARcloud::getHitIndex): Row in scan data table out of range.");
1468 } else if (column >= getScanSizePhi(scanID)) {
1469 helios_runtime_error("ERROR (LiDARcloud::getHitIndex): Column in scan data table out of range.");
1470 }
1471
1472 int hit = hit_tables.at(scanID).get(row, column);
1473
1474 assert(hit < getScanSizeTheta(scanID) * getScanSizePhi(scanID));
1475
1476 return hit;
1477}
1478
1480
1481 if (index >= hits.size()) {
1482 helios_runtime_error("ERROR (LiDARcloud::getHitGridCell): Hit point index out of bounds. Requesting hit #" + std::to_string(index) + " but scan only has " + std::to_string(hits.size()) + " hits.");
1483 } else if (hits.at(index).gridcell == -2) {
1484 cerr << "WARNING (LiDARcloud::getHitGridCell): hit grid cell for point #" << index << " was never set. Returning a value of `-1'. Did you forget to call calculateHitGridCell[*] first?" << endl;
1485 return -1;
1486 }
1487
1488 return hits.at(index).gridcell;
1489}
1490
1491void LiDARcloud::setHitGridCell(uint index, int cell) {
1492
1493 if (index >= hits.size()) {
1494 helios_runtime_error("ERROR (LiDARcloud::setHitGridCell): Hit point index out of bounds. Tried to set hit #" + std::to_string(index) + " but scan only has " + std::to_string(hits.size()) + " hits.");
1495 }
1496
1497 hits.at(index).gridcell = cell;
1498}
1499
1500void LiDARcloud::transformHitOrigin(uint index, const std::function<helios::vec3(const helios::vec3 &)> &transform) {
1501 // Moving-platform hits store their own per-pulse emission origin (labels origin_x/y/z), which must be
1502 // transformed together with the hit position so the two stay in the same coordinate frame. Static hits
1503 // carry no such labels and are left unchanged. All-three-or-none semantics preserved.
1504 if (doesHitDataExist(index, "origin_x") && doesHitDataExist(index, "origin_y") && doesHitDataExist(index, "origin_z")) {
1505 helios::vec3 o = helios::make_vec3(float(getHitData(index, "origin_x")), float(getHitData(index, "origin_y")), float(getHitData(index, "origin_z")));
1506 o = transform(o);
1507 setHitData(index, "origin_x", o.x);
1508 setHitData(index, "origin_y", o.y);
1509 setHitData(index, "origin_z", o.z);
1510 }
1511}
1512
1513helios::vec3 LiDARcloud::hitOriginOrFallback(uint index, const helios::vec3 &fallback) const {
1514 if (doesHitDataExist(index, "origin_x") && doesHitDataExist(index, "origin_y") && doesHitDataExist(index, "origin_z")) {
1515 return helios::make_vec3(float(getHitData(index, "origin_x")), float(getHitData(index, "origin_y")), float(getHitData(index, "origin_z")));
1516 }
1517 return fallback;
1518}
1519
1521
1522 for (auto &scan: scans) {
1523 scan.origin = scan.origin + shift;
1524 }
1525
1526 for (size_t i = 0; i < hits.size(); i++) {
1527 hits[i].position = hits[i].position + shift;
1528 transformHitOrigin(uint(i), [&](const vec3 &o) { return o + shift; });
1529 }
1530}
1531
1532void LiDARcloud::coordinateShift(uint scanID, const vec3 &shift) {
1533
1534 if (scanID >= scans.size()) {
1535 helios_runtime_error("ERROR (LiDARcloud::coordinateShift): Cannot apply coordinate shift to scan " + std::to_string(scanID) + " because it does not exist.");
1536 }
1537
1538 scans.at(scanID).origin = scans.at(scanID).origin + shift;
1539
1540 for (size_t i = 0; i < hits.size(); i++) {
1541 if (hits[i].scanID == scanID) {
1542 hits[i].position = hits[i].position + shift;
1543 transformHitOrigin(uint(i), [&](const vec3 &o) { return o + shift; });
1544 }
1545 }
1546}
1547
1549
1550 for (auto &scan: scans) {
1551 scan.origin = rotatePoint(scan.origin, rotation);
1552 }
1553
1554 for (size_t i = 0; i < hits.size(); i++) {
1555 hits[i].position = rotatePoint(hits[i].position, rotation);
1556 transformHitOrigin(uint(i), [&](const vec3 &o) { return rotatePoint(o, rotation); });
1557 // Recompute the stored ray direction from the hit's own (transformed) origin so it remains correct for moving scans.
1558 hits[i].direction = cart2sphere(hits[i].position - hitOriginOrFallback(uint(i), scans.at(hits[i].scanID).origin));
1559 }
1560}
1561
1563
1564 if (scanID >= scans.size()) {
1565 helios_runtime_error("ERROR (LiDARcloud::coordinateRotation): Cannot apply rotation to scan " + std::to_string(scanID) + " because it does not exist.");
1566 }
1567
1568 scans.at(scanID).origin = rotatePoint(scans.at(scanID).origin, rotation);
1569
1570 for (size_t i = 0; i < hits.size(); i++) {
1571 if (hits[i].scanID == scanID) {
1572 hits[i].position = rotatePoint(hits[i].position, rotation);
1573 transformHitOrigin(uint(i), [&](const vec3 &o) { return rotatePoint(o, rotation); });
1574 hits[i].direction = cart2sphere(hits[i].position - hitOriginOrFallback(uint(i), scans.at(scanID).origin));
1575 }
1576 }
1577}
1578
1579void LiDARcloud::coordinateRotation(float rotation, const vec3 &line_base, const vec3 &line_direction) {
1580
1581 for (auto &scan: scans) {
1582 scan.origin = rotatePointAboutLine(scan.origin, line_base, line_direction, rotation);
1583 }
1584
1585 for (size_t i = 0; i < hits.size(); i++) {
1586 hits[i].position = rotatePointAboutLine(hits[i].position, line_base, line_direction, rotation);
1587 transformHitOrigin(uint(i), [&](const vec3 &o) { return rotatePointAboutLine(o, line_base, line_direction, rotation); });
1588 hits[i].direction = cart2sphere(hits[i].position - hitOriginOrFallback(uint(i), scans.at(hits[i].scanID).origin));
1589 }
1590}
1591
1593 return triangles.size();
1594}
1595
1597 return triangulation_candidate_count;
1598}
1599
1601 return triangulation_dropped_lmax;
1602}
1603
1605 return triangulation_dropped_aspect;
1606}
1607
1609 return triangulation_dropped_degenerate;
1610}
1611
1613 if (index >= triangles.size()) {
1614 helios_runtime_error("ERROR (LiDARcloud::getTriangle): Triangle index out of bounds. Tried to get triangle #" + std::to_string(index) + " but point cloud only has " + std::to_string(triangles.size()) + " triangles.");
1615 }
1616
1617 return triangles.at(index);
1618}
1619
1620void LiDARcloud::addHitsToVisualizer(Visualizer *visualizer, uint pointsize) const {
1621 addHitsToVisualizer(visualizer, pointsize, "");
1622}
1623
1624void LiDARcloud::addHitsToVisualizer(Visualizer *visualizer, uint pointsize, const RGBcolor &point_color) const {
1625
1626 if (printmessages && scans.size() == 0) {
1627 std::cout << "WARNING (LiDARcloud::addHitsToVisualizer): There are no scans in the point cloud, and thus there is no geometry to add...skipping." << std::endl;
1628 return;
1629 }
1630
1631 for (uint i = 0; i < getHitCount(); i++) {
1632 vec3 center = getHitXYZ(i);
1633
1634 visualizer->addPoint(center, point_color, pointsize, Visualizer::COORDINATES_CARTESIAN);
1635 }
1636}
1637
1638void LiDARcloud::addHitsToVisualizer(Visualizer *visualizer, uint pointsize, const char *color_value) const {
1639
1640 if (printmessages && scans.size() == 0) {
1641 std::cout << "WARNING (LiDARcloud::addHitsToVisualizer): There are no scans in the point cloud, and thus there is no geometry to add...skipping." << std::endl;
1642 return;
1643 }
1644
1645 //-- hit points --//
1646 float minval = 1e9;
1647 float maxval = -1e9;
1648 if (strcmp(color_value, "gridcell") == 0) {
1649 minval = 0;
1650 maxval = getGridCellCount() - 1;
1651 } else if (strcmp(color_value, "") != 0) {
1652 for (uint i = 0; i < getHitCount(); i++) {
1653 if (doesHitDataExist(i, color_value)) {
1654 float data = float(getHitData(i, color_value));
1655 if (data < minval) {
1656 minval = data;
1657 }
1658 if (data > maxval) {
1659 maxval = data;
1660 }
1661 }
1662 }
1663 }
1664
1665 RGBcolor color;
1666 Colormap cmap = visualizer->getCurrentColormap();
1667 if (minval != 1e9 && maxval != -1e9) {
1668 cmap.setRange(minval, maxval);
1669 }
1670
1671 for (uint i = 0; i < getHitCount(); i++) {
1672
1673 if (strcmp(color_value, "") == 0) {
1674 color = getHitColor(i);
1675 } else if (strcmp(color_value, "gridcell") == 0) {
1676 if (getHitGridCell(i) < 0) {
1677 color = RGB::red;
1678 } else {
1679 color = cmap.query(getHitGridCell(i));
1680 }
1681 } else {
1682 if (!doesHitDataExist(i, color_value)) {
1683 color = RGB::red;
1684 } else {
1685 float data = float(getHitData(i, color_value));
1686 color = cmap.query(data);
1687 }
1688 }
1689
1690 vec3 center = getHitXYZ(i);
1691
1692 visualizer->addPoint(center, color, pointsize, Visualizer::COORDINATES_CARTESIAN);
1693 }
1694}
1695
1697
1698 if (printmessages && scans.size() == 0) {
1699 std::cout << "WARNING (LiDARcloud::addGridToVisualizer): There are no scans in the point cloud, and thus there is no geometry to add...skipping." << std::endl;
1700 return;
1701 }
1702
1703 float minval = 1e9;
1704 float maxval = -1e9;
1705 for (uint i = 0; i < getGridCellCount(); i++) {
1706 float data = getCellLeafAreaDensity(i);
1707 if (data < minval) {
1708 minval = data;
1709 }
1710 if (data > maxval) {
1711 maxval = data;
1712 }
1713 }
1714
1715 Colormap cmap = visualizer->getCurrentColormap();
1716 if (minval != 1e9 && maxval != -1e9) {
1717 cmap.setRange(minval, maxval);
1718 }
1719
1720 vec3 origin;
1721 for (uint i = 0; i < getGridCellCount(); i++) {
1722
1723 if (getCellLeafAreaDensity(i) == 0) {
1724 continue;
1725 }
1726
1727 vec3 center = getCellCenter(i);
1728
1729 vec3 anchor = getCellGlobalAnchor(i);
1730
1732
1733 center = rotatePointAboutLine(center, anchor, make_vec3(0, 0, 1), rotation.azimuth);
1734 vec3 size = getCellSize(i);
1735
1736 RGBAcolor color = make_RGBAcolor(cmap.query(getCellLeafAreaDensity(i)), 0.5);
1737
1738 visualizer->addVoxelByCenter(center, size, rotation, color, Visualizer::COORDINATES_CARTESIAN);
1739
1740 origin = origin + center / float(getGridCellCount());
1741 }
1742
1743 vec3 boxmin, boxmax;
1744 getHitBoundingBox(boxmin, boxmax);
1745
1746 float R = 2.f * sqrt(pow(boxmax.x - boxmin.x, 2) + pow(boxmax.y - boxmin.y, 2) + pow(boxmax.z - boxmin.z, 2));
1747}
1748
1750
1751 if (printmessages && scans.size() == 0) {
1752 std::cout << "WARNING (LiDARcloud::addGeometryToVisualizer): There are no scans in the point cloud, and thus there is no geometry to add...skipping." << std::endl;
1753 return;
1754 }
1755
1756 for (uint i = 0; i < triangles.size(); i++) {
1757
1758 Triangulation tri = triangles.at(i);
1759
1760 visualizer->addTriangle(tri.vertex0, tri.vertex1, tri.vertex2, tri.color, Visualizer::COORDINATES_CARTESIAN);
1761 }
1762}
1763
1764void LiDARcloud::addTrianglesToVisualizer(Visualizer *visualizer, uint gridcell) const {
1765
1766 if (printmessages && scans.size() == 0) {
1767 std::cout << "WARNING (LiDARcloud::addTrianglesToVisualizer): There are no scans in the point cloud, and thus there is no geometry to add...skipping." << std::endl;
1768 return;
1769 }
1770
1771 for (uint i = 0; i < triangles.size(); i++) {
1772
1773 Triangulation tri = triangles.at(i);
1774
1775 if (tri.gridcell == gridcell) {
1776 visualizer->addTriangle(tri.vertex0, tri.vertex1, tri.vertex2, tri.color, Visualizer::COORDINATES_CARTESIAN);
1777 }
1778 }
1779}
1780
1781void LiDARcloud::addGrid(const vec3 &center, const vec3 &size, const int3 &ndiv, float rotation) {
1782 if (size.x <= 0 || size.y <= 0 || size.z <= 0) {
1783 cerr << "failed.\n";
1784 helios_runtime_error("ERROR (LiDARcloud::addGrid): The grid cell size must be positive.");
1785 }
1786
1787 if (ndiv.x <= 0 || ndiv.y <= 0 || ndiv.z <= 0) {
1788 cerr << "failed.\n";
1789 helios_runtime_error("ERROR (LiDARcloud::addGrid): The number of grid cells in each direction must be positive.");
1790 }
1791
1792 // add cells to grid
1793 vec3 gsubsize = make_vec3(float(size.x) / float(ndiv.x), float(size.y) / float(ndiv.y), float(size.z) / float(ndiv.z));
1794
1795 float x, y, z;
1796 uint count = 0;
1797 for (int k = 0; k < ndiv.z; k++) {
1798 z = -0.5f * float(size.z) + (float(k) + 0.5f) * float(gsubsize.z);
1799 for (int j = 0; j < ndiv.y; j++) {
1800 y = -0.5f * float(size.y) + (float(j) + 0.5f) * float(gsubsize.y);
1801 for (int i = 0; i < ndiv.x; i++) {
1802 x = -0.5f * float(size.x) + (float(i) + 0.5f) * float(gsubsize.x);
1803
1804 vec3 subcenter = make_vec3(x, y, z);
1805
1806 vec3 subcenter_rot = rotatePoint(subcenter, make_SphericalCoord(0, rotation * M_PI / 180.f));
1807
1808 if (printmessages) {
1809 cout << "Adding grid cell #" << count << " with center " << subcenter_rot.x + center.x << "," << subcenter_rot.y + center.y << "," << subcenter.z + center.z << " and size " << gsubsize.x << " x " << gsubsize.y << " x "
1810 << gsubsize.z << endl;
1811 }
1812
1813 addGridCell(subcenter + center, center, gsubsize, size, rotation * M_PI / 180.f, make_int3(i, j, k), ndiv);
1814
1815 count++;
1816 }
1817 }
1818 }
1819}
1820
1821void LiDARcloud::addGridWireFrametoVisualizer(Visualizer *visualizer, float linewidth_pixels) const {
1822
1823
1824 for (int i = 0; i < getGridCellCount(); i++) {
1825 helios::vec3 center = getCellCenter(i);
1826 helios::vec3 size = getCellSize(i);
1827
1828 helios::vec3 boxmin, boxmax;
1829 boxmin = make_vec3(center.x - 0.5 * size.x, center.y - 0.5 * size.y, center.z - 0.5 * size.z);
1830 boxmax = make_vec3(center.x + 0.5 * size.x, center.y + 0.5 * size.y, center.z + 0.5 * size.z);
1831
1832 // vertical edges of the cell
1833 visualizer->addLine(make_vec3(boxmin.x, boxmin.y, boxmin.z), make_vec3(boxmin.x, boxmin.y, boxmax.z), RGB::black, linewidth_pixels, Visualizer::COORDINATES_CARTESIAN);
1834 visualizer->addLine(make_vec3(boxmin.x, boxmax.y, boxmin.z), make_vec3(boxmin.x, boxmax.y, boxmax.z), RGB::black, linewidth_pixels, Visualizer::COORDINATES_CARTESIAN);
1835 visualizer->addLine(make_vec3(boxmax.x, boxmin.y, boxmin.z), make_vec3(boxmax.x, boxmin.y, boxmax.z), RGB::black, linewidth_pixels, Visualizer::COORDINATES_CARTESIAN);
1836 visualizer->addLine(make_vec3(boxmax.x, boxmax.y, boxmin.z), make_vec3(boxmax.x, boxmax.y, boxmax.z), RGB::black, linewidth_pixels, Visualizer::COORDINATES_CARTESIAN);
1837
1838 // horizontal top edges
1839 visualizer->addLine(make_vec3(boxmin.x, boxmin.y, boxmax.z), make_vec3(boxmin.x, boxmax.y, boxmax.z), RGB::black, linewidth_pixels, Visualizer::COORDINATES_CARTESIAN);
1840 visualizer->addLine(make_vec3(boxmin.x, boxmin.y, boxmax.z), make_vec3(boxmax.x, boxmin.y, boxmax.z), RGB::black, linewidth_pixels, Visualizer::COORDINATES_CARTESIAN);
1841 visualizer->addLine(make_vec3(boxmax.x, boxmin.y, boxmax.z), make_vec3(boxmax.x, boxmax.y, boxmax.z), RGB::black, linewidth_pixels, Visualizer::COORDINATES_CARTESIAN);
1842 visualizer->addLine(make_vec3(boxmin.x, boxmax.y, boxmax.z), make_vec3(boxmax.x, boxmax.y, boxmax.z), RGB::black, linewidth_pixels, Visualizer::COORDINATES_CARTESIAN);
1843
1844 // horizontal bottom edges
1845 visualizer->addLine(make_vec3(boxmin.x, boxmin.y, boxmin.z), make_vec3(boxmin.x, boxmax.y, boxmin.z), RGB::black, linewidth_pixels, Visualizer::COORDINATES_CARTESIAN);
1846 visualizer->addLine(make_vec3(boxmin.x, boxmin.y, boxmin.z), make_vec3(boxmax.x, boxmin.y, boxmin.z), RGB::black, linewidth_pixels, Visualizer::COORDINATES_CARTESIAN);
1847 visualizer->addLine(make_vec3(boxmax.x, boxmin.y, boxmin.z), make_vec3(boxmax.x, boxmax.y, boxmin.z), RGB::black, linewidth_pixels, Visualizer::COORDINATES_CARTESIAN);
1848 visualizer->addLine(make_vec3(boxmin.x, boxmax.y, boxmin.z), make_vec3(boxmax.x, boxmax.y, boxmin.z), RGB::black, linewidth_pixels, Visualizer::COORDINATES_CARTESIAN);
1849 }
1850}
1851
1853
1854 size_t Ngroups = reconstructed_triangles.size();
1855
1856 std::vector<helios::RGBcolor> ctable;
1857 std::vector<float> clocs;
1858
1859 ctable.push_back(RGB::violet);
1860 ctable.push_back(RGB::blue);
1861 ctable.push_back(RGB::green);
1862 ctable.push_back(RGB::yellow);
1863 ctable.push_back(RGB::orange);
1864 ctable.push_back(RGB::red);
1865
1866 clocs.push_back(0.f);
1867 clocs.push_back(0.2f);
1868 clocs.push_back(0.4f);
1869 clocs.push_back(0.6f);
1870 clocs.push_back(0.8f);
1871 clocs.push_back(1.f);
1872
1873 Colormap colormap(ctable, clocs, 100, 0, Ngroups - 1);
1874
1875 for (size_t g = 0; g < Ngroups; g++) {
1876
1877 float randi = randu() * (Ngroups - 1);
1878 RGBcolor color = colormap.query(randi);
1879
1880 for (size_t t = 0; t < reconstructed_triangles.at(g).size(); t++) {
1881
1882 helios::vec3 v0 = reconstructed_triangles.at(g).at(t).vertex0;
1883 helios::vec3 v1 = reconstructed_triangles.at(g).at(t).vertex1;
1884 helios::vec3 v2 = reconstructed_triangles.at(g).at(t).vertex2;
1885
1886 // RGBcolor color = reconstructed_triangles.at(g).at(t).color;
1887
1888 visualizer->addTriangle(v0, v1, v2, color, Visualizer::COORDINATES_CARTESIAN);
1889 }
1890 }
1891
1892 Ngroups = reconstructed_alphamasks_center.size();
1893
1894 for (size_t g = 0; g < Ngroups; g++) {
1895
1896 visualizer->addRectangleByCenter(reconstructed_alphamasks_center.at(g), reconstructed_alphamasks_size.at(g), reconstructed_alphamasks_rotation.at(g), reconstructed_alphamasks_maskfile.c_str(), Visualizer::COORDINATES_CARTESIAN);
1897 }
1898}
1899
1901
1902 size_t Ngroups = reconstructed_trunk_triangles.size();
1903
1904 for (size_t g = 0; g < Ngroups; g++) {
1905
1906 for (size_t t = 0; t < reconstructed_trunk_triangles.at(g).size(); t++) {
1907
1908 helios::vec3 v0 = reconstructed_trunk_triangles.at(g).at(t).vertex0;
1909 helios::vec3 v1 = reconstructed_trunk_triangles.at(g).at(t).vertex1;
1910 helios::vec3 v2 = reconstructed_trunk_triangles.at(g).at(t).vertex2;
1911
1912 RGBcolor color = reconstructed_trunk_triangles.at(g).at(t).color;
1913
1914 visualizer->addTriangle(v0, v1, v2, color, Visualizer::COORDINATES_CARTESIAN);
1915 }
1916 }
1917}
1918
1919void LiDARcloud::addTrunkReconstructionToVisualizer(Visualizer *visualizer, const RGBcolor &trunk_color) const {
1920
1921 size_t Ngroups = reconstructed_trunk_triangles.size();
1922
1923 for (size_t g = 0; g < Ngroups; g++) {
1924
1925 for (size_t t = 0; t < reconstructed_trunk_triangles.at(g).size(); t++) {
1926
1927 helios::vec3 v0 = reconstructed_trunk_triangles.at(g).at(t).vertex0;
1928 helios::vec3 v1 = reconstructed_trunk_triangles.at(g).at(t).vertex1;
1929 helios::vec3 v2 = reconstructed_trunk_triangles.at(g).at(t).vertex2;
1930
1931 visualizer->addTriangle(v0, v1, v2, trunk_color, Visualizer::COORDINATES_CARTESIAN);
1932 }
1933 }
1934}
1935
1939
1940std::vector<uint> LiDARcloud::addLeafReconstructionToContext(Context *context, const int2 &subpatches) const {
1941
1942 std::vector<uint> UUIDs;
1943
1944 std::vector<uint> UUID_leaf_template;
1945 if (subpatches.x > 1 || subpatches.y > 1) {
1946 UUID_leaf_template = context->addTile(make_vec3(0, 0, 0), make_vec2(1, 1), make_SphericalCoord(0, 0), subpatches, reconstructed_alphamasks_maskfile.c_str());
1947 }
1948
1949 size_t Ngroups = reconstructed_alphamasks_center.size();
1950
1951 for (size_t g = 0; g < Ngroups; g++) {
1952
1953 helios::RGBcolor color = helios::RGB::red;
1954
1955 uint zone = reconstructed_alphamasks_gridcell.at(g);
1956
1957 if (reconstructed_alphamasks_size.at(g).x > 0 && reconstructed_alphamasks_size.at(g).y > 0) {
1958 std::vector<uint> UUIDs_leaf;
1959 if (subpatches.x == 1 && subpatches.y == 1) {
1960 UUIDs_leaf.push_back(context->addPatch(reconstructed_alphamasks_center.at(g), reconstructed_alphamasks_size.at(g), reconstructed_alphamasks_rotation.at(g), reconstructed_alphamasks_maskfile.c_str()));
1961 } else {
1962 UUIDs_leaf = context->copyPrimitive(UUID_leaf_template);
1963 context->scalePrimitive(UUIDs_leaf, make_vec3(reconstructed_alphamasks_size.at(g).x, reconstructed_alphamasks_size.at(g).y, 1));
1964 context->rotatePrimitive(UUIDs_leaf, -reconstructed_alphamasks_rotation.at(g).elevation, "x");
1965 context->rotatePrimitive(UUIDs_leaf, -reconstructed_alphamasks_rotation.at(g).azimuth, "z");
1966 context->translatePrimitive(UUIDs_leaf, reconstructed_alphamasks_center.at(g));
1967 }
1968 context->setPrimitiveData(UUIDs_leaf, "gridCell", zone);
1969 uint flag = reconstructed_alphamasks_direct_flag.at(g);
1970 context->setPrimitiveData(UUIDs_leaf, "directFlag", flag);
1971 UUIDs.insert(UUIDs.end(), UUIDs_leaf.begin(), UUIDs_leaf.end());
1972 }
1973 }
1974
1975 context->deletePrimitive(UUID_leaf_template);
1976
1977 return UUIDs;
1978}
1979
1981
1982 std::vector<uint> UUIDs;
1983
1984 size_t Ngroups = reconstructed_triangles.size();
1985
1986 for (size_t g = 0; g < Ngroups; g++) {
1987
1988 int leafGroup = round(context->randu() * (Ngroups - 1));
1989
1990 for (size_t t = 0; t < reconstructed_triangles.at(g).size(); t++) {
1991
1992 helios::vec3 v0 = reconstructed_triangles.at(g).at(t).vertex0;
1993 helios::vec3 v1 = reconstructed_triangles.at(g).at(t).vertex1;
1994 helios::vec3 v2 = reconstructed_triangles.at(g).at(t).vertex2;
1995
1996 RGBcolor color = reconstructed_triangles.at(g).at(t).color;
1997
1998 UUIDs.push_back(context->addTriangle(v0, v1, v2, color));
1999
2000 uint zone = reconstructed_triangles.at(g).at(t).gridcell;
2001 context->setPrimitiveData(UUIDs.back(), "gridCell", zone);
2002
2003 context->setPrimitiveData(UUIDs.back(), "leafGroup", leafGroup);
2004 }
2005 }
2006
2007 return UUIDs;
2008}
2009
2011
2012 std::vector<uint> UUIDs;
2013
2014 size_t Ngroups = reconstructed_trunk_triangles.size();
2015
2016 for (size_t g = 0; g < Ngroups; g++) {
2017
2018 for (size_t t = 0; t < reconstructed_trunk_triangles.at(g).size(); t++) {
2019
2020 helios::vec3 v0 = reconstructed_trunk_triangles.at(g).at(t).vertex0;
2021 helios::vec3 v1 = reconstructed_trunk_triangles.at(g).at(t).vertex1;
2022 helios::vec3 v2 = reconstructed_trunk_triangles.at(g).at(t).vertex2;
2023
2024 RGBcolor color = reconstructed_trunk_triangles.at(g).at(t).color;
2025
2026 UUIDs.push_back(context->addTriangle(v0, v1, v2, color));
2027 }
2028 }
2029
2030 return UUIDs;
2031}
2032
2034
2035 if (printmessages && hits.size() == 0) {
2036 std::cout << "WARNING (getHitBoundingBox): There are no hit points in the point cloud, cannot determine bounding box...skipping." << std::endl;
2037 return;
2038 }
2039
2040 boxmin = make_vec3(1e6, 1e6, 1e6);
2041 boxmax = make_vec3(-1e6, -1e6, -1e6);
2042
2043 for (std::size_t i = 0; i < hits.size(); i++) {
2044
2045 vec3 xyz = getHitXYZ(i);
2046
2047 if (xyz.x < boxmin.x) {
2048 boxmin.x = xyz.x;
2049 }
2050 if (xyz.x > boxmax.x) {
2051 boxmax.x = xyz.x;
2052 }
2053 if (xyz.y < boxmin.y) {
2054 boxmin.y = xyz.y;
2055 }
2056 if (xyz.y > boxmax.y) {
2057 boxmax.y = xyz.y;
2058 }
2059 if (xyz.z < boxmin.z) {
2060 boxmin.z = xyz.z;
2061 }
2062 if (xyz.z > boxmax.z) {
2063 boxmax.z = xyz.z;
2064 }
2065 }
2066}
2067
2069
2070 if (printmessages && getGridCellCount() == 0) {
2071 std::cout << "WARNING (getGridBoundingBox): There are no grid cells in the point cloud, cannot determine bounding box...skipping." << std::endl;
2072 return;
2073 }
2074
2075 boxmin = make_vec3(1e6, 1e6, 1e6);
2076 boxmax = make_vec3(-1e6, -1e6, -1e6);
2077
2078 std::size_t count = 0;
2079 for (uint c = 0; c < getGridCellCount(); c++) {
2080
2081 vec3 center = getCellCenter(c);
2082 vec3 size = getCellSize(c);
2083 vec3 cellanchor = getCellGlobalAnchor(c);
2084 float rotation = getCellRotation(c);
2085
2086 vec3 xyz_min = center - 0.5f * size;
2087 xyz_min = rotatePointAboutLine(xyz_min, cellanchor, make_vec3(0, 0, 1), rotation);
2088 vec3 xyz_max = center + 0.5f * size;
2089 xyz_max = rotatePointAboutLine(xyz_max, cellanchor, make_vec3(0, 0, 1), rotation);
2090
2091 if (xyz_min.x < boxmin.x) {
2092 boxmin.x = xyz_min.x;
2093 }
2094 if (xyz_max.x > boxmax.x) {
2095 boxmax.x = xyz_max.x;
2096 }
2097 if (xyz_min.y < boxmin.y) {
2098 boxmin.y = xyz_min.y;
2099 }
2100 if (xyz_max.y > boxmax.y) {
2101 boxmax.y = xyz_max.y;
2102 }
2103 if (xyz_min.z < boxmin.z) {
2104 boxmin.z = xyz_min.z;
2105 }
2106 if (xyz_max.z > boxmax.z) {
2107 boxmax.z = xyz_max.z;
2108 }
2109 }
2110}
2111
2112void LiDARcloud::distanceFilter(float maxdistance) {
2113
2114 std::size_t delete_count = 0;
2115 for (int i = (getHitCount() - 1); i >= 0; i--) {
2116
2117 vec3 xyz = getHitXYZ(i);
2118 // Range is measured from the beam's own emission origin (per-pulse for moving scans, scan origin for static).
2119 vec3 r = xyz - getHitOrigin(i);
2120
2121 if (r.magnitude() > maxdistance) {
2122 deleteHitPoint(i);
2123 delete_count++;
2124 }
2125 }
2126
2127 if (printmessages) {
2128 std::cout << "Removed " << delete_count << " hit points based on distance filter." << std::endl;
2129 }
2130}
2131
2132void LiDARcloud::reflectanceFilter(float minreflectance) {
2133
2134 std::size_t delete_count = 0;
2135 for (int r = (getHitCount() - 1); r >= 0; r--) {
2136 if (doesHitDataExist(r, "reflectance")) {
2137 double R = getHitData(r, "reflectance");
2138 if (R < minreflectance) {
2139 deleteHitPoint(r);
2140 delete_count++;
2141 }
2142 }
2143 }
2144
2145 if (printmessages) {
2146 std::cout << "Removed " << delete_count << " hit points based on reflectance filter." << std::endl;
2147 }
2148}
2149
2150void LiDARcloud::scalarFilter(const char *scalar_field, float threshold, const char *comparator) {
2151
2152 std::size_t delete_count = 0;
2153 for (int r = (getHitCount() - 1); r >= 0; r--) {
2154 if (doesHitDataExist(r, scalar_field)) {
2155 double R = getHitData(r, scalar_field);
2156 if (strcmp(comparator, "<") == 0) {
2157 if (R < threshold) {
2158 deleteHitPoint(r);
2159 delete_count++;
2160 }
2161 } else if (strcmp(comparator, ">") == 0) {
2162 if (R > threshold) {
2163 deleteHitPoint(r);
2164 delete_count++;
2165 }
2166 } else if (strcmp(comparator, "=") == 0) {
2167 if (R == threshold) {
2168 deleteHitPoint(r);
2169 delete_count++;
2170 }
2171 }
2172 }
2173 }
2174
2175 if (printmessages) {
2176 std::cout << "Removed " << delete_count << " hit points based on scalar filter." << std::endl;
2177 }
2178}
2179
2180void LiDARcloud::xyzFilter(float xmin, float xmax, float ymin, float ymax, float zmin, float zmax) {
2181
2182 xyzFilter(xmin, xmax, ymin, ymax, zmin, zmax, true);
2183}
2184
2185void LiDARcloud::xyzFilter(float xmin, float xmax, float ymin, float ymax, float zmin, float zmax, bool deleteOutside) {
2186
2187 if (xmin > xmax || ymin > ymax || zmin > zmax) {
2188 std::cout << "WARNING: at least one minimum value provided is greater than one maximum value. " << std::endl;
2189 }
2190
2191 std::size_t delete_count = 0;
2192
2193 if (deleteOutside) {
2194 for (int i = (getHitCount() - 1); i >= 0; i--) {
2195 vec3 xyz = getHitXYZ(i);
2196
2197 if (xyz.x < xmin || xyz.x > xmax || xyz.y < ymin || xyz.y > ymax || xyz.z < zmin || xyz.z > zmax) {
2198 deleteHitPoint(i);
2199 delete_count++;
2200 }
2201 }
2202 } else {
2203 for (int i = (getHitCount() - 1); i >= 0; i--) {
2204 vec3 xyz = getHitXYZ(i);
2205
2206 if (xyz.x >= xmin && xyz.x < xmax && xyz.y > ymin && xyz.y < ymax && xyz.z > zmin && xyz.z < zmax) {
2207 deleteHitPoint(i);
2208 delete_count++;
2209 }
2210 }
2211 }
2212
2213
2214 if (printmessages) {
2215 std::cout << "Removed " << delete_count << " hit points based on provided bounding box." << std::endl;
2216 }
2217}
2218
2219// bool sortcol0( const std::vector<float>& v0, const std::vector<float>& v1 ){
2220// return v0.at(0)<v1.at(0);
2221// }
2222
2223// bool sortcol1( const std::vector<float>& v0, const std::vector<float>& v1 ){
2224// return v0.at(1)<v1.at(1);
2225// }
2226
2227bool sortcol0(const std::vector<double> &v0, const std::vector<double> &v1) {
2228 return v0.at(0) < v1.at(0);
2229}
2230
2231bool sortcol1(const std::vector<double> &v0, const std::vector<double> &v1) {
2232 return v0.at(1) < v1.at(1);
2233}
2234
2235namespace {
2236
2238 double median_double(std::vector<double> &v) {
2239 const size_t n = v.size();
2240 const size_t mid = n / 2;
2241 std::nth_element(v.begin(), v.begin() + mid, v.end());
2242 const double hi = v.at(mid);
2243 if (n % 2 == 1) {
2244 return hi;
2245 }
2246 // even count: average the two central order statistics
2247 const double lo = *std::max_element(v.begin(), v.begin() + mid);
2248 return 0.5 * (lo + hi);
2249 }
2250
2252
2265 bool theilSenFit(const std::vector<double> &x, const std::vector<double> &y, double &slope, double &intercept) {
2266 const size_t n = x.size();
2267 if (n < 2) {
2268 return false;
2269 }
2270
2271 // Cap the number of point pairs considered. The full estimator is O(n^2); above this many samples
2272 // we walk pairs with a deterministic stride so the slope/intercept remain reproducible run-to-run.
2273 const size_t pair_cap = 1000;
2274 const size_t stride = (n > pair_cap) ? (n / pair_cap) : 1;
2275
2276 const size_t n_strided = n / stride + 1; // approximate number of sampled indices
2277 std::vector<double> slopes;
2278 slopes.reserve(n_strided * n_strided / 2);
2279 for (size_t i = 0; i < n; i += stride) {
2280 for (size_t j = i + 1; j < n; j += stride) {
2281 const double dx = x.at(j) - x.at(i);
2282 if (dx == 0.0) {
2283 continue;
2284 }
2285 slopes.push_back((y.at(j) - y.at(i)) / dx);
2286 }
2287 }
2288
2289 if (slopes.empty()) {
2290 return false;
2291 }
2292
2293 slope = median_double(slopes);
2294
2295 std::vector<double> intercepts;
2296 intercepts.reserve(n);
2297 for (size_t i = 0; i < n; i++) {
2298 intercepts.push_back(y.at(i) - slope * x.at(i));
2299 }
2300 intercept = median_double(intercepts);
2301
2302 return true;
2303 }
2304
2305} // namespace
2306
2307void LiDARcloud::maxPulseFilter(const char *scalar) {
2308
2309 if (printmessages) {
2310 std::cout << "Filtering point cloud by maximum " << scalar << " per pulse..." << std::flush;
2311 }
2312
2313 std::vector<std::vector<double>> timestamps;
2314 timestamps.resize(getHitCount());
2315
2316 std::size_t delete_count = 0;
2317 for (std::size_t r = 0; r < getHitCount(); r++) {
2318
2319 if (!doesHitDataExist(r, "timestamp")) {
2320 helios_runtime_error("ERROR (LiDARcloud::maxPulseFilter): Hit point " + std::to_string(r) + " does not have scalar data 'timestamp', which is required for max pulse filtering.");
2321 } else if (!doesHitDataExist(r, scalar)) {
2322 helios_runtime_error("ERROR (LiDARcloud::maxPulseFilter): Hit point " + std::to_string(r) + " does not have scalar data '" + scalar + "', which is required for max pulse filtering.");
2323 }
2324
2325 // Store the original hit index as double(r): see minPulseFilter for the precision rationale.
2326 std::vector<double> v{getHitData(r, "timestamp"), getHitData(r, scalar), double(r)};
2327
2328 timestamps.at(r) = v;
2329 }
2330
2331 std::sort(timestamps.begin(), timestamps.end(), sortcol0);
2332
2333 std::vector<std::vector<double>> isort;
2334 std::vector<int> to_delete;
2335 double time_old = timestamps.at(0).at(0);
2336 for (std::size_t r = 0; r < timestamps.size(); r++) {
2337
2338 if (timestamps.at(r).at(0) != time_old) {
2339
2340 if (isort.size() > 1) {
2341
2342 std::sort(isort.begin(), isort.end(), sortcol1);
2343
2344 for (int i = 0; i < isort.size() - 1; i++) {
2345 to_delete.push_back(int(isort.at(i).at(2)));
2346 }
2347 }
2348
2349 isort.resize(0);
2350 time_old = timestamps.at(r).at(0);
2351 }
2352
2353 isort.push_back(timestamps.at(r));
2354 }
2355
2356 std::sort(to_delete.begin(), to_delete.end());
2357
2358 for (int i = to_delete.size() - 1; i >= 0; i--) {
2359 deleteHitPoint(to_delete.at(i));
2360 }
2361
2362 if (printmessages) {
2363 std::cout << "done." << std::endl;
2364 }
2365}
2366
2367void LiDARcloud::minPulseFilter(const char *scalar) {
2368
2369 if (printmessages) {
2370 std::cout << "Filtering point cloud by minimum " << scalar << " per pulse..." << std::flush;
2371 }
2372
2373 std::vector<std::vector<double>> timestamps;
2374 timestamps.resize(getHitCount());
2375
2376 std::size_t delete_count = 0;
2377 for (std::size_t r = 0; r < getHitCount(); r++) {
2378
2379 if (!doesHitDataExist(r, "timestamp")) {
2380 helios_runtime_error("ERROR (LiDARcloud::minPulseFilter): Hit point " + std::to_string(r) + " does not have scalar data 'timestamp', which is required for min pulse filtering.");
2381 } else if (!doesHitDataExist(r, scalar)) {
2382 helios_runtime_error("ERROR (LiDARcloud::minPulseFilter): Hit point " + std::to_string(r) + " does not have scalar data '" + scalar + "', which is required for min pulse filtering.");
2383 }
2384
2385 // Store the original hit index as double(r), not float(r): the index round-trips
2386 // through this double vector and is read back via int(...). float has only 24 bits of
2387 // mantissa, so indices above 2^24 would alias and corrupt the delete mapping.
2388 std::vector<double> v{getHitData(r, "timestamp"), getHitData(r, scalar), double(r)};
2389
2390 timestamps.at(r) = v;
2391 }
2392
2393 std::sort(timestamps.begin(), timestamps.end(), sortcol0);
2394
2395 std::vector<std::vector<double>> isort;
2396 std::vector<int> to_delete;
2397 double time_old = timestamps.at(0).at(0);
2398 for (std::size_t r = 0; r < timestamps.size(); r++) {
2399
2400 if (timestamps.at(r).at(0) != time_old) {
2401
2402 if (isort.size() > 1) {
2403
2404 std::sort(isort.begin(), isort.end(), sortcol1);
2405
2406 for (int i = 1; i < isort.size(); i++) {
2407 to_delete.push_back(int(isort.at(i).at(2)));
2408 }
2409 }
2410
2411 isort.resize(0);
2412 time_old = timestamps.at(r).at(0);
2413 }
2414
2415 isort.push_back(timestamps.at(r));
2416 }
2417
2418 std::sort(to_delete.begin(), to_delete.end());
2419
2420 for (int i = to_delete.size() - 1; i >= 0; i--) {
2421 deleteHitPoint(to_delete.at(i));
2422 }
2423
2424 if (printmessages) {
2425 std::cout << "done." << std::endl;
2426 }
2427}
2428
2430
2431 if (printmessages) {
2432 std::cout << "Filtering point cloud to only first hits per pulse..." << std::flush;
2433 }
2434
2435 std::vector<float> target_index;
2436 target_index.resize(getHitCount());
2437 int min_tindex = 1;
2438
2439 for (std::size_t r = 0; r < target_index.size(); r++) {
2440
2441 if (!doesHitDataExist(r, "target_index")) {
2442 std::cerr << "failed\nERROR (LiDARcloud::firstHitFilter): Hit point " << r
2443 << " does not have scalar data "
2444 "target_index"
2445 ". No filtering will be performed."
2446 << std::endl;
2447 return;
2448 }
2449
2450 target_index.at(r) = getHitData(r, "target_index");
2451
2452 if (target_index.at(r) == 0) {
2453 min_tindex = 0;
2454 }
2455 }
2456
2457 for (int r = target_index.size() - 1; r >= 0; r--) {
2458
2459 if (target_index.at(r) != min_tindex) {
2460 deleteHitPoint(r);
2461 }
2462 }
2463
2464 if (printmessages) {
2465 std::cout << "done." << std::endl;
2466 }
2467}
2468
2470
2471 if (printmessages) {
2472 std::cout << "Filtering point cloud to only last hits per pulse..." << std::flush;
2473 }
2474
2475 std::vector<float> target_index;
2476 target_index.resize(getHitCount());
2477 int min_tindex = 1;
2478
2479 for (std::size_t r = 0; r < target_index.size(); r++) {
2480
2481 if (!doesHitDataExist(r, "target_index")) {
2482 std::cout << "failed\n";
2483 std::cerr << "ERROR (LiDARcloud::lastHitFilter): Hit point " << r
2484 << " does not have scalar data "
2485 "target_index"
2486 ". No filtering will be performed."
2487 << std::endl;
2488 return;
2489 } else if (!doesHitDataExist(r, "target_count")) {
2490 std::cout << "failed\n";
2491 std::cerr << "ERROR (LiDARcloud::lastHitFilter): Hit point " << r
2492 << " does not have scalar data "
2493 "target_count"
2494 ". No filtering will be performed."
2495 << std::endl;
2496 return;
2497 }
2498
2499 target_index.at(r) = getHitData(r, "target_index");
2500
2501 if (target_index.at(r) == 0) {
2502 min_tindex = 0;
2503 }
2504 }
2505
2506 for (int r = target_index.size() - 1; r >= 0; r--) {
2507
2508 float target_count = getHitData(r, "target_count");
2509
2510 if (target_index.at(r) == target_count - 1 + min_tindex) {
2511 deleteHitPoint(r);
2512 }
2513 }
2514
2515 if (printmessages) {
2516 std::cout << "done." << std::endl;
2517 }
2518}
2519
2520std::vector<helios::vec3> LiDARcloud::gapfillMisses_rowcolumn(uint scanID, const bool add_flags) {
2521
2522 if (printmessages) {
2523 std::cout << "Gap filling complete misses in scan " << scanID << " using row/column indices..." << std::flush;
2524 }
2525
2526 // The row/column path places filled misses from a single static scan origin and carries no per-point time from
2527 // which a moving platform's pose could be recovered, so it cannot correctly place misses for a moving-platform
2528 // scan. Moving scans carry per-pulse timestamps and are handled by the timestamp path (gapfillMisses_timestamp);
2529 // fail fast here rather than synthesize misses at the wrong origin.
2530 if (scans.at(scanID).isMoving) {
2531 helios_runtime_error("ERROR (LiDARcloud::gapfillMisses): the row/column gap-filling path does not support moving-platform scans (see addScanMoving). A moving scan should be gap-filled via its per-pulse timestamps; ensure the scan "
2532 "data carries 'timestamp' (and not 'row'/'column') so the timestamp-based path is used.");
2533 }
2534
2535 const float gap_distance = LIDAR_MISS_DISTANCE; // place gapfilled miss points at the canonical miss distance
2536 const helios::vec3 origin = getScanOrigin(scanID);
2537 const int Ntheta = (int) scans.at(scanID).Ntheta;
2538 const int Nphi = (int) scans.at(scanID).Nphi;
2539
2540 std::vector<helios::vec3> xyz_filled;
2541
2542 // ---- 1. Collect this scan's returns that carry row/column indices ---- //
2543 // Per row, accumulate the measured (column, zenith, azimuth) of each return. The measured direction comes
2544 // from getHitRaydir(), so the fit is grounded in the actual beam geometry (including tilt and sweep), not
2545 // the idealized rc2direction model.
2546 std::vector<std::vector<double>> row_cols(Ntheta); // column index per return, bucketed by row
2547 std::vector<std::vector<double>> row_zeniths(Ntheta); // measured zenith per return, bucketed by row
2548 std::vector<std::vector<double>> row_azimuths(Ntheta); // measured (unwrapped later) azimuth per return, bucketed by row
2549 std::set<std::pair<int, int>> occupied; // (row,column) cells that already contain a return
2550
2551 for (size_t r = 0; r < getHitCount(); r++) {
2552 if (getHitScanID(r) != (int) scanID) {
2553 continue;
2554 }
2555
2556 // Canonical miss flag: existing points are returns unless already flagged as misses.
2557 if (!doesHitDataExist(r, "is_miss")) {
2558 setHitData(r, "is_miss", 0.0);
2559 }
2560 if (add_flags) {
2561 setHitData(r, "gapfillMisses_code", 0.0); // 0 = original point
2562 }
2563
2564 if (!doesHitDataExist(r, "row") || !doesHitDataExist(r, "column")) {
2565 continue;
2566 }
2567
2568 const int row = (int) std::lround(getHitData(r, "row"));
2569 const int col = (int) std::lround(getHitData(r, "column"));
2570 if (row < 0 || row >= Ntheta || col < 0 || col >= Nphi) {
2571 continue; // index out of declared scan grid; ignore
2572 }
2573
2574 const helios::SphericalCoord raydir = getHitRaydir(r);
2575 row_cols.at(row).push_back((double) col);
2576 row_zeniths.at(row).push_back(raydir.zenith);
2577 row_azimuths.at(row).push_back(raydir.azimuth);
2578 occupied.insert(std::make_pair(row, col));
2579 }
2580
2581 // ---- 2. Per-row robust fit of the generative model ---- //
2582 // For each row with enough returns: zenith[row] = median(zeniths); azimuth = intercept[row] + slope[row]*column
2583 // via Theil-Sen. The azimuth samples in a row are unwrapped about their median first so the 0/2pi seam does
2584 // not corrupt the slope fit.
2585 const int min_returns_for_fit = 4; // K: rows with fewer returns are filled by cross-row extrapolation
2586 std::vector<double> zenith_lut(Ntheta, 0.0);
2587 std::vector<double> az_intercept_lut(Ntheta, 0.0);
2588 std::vector<double> az_slope_lut(Ntheta, 0.0);
2589 std::vector<bool> row_fitted(Ntheta, false);
2590
2591 for (int row = 0; row < Ntheta; row++) {
2592 if ((int) row_cols.at(row).size() < min_returns_for_fit) {
2593 continue;
2594 }
2595
2596 // robust zenith for this row
2597 std::vector<double> zeniths_copy = row_zeniths.at(row);
2598 const double zen = median_double(zeniths_copy);
2599
2600 // unwrap azimuths about a robust center so the seam does not split the samples
2601 std::vector<double> az_center_copy = row_azimuths.at(row);
2602 const double az_center = median_double(az_center_copy);
2603 std::vector<double> az_unwrapped = row_azimuths.at(row);
2604 for (double &a: az_unwrapped) {
2605 while (a - az_center > M_PI) {
2606 a -= 2.0 * M_PI;
2607 }
2608 while (a - az_center < -M_PI) {
2609 a += 2.0 * M_PI;
2610 }
2611 }
2612
2613 double slope = 0.0, intercept = 0.0;
2614 if (!theilSenFit(row_cols.at(row), az_unwrapped, slope, intercept)) {
2615 continue; // e.g. all returns in one column; treat as unfitted, extrapolate later
2616 }
2617
2618 zenith_lut.at(row) = zen;
2619 az_slope_lut.at(row) = slope;
2620 az_intercept_lut.at(row) = intercept;
2621 row_fitted.at(row) = true;
2622 }
2623
2624 // ---- 3. Extrapolate the per-row model across the row axis to cover sparse/empty rows ---- //
2625 // Collect the directly-fitted rows and robustly fit zenith-vs-row and intercept-vs-row (Theil-Sen). The
2626 // azimuth slope (sweep rate) is approximately constant across rows, so its robust median over fitted rows
2627 // is used. Evaluating these across-row fits at every row index defines a complete model over the whole grid,
2628 // including blank near-zenith rows (extrapolation).
2629 std::vector<double> fitted_row_idx, fitted_zenith, fitted_intercept, fitted_slope;
2630 for (int row = 0; row < Ntheta; row++) {
2631 if (row_fitted.at(row)) {
2632 fitted_row_idx.push_back((double) row);
2633 fitted_zenith.push_back(zenith_lut.at(row));
2634 fitted_intercept.push_back(az_intercept_lut.at(row));
2635 fitted_slope.push_back(az_slope_lut.at(row));
2636 }
2637 }
2638
2639 if ((int) fitted_row_idx.size() < 2) {
2640 helios_runtime_error("ERROR (LiDARcloud::gapfillMisses): scan " + std::to_string(scanID) + " has too few populated scan rows (" + std::to_string(fitted_row_idx.size()) +
2641 ") to robustly reconstruct the row/column scan-grid model. At least 2 rows with >= " + std::to_string(min_returns_for_fit) + " returns are required.");
2642 }
2643
2644 double zen_slope = 0.0, zen_intercept = 0.0;
2645 double int_slope = 0.0, int_intercept = 0.0;
2646 const bool zen_ok = theilSenFit(fitted_row_idx, fitted_zenith, zen_slope, zen_intercept);
2647 const bool int_ok = theilSenFit(fitted_row_idx, fitted_intercept, int_slope, int_intercept);
2648 const double median_slope = median_double(fitted_slope);
2649
2650 for (int row = 0; row < Ntheta; row++) {
2651 if (row_fitted.at(row)) {
2652 continue;
2653 }
2654 zenith_lut.at(row) = zen_ok ? (zen_intercept + zen_slope * (double) row) : fitted_zenith.front();
2655 az_intercept_lut.at(row) = int_ok ? (int_intercept + int_slope * (double) row) : fitted_intercept.front();
2656 az_slope_lut.at(row) = median_slope;
2657 }
2658
2659 // ---- 4. Emit a miss for every empty grid cell along its reconstructed direction ---- //
2660 uint npoints_interior = 0;
2661 uint npoints_extrapolated = 0;
2662 for (int row = 0; row < Ntheta; row++) {
2663 for (int col = 0; col < Nphi; col++) {
2664
2665 if (occupied.find(std::make_pair(row, col)) != occupied.end()) {
2666 continue; // cell already has a return
2667 }
2668
2669 const double zenith = zenith_lut.at(row);
2670 double azimuth = az_intercept_lut.at(row) + az_slope_lut.at(row) * (double) col;
2671 // wrap azimuth to [0, 2pi)
2672 azimuth = std::fmod(azimuth, 2.0 * M_PI);
2673 if (azimuth < 0.0) {
2674 azimuth += 2.0 * M_PI;
2675 }
2676
2677 const helios::SphericalCoord spherical(gap_distance, 0.5 * M_PI - (float) zenith, (float) azimuth);
2678 const helios::vec3 xyz = origin + helios::sphere2cart(spherical);
2679 xyz_filled.push_back(xyz);
2680
2681 std::map<std::string, double> data;
2682 data.insert(std::make_pair("is_miss", 1.0)); // gapfilled points are misses (transmitted beams)
2683 data.insert(std::make_pair("row", (double) row));
2684 data.insert(std::make_pair("column", (double) col));
2685 data.insert(std::make_pair("nRaysHit", 0.0)); // a miss: zero sub-rays of the pulse returned a hit
2686 if (add_flags) {
2687 // 1 = interior gapfill (row had its own direct fit); 4 = extrapolated row (model came from cross-row fit)
2688 data.insert(std::make_pair("gapfillMisses_code", row_fitted.at(row) ? 1.0 : 4.0));
2689 }
2690
2691 addHitPoint(scanID, xyz, spherical, data);
2692 if (row_fitted.at(row)) {
2693 npoints_interior++;
2694 } else {
2695 npoints_extrapolated++;
2696 }
2697 }
2698 }
2699
2700 if (printmessages) {
2701 std::cout << "filled " << xyz_filled.size() << " points (" << npoints_interior << " interior, " << npoints_extrapolated << " extrapolated-row)." << std::endl;
2702 }
2703
2704 return xyz_filled;
2705}
2706
2707std::vector<helios::vec3> LiDARcloud::gapfillMisses() {
2708 std::vector<helios::vec3> xyz_filled;
2709 for (uint scanID = 0; scanID < getScanCount(); scanID++) {
2710 std::vector<helios::vec3> filled_this_scan = gapfillMisses(scanID, false, false);
2711 xyz_filled.insert(xyz_filled.end(), filled_this_scan.begin(), filled_this_scan.end());
2712 }
2713 return xyz_filled;
2714}
2715
2716std::vector<helios::vec3> LiDARcloud::gapfillMisses(uint scanID) {
2717 return gapfillMisses(scanID, false, false);
2718}
2719
2720std::vector<helios::vec3> LiDARcloud::gapfillMisses(uint scanID, const bool gapfill_grid_only, const bool add_flags) {
2721
2722 // Validate scanID
2723 if (scanID >= getScanCount()) {
2724 helios_runtime_error("ERROR (LiDARcloud::gapfillMisses): Invalid scanID " + std::to_string(scanID) + ". Only " + std::to_string(getScanCount()) + " scans exist.");
2725 }
2726
2727 // Auto-detect which reconstruction path to use based on the data available on this scan's returns.
2728 // The row/column path reconstructs miss directions from the native scan-grid indices and is robust to
2729 // scanner tilt, angular noise, and azimuth sweep; the timestamp path reconstructs the grid from per-hit
2730 // timestamps. When both are available we prefer row/column. When neither is available we fail fast, since
2731 // there is no way to reconstruct miss directions.
2732 bool has_rowcolumn = false;
2733 bool has_timestamp = false;
2734 size_t scan_hit_count = 0;
2735 for (size_t r = 0; r < getHitCount(); r++) {
2736 if (getHitScanID(r) != (int) scanID) {
2737 continue;
2738 }
2739 scan_hit_count++;
2740 if (doesHitDataExist(r, "row") && doesHitDataExist(r, "column")) {
2741 has_rowcolumn = true;
2742 }
2743 if (doesHitDataExist(r, "timestamp")) {
2744 has_timestamp = true;
2745 }
2746 if (has_rowcolumn && has_timestamp) {
2747 break; // both present; row/column will be preferred
2748 }
2749 }
2750
2751 // A scan with no returns at all (e.g. rays that all missed empty geometry) has nothing to reconstruct;
2752 // return gracefully. The fail-fast below applies only when returns exist but carry neither timestamp nor
2753 // row/column indices, which is a genuine data-format problem.
2754 if (scan_hit_count == 0) {
2755 if (printmessages) {
2756 std::cout << "Gap filling complete misses in scan " << scanID << "...scan has no hits. Skipping gap fill." << std::endl;
2757 }
2758 return {};
2759 }
2760
2761 if (has_rowcolumn) {
2762 return gapfillMisses_rowcolumn(scanID, add_flags);
2763 } else if (has_timestamp) {
2764 return gapfillMisses_timestamp(scanID, gapfill_grid_only, add_flags);
2765 } else {
2766 helios_runtime_error("ERROR (LiDARcloud::gapfillMisses): scan " + std::to_string(scanID) +
2767 " has neither 'timestamp' nor 'row'/'column' hit data; cannot reconstruct miss directions. "
2768 "Provide either per-hit timestamps or scan row/column indices.");
2769 return {}; // unreachable; silences compiler warning
2770 }
2771}
2772
2773std::vector<helios::vec3> LiDARcloud::gapfillMisses_timestamp(uint scanID, const bool gapfill_grid_only, const bool add_flags) {
2774
2775 if (printmessages) {
2776 std::cout << "Gap filling complete misses in scan " << scanID << "..." << std::flush;
2777 }
2778
2779 float gap_distance = LIDAR_MISS_DISTANCE; // place gapfilled miss points at the canonical miss distance
2780
2781 helios::vec3 origin = getScanOrigin(scanID);
2782 std::vector<helios::vec3> xyz_filled;
2783
2784 // For a moving-platform scan each filled miss must be emitted from the platform pose at that miss's (interpolated)
2785 // timestamp, not from the single static origin. originAtTime() returns the per-pulse origin for moving scans and the
2786 // static scan origin otherwise; for moving scans the synthesized miss also stores its own origin_x/y/z so it carries
2787 // correct beam geometry into the leaf-area inversion (which reads getHitOrigin()).
2788 const ScanMetadata &gapfill_scan = scans.at(scanID);
2789 const bool gapfill_is_moving = gapfill_scan.isMoving;
2790 auto originAtTime = [&](double timestep) -> helios::vec3 {
2791 if (!gapfill_is_moving) {
2792 return origin;
2793 }
2794 helios::vec3 pos;
2795 helios::vec4 quat;
2796 gapfill_scan.poseAt(timestep, pos, quat);
2797 return pos + quat_rotate(quat, gapfill_scan.lever_arm);
2798 };
2799
2800 // Populating a hit table for each scan:
2801 // Column 0 - hit index; Column 1 - timestamp; Column 2 - ray zenith; Column 3 - ray azimuth
2802 std::vector<std::vector<double>> hit_table;
2803 for (size_t r = 0; r < getHitCount(); r++) {
2804 if (getHitScanID(r) == scanID) {
2805
2806 // Canonical miss flag: existing points are returns unless already flagged
2807 // as misses (e.g. imported misses from a miss-retaining format).
2808 if (!doesHitDataExist(r, "is_miss")) {
2809 setHitData(r, "is_miss", 0.0);
2810 }
2811
2812 if (add_flags) {
2813 // gapfillMisses_code = 0: original points
2814 setHitData(r, "gapfillMisses_code", 0.0);
2815 }
2816
2818
2819 if (!doesHitDataExist(r, "timestamp")) {
2820 helios_runtime_error("ERROR (LiDARcloud::gapfillMisses): Hit " + std::to_string(r) + " is missing required 'timestamp' data. Cannot perform gap filling.");
2821 }
2822
2823 double timestamp = getHitData(r, "timestamp");
2824 std::vector<double> data;
2825 data.resize(4);
2826 data.at(0) = float(r);
2827 data.at(1) = timestamp;
2828 data.at(2) = raydir.zenith;
2829 data.at(3) = raydir.azimuth;
2830 hit_table.push_back(data);
2831 }
2832 }
2833
2834 // Check for empty scan
2835 if (hit_table.empty()) {
2836 if (printmessages) {
2837 std::cout << "scan has no hits. Skipping gap fill." << std::endl;
2838 }
2839 return xyz_filled; // Return empty vector
2840 }
2841
2842 // sorting, initial dt and dtheta calculations, and determining minimum target index in the scan
2843
2844 // sort the hit table by column 1 (timestamp)
2845 std::sort(hit_table.begin(), hit_table.end(), sortcol1);
2846
2847 int min_tindex = 1;
2848 for (size_t r = 0; r < hit_table.size(); r++) {
2849
2850 // this is to figure out if target indexing uses 0 or 1 offset
2851 if (min_tindex == 1 && doesHitDataExist(hit_table.at(r).at(0), "target_index") && doesHitDataExist(hit_table.at(r).at(0), "target_count")) {
2852 if (getHitData(hit_table.at(r).at(0), "target_index") == 0) {
2853 min_tindex = 0;
2854 }
2855 }
2856 }
2857
2858 // getting rid of points with target index greater than the minimum
2859
2860 int ndup_target = 0;
2861 // create new array without duplicate timestamps (keep only first hits). Iterate the full
2862 // table - including the last element - so the target_index filter is applied uniformly; a
2863 // previous version excluded the last element from the loop and then appended it unfiltered,
2864 // which could admit a non-first-hit return.
2865 std::vector<std::vector<double>> hit_table_semiclean;
2866 for (size_t r = 0; r < hit_table.size(); r++) {
2867
2868 // only consider first hits
2869 if (doesHitDataExist(hit_table.at(r).at(0), "target_index") && doesHitDataExist(hit_table.at(r).at(0), "target_count")) {
2870 if (getHitData(hit_table.at(r).at(0), "target_index") > min_tindex) {
2871 ndup_target++;
2872 continue;
2873 }
2874 }
2875
2876 hit_table_semiclean.push_back(hit_table.at(r));
2877 }
2878
2879 // re-calculating dt
2880
2881 std::vector<double> dt_semiclean;
2882 dt_semiclean.resize(hit_table_semiclean.size(), 0.0);
2883 for (size_t r = 0; r + 1 < hit_table_semiclean.size(); r++) {
2884
2885 dt_semiclean.at(r) = hit_table_semiclean.at(r + 1).at(1) - hit_table_semiclean.at(r).at(1);
2886 // set the hit index of the new array
2887 hit_table_semiclean.at(r).at(0) = r;
2888 }
2889
2890 // checking for duplicate timestamps in the remaining data
2891
2892 int ndup = 0;
2893 // create new array without duplicate timestamps
2894 std::vector<std::vector<double>> hit_table_clean;
2895 for (size_t r = 0; r + 1 < hit_table_semiclean.size(); r++) {
2896
2897 // if there are still rows with duplicate timestamps, it probably means there is no "target_index" column, but multiple hits per timestamp are still included
2898 // proceed using this assumption, just get rid of the rows where dt = 0 for simplicity (last hits probably are what remain).
2899 if (dt_semiclean.at(r) == 0) {
2900 ndup++;
2901 continue;
2902 }
2903
2904 hit_table_clean.push_back(hit_table_semiclean.at(r));
2905 }
2906
2907 // The 2D-grid reconstruction below requires at least two cleaned hits to
2908 // compute dt/dtheta between consecutive beams. Clouds loaded from ASCII files
2909 // without row/column indices can collapse to zero or one cleaned hit after the
2910 // duplicate-timestamp filter above; without this guard the subsequent
2911 // `size() - 1` style loops underflow and read out of bounds.
2912 if (hit_table_clean.size() < 2) {
2913 if (printmessages) {
2914 std::cout << "insufficient hits to reconstruct scan grid. Skipping gap fill." << std::endl;
2915 }
2916 return xyz_filled; // Return empty vector
2917 }
2918
2919 // recalculate dt and dtheta with only one hit per beam
2920 // and calculate the minimum dt value
2921 std::vector<double> dt_clean;
2922 std::vector<float> dtheta_clean;
2923 dt_clean.resize(hit_table_clean.size(), 0.0);
2924 dtheta_clean.resize(hit_table_clean.size(), 0.f);
2925
2926 double dt_clean_min = 1e6;
2927 for (size_t r = 0; r + 1 < hit_table_clean.size(); r++) {
2928
2929 dt_clean.at(r) = hit_table_clean.at(r + 1).at(1) - hit_table_clean.at(r).at(1);
2930 dtheta_clean.at(r) = hit_table_clean.at(r + 1).at(2) - hit_table_clean.at(r).at(2);
2931 // set the hit index of the new array
2932 hit_table_clean.at(r).at(0) = r;
2933
2934 if (dt_clean.at(r) < dt_clean_min) {
2935 dt_clean_min = dt_clean.at(r);
2936 }
2937 }
2938
2939 // configuration of 2D map
2940 // reconfigure hit table into 2D (theta,phi) map
2941 std::vector<std::vector<std::vector<double>>> hit_table2D;
2942
2943 int column = 0;
2944 hit_table2D.resize(1);
2945 for (size_t r = 0; r + 1 < hit_table_clean.size(); r++) {
2946
2947 hit_table2D.at(column).push_back(hit_table_clean.at(r));
2948 // for small scans (like the rectangle test case, this needs to change to < 0 or some smaller angle (that is larger than noise))
2949 // if( dtheta_clean.at(r) < 0 ){
2950 // for normal scans, this threshold allows for 10 degrees drops in theta within a given sweep as noise. This can be adjusted as appropriate.
2951 if (dtheta_clean.at(r) < -0.1745329f) {
2952 column++;
2953 hit_table2D.resize(column + 1);
2954 }
2955 }
2956
2957 // calculate average dt and dtheta for subsequent points
2958
2959 // calculate average dt
2960 float dt_avg = 0;
2961 int dt_sum = 0;
2962
2963 // calculate the average dtheta to use for extrapolation
2964 float dtheta_avg = 0;
2965 int dtheta_sum = 0;
2966
2967 for (int j = 0; j < hit_table2D.size(); j++) {
2968 for (int i = 0; i < hit_table2D.at(j).size(); i++) {
2969 int r = int(hit_table2D.at(j).at(i).at(0));
2970 if (dt_clean.at(r) >= dt_clean_min && dt_clean.at(r) < 1.5 * dt_clean_min) {
2971 dt_avg += dt_clean.at(r);
2972 dt_sum++;
2973
2974 // calculate the average dtheta to use for extrapolation
2975 dtheta_avg += dtheta_clean.at(r);
2976 dtheta_sum++;
2977 }
2978 }
2979 }
2980
2981 if (dt_sum == 0 || dtheta_sum == 0) {
2982 if (printmessages) {
2983 std::cout << "insufficient valid hit pairs. Skipping gap fill." << std::endl;
2984 }
2985 return xyz_filled; // Return empty vector
2986 }
2987
2988 dt_avg = dt_avg / float(dt_sum);
2989 // Calculate the average dtheta to use for extrapolation
2990 dtheta_avg = dtheta_avg / float(dtheta_sum);
2991
2992 // dt_avg and dt_clean_min are used below as divisors and gap-spacing thresholds.
2993 // A degenerate scan grid (e.g. an ASCII cloud whose angular sampling can't be
2994 // reconstructed) can leave them zero, negative, or non-finite, which makes the
2995 // Ngap computation below blow up. Bail out rather than fill garbage.
2996 if (!std::isfinite(dt_avg) || dt_avg <= 0.f || !std::isfinite(dt_clean_min) || dt_clean_min <= 0.0) {
2997 if (printmessages) {
2998 std::cout << "degenerate timestamp spacing. Skipping gap fill." << std::endl;
2999 }
3000 return xyz_filled; // Return empty vector
3001 }
3002
3003 // Get theta range for grid position calculations (needed early for filled_positions)
3004 helios::vec2 theta_range = getScanRangeTheta(scanID);
3005
3006 // Track which grid positions have been filled (to avoid duplicates)
3007 std::set<std::pair<int, int>> filled_positions;
3008
3009 // Pre-populate with existing hit positions using proper direction2rc conversion
3010 for (size_t r = 0; r < getHitCount(); r++) {
3011 if (getHitScanID(r) == scanID) {
3013 helios::int2 rc = scans.at(scanID).direction2rc(raydir);
3014 filled_positions.insert(std::make_pair(rc.x, rc.y));
3015 }
3016 }
3017
3018 // identify gaps and fill
3019 for (int j = 0; j < hit_table2D.size(); j++) {
3020
3021 if (hit_table2D.at(j).size() > 0) {
3022 for (size_t i = 0; i + 1 < hit_table2D.at(j).size(); i++) {
3023
3024 double dt = hit_table2D.at(j).at(i + 1).at(1) - hit_table2D.at(j).at(i).at(1);
3025
3026 if (dt > 1.5f * dt_clean_min) { // missing hit(s)
3027
3028 // calculate number of missing hits
3029 int Ngap = round(dt / dt_avg) - 1;
3030
3031 // A gap can never span more beams than the scan has rows. Cap Ngap
3032 // to guard against a runaway fill loop if the reconstructed grid
3033 // spacing is much finer than the real one.
3034 int Ngap_max = (int) scans.at(scanID).Ntheta;
3035 if (Ngap > Ngap_max) {
3036 Ngap = Ngap_max;
3037 }
3038
3039 // fill missing points
3040 for (int k = 1; k <= Ngap; k++) {
3041
3042 float timestep = hit_table2D.at(j).at(i).at(1) + dt_avg * float(k);
3043
3044 // interpolate theta and phi
3045 float theta = hit_table2D.at(j).at(i).at(2) + (hit_table2D.at(j).at(i + 1).at(2) - hit_table2D.at(j).at(i).at(2)) * float(k) / float(Ngap + 1);
3046 float phi = hit_table2D.at(j).at(i).at(3) + (hit_table2D.at(j).at(i + 1).at(3) - hit_table2D.at(j).at(i).at(3)) * float(k) / float(Ngap + 1);
3047 // Wrap phi to [0, 2π] range
3048 if (phi > 2.f * M_PI) {
3049 phi = phi - 2.f * M_PI;
3050 } else if (phi < 0.f) {
3051 phi = phi + 2.f * M_PI;
3052 }
3053
3054 // Convert to grid indices using proper direction2rc method
3055 helios::SphericalCoord dir_to_check(gap_distance, 0.5 * M_PI - theta, phi);
3056 helios::int2 rc = scans.at(scanID).direction2rc(dir_to_check);
3057
3058 // Skip grid positions outside the scan's row/column range
3059 // (mirrors the bounds check in the extrapolation passes below).
3060 if (rc.x < 0 || rc.x >= (int) scans.at(scanID).Ntheta || rc.y < 0 || rc.y >= (int) scans.at(scanID).Nphi) {
3061 continue;
3062 }
3063
3064 auto grid_key = std::make_pair(rc.x, rc.y);
3065
3066 // Only add if this grid position hasn't been filled yet
3067 if (filled_positions.find(grid_key) == filled_positions.end()) {
3068
3069 helios::SphericalCoord spherical(gap_distance, 0.5 * M_PI - theta, phi);
3070 helios::vec3 fill_origin = originAtTime(timestep);
3071 helios::vec3 xyz = fill_origin + helios::sphere2cart(spherical);
3072 xyz_filled.push_back(xyz);
3073
3074 std::map<std::string, double> data;
3075 data.insert(std::pair<std::string, double>("timestamp", timestep));
3076 data.insert(std::pair<std::string, double>("target_index", min_tindex));
3077 data.insert(std::pair<std::string, double>("nRaysHit", 0.0)); // a miss: zero sub-rays of the pulse returned a hit
3078 data.insert(std::pair<std::string, double>("is_miss", 1.0)); // gapfilled points are misses (transmitted beams)
3079 if (gapfill_is_moving) {
3080 // Store the per-pulse emission origin so the synthesized miss carries correct beam geometry (getHitOrigin).
3081 data.insert(std::pair<std::string, double>("origin_x", fill_origin.x));
3082 data.insert(std::pair<std::string, double>("origin_y", fill_origin.y));
3083 data.insert(std::pair<std::string, double>("origin_z", fill_origin.z));
3084 }
3085 if (add_flags) {
3086 // gapfillMisses_code = 1: gapfilled points
3087 data.insert(std::pair<std::string, double>("gapfillMisses_code", 1.0));
3088 }
3089 addHitPoint(scanID, xyz, spherical, data);
3090 filled_positions.insert(grid_key); // Mark as filled
3091 }
3092 }
3093 }
3094 }
3095 }
3096 }
3097 uint npointsfilled = xyz_filled.size();
3098
3099 // Get actual grid spacing for proper edge extrapolation (theta_range already declared above)
3100 float grid_dtheta = (theta_range.y - theta_range.x) / float(scans.at(scanID).Ntheta - 1);
3101 float grid_dphi = (scans.at(scanID).phiMax - scans.at(scanID).phiMin) / float(scans.at(scanID).Nphi - 1);
3102
3103 if (gapfill_grid_only == true) {
3104 // instead of extrapolating to the angle ranges given in the xml file, we can extrapolate to the angle range of the voxel grid to save time.
3105 // to do this we loop through the vertices of the voxel grid.
3106 std::vector<helios::vec3> grid_vertices;
3107 helios::vec3 boxmin, boxmax;
3108 getGridBoundingBox(boxmin, boxmax); // axis aligned bounding box of all grid cells
3109 grid_vertices.push_back(boxmin);
3110 grid_vertices.push_back(boxmax);
3111 grid_vertices.push_back(helios::make_vec3(boxmin.x, boxmin.y, boxmax.z));
3112 grid_vertices.push_back(helios::make_vec3(boxmax.x, boxmax.y, boxmin.z));
3113 grid_vertices.push_back(helios::make_vec3(boxmin.x, boxmax.y, boxmin.z));
3114 grid_vertices.push_back(helios::make_vec3(boxmin.x, boxmax.y, boxmax.z));
3115 grid_vertices.push_back(helios::make_vec3(boxmax.x, boxmin.y, boxmin.z));
3116 grid_vertices.push_back(helios::make_vec3(boxmax.x, boxmin.y, boxmax.z));
3117
3118 float max_theta = 0;
3119 float min_theta = M_PI;
3120 float max_phi = 0;
3121 float min_phi = 2 * M_PI;
3122 for (uint gg = 0; gg < grid_vertices.size(); gg++) {
3123 helios::vec3 direction_cart = grid_vertices.at(gg) - getScanOrigin(scanID);
3124 helios::SphericalCoord sc = cart2sphere(direction_cart);
3125 if (sc.azimuth < min_phi) {
3126 min_phi = sc.azimuth;
3127 }
3128
3129 if (sc.azimuth > max_phi) {
3130 max_phi = sc.azimuth;
3131 }
3132
3133 if (sc.zenith < min_theta) {
3134 min_theta = sc.zenith;
3135 }
3136
3137 if (sc.zenith > max_theta) {
3138 max_theta = sc.zenith;
3139 }
3140 }
3141
3142 // if the min or max theta is outside of the values provided in xml, use the xml values
3143 if (min_theta < theta_range.x) {
3144 min_theta = theta_range.x;
3145 }
3146
3147 if (max_theta > theta_range.y) {
3148 max_theta = theta_range.y;
3149 }
3150
3151 theta_range = helios::make_vec2(min_theta, max_theta);
3152 }
3153
3154 // extrapolate missing points
3155 for (int j = 0; j < hit_table2D.size(); j++) {
3156
3157 if (hit_table2D.at(j).size() > 0) {
3158
3159 // upward edge points
3160 if (hit_table2D.at(j).front().at(2) > theta_range.x) {
3161
3162 float dtheta = dtheta_avg;
3163 float theta = hit_table2D.at(j).at(0).at(2) - dtheta;
3164 // just use the last value of phi in the sweep
3165 float phi = hit_table2D.at(j).at(0).at(3);
3166 float timestep = hit_table2D.at(j).at(0).at(1) - dt_avg;
3167 if (dtheta == 0) {
3168 continue;
3169 }
3170
3171 while (theta > theta_range.x) {
3172
3173 // Convert to grid indices using proper direction2rc method
3174 helios::SphericalCoord dir_to_check(gap_distance, 0.5 * M_PI - theta, phi);
3175 helios::int2 rc = scans.at(scanID).direction2rc(dir_to_check);
3176
3177 // Only add if this grid position is actually empty (avoid duplicates)
3178 if (rc.x >= 0 && rc.x < (int) scans.at(scanID).Ntheta && rc.y >= 0 && rc.y < (int) scans.at(scanID).Nphi) {
3179
3180 // Check if this grid position has already been filled (avoid duplicates)
3181 auto grid_key = std::make_pair(rc.x, rc.y);
3182 if (filled_positions.find(grid_key) == filled_positions.end()) {
3183
3184 helios::SphericalCoord spherical(gap_distance, 0.5 * M_PI - theta, phi);
3185 helios::vec3 fill_origin = originAtTime(timestep);
3186 helios::vec3 xyz = fill_origin + helios::sphere2cart(spherical);
3187 xyz_filled.push_back(xyz);
3188
3189 std::map<std::string, double> data;
3190 data.insert(std::pair<std::string, double>("timestamp", timestep));
3191 data.insert(std::pair<std::string, double>("target_index", min_tindex));
3192 data.insert(std::pair<std::string, double>("nRaysHit", 0.0)); // a miss: zero sub-rays of the pulse returned a hit
3193 data.insert(std::pair<std::string, double>("is_miss", 1.0)); // gapfilled points are misses (transmitted beams)
3194 if (gapfill_is_moving) {
3195 data.insert(std::pair<std::string, double>("origin_x", fill_origin.x));
3196 data.insert(std::pair<std::string, double>("origin_y", fill_origin.y));
3197 data.insert(std::pair<std::string, double>("origin_z", fill_origin.z));
3198 }
3199 if (add_flags) {
3200 // gapfillMisses_code = 3: upward edge points
3201 data.insert(std::pair<std::string, double>("gapfillMisses_code", 3.0));
3202 }
3203
3204 addHitPoint(scanID, xyz, spherical, data);
3205 filled_positions.insert(grid_key); // Mark this position as filled
3206 }
3207 }
3208
3209 theta = theta - dtheta;
3210 timestep = timestep - dt_avg;
3211 }
3212 }
3213
3214 // downward edge points
3215 if (hit_table2D.at(j).back().at(2) < theta_range.y) {
3216
3217 int sz = hit_table2D.at(j).size();
3218 // same concept as above for downward edge points
3219 float dtheta = dtheta_avg;
3220 float theta = hit_table2D.at(j).at(sz - 1).at(2) + dtheta;
3221 float phi = hit_table2D.at(j).at(sz - 1).at(3);
3222 float timestep = hit_table2D.at(j).at(sz - 1).at(1) + dt_avg;
3223 while (theta < theta_range.y) {
3224
3225 // Convert to grid indices using proper direction2rc method
3226 helios::SphericalCoord dir_to_check(gap_distance, 0.5 * M_PI - theta, phi);
3227 helios::int2 rc = scans.at(scanID).direction2rc(dir_to_check);
3228
3229 // Only add if this grid position is actually empty (avoid duplicates)
3230 if (rc.x >= 0 && rc.x < (int) scans.at(scanID).Ntheta && rc.y >= 0 && rc.y < (int) scans.at(scanID).Nphi) {
3231
3232 // Check if this grid position has already been filled (avoid duplicates)
3233 auto grid_key = std::make_pair(rc.x, rc.y);
3234 if (filled_positions.find(grid_key) == filled_positions.end()) {
3235
3236 helios::SphericalCoord spherical(gap_distance, 0.5 * M_PI - theta, phi);
3237 helios::vec3 fill_origin = originAtTime(timestep);
3238 helios::vec3 xyz = fill_origin + helios::sphere2cart(spherical);
3239 xyz_filled.push_back(xyz);
3240
3241 std::map<std::string, double> data;
3242 data.insert(std::pair<std::string, double>("timestamp", timestep));
3243 data.insert(std::pair<std::string, double>("target_index", min_tindex));
3244 data.insert(std::pair<std::string, double>("nRaysHit", 0.0)); // a miss: zero sub-rays of the pulse returned a hit
3245 data.insert(std::pair<std::string, double>("is_miss", 1.0)); // gapfilled points are misses (transmitted beams)
3246 if (gapfill_is_moving) {
3247 data.insert(std::pair<std::string, double>("origin_x", fill_origin.x));
3248 data.insert(std::pair<std::string, double>("origin_y", fill_origin.y));
3249 data.insert(std::pair<std::string, double>("origin_z", fill_origin.z));
3250 }
3251 if (add_flags) {
3252 // gapfillMisses_code = 2: downward edge points
3253 data.insert(std::pair<std::string, double>("gapfillMisses_code", 2.0));
3254 }
3255
3256 addHitPoint(scanID, xyz, spherical, data);
3257 filled_positions.insert(grid_key); // Mark this position as filled
3258 }
3259 }
3260
3261 theta = theta + dtheta;
3262 timestep = timestep + dt_avg;
3263 }
3264 }
3265 }
3266 }
3267
3268 uint npointsextrapolated = xyz_filled.size() - npointsfilled;
3269
3270 if (printmessages) {
3271 std::cout << "filled " << xyz_filled.size() << " points (" << npointsfilled << " interior, " << npointsextrapolated << " edge)." << std::endl;
3272 std::cout << " Processed " << hit_table2D.size() << " scan columns" << std::endl;
3273 }
3274 return xyz_filled;
3275}
3276
3277void LiDARcloud::triangulateHitPoints(float Lmax, float max_aspect_ratio) {
3278
3279 // Triangulation projects hits into the scan's (zenith, azimuth) grid space from a single origin. A moving-platform
3280 // scan has no fixed theta-phi grid (each pulse fires from a different pose), so the projection is meaningless and
3281 // would produce garbage triangles. Fail fast. For leaf-area inversion of a moving scan use the calculateLeafArea
3282 // overload that takes a supplied G(theta), which does not require triangulation.
3283 if (anyScanMoving()) {
3284 helios_runtime_error("ERROR (LiDARcloud::triangulateHitPoints): triangulation is not supported for moving-platform scans (see addScanMoving), which have no fixed theta-phi scan grid to triangulate. For leaf-area inversion of a "
3285 "moving scan, call the calculateLeafArea overload that takes a G(theta) argument (it does not require triangulation).");
3286 }
3287
3288 if (printmessages && getScanCount() == 0) {
3289 cout << "WARNING (triangulateHitPoints): No scans have been added to the point cloud. Skipping triangulation..." << endl;
3290 return;
3291 } else if (printmessages && getHitCount() == 0) {
3292 cout << "WARNING (triangulateHitPoints): No hit points have been added to the point cloud. Skipping triangulation..." << endl;
3293 return;
3294 }
3295
3296 if (!hitgridcellcomputed) {
3298 }
3299
3300 int Ntriangles = 0;
3301
3302 // Reset triangulation diagnostics for this run (see getTriangulation* getters).
3303 triangulation_candidate_count = 0;
3304 triangulation_dropped_lmax = 0;
3305 triangulation_dropped_aspect = 0;
3306 triangulation_dropped_degenerate = 0;
3307
3308 // For multi-return data, calculate adaptive separation ratio threshold
3309 bool use_adaptive_threshold = isMultiReturnData();
3310 float adaptive_sep_threshold = 0.0f;
3311
3312 if (use_adaptive_threshold) {
3313 if (printmessages) {
3314 std::cout << "Multi-return data detected - calculating adaptive separation ratio threshold..." << std::endl;
3315 }
3316
3317 // First pass: collect separation ratios from all potential triangles
3318 std::vector<float> all_separation_ratios;
3319
3320 for (uint s = 0; s < getScanCount(); s++) {
3321 std::vector<int> Delaunay_inds_pass1;
3322 std::vector<Shx> pts_pass1, pts_copy_pass1;
3323 int count_pass1 = 0;
3324
3325 for (int r = 0; r < getHitCount(); r++) {
3326 if (getHitScanID(r) == s && getHitGridCell(r) >= 0) {
3327 // Auto-filter first returns for multi-return data
3328 if (use_adaptive_threshold) {
3329 if (doesHitDataExist(r, "target_index") && getHitData(r, "target_index") != 0.0) {
3330 continue; // Skip non-first returns
3331 }
3332 }
3333
3334 helios::SphericalCoord direction = getHitRaydir(r);
3335 helios::vec3 direction_cart = getHitXYZ(r) - getScanOrigin(s);
3336 direction = cart2sphere(direction_cart);
3337
3338 Shx pt;
3339 pt.id = count_pass1;
3340 pt.r = direction.zenith;
3341 pt.c = direction.azimuth;
3342 pts_pass1.push_back(pt);
3343 Delaunay_inds_pass1.push_back(r);
3344 count_pass1++;
3345 }
3346 }
3347
3348 if (pts_pass1.size() == 0)
3349 continue;
3350
3351 // Handle coordinate wrapping
3352 float h[2] = {0, 0};
3353 for (int r = 0; r < pts_pass1.size(); r++) {
3354 if (pts_pass1.at(r).c < 0.5 * M_PI)
3355 h[0] += 1.f;
3356 else if (pts_pass1.at(r).c > 1.5 * M_PI)
3357 h[1] += 1.f;
3358 }
3359 h[0] /= float(pts_pass1.size());
3360 h[1] /= float(pts_pass1.size());
3361 if (h[0] + h[1] > 0.4) {
3362 for (int r = 0; r < pts_pass1.size(); r++) {
3363 pts_pass1.at(r).c += M_PI;
3364 if (pts_pass1.at(r).c > 2.f * M_PI)
3365 pts_pass1.at(r).c -= 2.f * M_PI;
3366 }
3367 }
3368
3369 std::vector<int> dupes_pass1;
3370 de_duplicate(pts_pass1, dupes_pass1);
3371
3372 std::vector<Triad> triads_pass1;
3373 // CDT uses robust geometric predicates, so the s_hull-era
3374 // rotate-and-retry recovery is no longer needed; a failure here is
3375 // deterministic and the scan is skipped.
3376 int success = triangulate_CDT(pts_pass1, triads_pass1);
3377
3378 if (success != 1)
3379 continue;
3380
3381 // Collect separation ratios (pre-filter by edge length)
3382 for (int t = 0; t < triads_pass1.size(); t++) {
3383 int ID0 = Delaunay_inds_pass1.at(triads_pass1.at(t).a);
3384 int ID1 = Delaunay_inds_pass1.at(triads_pass1.at(t).b);
3385 int ID2 = Delaunay_inds_pass1.at(triads_pass1.at(t).c);
3386
3387 helios::vec3 v0 = getHitXYZ(ID0);
3388 helios::vec3 v1 = getHitXYZ(ID1);
3389 helios::vec3 v2 = getHitXYZ(ID2);
3393
3394 float L0 = (v0 - v1).magnitude();
3395 float L1 = (v0 - v2).magnitude();
3396 float L2 = (v1 - v2).magnitude();
3397
3398 // Skip triangles that fail edge length filter
3399 if (L0 > Lmax || L1 > Lmax || L2 > Lmax) {
3400 continue;
3401 }
3402
3403 float ang01 = sqrt(pow(r0.zenith - r1.zenith, 2) + pow(r0.azimuth - r1.azimuth, 2));
3404 float ang02 = sqrt(pow(r0.zenith - r2.zenith, 2) + pow(r0.azimuth - r2.azimuth, 2));
3405 float ang12 = sqrt(pow(r1.zenith - r2.zenith, 2) + pow(r1.azimuth - r2.azimuth, 2));
3406
3407 float ratio01 = L0 / (ang01 + 1e-6);
3408 float ratio02 = L1 / (ang02 + 1e-6);
3409 float ratio12 = L2 / (ang12 + 1e-6);
3410 float max_sep_ratio = max(max(ratio01, ratio02), ratio12);
3411
3412 all_separation_ratios.push_back(max_sep_ratio);
3413 }
3414 }
3415
3416 // Calculate 25th percentile and set adaptive threshold
3417 if (!all_separation_ratios.empty()) {
3418 std::sort(all_separation_ratios.begin(), all_separation_ratios.end());
3419 size_t idx_25 = all_separation_ratios.size() / 4;
3420 float percentile_25 = all_separation_ratios[idx_25];
3421 adaptive_sep_threshold = 8.5f * percentile_25;
3422
3423 if (printmessages) {
3424 std::cout << " 25th percentile separation ratio: " << percentile_25 << std::endl;
3425 std::cout << " Adaptive threshold: " << adaptive_sep_threshold << std::endl;
3426 }
3427 }
3428 }
3429
3430 // Second pass: perform triangulation with adaptive filtering
3431 for (uint s = 0; s < getScanCount(); s++) {
3432
3433 // Cancellation checkpoint between scans: a cancelled run discards any mesh built so far and
3434 // returns empty (triangulationcomputed stays false) rather than starting the next scan.
3435 if (triangulationCancelled(cancel_flag)) {
3436 triangles.clear();
3437 return;
3438 }
3439
3440 std::vector<int> Delaunay_inds;
3441
3442 std::vector<Shx> pts, pts_copy;
3443
3444 int count = 0;
3445 for (int r = 0; r < getHitCount(); r++) {
3446
3447 // Coarse cancellation poll inside the gather loop (~every 128k hits) so a huge single
3448 // scan can be aborted before it even reaches the Delaunay call.
3449 if ((r & 0x1FFFF) == 0 && triangulationCancelled(cancel_flag)) {
3450 triangles.clear();
3451 return;
3452 }
3453
3454 if (getHitScanID(r) == s && getHitGridCell(r) >= 0) {
3455 // Auto-filter first returns for multi-return data
3456 if (use_adaptive_threshold) {
3457 if (doesHitDataExist(r, "target_index") && getHitData(r, "target_index") != 0.0) {
3458 continue; // Skip non-first returns
3459 }
3460 }
3461
3462 helios::SphericalCoord direction = getHitRaydir(r);
3463
3464 helios::vec3 direction_cart = getHitXYZ(r) - getScanOrigin(s);
3465 direction = cart2sphere(direction_cart);
3466
3467 Shx pt;
3468 pt.id = count;
3469 pt.r = direction.zenith;
3470 pt.c = direction.azimuth;
3471
3472 pts.push_back(pt);
3473
3474 Delaunay_inds.push_back(r);
3475
3476 count++;
3477 }
3478 }
3479
3480 if (pts.size() == 0) {
3481 if (printmessages) {
3482 std::cout << "Scan " << s << " contains no triangles. Skipping this scan..." << std::endl;
3483 }
3484 continue;
3485 }
3486
3487 float h[2] = {0, 0};
3488 for (int r = 0; r < pts.size(); r++) {
3489 if (pts.at(r).c < 0.5 * M_PI) {
3490 h[0] += 1.f;
3491 } else if (pts.at(r).c > 1.5 * M_PI) {
3492 h[1] += 1.f;
3493 }
3494 }
3495 h[0] /= float(pts.size());
3496 h[1] /= float(pts.size());
3497 if (h[0] + h[1] > 0.4) {
3498 if (printmessages) {
3499 std::cout << "Shifting scan " << s << std::endl;
3500 }
3501 for (int r = 0; r < pts.size(); r++) {
3502 pts.at(r).c += M_PI;
3503 if (pts.at(r).c > 2.f * M_PI) {
3504 pts.at(r).c -= 2.f * M_PI;
3505 }
3506 }
3507 }
3508
3509 // Snap coordinates to fixed precision for cross-platform consistency.
3510 // Even with CDT's robust predicates (which make the triangulation
3511 // deterministic for identical input), the upstream cart2sphere
3512 // coordinates can differ at the ULP level across architectures
3513 // (ARM64 vs x86_64); snapping collapses those so de_duplicate and the
3514 // tessellation stay platform-independent.
3515 const float COORD_SNAP_PRECISION = 1e-6f;
3516 for (auto &pt: pts) {
3517 pt.r = std::round(pt.r / COORD_SNAP_PRECISION) * COORD_SNAP_PRECISION;
3518 pt.c = std::round(pt.c / COORD_SNAP_PRECISION) * COORD_SNAP_PRECISION;
3519 }
3520
3521 std::vector<int> dupes;
3522 int nx = de_duplicate(pts, dupes);
3523 pts_copy = pts;
3524
3525 std::vector<Triad> triads;
3526
3527 if (printmessages) {
3528 std::cout << "starting triangulation for scan " << s << "..." << std::endl;
3529 }
3530
3531 // Cancellation poll immediately before the (uninterruptible) Delaunay call: if the caller
3532 // already requested an abort, skip this scan's CDT work entirely and return empty.
3533 if (triangulationCancelled(cancel_flag)) {
3534 triangles.clear();
3535 return;
3536 }
3537
3538 // CDT uses robust geometric predicates, so the s_hull-era
3539 // rotate-and-retry recovery is no longer needed; a failure here is
3540 // deterministic and the scan is skipped.
3541 int success = triangulate_CDT(pts, triads);
3542
3543 // Cancellation poll immediately after CDT: bail before building the output mesh so a run
3544 // cancelled during the (one-scan-bounded) tessellation still aborts promptly.
3545 if (triangulationCancelled(cancel_flag)) {
3546 triangles.clear();
3547 return;
3548 }
3549
3550 if (success != 1) {
3551 if (printmessages) {
3552 std::cout << "FAILED: could not triangulate scan " << s << ". Skipping this scan." << std::endl;
3553 }
3554 continue;
3555 } else if (printmessages) {
3556 std::cout << "finished triangulation" << std::endl;
3557 }
3558
3559 triangulation_candidate_count += triads.size();
3560
3561 for (int t = 0; t < triads.size(); t++) {
3562
3563 // Coarse cancellation poll inside the triad-build loop (~every 128k triangles).
3564 if ((t & 0x1FFFF) == 0 && triangulationCancelled(cancel_flag)) {
3565 triangles.clear();
3566 return;
3567 }
3568
3569 int ID0 = Delaunay_inds.at(triads.at(t).a);
3570 int ID1 = Delaunay_inds.at(triads.at(t).b);
3571 int ID2 = Delaunay_inds.at(triads.at(t).c);
3572
3573 helios::vec3 vertex0 = getHitXYZ(ID0);
3574 helios::SphericalCoord raydir0 = getHitRaydir(ID0);
3575
3576 helios::vec3 vertex1 = getHitXYZ(ID1);
3577 helios::SphericalCoord raydir1 = getHitRaydir(ID1);
3578
3579 helios::vec3 vertex2 = getHitXYZ(ID2);
3580 helios::SphericalCoord raydir2 = getHitRaydir(ID2);
3581
3582 helios::vec3 v;
3583 v = vertex0 - vertex1;
3584 float L0 = v.magnitude();
3585 v = vertex0 - vertex2;
3586 float L1 = v.magnitude();
3587 v = vertex1 - vertex2;
3588 float L2 = v.magnitude();
3589
3590 float aspect_ratio = max(max(L0, L1), L2) / min(min(L0, L1), L2);
3591
3592 // Apply filtering. Attribute each dropped triangle to ONE primary
3593 // reason in priority order (Lmax, then aspect/separation) so the
3594 // diagnostic counts reconcile: candidates == kept + dropped_lmax +
3595 // dropped_aspect + dropped_degenerate.
3596 bool dropped_lmax = (L0 > Lmax || L1 > Lmax || L2 > Lmax);
3597 bool dropped_aspect = false;
3598
3599 if (use_adaptive_threshold) {
3600 // Multi-return: use BOTH separation ratio filter AND aspect ratio filter
3601 float ang01 = sqrt(pow(raydir0.zenith - raydir1.zenith, 2) + pow(raydir0.azimuth - raydir1.azimuth, 2));
3602 float ang02 = sqrt(pow(raydir0.zenith - raydir2.zenith, 2) + pow(raydir0.azimuth - raydir2.azimuth, 2));
3603 float ang12 = sqrt(pow(raydir1.zenith - raydir2.zenith, 2) + pow(raydir1.azimuth - raydir2.azimuth, 2));
3604
3605 float ratio01 = L0 / (ang01 + 1e-6);
3606 float ratio02 = L1 / (ang02 + 1e-6);
3607 float ratio12 = L2 / (ang12 + 1e-6);
3608 float max_sep_ratio = max(max(ratio01, ratio02), ratio12);
3609
3610 dropped_aspect = (max_sep_ratio > adaptive_sep_threshold) || (aspect_ratio > max_aspect_ratio);
3611 } else {
3612 // Single-return: use aspect ratio filter
3613 dropped_aspect = (aspect_ratio > max_aspect_ratio);
3614 }
3615
3616 if (dropped_lmax) {
3617 triangulation_dropped_lmax++;
3618 continue;
3619 }
3620 if (dropped_aspect) {
3621 triangulation_dropped_aspect++;
3622 continue;
3623 }
3624
3625 int gridcell = getHitGridCell(ID0);
3626
3627 if (printmessages && gridcell == -2) {
3628 cout << "WARNING (triangulateHitPoints): You typically want to define the hit grid cell for all hit points before performing triangulation." << endl;
3629 }
3630
3631 RGBcolor color = make_RGBcolor(0, 0, 0);
3632 color.r = (hits.at(ID0).color.r + hits.at(ID1).color.r + hits.at(ID2).color.r) / 3.f;
3633 color.g = (hits.at(ID0).color.g + hits.at(ID1).color.g + hits.at(ID2).color.g) / 3.f;
3634 color.b = (hits.at(ID0).color.b + hits.at(ID1).color.b + hits.at(ID2).color.b) / 3.f;
3635
3636 Triangulation tri(s, vertex0, vertex1, vertex2, ID0, ID1, ID2, color, gridcell);
3637
3638 if (tri.area != tri.area) {
3639 triangulation_dropped_degenerate++;
3640 continue;
3641 }
3642
3643 triangles.push_back(tri);
3644
3645 Ntriangles++;
3646 }
3647 }
3648
3649 triangulationcomputed = true;
3650
3651 if (printmessages) {
3652 cout << "\r ";
3653 cout << "\rTriangulating...formed " << Ntriangles << " total triangles." << endl;
3654 }
3655}
3656
3657int LiDARcloud::getContainingGridCell(const helios::vec3 &p) const {
3658
3659 // Mirrors the per-cell containment test in calculateHitGridCell(): the original ray-from-origin
3660 // slab test reduces to a point-in-AABB containment test, applied in each cell's local frame
3661 // (inverse-rotated about the cell anchor when the cell is rotated). Kept here as a self-contained
3662 // helper using the bounds-checked getters; calculateHitGridCell() caches cell geometry in flat
3663 // arrays for its OpenMP hot loop, but the external-triangle path is far smaller and does not need
3664 // that, so the two stay logically identical without sharing the cached buffers.
3665 const uint Ncells = getGridCellCount();
3666 for (uint c = 0; c < Ncells; c++) {
3667
3668 helios::vec3 center = getCellCenter(c);
3669 helios::vec3 size = getCellSize(c);
3670 helios::vec3 lo = center - size * 0.5f;
3671 helios::vec3 hi = center + size * 0.5f;
3672
3673 helios::vec3 q = p;
3674 float rotation = getCellRotation(c);
3675 if (fabs(rotation) > 1e-6f) {
3677 q = rotatePointAboutLine(p - anchor, helios::make_vec3(0, 0, 0), helios::make_vec3(0, 0, 1), -rotation) + anchor;
3678 }
3679
3680 if (q.x >= lo.x && q.x <= hi.x && q.y >= lo.y && q.y <= hi.y && q.z >= lo.z && q.z <= hi.z) {
3681 return static_cast<int>(c);
3682 }
3683 }
3684 return -1;
3685}
3686
3687void LiDARcloud::setExternalTriangulation(const std::vector<helios::vec3> &triangle_vertices, const std::vector<int> &scanIDs) {
3688
3689 if (triangle_vertices.size() % 3 != 0) {
3690 helios_runtime_error("ERROR (LiDARcloud::setExternalTriangulation): triangle_vertices size (" + std::to_string(triangle_vertices.size()) + ") must be a multiple of 3 (three vertices per triangle).");
3691 }
3692
3693 const size_t Ntri = triangle_vertices.size() / 3;
3694
3695 if (scanIDs.size() != Ntri) {
3696 helios_runtime_error("ERROR (LiDARcloud::setExternalTriangulation): scanIDs size (" + std::to_string(scanIDs.size()) + ") must equal the triangle count (" + std::to_string(Ntri) +
3697 "). Each triangle requires a source scan for the G(theta) ray direction.");
3698 }
3699
3700 if (getGridCellCount() == 0) {
3701 helios_runtime_error("ERROR (LiDARcloud::setExternalTriangulation): a grid must be defined (see addGrid()) before supplying an external triangulation, so each triangle can be assigned to a grid cell.");
3702 }
3703
3704 const uint Nscans = getScanCount();
3705 for (size_t t = 0; t < Ntri; t++) {
3706 if (scanIDs.at(t) < 0 || static_cast<uint>(scanIDs.at(t)) >= Nscans) {
3707 helios_runtime_error("ERROR (LiDARcloud::setExternalTriangulation): triangle " + std::to_string(t) + " has scanID " + std::to_string(scanIDs.at(t)) + ", which is not a valid scan index in [0, " + std::to_string(Nscans) +
3708 "). Per-scan provenance is required; a merged mesh with no scan association is not a valid input.");
3709 }
3710 }
3711
3712 // Discard any previous triangulation and reset diagnostics for this run (see getTriangulation*).
3713 triangles.clear();
3714 triangulation_candidate_count = Ntri;
3715 triangulation_dropped_lmax = 0;
3716 triangulation_dropped_aspect = 0;
3717 triangulation_dropped_degenerate = 0;
3718
3719 for (size_t t = 0; t < Ntri; t++) {
3720
3721 const helios::vec3 &v0 = triangle_vertices.at(3 * t + 0);
3722 const helios::vec3 &v1 = triangle_vertices.at(3 * t + 1);
3723 const helios::vec3 &v2 = triangle_vertices.at(3 * t + 2);
3724
3725 // Assign by centroid containment so the triangle lands in the same cell its bulk occupies.
3726 helios::vec3 centroid = (v0 + v1 + v2) / 3.f;
3727 int gridcell = getContainingGridCell(centroid);
3728
3729 // ID0/ID1/ID2 are hit-point indices for the internal path; unused by G(theta), so -1 here.
3730 Triangulation tri(scanIDs.at(t), v0, v1, v2, -1, -1, -1, helios::RGB::green, gridcell);
3731
3732 // Drop degenerate triangles (collinear/zero-extent vertices give NaN area via Heron's formula),
3733 // matching triangulateHitPoints().
3734 if (tri.area != tri.area || tri.area <= 0.f) {
3735 triangulation_dropped_degenerate++;
3736 continue;
3737 }
3738
3739 triangles.push_back(tri);
3740 }
3741
3742 triangulationcomputed = true;
3743
3744 if (printmessages) {
3745 std::cout << "Set external triangulation: " << triangles.size() << " triangles (" << triangulation_dropped_degenerate << " degenerate dropped)." << std::endl;
3746 }
3747}
3748
3749void LiDARcloud::triangulateHitPoints(float Lmax, float max_aspect_ratio, const char *scalar_field, float threshold, const char *comparator) {
3750
3751 // See the two-argument overload: triangulation requires a fixed theta-phi scan grid that moving-platform scans lack.
3752 if (anyScanMoving()) {
3753 helios_runtime_error("ERROR (LiDARcloud::triangulateHitPoints): triangulation is not supported for moving-platform scans (see addScanMoving), which have no fixed theta-phi scan grid to triangulate. For leaf-area inversion of a "
3754 "moving scan, call the calculateLeafArea overload that takes a G(theta) argument (it does not require triangulation).");
3755 }
3756
3757 if (printmessages && getScanCount() == 0) {
3758 cout << "WARNING (triangulateHitPoints): No scans have been added to the point cloud. Skipping triangulation..." << endl;
3759 return;
3760 } else if (printmessages && getHitCount() == 0) {
3761 cout << "WARNING (triangulateHitPoints): No hit points have been added to the point cloud. Skipping triangulation..." << endl;
3762 return;
3763 }
3764
3765 if (!hitgridcellcomputed) {
3767 }
3768
3769 int Ntriangles = 0;
3770
3771 // Reset triangulation diagnostics for this run (see getTriangulation* getters).
3772 triangulation_candidate_count = 0;
3773 triangulation_dropped_lmax = 0;
3774 triangulation_dropped_aspect = 0;
3775 triangulation_dropped_degenerate = 0;
3776
3777 // For multi-return data, calculate adaptive separation ratio threshold
3778 bool use_adaptive_threshold = isMultiReturnData();
3779 float adaptive_sep_threshold = 0.0f;
3780
3781 if (use_adaptive_threshold) {
3782 if (printmessages) {
3783 std::cout << "Multi-return data detected - calculating adaptive separation ratio threshold..." << std::endl;
3784 }
3785
3786 // First pass: collect separation ratios from all potential triangles
3787 std::vector<float> all_separation_ratios;
3788
3789 for (uint s = 0; s < getScanCount(); s++) {
3790 std::vector<int> Delaunay_inds_pass1;
3791 std::vector<Shx> pts_pass1, pts_copy_pass1;
3792 int count_pass1 = 0;
3793
3794 for (int r = 0; r < getHitCount(); r++) {
3795 if (getHitScanID(r) == s && getHitGridCell(r) >= 0) {
3796 helios::SphericalCoord direction = getHitRaydir(r);
3797 helios::vec3 direction_cart = getHitXYZ(r) - getScanOrigin(s);
3798 direction = cart2sphere(direction_cart);
3799
3800 Shx pt;
3801 pt.id = count_pass1;
3802 pt.r = direction.zenith;
3803 pt.c = direction.azimuth;
3804 pts_pass1.push_back(pt);
3805 Delaunay_inds_pass1.push_back(r);
3806 count_pass1++;
3807 }
3808 }
3809
3810 if (pts_pass1.size() == 0)
3811 continue;
3812
3813 // Handle coordinate wrapping
3814 float h[2] = {0, 0};
3815 for (int r = 0; r < pts_pass1.size(); r++) {
3816 if (pts_pass1.at(r).c < 0.5 * M_PI)
3817 h[0] += 1.f;
3818 else if (pts_pass1.at(r).c > 1.5 * M_PI)
3819 h[1] += 1.f;
3820 }
3821 h[0] /= float(pts_pass1.size());
3822 h[1] /= float(pts_pass1.size());
3823 if (h[0] + h[1] > 0.4) {
3824 for (int r = 0; r < pts_pass1.size(); r++) {
3825 pts_pass1.at(r).c += M_PI;
3826 if (pts_pass1.at(r).c > 2.f * M_PI)
3827 pts_pass1.at(r).c -= 2.f * M_PI;
3828 }
3829 }
3830
3831 std::vector<int> dupes_pass1;
3832 de_duplicate(pts_pass1, dupes_pass1);
3833
3834 std::vector<Triad> triads_pass1;
3835 // CDT uses robust geometric predicates, so the s_hull-era
3836 // rotate-and-retry recovery is no longer needed; a failure here is
3837 // deterministic and the scan is skipped.
3838 int success = triangulate_CDT(pts_pass1, triads_pass1);
3839
3840 if (success != 1)
3841 continue;
3842
3843 // Collect separation ratios (pre-filter by edge length)
3844 for (int t = 0; t < triads_pass1.size(); t++) {
3845 int ID0 = Delaunay_inds_pass1.at(triads_pass1.at(t).a);
3846 int ID1 = Delaunay_inds_pass1.at(triads_pass1.at(t).b);
3847 int ID2 = Delaunay_inds_pass1.at(triads_pass1.at(t).c);
3848
3849 helios::vec3 v0 = getHitXYZ(ID0);
3850 helios::vec3 v1 = getHitXYZ(ID1);
3851 helios::vec3 v2 = getHitXYZ(ID2);
3855
3856 float L0 = (v0 - v1).magnitude();
3857 float L1 = (v0 - v2).magnitude();
3858 float L2 = (v1 - v2).magnitude();
3859
3860 // Skip triangles that fail edge length filter
3861 if (L0 > Lmax || L1 > Lmax || L2 > Lmax) {
3862 continue;
3863 }
3864
3865 float ang01 = sqrt(pow(r0.zenith - r1.zenith, 2) + pow(r0.azimuth - r1.azimuth, 2));
3866 float ang02 = sqrt(pow(r0.zenith - r2.zenith, 2) + pow(r0.azimuth - r2.azimuth, 2));
3867 float ang12 = sqrt(pow(r1.zenith - r2.zenith, 2) + pow(r1.azimuth - r2.azimuth, 2));
3868
3869 float ratio01 = L0 / (ang01 + 1e-6);
3870 float ratio02 = L1 / (ang02 + 1e-6);
3871 float ratio12 = L2 / (ang12 + 1e-6);
3872 float max_sep_ratio = max(max(ratio01, ratio02), ratio12);
3873
3874 all_separation_ratios.push_back(max_sep_ratio);
3875 }
3876 }
3877
3878 // Calculate 25th percentile and set adaptive threshold
3879 if (!all_separation_ratios.empty()) {
3880 std::sort(all_separation_ratios.begin(), all_separation_ratios.end());
3881 size_t idx_25 = all_separation_ratios.size() / 4;
3882 float percentile_25 = all_separation_ratios[idx_25];
3883 adaptive_sep_threshold = 8.5f * percentile_25;
3884
3885 if (printmessages) {
3886 std::cout << " 25th percentile separation ratio: " << percentile_25 << std::endl;
3887 std::cout << " Adaptive threshold: " << adaptive_sep_threshold << std::endl;
3888 }
3889 }
3890 }
3891
3892 // Second pass: perform triangulation with adaptive filtering
3893 for (uint s = 0; s < getScanCount(); s++) {
3894
3895 // Cancellation checkpoint between scans: a cancelled run discards any mesh built so far and
3896 // returns empty (triangulationcomputed stays false) rather than starting the next scan.
3897 if (triangulationCancelled(cancel_flag)) {
3898 triangles.clear();
3899 return;
3900 }
3901
3902 std::vector<int> Delaunay_inds;
3903
3904 std::vector<Shx> pts, pts_copy;
3905
3906 std::size_t delete_count = 0;
3907 int count = 0;
3908
3909 for (int r = 0; r < getHitCount(); r++) {
3910
3911 // Coarse cancellation poll inside the gather loop (~every 128k hits) so a huge single
3912 // scan can be aborted before it even reaches the Delaunay call.
3913 if ((r & 0x1FFFF) == 0 && triangulationCancelled(cancel_flag)) {
3914 triangles.clear();
3915 return;
3916 }
3917
3918 if (getHitScanID(r) == s && getHitGridCell(r) >= 0) {
3919
3920 if (doesHitDataExist(r, scalar_field)) {
3921 double R = getHitData(r, scalar_field);
3922 if (strcmp(comparator, "<") == 0) {
3923 if (R < threshold) {
3924 delete_count++;
3925 continue;
3926 }
3927 } else if (strcmp(comparator, ">") == 0) {
3928 if (R > threshold) {
3929 delete_count++;
3930 continue;
3931 }
3932 } else if (strcmp(comparator, "=") == 0) {
3933 if (R == threshold) {
3934
3935 delete_count++;
3936 continue;
3937 }
3938 }
3939 }
3940
3941 helios::SphericalCoord direction = getHitRaydir(r);
3942
3943 helios::vec3 direction_cart = getHitXYZ(r) - getScanOrigin(s);
3944 direction = cart2sphere(direction_cart);
3945
3946 Shx pt;
3947 pt.id = count;
3948 pt.r = direction.zenith;
3949 pt.c = direction.azimuth;
3950
3951 pts.push_back(pt);
3952
3953 Delaunay_inds.push_back(r);
3954
3955 count++;
3956 }
3957 }
3958
3959 if (printmessages) {
3960 std::cout << "Scan " << s << " triangulation: " << count << " points used, " << delete_count << " points filtered out";
3961 if (strlen(scalar_field) > 0) {
3962 std::cout << " (filter: " << scalar_field << " " << comparator << " " << threshold << ")";
3963 }
3964 std::cout << std::endl;
3965 }
3966
3967 if (pts.size() == 0) {
3968 if (printmessages) {
3969 std::cout << "Scan " << s << " contains no triangles. Skipping this scan..." << std::endl;
3970 }
3971 continue;
3972 }
3973
3974 float h[2] = {0, 0};
3975 for (int r = 0; r < pts.size(); r++) {
3976 if (pts.at(r).c < 0.5 * M_PI) {
3977 h[0] += 1.f;
3978 } else if (pts.at(r).c > 1.5 * M_PI) {
3979 h[1] += 1.f;
3980 }
3981 }
3982 h[0] /= float(pts.size());
3983 h[1] /= float(pts.size());
3984 if (h[0] + h[1] > 0.4) {
3985 if (printmessages) {
3986 std::cout << "Shifting scan " << s << std::endl;
3987 }
3988 for (int r = 0; r < pts.size(); r++) {
3989 pts.at(r).c += M_PI;
3990 if (pts.at(r).c > 2.f * M_PI) {
3991 pts.at(r).c -= 2.f * M_PI;
3992 }
3993 }
3994 }
3995
3996 // Snap coordinates to fixed precision for cross-platform consistency.
3997 // Even with CDT's robust predicates (which make the triangulation
3998 // deterministic for identical input), the upstream cart2sphere
3999 // coordinates can differ at the ULP level across architectures
4000 // (ARM64 vs x86_64); snapping collapses those so de_duplicate and the
4001 // tessellation stay platform-independent.
4002 const float COORD_SNAP_PRECISION = 1e-6f;
4003 for (auto &pt: pts) {
4004 pt.r = std::round(pt.r / COORD_SNAP_PRECISION) * COORD_SNAP_PRECISION;
4005 pt.c = std::round(pt.c / COORD_SNAP_PRECISION) * COORD_SNAP_PRECISION;
4006 }
4007
4008 std::vector<int> dupes;
4009 int nx = de_duplicate(pts, dupes);
4010 pts_copy = pts;
4011
4012 std::vector<Triad> triads;
4013
4014 if (printmessages) {
4015 std::cout << "starting triangulation for scan " << s << "..." << std::endl;
4016 }
4017
4018 // Cancellation poll immediately before the (uninterruptible) Delaunay call: if the caller
4019 // already requested an abort, skip this scan's CDT work entirely and return empty.
4020 if (triangulationCancelled(cancel_flag)) {
4021 triangles.clear();
4022 return;
4023 }
4024
4025 // CDT uses robust geometric predicates, so the s_hull-era
4026 // rotate-and-retry recovery is no longer needed; a failure here is
4027 // deterministic and the scan is skipped.
4028 int success = triangulate_CDT(pts, triads);
4029
4030 // Cancellation poll immediately after CDT: bail before building the output mesh so a run
4031 // cancelled during the (one-scan-bounded) tessellation still aborts promptly.
4032 if (triangulationCancelled(cancel_flag)) {
4033 triangles.clear();
4034 return;
4035 }
4036
4037 if (success != 1) {
4038 if (printmessages) {
4039 std::cout << "FAILED: could not triangulate scan " << s << ". Skipping this scan." << std::endl;
4040 }
4041 continue;
4042 } else if (printmessages) {
4043 std::cout << "finished triangulation" << std::endl;
4044 }
4045
4046 triangulation_candidate_count += triads.size();
4047
4048 for (int t = 0; t < triads.size(); t++) {
4049
4050 // Coarse cancellation poll inside the triad-build loop (~every 128k triangles).
4051 if ((t & 0x1FFFF) == 0 && triangulationCancelled(cancel_flag)) {
4052 triangles.clear();
4053 return;
4054 }
4055
4056 int ID0 = Delaunay_inds.at(triads.at(t).a);
4057 int ID1 = Delaunay_inds.at(triads.at(t).b);
4058 int ID2 = Delaunay_inds.at(triads.at(t).c);
4059
4060 helios::vec3 vertex0 = getHitXYZ(ID0);
4061 helios::SphericalCoord raydir0 = getHitRaydir(ID0);
4062
4063 helios::vec3 vertex1 = getHitXYZ(ID1);
4064 helios::SphericalCoord raydir1 = getHitRaydir(ID1);
4065
4066 helios::vec3 vertex2 = getHitXYZ(ID2);
4067 helios::SphericalCoord raydir2 = getHitRaydir(ID2);
4068
4069 helios::vec3 v;
4070 v = vertex0 - vertex1;
4071 float L0 = v.magnitude();
4072 v = vertex0 - vertex2;
4073 float L1 = v.magnitude();
4074 v = vertex1 - vertex2;
4075 float L2 = v.magnitude();
4076
4077 float aspect_ratio = max(max(L0, L1), L2) / min(min(L0, L1), L2);
4078
4079 // Apply filtering. Attribute each dropped triangle to ONE primary
4080 // reason in priority order (Lmax, then aspect/separation) so the
4081 // diagnostic counts reconcile: candidates == kept + dropped_lmax +
4082 // dropped_aspect + dropped_degenerate.
4083 bool dropped_lmax = (L0 > Lmax || L1 > Lmax || L2 > Lmax);
4084 bool dropped_aspect = false;
4085
4086 if (use_adaptive_threshold) {
4087 // Multi-return: use BOTH separation ratio filter AND aspect ratio filter
4088 float ang01 = sqrt(pow(raydir0.zenith - raydir1.zenith, 2) + pow(raydir0.azimuth - raydir1.azimuth, 2));
4089 float ang02 = sqrt(pow(raydir0.zenith - raydir2.zenith, 2) + pow(raydir0.azimuth - raydir2.azimuth, 2));
4090 float ang12 = sqrt(pow(raydir1.zenith - raydir2.zenith, 2) + pow(raydir1.azimuth - raydir2.azimuth, 2));
4091
4092 float ratio01 = L0 / (ang01 + 1e-6);
4093 float ratio02 = L1 / (ang02 + 1e-6);
4094 float ratio12 = L2 / (ang12 + 1e-6);
4095 float max_sep_ratio = max(max(ratio01, ratio02), ratio12);
4096
4097 dropped_aspect = (max_sep_ratio > adaptive_sep_threshold) || (aspect_ratio > max_aspect_ratio);
4098 } else {
4099 // Single-return: use aspect ratio filter
4100 dropped_aspect = (aspect_ratio > max_aspect_ratio);
4101 }
4102
4103 if (dropped_lmax) {
4104 triangulation_dropped_lmax++;
4105 continue;
4106 }
4107 if (dropped_aspect) {
4108 triangulation_dropped_aspect++;
4109 continue;
4110 }
4111
4112 int gridcell = getHitGridCell(ID0);
4113
4114 if (printmessages && gridcell == -2) {
4115 cout << "WARNING (triangulateHitPoints): You typically want to define the hit grid cell for all hit points before performing triangulation." << endl;
4116 }
4117
4118 RGBcolor color = make_RGBcolor(0, 0, 0);
4119 color.r = (hits.at(ID0).color.r + hits.at(ID1).color.r + hits.at(ID2).color.r) / 3.f;
4120 color.g = (hits.at(ID0).color.g + hits.at(ID1).color.g + hits.at(ID2).color.g) / 3.f;
4121 color.b = (hits.at(ID0).color.b + hits.at(ID1).color.b + hits.at(ID2).color.b) / 3.f;
4122
4123 Triangulation tri(s, vertex0, vertex1, vertex2, ID0, ID1, ID2, color, gridcell);
4124
4125 if (tri.area != tri.area) {
4126 triangulation_dropped_degenerate++;
4127 continue;
4128 }
4129
4130 triangles.push_back(tri);
4131
4132 Ntriangles++;
4133 }
4134 }
4135
4136 triangulationcomputed = true;
4137
4138 if (printmessages) {
4139 cout << "\r ";
4140 cout << "\rTriangulating...formed " << Ntriangles << " total triangles." << endl;
4141 }
4142}
4143
4144
4146
4147 if (scans.size() == 0) {
4148 if (printmessages) {
4149 std::cout << "WARNING (addTrianglesToContext): There are no scans in the point cloud, and thus there are no triangles to add...skipping." << std::endl;
4150 }
4151 return;
4152 }
4153
4154 for (std::size_t i = 0; i < getTriangleCount(); i++) {
4155
4156 Triangulation tri = getTriangle(i);
4157
4158 context->addTriangle(tri.vertex0, tri.vertex1, tri.vertex2, tri.color);
4159 }
4160}
4161
4163 return grid_cells.size();
4164}
4165
4166void LiDARcloud::addGridCell(const vec3 &center, const vec3 &size, float rotation) {
4167 addGridCell(center, center, size, size, rotation, make_int3(1, 1, 1), make_int3(1, 1, 1));
4168}
4169
4170void LiDARcloud::addGridCell(const vec3 &center, const vec3 &global_anchor, const vec3 &size, const vec3 &global_size, float rotation, const int3 &global_ijk, const int3 &global_count) {
4171
4172 GridCell newcell(center, global_anchor, size, global_size, rotation, global_ijk, global_count);
4173
4174 grid_cells.push_back(newcell);
4175}
4176
4178
4179 if (index >= getGridCellCount()) {
4180 helios_runtime_error("ERROR (LiDARcloud::getCellCenter): grid cell index out of range. Requested center of cell #" + std::to_string(index) + " but there are only " + std::to_string(getGridCellCount()) + " cells in the grid.");
4181 }
4182
4183 return grid_cells.at(index).center;
4184}
4185
4187
4188 if (index >= getGridCellCount()) {
4189 helios_runtime_error("ERROR (LiDARcloud::getCellGlobalAnchor): grid cell index out of range. Requested anchor of cell #" + std::to_string(index) + " but there are only " + std::to_string(getGridCellCount()) + " cells in the grid.");
4190 }
4191
4192 return grid_cells.at(index).global_anchor;
4193}
4194
4196
4197 if (index >= getGridCellCount()) {
4198 helios_runtime_error("ERROR (LiDARcloud::getCellCenter): grid cell index out of range. Requested size of cell #" + std::to_string(index) + " but there are only " + std::to_string(getGridCellCount()) + " cells in the grid.");
4199 }
4200
4201 return grid_cells.at(index).size;
4202}
4203
4205
4206 if (index >= getGridCellCount()) {
4207 helios_runtime_error("ERROR (LiDARcloud::getCellRotation): grid cell index out of range. Requested rotation of cell #" + std::to_string(index) + " but there are only " + std::to_string(getGridCellCount()) + " cells in the grid.");
4208 }
4209
4210 return grid_cells.at(index).azimuthal_rotation;
4211}
4212
4214
4215 size_t Nprims = context->getPrimitiveCount();
4216
4217 uint Nscans = getScanCount();
4218
4219 uint Ncells = getGridCellCount();
4220
4221 std::vector<float> Gtheta;
4222 Gtheta.resize(Ncells);
4223
4224 std::vector<float> area_sum;
4225 area_sum.resize(Ncells, 0.f);
4226 std::vector<uint> cell_tri_count;
4227 cell_tri_count.resize(Ncells, 0);
4228
4229 std::vector<uint> UUIDs = context->getAllUUIDs();
4230 for (int p = 0; p < UUIDs.size(); p++) {
4231
4232 uint UUID = UUIDs.at(p);
4233
4234 if (context->doesPrimitiveDataExist(UUID, "gridCell")) {
4235
4236 uint gridCell;
4237 context->getPrimitiveData(UUID, "gridCell", gridCell);
4238
4239 std::vector<vec3> vertices = context->getPrimitiveVertices(UUID);
4240 float area = context->getPrimitiveArea(UUID);
4241 vec3 normal = context->getPrimitiveNormal(UUID);
4242
4243 for (int s = 0; s < Nscans; s++) {
4244 vec3 origin = getScanOrigin(s);
4245 vec3 raydir = vertices.front() - origin;
4246 raydir.normalize();
4247
4248 if (area == area) { // in rare cases you can get area=NaN
4249
4250 Gtheta.at(gridCell) += fabs(normal * raydir) * area;
4251
4252 area_sum.at(gridCell) += area;
4253 cell_tri_count.at(gridCell) += 1;
4254 }
4255 }
4256 }
4257 }
4258
4259 for (uint v = 0; v < Ncells; v++) {
4260 if (cell_tri_count[v] > 0) {
4261 Gtheta[v] *= float(cell_tri_count[v]) / (area_sum[v]);
4262 }
4263 }
4264
4265
4266 std::vector<float> output_Gtheta;
4267 output_Gtheta.resize(Ncells, 0.f);
4268
4269 for (int v = 0; v < Ncells; v++) {
4270 output_Gtheta.at(v) = Gtheta.at(v);
4271 if (context->doesPrimitiveDataExist(UUIDs.at(v), "gridCell")) {
4272 context->setPrimitiveData(UUIDs.at(v), "synthetic_Gtheta", Gtheta.at(v));
4273 }
4274 }
4275
4276 return output_Gtheta;
4277}
4278
4279void LiDARcloud::setCellLeafArea(float area, uint index) {
4280
4281 if (index > getGridCellCount()) {
4282 helios_runtime_error("ERROR (LiDARcloud::setCellLeafArea): grid cell index out of range.");
4283 }
4284
4285 grid_cells.at(index).leaf_area = area;
4286}
4287
4289
4290 if (index >= getGridCellCount()) {
4291 helios_runtime_error("ERROR (LiDARcloud::getCellLeafArea): grid cell index out of range. Requested leaf area of cell #" + std::to_string(index) + " but there are only " + std::to_string(getGridCellCount()) + " cells in the grid.");
4292 }
4293
4294 return grid_cells.at(index).leaf_area;
4295}
4296
4298
4299 if (index >= getGridCellCount()) {
4300 helios_runtime_error("ERROR (LiDARcloud::getCellLeafAreaDensity): grid cell index out of range. Requested leaf area density of cell #" + std::to_string(index) + " but there are only " + std::to_string(getGridCellCount()) +
4301 " cells in the grid.");
4302 }
4303
4304 helios::vec3 gridsize = grid_cells.at(index).size;
4305 return grid_cells.at(index).leaf_area / (gridsize.x * gridsize.y * gridsize.z);
4306}
4307
4308void LiDARcloud::setCellGtheta(float Gtheta, uint index) {
4309
4310 if (index > getGridCellCount()) {
4311 helios_runtime_error("ERROR (LiDARcloud::setCellGtheta): grid cell index out of range.");
4312 }
4313
4314 grid_cells.at(index).Gtheta = Gtheta;
4315}
4316
4318
4319 if (index >= getGridCellCount()) {
4320 helios_runtime_error("ERROR (LiDARcloud::getCellGtheta): grid cell index out of range. Requested leaf area of cell #" + std::to_string(index) + " but there are only " + std::to_string(getGridCellCount()) + " cells in the grid.");
4321 }
4322
4323 return grid_cells.at(index).Gtheta;
4324}
4325
4327
4328 if (index >= getGridCellCount()) {
4329 helios_runtime_error("ERROR (LiDARcloud::getCellBeamCount): grid cell index out of range. Requested beam count of cell #" + std::to_string(index) + " but there are only " + std::to_string(getGridCellCount()) + " cells in the grid.");
4330 }
4331
4332 return grid_cells.at(index).beam_count;
4333}
4334
4336
4337 if (index >= getGridCellCount()) {
4338 helios_runtime_error("ERROR (LiDARcloud::getCellRelativeDensityIndex): grid cell index out of range. Requested RDI of cell #" + std::to_string(index) + " but there are only " + std::to_string(getGridCellCount()) + " cells in the grid.");
4339 }
4340
4341 return grid_cells.at(index).I_rdi;
4342}
4343
4345
4346 if (index >= getGridCellCount()) {
4347 helios_runtime_error("ERROR (LiDARcloud::getCellMeanPathLength): grid cell index out of range. Requested mean path length of cell #" + std::to_string(index) + " but there are only " + std::to_string(getGridCellCount()) +
4348 " cells in the grid.");
4349 }
4350
4351 return grid_cells.at(index).zbar_e;
4352}
4353
4355
4356 if (index >= getGridCellCount()) {
4357 helios_runtime_error("ERROR (LiDARcloud::getCellLADVariance): grid cell index out of range. Requested LAD variance of cell #" + std::to_string(index) + " but there are only " + std::to_string(getGridCellCount()) + " cells in the grid.");
4358 }
4359
4360 return grid_cells.at(index).LAD_variance;
4361}
4362
4363bool LiDARcloud::getCellLeafAreaConfidenceInterval(uint index, float confidence_level, float &lower, float &upper) const {
4364
4365 if (index >= getGridCellCount()) {
4366 helios_runtime_error("ERROR (LiDARcloud::getCellLeafAreaConfidenceInterval): grid cell index out of range. Requested cell #" + std::to_string(index) + " but there are only " + std::to_string(getGridCellCount()) + " cells in the grid.");
4367 }
4368 if (confidence_level <= 0.f || confidence_level >= 1.f) {
4369 helios_runtime_error("ERROR (LiDARcloud::getCellLeafAreaConfidenceInterval): confidence_level must be strictly between 0 and 1.");
4370 }
4371
4372 const GridCell &cell = grid_cells.at(index);
4373 if (cell.LAD_variance < 0.f || cell.beam_count <= 0) {
4374 return false; // variance undefined for this voxel
4375 }
4376
4377 const float volume = cell.size.x * cell.size.y * cell.size.z;
4378 const float a = (volume > 0.f) ? cell.leaf_area / volume : 0.f; // LAD point estimate
4379 const float L = a * cell.Gtheta * cell.zbar_e; // voxel optical depth
4380 if (!ciValidPimont(L, cell.L1_element, cell.beam_count, confidence_level)) {
4381 return false; // outside the trustworthy regime -> refuse to emit an interval
4382 }
4383
4384 // Two-sided z-multiplier for the requested confidence level.
4385 const double z = normalQuantile(1.0 - (1.0 - (double) confidence_level) / 2.0);
4386 const float lad_se = std::sqrt(cell.LAD_variance); // standard error of LAD [1/m]
4387 const float half_width = (float) z * volume * lad_se; // converted to leaf-area scale [m^2]
4388 lower = cell.leaf_area - half_width;
4389 if (lower < 0.f) {
4390 lower = 0.f; // leaf area is non-negative
4391 }
4392 upper = cell.leaf_area + half_width;
4393 return true;
4394}
4395
4396bool LiDARcloud::getGroupLADConfidenceInterval(const std::vector<uint> &indices, float confidence_level, float &mean_lad, float &lower, float &upper) const {
4397
4398 if (confidence_level <= 0.f || confidence_level >= 1.f) {
4399 helios_runtime_error("ERROR (LiDARcloud::getGroupLADConfidenceInterval): confidence_level must be strictly between 0 and 1.");
4400 }
4401
4402 // Aggregate over the valid voxels in the group (Pimont et al. 2018, Eq. 39): the CI on the mean
4403 // LAD assumes voxel independence and uses the sum of the per-voxel LAD variances. Voxels outside
4404 // the Table-3 validity envelope (or with undefined variance) are skipped.
4405 double sum_lad = 0.0;
4406 double sum_variance = 0.0;
4407 uint n_valid = 0;
4408 for (uint index: indices) {
4409 if (index >= getGridCellCount()) {
4410 helios_runtime_error("ERROR (LiDARcloud::getGroupLADConfidenceInterval): grid cell index out of range. Requested cell #" + std::to_string(index) + " but there are only " + std::to_string(getGridCellCount()) + " cells in the grid.");
4411 }
4412 const GridCell &cell = grid_cells.at(index);
4413 if (cell.LAD_variance < 0.f || cell.beam_count <= 0) {
4414 continue;
4415 }
4416 const float volume = cell.size.x * cell.size.y * cell.size.z;
4417 const float a = (volume > 0.f) ? cell.leaf_area / volume : 0.f;
4418 const float L = a * cell.Gtheta * cell.zbar_e;
4419 if (!ciValidPimont(L, cell.L1_element, cell.beam_count, confidence_level)) {
4420 continue;
4421 }
4422 sum_lad += a;
4423 sum_variance += cell.LAD_variance;
4424 n_valid++;
4425 }
4426
4427 if (n_valid == 0) {
4428 return false;
4429 }
4430
4431 mean_lad = (float) (sum_lad / (double) n_valid);
4432 const double z = normalQuantile(1.0 - (1.0 - (double) confidence_level) / 2.0);
4433 const float half_width = (float) (z * std::sqrt(sum_variance) / (double) n_valid);
4434 lower = mean_lad - half_width;
4435 if (lower < 0.f) {
4436 lower = 0.f;
4437 }
4438 upper = mean_lad + half_width;
4439 return true;
4440}
4441
4442void LiDARcloud::leafReconstructionFloodfill() {
4443
4444 size_t group_count = 0;
4445 int current_group = 0;
4446
4447 vector<vector<int>> nodes;
4448 nodes.resize(getHitCount());
4449
4450 size_t Ntri = 0;
4451 for (size_t t = 0; t < getTriangleCount(); t++) {
4452
4453 Triangulation tri = getTriangle(t);
4454
4455 if (tri.gridcell >= 0) {
4456
4457 nodes.at(tri.ID0).push_back(t);
4458 nodes.at(tri.ID1).push_back(t);
4459 nodes.at(tri.ID2).push_back(t);
4460
4461 Ntri++;
4462 }
4463 }
4464
4465 std::vector<int> fill_flag;
4466 fill_flag.resize(Ntri);
4467 for (size_t t = 0; t < Ntri; t++) {
4468 fill_flag.at(t) = -1;
4469 }
4470
4471 for (size_t t = 0; t < Ntri; t++) { // looping through all triangles
4472
4473 if (fill_flag.at(t) < 0) {
4474
4475 floodfill(t, triangles, fill_flag, nodes, current_group, 0, 1e3);
4476
4477 current_group++;
4478 }
4479 }
4480
4481 for (size_t t = 0; t < Ntri; t++) { // looping through all triangles
4482
4483 if (fill_flag.at(t) >= 0) {
4484 int fill_group = fill_flag.at(t);
4485
4486 if (fill_group >= reconstructed_triangles.size()) {
4487 reconstructed_triangles.resize(fill_group + 1);
4488 }
4489
4490 reconstructed_triangles.at(fill_group).push_back(triangles.at(t));
4491 }
4492 }
4493}
4494
4495void LiDARcloud::floodfill(size_t t, std::vector<Triangulation> &cloud_triangles, std::vector<int> &fill_flag, std::vector<std::vector<int>> &nodes, int tag, int depth, int maxdepth) {
4496
4497 Triangulation tri = cloud_triangles.at(t);
4498
4499 int verts[3] = {tri.ID0, tri.ID1, tri.ID2};
4500
4501 std::vector<int> connection_list;
4502
4503 for (int i = 0; i < 3; i++) {
4504 std::vector<int> connected_tris = nodes.at(verts[i]);
4505 connection_list.insert(connection_list.begin(), connected_tris.begin(), connected_tris.end());
4506 }
4507
4508 std::sort(connection_list.begin(), connection_list.end());
4509
4510 int count = 0;
4511 for (int tt = 1; tt < connection_list.size(); tt++) {
4512 if (connection_list.at(tt - 1) != connection_list.at(tt)) {
4513
4514 if (count >= 2) {
4515
4516 int index = connection_list.at(tt - 1);
4517
4518 if (fill_flag.at(index) == -1 && index != t) {
4519
4520 fill_flag.at(index) = tag;
4521
4522 if (depth < maxdepth) {
4523 floodfill(index, cloud_triangles, fill_flag, nodes, tag, depth + 1, maxdepth);
4524 }
4525 }
4526 }
4527
4528 count = 1;
4529 } else {
4530 count++;
4531 }
4532 }
4533}
4534
4535void LiDARcloud::leafReconstructionAlphaMask(float minimum_leaf_group_area, float maximum_leaf_group_area, float leaf_aspect_ratio, const char *mask_file) {
4536 leafReconstructionAlphaMask(minimum_leaf_group_area, maximum_leaf_group_area, leaf_aspect_ratio, -1.f, mask_file);
4537}
4538
4539void LiDARcloud::leafReconstructionAlphaMask(float minimum_leaf_group_area, float maximum_leaf_group_area, float leaf_aspect_ratio, float leaf_length_constant, const char *mask_file) {
4540
4541 if (printmessages) {
4542 cout << "Performing alphamask leaf reconstruction..." << flush;
4543 }
4544
4545 if (triangles.size() == 0) {
4546 std::cout << "failed." << std::endl;
4547 helios_runtime_error("ERROR (LiDARcloud::leafReconstructionAlphamask): There are no triangulated points. Either the triangulation failed or 'triangulateHitPoints()' was not called.");
4548 }
4549
4550 std::string file = mask_file;
4551 if (file.substr(file.find_last_of(".") + 1) != "png") {
4552 std::cout << "failed." << std::endl;
4553 helios_runtime_error("ERROR (LiDARcloud::leafReconstructionAlphaMask): Mask data file " + std::string(mask_file) + " must be PNG image format.");
4554 }
4555 std::vector<std::vector<bool>> maskdata = readPNGAlpha(mask_file);
4556 if (maskdata.size() == 0) {
4557 std::cout << "failed." << std::endl;
4558 helios_runtime_error("ERROR (LiDARcloud::leafReconstructionAlphaMask): Could not load mask file " + std::string(mask_file) + ". It contains no data.");
4559 }
4560 int ix = maskdata.front().size();
4561 int jy = maskdata.size();
4562 int2 masksize = make_int2(ix, jy);
4563 uint Atotal = 0;
4564 uint Asolid = 0;
4565 for (uint j = 0; j < masksize.y; j++) {
4566 for (uint i = 0; i < masksize.x; i++) {
4567 Atotal++;
4568 if (maskdata.at(j).at(i)) {
4569 Asolid++;
4570 }
4571 }
4572 }
4573
4574 float solidfraction = float(Asolid) / float(Atotal);
4575
4576 float total_area = 0.f;
4577
4578 std::vector<std::vector<float>> group_areas;
4579 group_areas.resize(getGridCellCount());
4580
4581 reconstructed_alphamasks_maskfile = mask_file;
4582
4583 leafReconstructionFloodfill();
4584
4585 // Filter out small groups by an area threshold
4586
4587 uint group_count = reconstructed_triangles.size();
4588
4589 float group_area_max = 0;
4590
4591 std::vector<bool> group_filter_flag;
4592 group_filter_flag.resize(reconstructed_triangles.size());
4593
4594 for (int group = group_count - 1; group >= 0; group--) {
4595
4596 float garea = 0.f;
4597
4598 for (size_t t = 0; t < reconstructed_triangles.at(group).size(); t++) {
4599
4600 float triangle_area = reconstructed_triangles.at(group).at(t).area;
4601
4602 garea += triangle_area;
4603 }
4604
4605 if (garea < minimum_leaf_group_area || garea > maximum_leaf_group_area) {
4606 group_filter_flag.at(group) = false;
4607 // reconstructed_triangles.erase( reconstructed_triangles.begin()+group );
4608 } else {
4609 group_filter_flag.at(group) = true;
4610 int cell = reconstructed_triangles.at(group).front().gridcell;
4611 group_areas.at(cell).push_back(garea);
4612 }
4613 }
4614
4615 vector<float> Lavg;
4616 Lavg.resize(getGridCellCount(), 0.f);
4617
4618 int Navg = 20;
4619
4620 for (int v = 0; v < getGridCellCount(); v++) {
4621
4622 std::sort(group_areas.at(v).begin(), group_areas.at(v).end());
4623 // std::partial_sort( group_areas.at(v).begin(), group_areas.at(v).begin()+Navg,group_areas.at(v).end(), std::greater<float>() );
4624
4625 if (group_areas.at(v).size() > Navg) {
4626 for (int i = group_areas.at(v).size() - 1; i >= group_areas.at(v).size() - Navg; i--) {
4627 Lavg.at(v) += sqrtf(group_areas.at(v).at(i)) / float(Navg);
4628 }
4629 } else if (group_areas.at(v).size() == 0) {
4630 Lavg.at(v) = 0.05; // NOTE: hard-coded
4631 } else {
4632 for (int i = 0; i < group_areas.at(v).size(); i++) {
4633 Lavg.at(v) += sqrtf(group_areas.at(v).at(i)) / float(group_areas.at(v).size());
4634 }
4635 }
4636
4637 if (printmessages) {
4638 std::cout << "Average leaf length for volume #" << v << " : " << Lavg.at(v) << endl;
4639 }
4640 }
4641
4642 // Form alphamasks
4643
4644 for (int group = 0; group < reconstructed_triangles.size(); group++) {
4645
4646 if (!group_filter_flag.at(group)) {
4647 continue;
4648 }
4649
4650 int cell = reconstructed_triangles.at(group).front().gridcell;
4651
4652 helios::vec3 position = make_vec3(0, 0, 0);
4653 for (int t = 0; t < reconstructed_triangles.at(group).size(); t++) {
4654 position = position + reconstructed_triangles.at(group).at(t).vertex0 / float(reconstructed_triangles.at(group).size());
4655 }
4656
4657 int gind = round(randu() * (reconstructed_triangles.at(group).size() - 1));
4658
4659 reconstructed_alphamasks_center.push_back(position);
4660 float l = Lavg.at(reconstructed_triangles.at(group).front().gridcell) * sqrt(leaf_aspect_ratio / solidfraction);
4661 float w = l / leaf_aspect_ratio;
4662 reconstructed_alphamasks_size.push_back(helios::make_vec2(w, l));
4663 helios::vec3 normal = cross(reconstructed_triangles.at(group).at(gind).vertex1 - reconstructed_triangles.at(group).at(gind).vertex0, reconstructed_triangles.at(group).at(gind).vertex2 - reconstructed_triangles.at(group).at(gind).vertex0);
4664 reconstructed_alphamasks_rotation.push_back(make_SphericalCoord(cart2sphere(normal).zenith, cart2sphere(normal).azimuth));
4665 reconstructed_alphamasks_gridcell.push_back(reconstructed_triangles.at(group).front().gridcell);
4666 reconstructed_alphamasks_direct_flag.push_back(1);
4667 }
4668
4669 if (printmessages) {
4670 cout << "done." << endl;
4671 cout << "Directly reconstructed " << reconstructed_alphamasks_center.size() << " leaf groups." << endl;
4672 }
4673
4674 backfillLeavesAlphaMask(Lavg, leaf_aspect_ratio, solidfraction, group_filter_flag);
4675
4676 for (int group = 0; group < reconstructed_triangles.size(); group++) {
4677
4678 if (!group_filter_flag.at(group)) {
4679 std::swap(reconstructed_triangles.at(group), reconstructed_triangles.back());
4680 reconstructed_triangles.pop_back();
4681 }
4682 }
4683
4684 // reconstructed_triangles.resize(0);
4685}
4686
4687
4688void LiDARcloud::backfillLeavesAlphaMask(const vector<float> &leaf_size, float leaf_aspect_ratio, float solidfraction, const vector<bool> &group_filter_flag) {
4689
4690 if (printmessages) {
4691 cout << "Backfilling leaves..." << endl;
4692 }
4693
4694 unsigned seed = std::chrono::system_clock::now().time_since_epoch().count();
4695 std::minstd_rand0 generator;
4696 generator.seed(seed);
4697 std::normal_distribution<float> randn;
4698
4699 uint Ngroups = reconstructed_triangles.size();
4700
4701 uint Ncells = getGridCellCount();
4702
4703 std::vector<std::vector<uint>> group_gridcell;
4704 group_gridcell.resize(Ncells);
4705
4706 // Calculate the current alphamask leaf area for each grid cell
4707 std::vector<float> leaf_area_current;
4708 leaf_area_current.resize(Ncells);
4709
4710 int cell;
4711 int count = 0;
4712 for (uint g = 0; g < Ngroups; g++) {
4713 if (group_filter_flag.at(g)) {
4714 if (reconstructed_triangles.at(g).size() > 0) {
4715 cell = reconstructed_triangles.at(g).front().gridcell;
4716 leaf_area_current.at(cell) += leaf_size.at(cell) * leaf_size.at(cell) * solidfraction;
4717 group_gridcell.at(cell).push_back(count);
4718 }
4719 count++;
4720 }
4721 }
4722
4723 std::vector<int> deleted_groups;
4724 int backfill_count = 0;
4725
4726 helios::WarningAggregator backfill_warnings;
4727 backfill_warnings.setEnabled(printmessages);
4728
4729 // Get the total theoretical leaf area for each grid cell based on LiDAR scan
4730 for (uint v = 0; v < Ncells; v++) {
4731
4732 float leaf_area_total = getCellLeafArea(v);
4733
4734 float reconstruct_frac = (leaf_area_total - leaf_area_current.at(v)) / leaf_area_total;
4735
4736 if (leaf_area_total == 0 || reconstructed_alphamasks_size.size() == 0) { // no leaves in gridcell
4737 backfill_warnings.addWarning("volume_no_measured_leaf_area", "skipping volume #" + std::to_string(v) + " because it has no measured leaf area.");
4738 continue;
4739 } else if (getTriangleCount() == 0) {
4740 backfill_warnings.addWarning("volume_no_triangles", "skipping volume #" + std::to_string(v) + " because it has no triangles.");
4741 continue;
4742 } else if (leaf_area_current.at(v) == 0) { // no directly reconstructed leaves in gridcell
4743
4744 std::vector<SphericalCoord> tri_rots;
4745
4746 size_t Ntri = 0;
4747 for (size_t t = 0; t < getTriangleCount(); t++) {
4748 Triangulation tri = getTriangle(t);
4749 if (tri.gridcell == v) {
4750 helios::vec3 normal = cross(tri.vertex1 - tri.vertex0, tri.vertex2 - tri.vertex0);
4751 tri_rots.push_back(make_SphericalCoord(cart2sphere(normal).zenith, cart2sphere(normal).azimuth));
4752 }
4753 }
4754
4755 while (leaf_area_current.at(v) < leaf_area_total) {
4756
4757 int randi = round(randu() * (tri_rots.size() - 1));
4758
4759 helios::vec3 cellsize = getCellSize(v);
4760 helios::vec3 cellcenter = getCellCenter(v);
4761 float rotation = getCellRotation(v);
4762
4763 helios::vec3 shift = cellcenter + rotatePoint(helios::make_vec3((randu() - 0.5) * cellsize.x, (randu() - 0.5) * cellsize.y, (randu() - 0.5) * cellsize.z), 0, rotation);
4764
4765 reconstructed_alphamasks_center.push_back(shift);
4766 reconstructed_alphamasks_size.push_back(reconstructed_alphamasks_size.front());
4767 reconstructed_alphamasks_rotation.push_back(tri_rots.at(randi));
4768 reconstructed_alphamasks_gridcell.push_back(v);
4769 reconstructed_alphamasks_direct_flag.push_back(0);
4770
4771 leaf_area_current.at(v) += reconstructed_alphamasks_size.back().x * reconstructed_alphamasks_size.back().y * solidfraction;
4772 }
4773
4774
4775 } else if (leaf_area_current.at(v) > leaf_area_total) { // too much leaf area in gridcell
4776
4777 while (leaf_area_current.at(v) > leaf_area_total) {
4778
4779 int randi = round(randu() * (group_gridcell.at(v).size() - 1));
4780
4781 int group_index = group_gridcell.at(v).at(randi);
4782
4783 deleted_groups.push_back(group_index);
4784
4785 leaf_area_current.at(v) -= reconstructed_alphamasks_size.at(group_index).x * reconstructed_alphamasks_size.at(group_index).y * solidfraction;
4786 }
4787
4788 } else { // not enough leaf area in gridcell
4789
4790 while (leaf_area_current.at(v) < leaf_area_total) {
4791
4792 int randi = round(randu() * (group_gridcell.at(v).size() - 1));
4793
4794 int group_index = group_gridcell.at(v).at(randi);
4795
4796 helios::vec3 cellsize = getCellSize(v);
4797 helios::vec3 cellcenter = getCellCenter(v);
4798 float rotation = getCellRotation(v);
4799 helios::vec3 cellanchor = getCellGlobalAnchor(v);
4800
4801 // helios::vec3 shift = reconstructed_alphamasks_center.at(group_index) + helios::make_vec3( 0.45*(randu()-0.5)*cellsize.x, 0.45*(randu()-0.5)*cellsize.y, 0.45*(randu()-0.5)*cellsize.z ); //uniform shift about group
4802 helios::vec3 shift = reconstructed_alphamasks_center.at(group_index) + helios::make_vec3(0.25 * randn(generator) * cellsize.x, 0.25 * randn(generator) * cellsize.y, 0.25 * randn(generator) * cellsize.z); // Gaussian shift about group
4803 // helios::vec3 shift = cellcenter + helios::make_vec3( (randu()-0.5)*cellsize.x, (randu()-0.5)*cellsize.y, (randu()-0.5)*cellsize.z ); //uniform shift within voxel
4804 shift = rotatePointAboutLine(shift, cellanchor, make_vec3(0, 0, 1), rotation);
4805
4806 if (group_index >= reconstructed_alphamasks_center.size()) {
4807 helios_runtime_error("FAILED: " + std::to_string(group_index) + " " + std::to_string(reconstructed_alphamasks_center.size()) + " " + std::to_string(randi));
4808 } else if (reconstructed_alphamasks_gridcell.at(group_index) != v) {
4809 helios_runtime_error("FAILED: selected leaf group is not from this grid cell");
4810 }
4811
4812 reconstructed_alphamasks_center.push_back(shift);
4813 reconstructed_alphamasks_size.push_back(reconstructed_alphamasks_size.at(group_index));
4814 reconstructed_alphamasks_rotation.push_back(reconstructed_alphamasks_rotation.at(group_index));
4815 reconstructed_alphamasks_gridcell.push_back(v);
4816 reconstructed_alphamasks_direct_flag.push_back(0);
4817
4818 leaf_area_current.at(v) += reconstructed_alphamasks_size.at(group_index).x * reconstructed_alphamasks_size.at(group_index).y * solidfraction;
4819
4820 backfill_count++;
4821 }
4822 }
4823 }
4824
4825 for (uint v = 0; v < Ncells; v++) {
4826
4827 float leaf_area_total = getCellLeafArea(v);
4828
4829 float current_area = 0;
4830 for (uint i = 0; i < reconstructed_alphamasks_size.size(); i++) {
4831 if (reconstructed_alphamasks_gridcell.at(i) == v) {
4832 current_area += reconstructed_alphamasks_size.at(i).x * reconstructed_alphamasks_size.at(i).y * solidfraction;
4833 }
4834 }
4835 }
4836
4837 if (printmessages) {
4838 cout << "Backfilled " << backfill_count << " total leaf groups." << endl;
4839 cout << "Deleted " << deleted_groups.size() << " total leaf groups." << endl;
4840 }
4841
4842 for (int i = deleted_groups.size() - 1; i >= 0; i--) {
4843 int group_index = deleted_groups.at(i);
4844 if (group_index >= 0 && group_index < reconstructed_alphamasks_center.size()) {
4845 // use swap-and-pop method
4846 std::swap(reconstructed_alphamasks_center.at(group_index), reconstructed_alphamasks_center.back());
4847 reconstructed_alphamasks_center.pop_back();
4848 std::swap(reconstructed_alphamasks_size.at(group_index), reconstructed_alphamasks_size.back());
4849 reconstructed_alphamasks_size.pop_back();
4850 std::swap(reconstructed_alphamasks_rotation.at(group_index), reconstructed_alphamasks_rotation.back());
4851 reconstructed_alphamasks_rotation.pop_back();
4852 std::swap(reconstructed_alphamasks_gridcell.at(group_index), reconstructed_alphamasks_gridcell.back());
4853 reconstructed_alphamasks_gridcell.pop_back();
4854 std::swap(reconstructed_alphamasks_direct_flag.at(group_index), reconstructed_alphamasks_direct_flag.back());
4855 reconstructed_alphamasks_direct_flag.pop_back();
4856 }
4857 }
4858
4859 backfill_warnings.report(std::cerr);
4860
4861 if (printmessages) {
4862 cout << "done." << endl;
4863 }
4864}
4865
4866void LiDARcloud::calculateLeafAngleCDF(uint Nbins, std::vector<std::vector<float>> &CDF_theta, std::vector<std::vector<float>> &CDF_phi) {
4867
4868 uint Ncells = getGridCellCount();
4869
4870 std::vector<std::vector<float>> PDF_theta, PDF_phi;
4871 CDF_theta.resize(Ncells);
4872 PDF_theta.resize(Ncells);
4873 CDF_phi.resize(Ncells);
4874 PDF_phi.resize(Ncells);
4875 for (uint v = 0; v < Ncells; v++) {
4876 CDF_theta.at(v).resize(Nbins, 0.f);
4877 PDF_theta.at(v).resize(Nbins, 0.f);
4878 CDF_phi.at(v).resize(Nbins, 0.f);
4879 PDF_phi.at(v).resize(Nbins, 0.f);
4880 }
4881 float db_theta = 0.5 * M_PI / Nbins;
4882 float db_phi = 2.f * M_PI / Nbins;
4883
4884 // calculate PDF from triangulated hit points (not reconstructed triangles)
4885 for (size_t t = 0; t < triangles.size(); t++) {
4886 float triangle_area = triangles.at(t).area;
4887 int gridcell = triangles.at(t).gridcell;
4888
4889 if (gridcell >= 0 && gridcell < (int) Ncells) { // Valid grid cell
4890 helios::vec3 normal = cross(triangles.at(t).vertex1 - triangles.at(t).vertex0, triangles.at(t).vertex2 - triangles.at(t).vertex0);
4891 normal.z = fabs(normal.z); // keep in upper hemisphere
4892
4893 helios::SphericalCoord normal_dir = cart2sphere(normal);
4894
4895 int bin_theta = floor(normal_dir.zenith / db_theta);
4896 if (bin_theta >= Nbins) {
4897 bin_theta = Nbins - 1;
4898 }
4899
4900 int bin_phi = floor(normal_dir.azimuth / db_phi);
4901 if (bin_phi >= Nbins) {
4902 bin_phi = Nbins - 1;
4903 }
4904
4905 PDF_theta.at(gridcell).at(bin_theta) += triangle_area;
4906 PDF_phi.at(gridcell).at(bin_phi) += triangle_area;
4907 }
4908 }
4909
4910 // calculate PDF from CDF
4911 for (uint v = 0; v < Ncells; v++) {
4912 for (uint i = 0; i < Nbins; i++) {
4913 for (uint j = 0; j <= i; j++) {
4914 CDF_theta.at(v).at(i) += PDF_theta.at(v).at(j);
4915 CDF_phi.at(v).at(i) += PDF_phi.at(v).at(j);
4916 }
4917 }
4918 }
4919
4920 // char filename[50];
4921 // std::ofstream file_theta, file_phi;
4922 // for (uint v = 0; v < Ncells; v++) {
4923 // sprintf(filename, "../output/PDF_theta%d.txt", v);
4924 // file_theta.open(filename);
4925 // sprintf(filename, "../output/PDF_phi%d.txt", v);
4926 // file_phi.open(filename);
4927 // for (uint i = 0; i < Nbins; i++) {
4928 // file_theta << PDF_theta.at(v).at(i) << std::endl;
4929 // file_phi << PDF_phi.at(v).at(i) << std::endl;
4930 // }
4931 // file_theta.close();
4932 // file_phi.close();
4933 // }
4934}
4935
4937
4938 // loop through the vertices of the voxel grid.
4939 std::vector<helios::vec3> grid_vertices;
4940 helios::vec3 boxmin, boxmax;
4941 getGridBoundingBox(boxmin, boxmax); // axis aligned bounding box of all grid cells
4942 grid_vertices.push_back(boxmin);
4943 grid_vertices.push_back(boxmax);
4944 grid_vertices.push_back(helios::make_vec3(boxmin.x, boxmin.y, boxmax.z));
4945 grid_vertices.push_back(helios::make_vec3(boxmax.x, boxmax.y, boxmin.z));
4946 grid_vertices.push_back(helios::make_vec3(boxmin.x, boxmax.y, boxmin.z));
4947 grid_vertices.push_back(helios::make_vec3(boxmin.x, boxmax.y, boxmax.z));
4948 grid_vertices.push_back(helios::make_vec3(boxmax.x, boxmin.y, boxmin.z));
4949 grid_vertices.push_back(helios::make_vec3(boxmax.x, boxmin.y, boxmax.z));
4950
4951 float max_theta = 0;
4952 float min_theta = M_PI;
4953 float max_phi = 0;
4954 float min_phi = 2 * M_PI;
4955 for (uint gg = 0; gg < grid_vertices.size(); gg++) {
4956 helios::vec3 direction_cart = grid_vertices.at(gg) - getScanOrigin(source);
4957 helios::SphericalCoord sc = cart2sphere(direction_cart);
4958
4959 if (sc.azimuth < min_phi) {
4960 min_phi = sc.azimuth;
4961 }
4962
4963 if (sc.azimuth > max_phi) {
4964 max_phi = sc.azimuth;
4965 }
4966
4967 if (sc.zenith < min_theta) {
4968 min_theta = sc.zenith;
4969 }
4970
4971 if (sc.zenith > max_theta) {
4972 max_theta = sc.zenith;
4973 }
4974 }
4975
4976 vec2 theta_range = helios::make_vec2(min_theta, max_theta);
4977 vec2 phi_range = helios::make_vec2(min_phi, max_phi);
4978
4979 for (int r = (getHitCount() - 1); r >= 0; r--) {
4980 if (getHitScanID(r) == source) {
4982 float this_theta = raydir.zenith;
4983 float this_phi = raydir.azimuth;
4984 double this_phi_d = double(this_phi);
4985 setHitData(r, "beam_azimuth", this_phi_d);
4986 if (this_phi < phi_range.x || this_phi > phi_range.y || this_theta < theta_range.x || this_theta > theta_range.y) {
4987 deleteHitPoint(r);
4988 }
4989 }
4990 }
4991}
4992
4993// ========== SHARED METHODS FOR GPU AND CD IMPLEMENTATIONS ==========
4994
4995void LiDARcloud::computeGtheta(uint Ncells, uint Nscans, std::vector<float> &Gtheta, std::vector<float> &Gtheta_bar) {
4996
4997 // Initialize output vectors
4998 Gtheta.resize(Ncells, 0.f);
4999 Gtheta_bar.resize(Ncells, 0.f);
5000
5001 const size_t Ntri = getTriangleCount();
5002
5003 std::vector<float> denom_sum;
5004 denom_sum.resize(Ncells, 0.f);
5005 std::vector<uint> cell_tri_count;
5006 cell_tri_count.resize(Ncells, 0);
5007
5008 // Compute G(theta) for each triangle
5009 for (size_t t = 0; t < Ntri; t++) {
5010
5011 Triangulation tri = getTriangle(t);
5012 int cell = tri.gridcell;
5013
5014 if (cell >= 0 && cell < Ncells) { // triangle is inside a grid cell
5015
5016 helios::vec3 t0 = tri.vertex0;
5017 helios::vec3 t1 = tri.vertex1;
5018 helios::vec3 t2 = tri.vertex2;
5019
5020 helios::vec3 v0 = t1 - t0;
5021 helios::vec3 v1 = t2 - t0;
5022 helios::vec3 v2 = t2 - t1;
5023
5024 float L0 = v0.magnitude();
5025 float L1 = v1.magnitude();
5026 float L2 = v2.magnitude();
5027
5028 // Heron's formula for triangle area
5029 float S = 0.5f * (L0 + L1 + L2);
5030 float area = sqrt(S * (S - L0) * (S - L1) * (S - L2));
5031
5032 // Triangle normal from two edges sharing vertex t0 (standard convention). Sign is
5033 // irrelevant here since only fabs(normal . raydir) is used below.
5034 helios::vec3 normal = cross(v0, v1);
5035 normal.normalize();
5036
5037 helios::vec3 raydir = t0 - getScanOrigin(tri.scanID);
5038 raydir.normalize();
5039
5040 float theta = fabs(acos_safe(raydir.z));
5041
5042 // Skip degenerate triangles: Heron's formula yields NaN (slightly-negative radicand
5043 // from float error) or zero area for collinear/zero-extent vertices.
5044 if (std::isfinite(area) && area > 0.f) {
5045 float normal_dot_ray = fabs(normal * raydir);
5046 Gtheta.at(cell) += normal_dot_ray * area * fabs(sin(theta));
5047 denom_sum.at(cell) += fabs(sin(theta)) * area;
5048 cell_tri_count.at(cell) += 1;
5049 }
5050 }
5051 }
5052
5053 // Normalize by denominator and average over scans
5054 for (uint v = 0; v < Ncells; v++) {
5055 if (cell_tri_count[v] > 0) {
5056 Gtheta[v] = Gtheta[v] / denom_sum[v];
5057 Gtheta_bar[v] += Gtheta[v] / float(Nscans);
5058 }
5059 }
5060}
5061
5062bool LiDARcloud::invertLAD(uint voxel_index, float P, float Gtheta, const std::vector<float> &dr_samples, int min_voxel_hits, const helios::vec3 &gridsize, float &leaf_area, helios::WarningAggregator &warnings) {
5063
5064 // Validation checks
5065 if (Gtheta == 0 || Gtheta != Gtheta) { // Check for zero or NaN
5066 leaf_area = 0.0f;
5067 return false;
5068 }
5069
5070 if (dr_samples.size() < min_voxel_hits) {
5071 leaf_area = 0.0f;
5072 return false;
5073 }
5074
5075 // Secant method parameters
5076 float etol = 5e-5f;
5077 uint maxiter = 100;
5078
5079 // Relative-error denominator. P is a transmission probability in [0,1]; a fully
5080 // intercepted voxel (P==0, dense closed canopy) is physically valid but would make
5081 // the relative error fabs(mean-P)/P divide by zero. Clamp the denominator to a small
5082 // positive value so the secant iteration stays finite; the analytic fallback below
5083 // additionally guards -log(P).
5084 const float P_error_denom = fmax(P, 1e-6f);
5085
5086 // Initial guesses
5087 float a = 0.1f;
5088 float h = 0.01f;
5089
5090 // Compute initial error
5091 float mean = 0.f;
5092 for (size_t j = 0; j < dr_samples.size(); j++) {
5093 mean += exp(-a * dr_samples[j] * Gtheta);
5094 }
5095 mean /= float(dr_samples.size());
5096 float error = fabs(mean - P) / P_error_denom;
5097
5098 float tmp = a;
5099 a = a + h;
5100
5101 // Secant method iteration
5102 uint iter = 0;
5103 float aold, eold;
5104 while (error > etol && iter < maxiter) {
5105
5106 aold = tmp;
5107 eold = error;
5108
5109 mean = 0.f;
5110 for (size_t j = 0; j < dr_samples.size(); j++) {
5111 mean += exp(-a * dr_samples[j] * Gtheta);
5112 }
5113 mean /= float(dr_samples.size());
5114 error = fabs(mean - P) / P_error_denom;
5115
5116 tmp = a;
5117
5118 if (error == eold) {
5119 break; // No progress
5120 }
5121
5122 // Secant update
5123 a = fabs((aold * error - a * eold) / (error - eold));
5124 iter++;
5125 }
5126
5127 // Calculate mean dr
5128 float dr_bar = 0.0f;
5129 for (size_t i = 0; i < dr_samples.size(); i++) {
5130 dr_bar += dr_samples[i];
5131 }
5132 dr_bar /= float(dr_samples.size());
5133
5134 // Check convergence and use fallback if needed. The secant loop can terminate
5135 // without finding a root (the "no progress" break at error == eold, or hitting
5136 // maxiter), leaving 'a' near the 0.1 initial guess. Such a stall must NOT be
5137 // treated as converged: require the achieved error to actually be below the
5138 // tolerance, not merely that 'a' is finite. Otherwise a stalled solve silently
5139 // returns leaf_area ~= 0.1 * volume instead of the physically-correct fallback.
5140 bool converged = (error <= etol && a == a && a <= 100);
5141 bool used_fallback = false;
5142
5143 if (!converged) {
5144 warnings.addWarning("invertLAD_did_not_converge", "LAD inversion failed for volume #" + std::to_string(voxel_index) + ". Using average dr formulation.");
5145 a = (1.f - P) / (dr_bar * Gtheta);
5146 used_fallback = true;
5147 }
5148
5149 // Additional constraint for high LAD values
5150 if (a > 5) {
5151 a = fmin((1.f - P) / dr_bar / Gtheta, -log(P_error_denom) / dr_bar / Gtheta);
5152 }
5153
5154 // Compute final leaf area
5155 leaf_area = a * gridsize.x * gridsize.y * gridsize.z;
5156
5157 return true;
5158}
5159
5160LiDARcloud::LADInversionResult LiDARcloud::invertLADWithVariance(uint voxel_index, float P, float Gtheta, const std::vector<float> &dr_samples, float sum_frac_sq, float element_width, int min_voxel_hits, const helios::vec3 &gridsize,
5161 helios::WarningAggregator &warnings) {
5162
5163 // The point estimate is produced by the existing Beer-Lambert inversion (unchanged). On top of
5164 // it we compute the per-voxel statistical SAMPLING variance of LAD following Pimont et al.
5165 // (2018), RSE 215:343-370. The point estimator solves mean(exp(-a*dr*Gtheta)) = P, which for
5166 // equal path lengths is a = -log(1-I)/(dr_bar*Gtheta), i.e. the Beer-Lambert estimator with
5167 // a = LAD (the projection coefficient Gtheta is folded into the exponent). We therefore use the
5168 // Beer-Lambert delta-method variance, consistent with this estimator: with I = 1 - P,
5169 // d a / d I = 1 / ((1-I) * dr_bar * Gtheta)
5170 // var(a) = var(I) / ((1-I)^2 * dr_bar^2 * Gtheta^2)
5171 // and var(I) is the sum of (a) a finite-N sampling term and (b) an N-independent
5172 // element-position-variability term.
5173
5174 LADInversionResult result;
5175 result.beam_count = (int) dr_samples.size();
5176 result.I_rdi = 1.f - P;
5177
5178 // Point estimate (unchanged behavior).
5179 float leaf_area = 0.f;
5180 bool ok = invertLAD(voxel_index, P, Gtheta, dr_samples, min_voxel_hits, gridsize, leaf_area, warnings);
5181 result.leaf_area = leaf_area;
5182 result.converged = ok;
5183
5184 // Mean and variance of the per-beam path lengths.
5185 const int N = result.beam_count;
5186 if (N > 0) {
5187 float sum = 0.f;
5188 for (float d: dr_samples) {
5189 sum += d;
5190 }
5191 result.zbar_e = sum / float(N);
5192 float ss = 0.f;
5193 for (float d: dr_samples) {
5194 ss += (d - result.zbar_e) * (d - result.zbar_e);
5195 }
5196 result.var_path = ss / float(N);
5197 }
5198
5199 // Variance is only defined for a successful inversion with a usable geometry.
5200 const float dr_bar = result.zbar_e;
5201 if (!ok || N < min_voxel_hits || Gtheta <= 0.f || Gtheta != Gtheta || dr_bar <= 0.f) {
5202 result.LAD_variance = -1.f;
5203 return result;
5204 }
5205
5206 const float I = result.I_rdi;
5207 // Bounded RDI for numerical stability near the fully-intercepted (I -> 1) case (Pimont Eq. C26).
5208 const float I_b = std::min(I, 1.f - 1.f / (2.f * float(N) + 2.f));
5209
5210 // --- Term (a): finite-beam sampling variance of the RDI ---
5211 // Binomial variance I_b(1-I_b)/N is a provable upper bound on the variance of the per-beam
5212 // transmittance mean (a [0,1]-bounded statistic). For multi-return data the per-beam fraction
5213 // carries sub-beam information; we guard against model mismatch by taking the larger of the
5214 // binomial bound and the empirical variance of the per-beam fractions (for single-return data
5215 // the per-beam fraction is in {0,1}, so the empirical variance equals the binomial bound and
5216 // the guard is a no-op).
5217 const float binomial_varI = I_b * (1.f - I_b) / float(N);
5218 float empirical_varI = (sum_frac_sq / float(N) - P * P) / float(N); // var of the per-beam mean
5219 if (empirical_varI < 0.f) {
5220 empirical_varI = 0.f; // floating-point guard
5221 }
5222 const float var_I_a = std::max(binomial_varI, empirical_varI);
5223
5224 // --- Term (b): N-independent element-position-variability variance of the RDI ---
5225 // Requires the single-element optical depth L1 = lambda1*delta (Pimont Appendix A). For a flat
5226 // leaf of width w the mean element cross section is S1 = pi*w^2/8, giving L1 = pi*w^2/(8*delta^2)
5227 // with delta the characteristic voxel size. The asymptotic RDI variance is the empirical fit
5228 // sigma2_Iinf = 0.23 * L1 * (1-I) * I^(1.9 - 2.3*L1) (valid L1 < 0.3; Pimont Fig. 2).
5229 float var_I_b = 0.f;
5230 if (element_width > 0.f) {
5231 const float volume = gridsize.x * gridsize.y * gridsize.z;
5232 const float delta = std::cbrt(volume); // characteristic voxel size [m]
5233 const float L1 = (float) (M_PI) *element_width * element_width / (8.f * delta * delta);
5234 result.L1_element = L1;
5235 result.element_size_known = true;
5236 if (L1 < 0.3f && I > 0.f && I < 1.f) {
5237 var_I_b = 0.23f * L1 * (1.f - I) * std::pow(I, 1.9f - 2.3f * L1);
5238 if (var_I_b < 0.f) {
5239 var_I_b = 0.f;
5240 }
5241 }
5242 }
5243
5244 // Delta-method propagation to var(a). Use the bounded RDI in the denominator to stay finite.
5245 const float denom = (1.f - I_b) * (1.f - I_b) * dr_bar * dr_bar * Gtheta * Gtheta;
5246 result.LAD_variance = (var_I_a + var_I_b) / denom;
5247
5248 return result;
5249}
5250
5251bool LiDARcloud::ciValidPimont(float L, float L1, int N, float confidence_level) const {
5252 // Range-of-validity envelope for the confidence interval of the bias-corrected estimator
5253 // (Pimont et al. 2018, Table 3). Outside these ranges the Wald interval is not trustworthy, so
5254 // the caller refuses to emit one. When the element size was not supplied (L1 < 0) the
5255 // element-position term was omitted, leaving a sampling-only variance; we gate as if elements
5256 // were small (L1 = 0), which is the most favorable assumption (documented as sampling-only).
5257 const float l1 = (L1 < 0.f) ? 0.f : L1;
5258 if (confidence_level > 0.925f) { // 95% envelope
5259 return (L <= 2.0f && l1 <= 0.05f && N >= 30) || (L <= 2.5f && l1 <= 0.01f && N >= 150) || (l1 <= 0.05f && N >= 150);
5260 }
5261 // 90% envelope (also used as a conservative proxy for confidence levels below 0.90)
5262 return (L >= 0.5f && L <= 2.0f && l1 <= 0.05f && N >= 40) || (l1 <= 0.01f && N >= 100) || (l1 <= 0.05f && N >= 200);
5263}
5264
5268
5270 // Default characteristic element width of 5 cm (matches the leaf-reconstruction fallback). This
5271 // feeds only the element-position term of the LAD sampling-uncertainty estimate; the leaf-area
5272 // point estimate is independent of it.
5273 calculateLeafArea(context, min_voxel_hits, 0.05f);
5274}
5275
5276void LiDARcloud::calculateLeafArea(helios::Context *context, int min_voxel_hits, float element_width) {
5277 // Triangulation-derived G(theta) (the original behavior). Sentinel < 0 => compute G(theta) per voxel.
5278 calculateLeafArea_inner(context, min_voxel_hits, element_width, -1.f);
5279}
5280
5281void LiDARcloud::calculateLeafArea(helios::Context *context, float Gtheta, int min_voxel_hits, float element_width) {
5282 // Caller-supplied G(theta) for scans that cannot be triangulated (e.g. moving-platform scans).
5283 if (!(Gtheta > 0.f) || Gtheta > 1.f) {
5284 helios_runtime_error("ERROR (LiDARcloud::calculateLeafArea): The supplied G(theta) must be in the range (0,1], but " + std::to_string(Gtheta) + " was provided. Use 0.5 for a spherical (random) leaf-angle distribution.");
5285 }
5286 calculateLeafArea_inner(context, min_voxel_hits, element_width, Gtheta);
5287}
5288
5289void LiDARcloud::accumulateBeamCell(const uint *return_indices, size_t Nreturns, const std::vector<float> &dr, const std::vector<uint> &hit_location, float &P_equal_numerator, float &P_equal_denominator, float &P_equal_sumsq,
5290 std::vector<float> &dr_array_cell) {
5291
5292 float E_before = 0, E_inside = 0, E_after = 0;
5293 float drr = 0;
5294 int dr_count = 0; // Count returns with dr > 0
5295
5296 // Count returns in each location for this beam. Misses (transmitted beams)
5297 // are class-3 (after voxel), so they are folded into E_after here.
5298 for (size_t r = 0; r < Nreturns; r++) {
5299 uint local_index = return_indices[r];
5300 if (dr[local_index] > 0) {
5301 drr += dr[local_index];
5302 dr_count++;
5303 }
5304
5305 if (hit_location[local_index] == 1)
5306 E_before++;
5307 else if (hit_location[local_index] == 2)
5308 E_inside++;
5309 else if (hit_location[local_index] == 3)
5310 E_after++;
5311 }
5312
5313 // Equal weighting P calculation - simple average per Eq. 7
5314 // P = (1/B_tot) Σ[E_after / (E_inside + E_after)]. A fully-transmitted beam
5315 // (E_inside == 0, E_after >= 1) contributes frac = 1; a beam that only
5316 // terminated before the voxel (E_before only) contributes nothing.
5317 if (E_inside != 0 || E_after != 0) {
5318 float frac = E_after / (E_inside + E_after);
5319 P_equal_numerator += frac;
5320 P_equal_sumsq += frac * frac;
5321 P_equal_denominator += 1;
5322 }
5323
5324 // Average dr over returns that actually intersect voxel
5325 if (dr_count > 0) {
5326 float drrx = drr / float(dr_count);
5327 dr_array_cell.push_back(drrx);
5328 }
5329}
5330
5331// Ray-AABB slab test producing the entry (t0) and exit (t1) parameters for a single voxel. This uses the IDENTICAL
5332// per-axis expression as the brute-force inversion path ((min-origin)/dir, swap, max-of-mins / min-of-maxs) so that the
5333// DDA fast path classifies returns bit-for-bit the same as the brute-force path. Returns true if the ray's slab
5334// interval is non-empty (t0 < t1). Axis-parallel rays are handled by IEEE infinity arithmetic, as in the original.
5335static bool cellSlab(const helios::vec3 &origin, const helios::vec3 &direction, const helios::vec3 &voxel_min, const helios::vec3 &voxel_max, float &t0, float &t1) {
5336 float tx_min = (voxel_min.x - origin.x) / direction.x;
5337 float tx_max = (voxel_max.x - origin.x) / direction.x;
5338 if (tx_min > tx_max)
5339 std::swap(tx_min, tx_max);
5340
5341 float ty_min = (voxel_min.y - origin.y) / direction.y;
5342 float ty_max = (voxel_max.y - origin.y) / direction.y;
5343 if (ty_min > ty_max)
5344 std::swap(ty_min, ty_max);
5345
5346 float tz_min = (voxel_min.z - origin.z) / direction.z;
5347 float tz_max = (voxel_max.z - origin.z) / direction.z;
5348 if (tz_min > tz_max)
5349 std::swap(tz_min, tz_max);
5350
5351 t0 = std::max({tx_min, ty_min, tz_min});
5352 t1 = std::min({tx_max, ty_max, tz_max});
5353 return t0 < t1;
5354}
5355
5356// Ray-AABB slab test against the whole-grid bounding box, used to find where a beam enters/exits the lattice before
5357// running DDA. Handles axis-parallel rays explicitly (the fixed coordinate must lie within the box on that axis).
5358static bool rayGridIntersect(const helios::vec3 &origin, const helios::vec3 &direction, const helios::vec3 &grid_min, const helios::vec3 &grid_max, float &t_enter, float &t_exit) {
5359 const float o[3] = {origin.x, origin.y, origin.z};
5360 const float d[3] = {direction.x, direction.y, direction.z};
5361 const float lo[3] = {grid_min.x, grid_min.y, grid_min.z};
5362 const float hi[3] = {grid_max.x, grid_max.y, grid_max.z};
5363
5364 float tmin = -std::numeric_limits<float>::max();
5365 float tmax = std::numeric_limits<float>::max();
5366 for (int ax = 0; ax < 3; ax++) {
5367 if (fabs(d[ax]) < 1e-9f) {
5368 if (o[ax] < lo[ax] || o[ax] > hi[ax])
5369 return false; // parallel and outside the slab
5370 } else {
5371 float t1 = (lo[ax] - o[ax]) / d[ax];
5372 float t2 = (hi[ax] - o[ax]) / d[ax];
5373 if (t1 > t2)
5374 std::swap(t1, t2);
5375 tmin = std::max(tmin, t1);
5376 tmax = std::min(tmax, t2);
5377 if (tmin > tmax)
5378 return false;
5379 }
5380 }
5381 t_enter = tmin;
5382 t_exit = tmax;
5383 return true;
5384}
5385
5386LiDARcloud::VoxelLattice LiDARcloud::detectVoxelLattice() const {
5387
5388 VoxelLattice lattice;
5389
5390 const uint Ncells = getGridCellCount();
5391 if (Ncells == 0) {
5392 return lattice; // invalid (no cells)
5393 }
5394
5395 // Reference values taken from the first cell; every cell must agree for a regular lattice.
5396 const GridCell &ref = grid_cells.front();
5397 const helios::int3 count = ref.global_count;
5398 if (count.x <= 0 || count.y <= 0 || count.z <= 0) {
5399 return lattice;
5400 }
5401
5402 // The grid produced by addGrid() has exactly count.x*count.y*count.z cells. A different total
5403 // means the cells were assembled some other way and cannot be assumed to tile the lattice.
5404 const size_t expected_cells = (size_t) count.x * (size_t) count.y * (size_t) count.z;
5405 if ((size_t) Ncells != expected_cells) {
5406 return lattice;
5407 }
5408
5409 const helios::vec3 cell_extent = make_vec3(ref.global_size.x / float(count.x), ref.global_size.y / float(count.y), ref.global_size.z / float(count.z));
5410 const helios::vec3 lattice_origin = ref.global_anchor - ref.global_size * 0.5f;
5411
5412 // Tolerances scaled to the cell size so the checks are robust to float round-off in the stored centers.
5413 const float pos_tol = 1e-4f * std::max({cell_extent.x, cell_extent.y, cell_extent.z, 1e-6f});
5414 const float rot_tol = 1e-6f;
5415
5416 std::vector<int> ijk_to_index(expected_cells, -1);
5417
5418 for (uint c = 0; c < Ncells; c++) {
5419 const GridCell &cell = grid_cells.at(c);
5420
5421 // Shared lattice parameters
5422 if (cell.global_count.x != count.x || cell.global_count.y != count.y || cell.global_count.z != count.z) {
5423 return lattice;
5424 }
5425 if ((cell.global_anchor - ref.global_anchor).magnitude() > pos_tol || (cell.global_size - ref.global_size).magnitude() > pos_tol || fabs(cell.azimuthal_rotation - ref.azimuthal_rotation) > rot_tol) {
5426 return lattice;
5427 }
5428
5429 // Per-cell size must equal the lattice cell extent
5430 if (fabs(cell.size.x - cell_extent.x) > pos_tol || fabs(cell.size.y - cell_extent.y) > pos_tol || fabs(cell.size.z - cell_extent.z) > pos_tol) {
5431 return lattice;
5432 }
5433
5434 // global_ijk must be in range and unique
5435 const helios::int3 ijk = cell.global_ijk;
5436 if (ijk.x < 0 || ijk.x >= count.x || ijk.y < 0 || ijk.y >= count.y || ijk.z < 0 || ijk.z >= count.z) {
5437 return lattice;
5438 }
5439 const size_t flat = ((size_t) ijk.z * count.y + ijk.y) * count.x + ijk.x;
5440 if (ijk_to_index[flat] != -1) {
5441 return lattice; // duplicate ijk
5442 }
5443 ijk_to_index[flat] = (int) c;
5444
5445 // Stored center must match the lattice position for this ijk (un-rotated frame: addGrid stores
5446 // the un-rotated offset; see addGrid()/calculateHitGridCell()).
5447 const helios::vec3 expected_center = lattice_origin + make_vec3((float(ijk.x) + 0.5f) * cell_extent.x, (float(ijk.y) + 0.5f) * cell_extent.y, (float(ijk.z) + 0.5f) * cell_extent.z);
5448 if ((cell.center - expected_center).magnitude() > pos_tol) {
5449 return lattice;
5450 }
5451 }
5452
5453 lattice.valid = true;
5454 lattice.origin = lattice_origin;
5455 lattice.anchor = ref.global_anchor;
5456 lattice.cell_extent = cell_extent;
5457 lattice.rotation = ref.azimuthal_rotation;
5458 lattice.count = count;
5459 lattice.ijk_to_index = std::move(ijk_to_index);
5460
5461 return lattice;
5462}
5463
5464void LiDARcloud::calculateLeafArea_inner(helios::Context *context, int min_voxel_hits, float element_width, float supplied_Gtheta) {
5465
5466 const bool use_supplied_Gtheta = (supplied_Gtheta > 0.f);
5467
5468 if (printmessages) {
5469 std::cout << "Calculating leaf area (CollisionDetection)..." << std::endl;
5470 }
5471
5472 // Validation checks (same as GPU version). Triangulation is required only to estimate G(theta); when the caller
5473 // supplies G(theta) directly (e.g. for a moving-platform scan, which cannot be triangulated) it is not needed.
5474 if (!use_supplied_Gtheta && !triangulationcomputed) {
5475 helios_runtime_error("ERROR (LiDARcloud::calculateLeafAreaCD): Triangulation must be performed prior to leaf area calculation. See triangulateHitPoints(). For scans that cannot be triangulated (e.g. moving-platform scans), use the "
5476 "calculateLeafArea overload that takes a G(theta) argument.");
5477 }
5478
5479 if (!hitgridcellcomputed) {
5481 }
5482
5483 // Initialize CollisionDetection if needed
5485
5486 const uint Nscans = getScanCount();
5487 const uint Ncells = getGridCellCount();
5488
5489 // Leaf-area inversion requires the full fired-beam population, including pulses
5490 // that returned nothing (misses / transmitted beams) so that the per-voxel
5491 // transmission probability has a valid denominator. Misses must be supplied
5492 // upstream: either the imported scan format retains them, or gapfillMisses() was
5493 // run to synthesize them. We fail fast (rather than silently producing biased LAD)
5494 // if no misses are present. This single beam-based equal-weighting algorithm
5495 // handles both single- and multi-return data: each pulse's returns are grouped
5496 // into a beam (one return per beam for single-return data) and classified
5497 // before/inside/after/miss relative to each voxel; P = E_after/(E_inside+E_after).
5498 if (!hasMisses()) {
5500 "ERROR (LiDARcloud::calculateLeafArea): No miss points found in the point cloud. Leaf area inversion requires fired pulses that returned nothing (misses) in order to count transmitted beams. Provide a scan format that retains misses, or call gapfillMisses() to synthesize them, before calling calculateLeafArea().");
5501 }
5502
5503 if (printmessages) {
5504 if (isMultiReturnData()) {
5505 std::cout << "Multi-return data detected - using beam-based equal weighting algorithm (CD)" << std::endl;
5506 } else {
5507 std::cout << "Single-return data with misses - using beam-based equal weighting algorithm (CD)" << std::endl;
5508 }
5509 }
5510
5511 {
5512 // ============ BEAM-BASED EQUAL WEIGHTING ALGORITHM (CPU) ============
5513
5514 // Additional arrays for equal weighting P calculation
5515 std::vector<std::vector<float>> P_equal_numerator_array(Ncells);
5516 std::vector<std::vector<float>> P_equal_denominator_array(Ncells);
5517 // Sum of squared per-beam transmittance fractions, per scan per voxel. Used by the
5518 // LAD sampling-variance estimate to guard the binomial variance against the empirical
5519 // spread of multi-return per-beam fractions (see invertLADWithVariance()).
5520 std::vector<std::vector<float>> P_equal_sumsq_array(Ncells);
5521 std::vector<std::vector<float>> dr_array(Ncells);
5522
5523 // Initialize aggregation arrays
5524 std::vector<std::vector<float>> dr_agg;
5525 dr_agg.resize(Ncells);
5526 std::vector<float> Gtheta_bar;
5527 Gtheta_bar.resize(Ncells, 0.f);
5528
5529 // When the grid cells form a regular lattice (the common addGrid() case), walk each beam through only the
5530 // voxels it pierces (3D-DDA) instead of testing every hit against every voxel. This reduces the inversion from
5531 // O(Nscans * Ncells * Nhits) to O(Nscans * Nbeams * cells_pierced_per_beam). Grids that are not a regular
5532 // lattice (e.g. assembled cell-by-cell with mixed sizes/rotations) fall back to the brute-force per-cell loop.
5533 const VoxelLattice lattice = detectVoxelLattice();
5534
5535 // Process each scan
5536 for (uint s = 0; s < Nscans; s++) {
5537
5538 // Collect hits for this scan
5539 std::vector<helios::vec3> this_scan_xyz;
5540 std::vector<uint> this_scan_index;
5541 // global hit index -> local position in this scan's arrays. A flat vector (rather than a
5542 // std::map) keeps the per-beam lookups in the classification loop below O(1) and cache-friendly:
5543 // that loop performs ~Ncells * Nbeams lookups per scan (billions for a dense scan), so a
5544 // red-black-tree lookup here dominates the whole inversion. Unused entries stay at the sentinel.
5545 std::vector<uint> global_to_local(getHitCount(), 0);
5546 for (size_t r = 0; r < getHitCount(); r++) {
5547 if (getHitScanID(r) == s) {
5548 global_to_local[(uint) r] = (uint) this_scan_xyz.size();
5549 this_scan_xyz.push_back(getHitXYZ(r));
5550 this_scan_index.push_back(r);
5551 }
5552 }
5553 size_t Nhits = this_scan_xyz.size();
5554 if (Nhits == 0)
5555 continue;
5556
5557 // Group hits by timestamp into beams (CSR layout). beam_members holds GLOBAL hit indices; the
5558 // per-voxel classification arrays (dr, hit_location) are local to this scan, so beam members are
5559 // mapped back to local positions below.
5560 BeamGrouping beams = groupHitsByTimestamp(this_scan_index);
5561 uint Nbeams = beams.Nbeams;
5562
5563 // Per-hit beam emission origin. For a moving-platform scan each pulse was fired from a different position,
5564 // so the beam geometry (direction and voxel entry/exit) must be measured from that pulse's own origin -
5565 // getHitOrigin() returns the per-pulse origin for moving scans and the single scan origin for static scans.
5566 // Precomputed once here (out of the Ncells x Nhits hot loop, which would otherwise repeat the map lookups).
5567 std::vector<helios::vec3> this_scan_origin(Nhits);
5568 for (size_t i = 0; i < Nhits; i++) {
5569 this_scan_origin[i] = getHitOrigin(this_scan_index[i]);
5570 }
5571
5572 // Precompute each beam member's local (this-scan) return index once, in the same CSR layout as
5573 // beams (flat array indexed by beams.beam_offsets). A flat array avoids the per-beam allocation a
5574 // vector-of-vectors would incur (prohibitive for tens of millions of beams).
5575 std::vector<uint> beam_members_local(beams.beam_members.size());
5576 for (size_t m = 0; m < beams.beam_members.size(); m++) {
5577 beam_members_local[m] = global_to_local[beams.beam_members[m]];
5578 }
5579
5580 if (lattice.valid && !force_bruteforce_LAD) {
5581
5582 // -------- FAST PATH: per-beam 3D-DDA over the voxel lattice --------
5583
5584 // Walk one beam through the lattice, classifying its returns in each pierced voxel and accumulating
5585 // into the supplied per-cell scratch. Factored into a lambda so it can run with thread-private scratch.
5586 // The caller supplies reusable per-thread scratch buffers (ret_dist/dr_cell/hl_cell/local_seq) so the
5587 // hot per-beam path performs no heap allocation - with millions of beams, per-call allocation would
5588 // dominate the runtime and serialize the threads through the allocator lock.
5589 auto process_beam = [&](uint k, std::vector<float> &P_num, std::vector<float> &P_denom, std::vector<float> &P_sumsq, std::vector<std::vector<float>> &dr_cells, std::vector<float> &ret_dist, std::vector<float> &dr_cell,
5590 std::vector<uint> &hl_cell, std::vector<uint> &local_seq) {
5591 const uint beam_start = beams.beam_offsets[k];
5592 const size_t Nret = beams.beam_offsets[k + 1] - beam_start;
5593 if (Nret == 0)
5594 return;
5595
5596 // All returns of a pulse share one emission origin; use the first return's origin.
5597 helios::vec3 origin = this_scan_origin[beam_members_local[beam_start]];
5598
5599 // Beam direction: use the farthest return (typically the miss point) so the ray spans the
5600 // full beam. Transform origin and hit points into the un-rotated lattice frame (rotation is
5601 // shared across all cells and pivots on the lattice anchor - matches calculateHitGridCell()).
5602 helios::vec3 origin_L = origin;
5603 if (fabs(lattice.rotation) > 1e-6f) {
5604 origin_L = rotatePointAboutLine(origin - lattice.anchor, helios::make_vec3(0, 0, 0), helios::make_vec3(0, 0, 1), -lattice.rotation) + lattice.anchor;
5605 }
5606
5607 helios::vec3 direction(0, 0, 0);
5608 float max_hit_distance = -1.f;
5609 // Per-return distance from origin (lattice frame), into the reusable buffer.
5610 ret_dist.resize(Nret);
5611 for (size_t j = 0; j < Nret; j++) {
5612 uint i = beam_members_local[beam_start + j];
5613 helios::vec3 hit_L = this_scan_xyz[i];
5614 if (fabs(lattice.rotation) > 1e-6f) {
5615 hit_L = rotatePointAboutLine(hit_L - lattice.anchor, helios::make_vec3(0, 0, 0), helios::make_vec3(0, 0, 1), -lattice.rotation) + lattice.anchor;
5616 }
5617 helios::vec3 d = hit_L - origin_L;
5618 float dist = d.magnitude();
5619 ret_dist[j] = dist;
5620 if (dist > max_hit_distance) {
5621 max_hit_distance = dist;
5622 direction = d;
5623 }
5624 }
5625 if (max_hit_distance <= 0.f)
5626 return;
5627 direction.normalize();
5628
5629 // Slab-test the beam against the whole-grid AABB to find the entry/exit parameters.
5630 helios::vec3 grid_min = lattice.origin;
5631 helios::vec3 grid_max = lattice.origin + make_vec3(lattice.cell_extent.x * lattice.count.x, lattice.cell_extent.y * lattice.count.y, lattice.cell_extent.z * lattice.count.z);
5632
5633 float t_enter, t_exit;
5634 if (!rayGridIntersect(origin_L, direction, grid_min, grid_max, t_enter, t_exit))
5635 return; // beam misses the grid entirely
5636
5637 float t_start = std::max(t_enter, 0.f);
5638 if (t_exit <= 1e-6f)
5639 return; // grid entirely behind the origin
5640
5641 // Amanatides-Woo DDA initialization.
5642 helios::vec3 entry = origin_L + direction * t_start;
5643 int ijk[3];
5644 int step[3];
5645 float tMax[3];
5646 float tDelta[3];
5647 const float origin_arr[3] = {origin_L.x, origin_L.y, origin_L.z};
5648 const float dir_arr[3] = {direction.x, direction.y, direction.z};
5649 const float entry_arr[3] = {entry.x, entry.y, entry.z};
5650 const float gmin_arr[3] = {grid_min.x, grid_min.y, grid_min.z};
5651 const float extent_arr[3] = {lattice.cell_extent.x, lattice.cell_extent.y, lattice.cell_extent.z};
5652 const int count_arr[3] = {lattice.count.x, lattice.count.y, lattice.count.z};
5653 for (int ax = 0; ax < 3; ax++) {
5654 int idx = (int) std::floor((entry_arr[ax] - gmin_arr[ax]) / extent_arr[ax]);
5655 if (idx < 0)
5656 idx = 0;
5657 if (idx >= count_arr[ax])
5658 idx = count_arr[ax] - 1;
5659 ijk[ax] = idx;
5660 if (fabs(dir_arr[ax]) < 1e-9f) {
5661 // Ray parallel to this axis' slabs: never crosses a boundary on this axis.
5662 step[ax] = 0;
5663 tMax[ax] = std::numeric_limits<float>::max();
5664 tDelta[ax] = std::numeric_limits<float>::max();
5665 } else {
5666 step[ax] = (dir_arr[ax] > 0) ? 1 : -1;
5667 float next_boundary = gmin_arr[ax] + float(idx + (step[ax] > 0 ? 1 : 0)) * extent_arr[ax];
5668 tMax[ax] = (next_boundary - origin_arr[ax]) / dir_arr[ax];
5669 tDelta[ax] = extent_arr[ax] / fabs(dir_arr[ax]);
5670 }
5671 }
5672
5673 // Walk the lattice from entry to exit, classifying the beam's returns in each pierced voxel.
5674 // dr_cell/hl_cell/local_seq are the caller's reusable buffers; size them to this beam.
5675 dr_cell.assign(Nret, 0.f);
5676 hl_cell.assign(Nret, 0);
5677 local_seq.resize(Nret);
5678 for (uint j = 0; j < Nret; j++)
5679 local_seq[j] = j;
5680
5681 const size_t max_steps = (size_t) count_arr[0] + count_arr[1] + count_arr[2] + 3;
5682 for (size_t stepcount = 0; stepcount <= max_steps; stepcount++) {
5683 if (ijk[0] < 0 || ijk[0] >= count_arr[0] || ijk[1] < 0 || ijk[1] >= count_arr[1] || ijk[2] < 0 || ijk[2] >= count_arr[2])
5684 break;
5685
5686 const size_t flat = ((size_t) ijk[2] * count_arr[1] + ijk[1]) * count_arr[0] + ijk[0];
5687 int cell_index = lattice.ijk_to_index[flat];
5688 if (cell_index >= 0) {
5689 // Compute (t0,t1) from this cell's own corners with the SAME slab expression the
5690 // brute-force path uses, so classification is bit-identical (not from running tMax).
5691 helios::vec3 cmin = lattice.origin + make_vec3(ijk[0] * lattice.cell_extent.x, ijk[1] * lattice.cell_extent.y, ijk[2] * lattice.cell_extent.z);
5692 helios::vec3 cmax = cmin + lattice.cell_extent;
5693
5694 float ct0, ct1;
5695 if (cellSlab(origin_L, direction, cmin, cmax, ct0, ct1) && ct1 > 1e-6f) {
5696 float drval = fabs(ct1 - ct0);
5697 // Classify each return of this beam against [ct0,ct1].
5698 for (size_t j = 0; j < Nret; j++) {
5699 float hd = ret_dist[j];
5700 dr_cell[j] = drval;
5701 if (hd >= ct0 && hd <= ct1)
5702 hl_cell[j] = 2;
5703 else if (hd > ct1)
5704 hl_cell[j] = 3;
5705 else
5706 hl_cell[j] = 1;
5707 }
5708 accumulateBeamCell(local_seq.data(), Nret, dr_cell, hl_cell, P_num[cell_index], P_denom[cell_index], P_sumsq[cell_index], dr_cells[cell_index]);
5709 }
5710 }
5711
5712 // Advance to the next voxel along the smallest tMax.
5713 int axis = 0;
5714 if (tMax[1] < tMax[axis])
5715 axis = 1;
5716 if (tMax[2] < tMax[axis])
5717 axis = 2;
5718 if (tMax[axis] > t_exit)
5719 break; // exited the grid
5720 if (step[axis] == 0)
5721 break; // no further progress possible
5722 ijk[axis] += step[axis];
5723 tMax[axis] += tDelta[axis];
5724 }
5725 };
5726
5727 // Per-(scan,cell) accumulators. The brute-force path pushes one value per scan into
5728 // P_equal_*_array[c]; with per-beam nesting we accumulate here and flush once at scan end.
5729 std::vector<float> P_num_scratch(Ncells, 0.f);
5730 std::vector<float> P_denom_scratch(Ncells, 0.f);
5731 std::vector<float> P_sumsq_scratch(Ncells, 0.f);
5732 std::vector<std::vector<float>> dr_scratch(Ncells);
5733
5734 // Parallelize over beams (independent). Each thread accumulates into private scratch, then the threads'
5735 // results are reduced in ascending thread-id order. The dr-sample order still depends on how beams are
5736 // distributed across threads, so the per-voxel dr mean differs at the FP-rounding level from the serial
5737 // order - immaterial to the inversion (which uses the mean and count), exactly as the prior
5738 // OpenMP-over-hits brute-force path was already order-dependent.
5739#ifdef _OPENMP
5740 int num_threads = omp_get_max_threads();
5741#else
5742 int num_threads = 1;
5743#endif
5744 std::vector<std::vector<float>> P_num_thread(num_threads, std::vector<float>(Ncells, 0.f));
5745 std::vector<std::vector<float>> P_denom_thread(num_threads, std::vector<float>(Ncells, 0.f));
5746 std::vector<std::vector<float>> P_sumsq_thread(num_threads, std::vector<float>(Ncells, 0.f));
5747 std::vector<std::vector<std::vector<float>>> dr_thread(num_threads, std::vector<std::vector<float>>(Ncells));
5748
5749#pragma omp parallel
5750 {
5751#ifdef _OPENMP
5752 int tid = omp_get_thread_num();
5753#else
5754 int tid = 0;
5755#endif
5756 // Per-thread reusable scratch buffers (no per-beam allocation in the hot loop).
5757 std::vector<float> ret_dist, dr_cell;
5758 std::vector<uint> hl_cell, local_seq;
5759#pragma omp for schedule(dynamic, 256)
5760 for (int k = 0; k < static_cast<int>(Nbeams); k++) {
5761 process_beam((uint) k, P_num_thread[tid], P_denom_thread[tid], P_sumsq_thread[tid], dr_thread[tid], ret_dist, dr_cell, hl_cell, local_seq);
5762 }
5763 }
5764
5765 // Deterministic reduction across threads (ascending thread id).
5766 for (int t = 0; t < num_threads; t++) {
5767 for (uint c = 0; c < Ncells; c++) {
5768 P_num_scratch[c] += P_num_thread[t][c];
5769 P_denom_scratch[c] += P_denom_thread[t][c];
5770 P_sumsq_scratch[c] += P_sumsq_thread[t][c];
5771 for (float v: dr_thread[t][c])
5772 dr_scratch[c].push_back(v);
5773 }
5774 }
5775
5776 // Flush per-(scan,cell) accumulators into the cross-scan arrays.
5777 for (uint c = 0; c < Ncells; c++) {
5778 P_equal_numerator_array.at(c).push_back(P_num_scratch[c]);
5779 P_equal_denominator_array.at(c).push_back(P_denom_scratch[c]);
5780 P_equal_sumsq_array.at(c).push_back(P_sumsq_scratch[c]);
5781 for (float v: dr_scratch[c])
5782 dr_array.at(c).push_back(v);
5783 }
5784
5785 } else {
5786
5787 // -------- FALLBACK PATH: brute-force per-cell slab test (non-lattice grids) --------
5788
5789 // CPU-based voxel intersection with hit_location classification
5790 std::vector<float> dr(Nhits, 0.0f);
5791 std::vector<uint> hit_location(Nhits, 0);
5792
5793 // Process each voxel
5794 for (uint c = 0; c < Ncells; c++) {
5795
5796 helios::vec3 center = getCellCenter(c);
5797 helios::vec3 size = getCellSize(c);
5798 float rotation = getCellRotation(c);
5799 helios::vec3 anchor = getCellGlobalAnchor(c); // rotate about the grid anchor (matches calculateHitGridCell())
5800
5801 // Reset for this voxel
5802 std::fill(dr.begin(), dr.end(), 0.0f);
5803 std::fill(hit_location.begin(), hit_location.end(), 0);
5804
5805// Test each hit against this voxel (CPU/OpenMP)
5806#pragma omp parallel for
5807 for (int i = 0; i < static_cast<int>(Nhits); i++) {
5808 helios::vec3 hit_xyz = this_scan_xyz[i];
5809 helios::vec3 origin = this_scan_origin[i]; // this beam's emission origin (per-pulse for moving scans)
5810
5811 // Inverse rotate if needed. Apply the same inverse rotation (about the grid anchor) to BOTH
5812 // the hit point and the beam origin so the ray-voxel geometry stays consistent with hit binning.
5813 if (fabs(rotation) > 1e-6f) {
5814 hit_xyz = rotatePointAboutLine(hit_xyz - anchor, helios::make_vec3(0, 0, 0), helios::make_vec3(0, 0, 1), -rotation) + anchor;
5815 origin = rotatePointAboutLine(origin - anchor, helios::make_vec3(0, 0, 0), helios::make_vec3(0, 0, 1), -rotation) + anchor;
5816 }
5817
5818 // Ray from origin to hit
5819 helios::vec3 direction = hit_xyz - origin;
5820 float hit_distance = direction.magnitude();
5821 direction.normalize();
5822
5823 // AABB bounds
5824 helios::vec3 voxel_min = center - size * 0.5f;
5825 helios::vec3 voxel_max = center + size * 0.5f;
5826
5827 // Ray-AABB intersection (slab method)
5828 float tx_min = (voxel_min.x - origin.x) / direction.x;
5829 float tx_max = (voxel_max.x - origin.x) / direction.x;
5830 if (tx_min > tx_max)
5831 std::swap(tx_min, tx_max);
5832
5833 float ty_min = (voxel_min.y - origin.y) / direction.y;
5834 float ty_max = (voxel_max.y - origin.y) / direction.y;
5835 if (ty_min > ty_max)
5836 std::swap(ty_min, ty_max);
5837
5838 float tz_min = (voxel_min.z - origin.z) / direction.z;
5839 float tz_max = (voxel_max.z - origin.z) / direction.z;
5840 if (tz_min > tz_max)
5841 std::swap(tz_min, tz_max);
5842
5843 float t0 = std::max({tx_min, ty_min, tz_min});
5844 float t1 = std::min({tx_max, ty_max, tz_max});
5845
5846 // Classify each hit/beam termination by where it lies along the beam
5847 // relative to this voxel's entry (t0) and exit (t1):
5848 // 1 = before voxel (return stopped short of it)
5849 // 2 = inside voxel (return terminated within it)
5850 // 3 = after voxel (beam passed through and terminated beyond the exit)
5851 // A "miss" (a fired pulse that returned nothing, placed far out along the
5852 // beam) is, geometrically, simply a beam that passed through and kept going,
5853 // so it is class-3 like any other transmitted beam. Classification is
5854 // therefore purely geometric and INDEPENDENT of the absolute placement
5855 // distance of the miss point: a miss at 1001 m and a miss at 20000 m both
5856 // classify as "after voxel" and contribute identically to the transmission
5857 // probability P. This is the correct Beer-Lambert treatment - a transmitted
5858 // beam is a transmission event regardless of where (or whether) it eventually
5859 // returned.
5860 if (t0 < t1 && t1 > 1e-6f) {
5861 dr[i] = fabs(t1 - t0);
5862
5863 if (hit_distance >= t0 && hit_distance <= t1) {
5864 hit_location[i] = 2; // Inside voxel
5865 } else if (hit_distance > t1) {
5866 hit_location[i] = 3; // After voxel (transmitted through, incl. misses)
5867 } else if (hit_distance < t0) {
5868 hit_location[i] = 1; // Before voxel
5869 }
5870 }
5871 }
5872
5873 // Beam-level processing
5874 float P_equal_numerator = 0;
5875 float P_equal_denominator = 0;
5876 float P_equal_sumsq = 0; // sum of squared per-beam fractions (for sampling-variance guard)
5877
5878 for (uint k = 0; k < Nbeams; k++) {
5879 accumulateBeamCell(&beam_members_local[beams.beam_offsets[k]], beams.beamSize(k), dr, hit_location, P_equal_numerator, P_equal_denominator, P_equal_sumsq, dr_array.at(c));
5880 }
5881
5882 P_equal_numerator_array.at(c).push_back(P_equal_numerator);
5883 P_equal_denominator_array.at(c).push_back(P_equal_denominator);
5884 P_equal_sumsq_array.at(c).push_back(P_equal_sumsq);
5885 }
5886 }
5887 }
5888
5889 // Obtain G(theta) per voxel. Normally computed from triangulation; when the caller supplied a value (e.g. for a
5890 // moving-platform scan that cannot be triangulated), apply that single value to every voxel instead.
5891 std::vector<float> Gtheta;
5892 if (use_supplied_Gtheta) {
5893 Gtheta.assign(Ncells, supplied_Gtheta);
5894 } else {
5895 computeGtheta(Ncells, Nscans, Gtheta, Gtheta_bar);
5896 }
5897
5898 // LAD inversion with equal weighting P
5899 if (printmessages) {
5900 std::cout << "Inverting to find LAD..." << std::flush;
5901 }
5902
5903 helios::WarningAggregator invertLAD_warnings;
5904 invertLAD_warnings.setEnabled(printmessages);
5905
5906 for (uint v = 0; v < Ncells; v++) {
5907 // Calculate P using equal weighting formula
5908 float P = 0.0f;
5909 float P_num_sum = 0.0f, P_denom_sum = 0.0f, P_sumsq_sum = 0.0f;
5910 for (uint s = 0; s < P_equal_numerator_array[v].size(); s++) {
5911 P_num_sum += P_equal_numerator_array[v][s];
5912 P_denom_sum += P_equal_denominator_array[v][s];
5913 P_sumsq_sum += P_equal_sumsq_array[v][s];
5914 }
5915 if (P_denom_sum > 0) {
5916 P = P_num_sum / P_denom_sum;
5917 }
5918
5919 // Aggregate dr across all scans
5920 for (uint s = 0; s < dr_array[v].size(); s++) {
5921 if (dr_array[v][s] > 0) {
5922 dr_agg[v].push_back(dr_array[v][s]);
5923 }
5924 }
5925
5926 // Apply min_voxel_hits filtering
5927 if (dr_agg[v].size() < min_voxel_hits) {
5928 setCellLeafArea(0, v);
5929 setCellGtheta(Gtheta[v], v);
5930 grid_cells.at(v).beam_count = (int) dr_agg[v].size();
5931 grid_cells.at(v).LAD_variance = -1.f;
5932 grid_cells.at(v).ci_valid = false;
5933 continue;
5934 }
5935
5936 // Invert for leaf area AND its sampling variance. The point estimate is unchanged from
5937 // the shared invertLAD(); the additional output quantifies statistical sampling
5938 // uncertainty (Pimont et al. 2018), which is stored on the grid cell.
5939 helios::vec3 gridsize = getCellSize(v);
5940 LADInversionResult inv = invertLADWithVariance(v, P, Gtheta[v], dr_agg[v], P_sumsq_sum, element_width, min_voxel_hits, gridsize, invertLAD_warnings);
5941
5942 setCellLeafArea(inv.leaf_area, v);
5943 setCellGtheta(Gtheta[v], v);
5944
5945 GridCell &cell = grid_cells.at(v);
5946 cell.beam_count = inv.beam_count;
5947 cell.I_rdi = inv.I_rdi;
5948 cell.zbar_e = inv.zbar_e;
5949 cell.var_path = inv.var_path;
5950 cell.L1_element = inv.L1_element;
5951 cell.LAD_variance = inv.LAD_variance;
5952
5953 // Pre-evaluate CI validity at the 95% level for export / quick filtering. Per-query
5954 // accessors re-check validity at the requested confidence level. L = lambda*delta is the
5955 // voxel optical depth: lambda = a*Gtheta and a = leaf_area/volume, so L = a*Gtheta*zbar_e.
5956 const float volume = gridsize.x * gridsize.y * gridsize.z;
5957 const float a = (volume > 0.f) ? inv.leaf_area / volume : 0.f;
5958 const float L = a * Gtheta[v] * inv.zbar_e;
5959 cell.ci_valid = (inv.LAD_variance >= 0.f) && ciValidPimont(L, inv.L1_element, inv.beam_count, 0.95f);
5960 }
5961
5962 invertLAD_warnings.report(std::cerr);
5963
5964 if (printmessages) {
5965 std::cout << "done." << std::endl;
5966 }
5967 }
5968}
5969
5970// Deprecated wrapper functions for backward compatibility
5974
5976 calculateLeafArea(context, min_voxel_hits);
5977}
5978
5980 if (collision_detection != nullptr) {
5981 collision_detection->enableGPUAcceleration();
5982 }
5983}
5984
5986 if (collision_detection != nullptr) {
5987 collision_detection->disableGPUAcceleration();
5988 }
5989}
5990
5992 return CollisionDetection::isGPUAvailable(); // static - valid even before the instance exists
5993}
5994
5996 // collision_detection is created lazily; before then the effective state is the
5997 // constructor default, which is isGPUAvailable().
5998 return collision_detection != nullptr ? collision_detection->isGPUAccelerationEnabled() : CollisionDetection::isGPUAvailable();
5999}
6000
6002
6003 if (printmessages) {
6004 std::cout << "Grouping hit points by grid cell (CPU)..." << std::flush;
6005 }
6006
6007 const size_t total_hits = getHitCount();
6008 const uint Ncells = getGridCellCount();
6009
6010 if (total_hits == 0) {
6011 std::cout << "WARNING (calculateHitGridCellCD): There are no hits currently in the point cloud. Skipping grid cell binning calculation." << std::endl;
6012 return;
6013 }
6014
6015 // Hoist the (constant) voxel geometry out of the per-hit loop into flat arrays. The inner loop
6016 // below runs up to total_hits * Ncells times (billions for a dense scan), so re-fetching each
6017 // cell's center/anchor/size/rotation through the bounds-checked getters every iteration dominates
6018 // the cost. Caching them once in contiguous vectors keeps the hot loop reading from cache instead.
6019 std::vector<helios::vec3> cell_min(Ncells), cell_max(Ncells), cell_anchor(Ncells);
6020 std::vector<float> cell_rotation(Ncells);
6021 std::vector<bool> cell_rotated(Ncells);
6022 for (uint c = 0; c < Ncells; c++) {
6023 helios::vec3 center = getCellCenter(c);
6024 helios::vec3 size = getCellSize(c);
6025 cell_min[c] = center - size * 0.5f;
6026 cell_max[c] = center + size * 0.5f;
6027 cell_anchor[c] = getCellGlobalAnchor(c);
6028 cell_rotation[c] = getCellRotation(c);
6029 cell_rotated[c] = (fabs(cell_rotation[c]) > 1e-6f);
6030 }
6031
6032// Process each hit point (parallelized with OpenMP)
6033#pragma omp parallel for schedule(dynamic, 1000)
6034 for (int r = 0; r < static_cast<int>(total_hits); r++) {
6035
6036 helios::vec3 hit_xyz = getHitXYZ(r);
6037 int assigned_cell = -1; // Default: not in any cell
6038
6039 // Test against each voxel. The original ray-from-origin slab test reduces exactly to a
6040 // point-in-AABB containment test: the ray is cast from the origin through the hit point P,
6041 // so it passes through P at parameter T = |P|, and "T lies within the box's [t0,t1] entry/exit
6042 // interval" is true iff P lies within the box bounds. (The old t1 > 1e-6 guard only excluded a
6043 // box containing the origin, which cannot happen for a hit point away from the origin.)
6044 for (uint c = 0; c < Ncells; c++) {
6045
6046 // Inverse rotate hit point into the voxel's local axis-aligned frame if the voxel is rotated.
6047 helios::vec3 p = hit_xyz;
6048 if (cell_rotated[c]) {
6049 p = rotatePointAboutLine(hit_xyz - cell_anchor[c], helios::make_vec3(0, 0, 0), helios::make_vec3(0, 0, 1), -cell_rotation[c]) + cell_anchor[c];
6050 }
6051
6052 const helios::vec3 &lo = cell_min[c];
6053 const helios::vec3 &hi = cell_max[c];
6054 if (p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y && p.z >= lo.z && p.z <= hi.z) {
6055 assigned_cell = c;
6056 break; // Found the cell, stop searching
6057 }
6058 }
6059
6060 // Store result (thread-safe due to unique index per thread)
6061 setHitGridCell(r, assigned_cell);
6062 }
6063
6064 if (printmessages) {
6065 std::cout << "done." << std::endl;
6066 }
6067
6068 hitgridcellcomputed = true;
6069}
6070
6071bool LiDARcloud::isMultiReturnData() const {
6072 // Check if any hit has target_count > 1 (multi-return indicator)
6073 for (size_t r = 0; r < getHitCount(); r++) {
6074 if (doesHitDataExist(r, "target_count")) {
6075 if (getHitData(r, "target_count") > 1) {
6076 // Multi-return data requires timestamp for beam grouping
6077 if (!doesHitDataExist(r, "timestamp")) {
6078 helios_runtime_error("ERROR (isMultiReturnData): Multi-return data detected (target_count > 1) but 'timestamp' field is missing. Cannot group hits into beams.");
6079 }
6080 // Multi-return data requires target_index for triangulation filtering
6081 if (!doesHitDataExist(r, "target_index")) {
6082 helios_runtime_error("ERROR (isMultiReturnData): Multi-return data detected (target_count > 1) but 'target_index' field is missing. Cannot filter first returns for triangulation.");
6083 }
6084 return true;
6085 }
6086 }
6087 }
6088 return false;
6089}
6090
6091bool LiDARcloud::isHitMiss(uint index) const {
6092 // Canonical miss flag (matches the Python `is_miss` LAS extra dimension): a hit is
6093 // a "miss" (the pulse was fired but returned nothing - transmitted to the sky) when
6094 // is_miss == 1. This flag is the durable contract and is set by every path that
6095 // produces misses (the importer, gapfillMisses(), and syntheticScan()), so the
6096 // distance fallback below is not reached for any data Helios produces. Note that the
6097 // placement distance of a miss point is path-dependent: gapfillMisses() uses
6098 // LIDAR_MISS_DISTANCE, while syntheticScan() leaves misses at the ray-tracer no-hit
6099 // distance LIDAR_RAYTRACE_MISS_T so they classify as transmitted-through beams in the
6100 // leaf-area inversion. Miss classification must therefore key on the flag, not distance.
6101 if (doesHitDataExist(index, "is_miss")) {
6102 return getHitData(index, "is_miss") != 0.0;
6103 }
6104 // Interim fallback for legacy data that predates the is_miss flag: treat a return whose
6105 // range from its scan origin reaches the gapfill miss sentinel distance as a miss. This
6106 // only catches the gapfillMisses() convention; flagged data never reaches this line.
6107 helios::vec3 d = getHitXYZ(index) - getScanOrigin(getHitScanID(index));
6108 return d.magnitude() >= 0.98f * LIDAR_MISS_DISTANCE;
6109}
6110
6112 for (size_t r = 0; r < getHitCount(); r++) {
6113 if (isHitMiss(r)) {
6114 return true;
6115 }
6116 }
6117 return false;
6118}
6119
6120LiDARcloud::BeamGrouping LiDARcloud::groupHitsByTimestamp(const std::vector<uint> &scan_indices) const {
6121
6122 BeamGrouping result;
6123
6124 if (scan_indices.empty()) {
6125 result.Nbeams = 0;
6126 return result;
6127 }
6128
6129 const size_t N = scan_indices.size();
6130
6131 // Timestamp groups multiple returns of one pulse into a single beam. When the
6132 // data has no timestamp (e.g. single-return data where each pulse yields at most
6133 // one return), there is nothing to group: each hit is its own one-return beam.
6134 bool has_timestamp = true;
6135 for (uint idx: scan_indices) {
6136 if (!doesHitDataExist(idx, "timestamp")) {
6137 has_timestamp = false;
6138 break;
6139 }
6140 }
6141 if (!has_timestamp) {
6142 // Each hit is its own beam. CSR layout: members are the scan indices in order, offsets are 0,1,2,...,N.
6143 result.Nbeams = (uint) N;
6144 result.beam_members = scan_indices;
6145 result.beam_offsets.resize(N + 1);
6146 for (size_t i = 0; i <= N; i++) {
6147 result.beam_offsets[i] = (uint) i;
6148 }
6149 return result;
6150 }
6151
6152 // Cache each hit's timestamp once (getHitData is a per-call lookup; the sort below would otherwise
6153 // call it O(N log N) times). Sort indices by timestamp so returns of the same pulse are contiguous.
6154 std::vector<double> timestamps(N);
6155 for (size_t i = 0; i < N; i++) {
6156 timestamps[i] = getHitData(scan_indices[i], "timestamp");
6157 }
6158 std::vector<uint> order(N);
6159 for (size_t i = 0; i < N; i++) {
6160 order[i] = (uint) i;
6161 }
6162 std::sort(order.begin(), order.end(), [&](uint a, uint b) { return timestamps[a] < timestamps[b]; });
6163
6164 // Build the CSR layout in one pass: members are the timestamp-sorted scan indices, and a new beam
6165 // starts wherever the timestamp changes.
6166 result.beam_members.resize(N);
6167 result.beam_offsets.clear();
6168 result.beam_offsets.push_back(0);
6169 double previous_time = 0.0;
6170 for (size_t i = 0; i < N; i++) {
6171 uint si = order[i];
6172 result.beam_members[i] = scan_indices[si];
6173 if (i == 0) {
6174 previous_time = timestamps[si];
6175 } else if (timestamps[si] != previous_time) {
6176 result.beam_offsets.push_back((uint) i);
6177 previous_time = timestamps[si];
6178 }
6179 }
6180 result.beam_offsets.push_back((uint) N);
6181 result.Nbeams = (uint) result.beam_offsets.size() - 1;
6182
6183 return result;
6184}
6185
6187
6188 std::vector<uint> UUIDs_all = context->getAllUUIDs();
6189 const uint N = UUIDs_all.size();
6190 const uint Ncells = getGridCellCount();
6191
6192 // Result: which voxel each primitive belongs to (-1 if none)
6193 std::vector<int> prim_vol(N, -1);
6194
6195// CPU/OpenMP version of primitive-to-voxel assignment
6196#pragma omp parallel for
6197 for (int p = 0; p < static_cast<int>(N); p++) {
6198 std::vector<helios::vec3> verts = context->getPrimitiveVertices(UUIDs_all[p]);
6199 helios::vec3 prim_xyz = verts[0]; // Use first vertex
6200
6201 // Test against each voxel (same logic as calculateHitGridCellCD)
6202 for (uint c = 0; c < Ncells; c++) {
6203 helios::vec3 center = getCellCenter(c);
6205 helios::vec3 size = getCellSize(c);
6206 float rotation = getCellRotation(c);
6207
6208 // Inverse rotate primitive position if voxel is rotated
6209 helios::vec3 prim_xyz_rot = prim_xyz;
6210 if (fabs(rotation) > 1e-6f) {
6211 prim_xyz_rot = rotatePointAboutLine(prim_xyz - anchor, helios::make_vec3(0, 0, 0), helios::make_vec3(0, 0, 1), -rotation) + anchor;
6212 }
6213
6214 // Point-in-AABB test (treating as ray from origin for consistency with GPU kernel)
6215 helios::vec3 origin_pt = helios::make_vec3(0, 0, 0);
6216 helios::vec3 direction = prim_xyz_rot - origin_pt;
6217 direction.normalize();
6218
6219 // AABB bounds
6220 float x0 = center.x - 0.5f * size.x;
6221 float x1 = center.x + 0.5f * size.x;
6222 float y0 = center.y - 0.5f * size.y;
6223 float y1 = center.y + 0.5f * size.y;
6224 float z0 = center.z - 0.5f * size.z;
6225 float z1 = center.z + 0.5f * size.z;
6226
6227 // Slab method
6228 float tx_min = (x0 - origin_pt.x) / direction.x;
6229 float tx_max = (x1 - origin_pt.x) / direction.x;
6230 if (tx_min > tx_max)
6231 std::swap(tx_min, tx_max);
6232
6233 float ty_min = (y0 - origin_pt.y) / direction.y;
6234 float ty_max = (y1 - origin_pt.y) / direction.y;
6235 if (ty_min > ty_max)
6236 std::swap(ty_min, ty_max);
6237
6238 float tz_min = (z0 - origin_pt.z) / direction.z;
6239 float tz_max = (z1 - origin_pt.z) / direction.z;
6240 if (tz_min > tz_max)
6241 std::swap(tz_min, tz_max);
6242
6243 float t0 = std::max({tx_min, ty_min, tz_min});
6244 float t1 = std::min({tx_max, ty_max, tz_max});
6245
6246 // Check if primitive is inside voxel
6247 if (t0 < t1 && t1 > 1e-6f) {
6248 float T = (prim_xyz_rot - origin_pt).magnitude();
6249 if (T >= t0 && T <= t1) {
6250 prim_vol[p] = c;
6251 break; // Found the voxel
6252 }
6253 }
6254 }
6255 }
6256
6257 // Sum primitive areas per voxel
6258 std::vector<float> total_area(Ncells, 0.f);
6259 for (size_t p = 0; p < N; p++) {
6260 if (prim_vol[p] >= 0) {
6261 uint gridcell = prim_vol[p];
6262 total_area[gridcell] += context->getPrimitiveArea(UUIDs_all[p]);
6263 context->setPrimitiveData(UUIDs_all[p], "gridCell", gridcell);
6264 }
6265 }
6266
6267 // Per-voxel leaf area to return
6268 std::vector<float> output_LeafArea(Ncells);
6269 for (uint v = 0; v < Ncells; v++) {
6270 output_LeafArea[v] = total_area[v];
6271 }
6272
6273 // Annotate each primitive with the total leaf area of its containing voxel. Iterate
6274 // primitives (indexed 0..N), NOT cells: UUIDs_all is sized by primitive count, so
6275 // indexing it by a cell index is out of bounds whenever Ncells > N and otherwise maps
6276 // the wrong UUID to a voxel total.
6277 for (size_t p = 0; p < N; p++) {
6278 if (prim_vol[p] >= 0) {
6279 context->setPrimitiveData(UUIDs_all[p], "synthetic_leaf_area", total_area[prim_vol[p]]);
6280 }
6281 }
6282
6283 return output_LeafArea;
6284}
6285
6286namespace {
6288
6309 std::vector<std::vector<float>> detectReturnsFromSubrays(std::vector<std::vector<float>> &t_pulse, float total_pulse_weight, int Npulse, float range_resolution, float detection_threshold, int max_returns,
6310 SingleReturnSelection single_return_selection, float miss_distance) {
6311
6312 std::vector<std::vector<float>> t_hit;
6313 if (t_pulse.empty()) {
6314 return t_hit;
6315 }
6316
6317 std::sort(t_pulse.begin(), t_pulse.end(), [](const std::vector<float> &a, const std::vector<float> &b) { return a[0] < b[0]; });
6318
6319 // Total emitted beam energy used to normalize intensity into an energy fraction. Falls back to the sub-ray count
6320 // (so equal weights reproduce the historical intensity = sum(cos)/Npulse) if the weight sum is degenerate.
6321 const float denom = (total_pulse_weight > 0.f) ? total_pulse_weight : float(Npulse);
6322
6323 // Group sorted sub-ray hits into returns. A hit joins the current return if it lies within range_resolution of the
6324 // return's first (nearest) member; otherwise it opens a new return. This is the peak-resolution of the waveform:
6325 // members within one pulse range-extent are unresolved and merge into one (blended) return.
6326 size_t i = 0;
6327 while (i < t_pulse.size()) {
6328 const float t0 = t_pulse[i][0];
6329 double sum_w = 0.0, sum_wt = 0.0, sum_wt2 = 0.0, sum_wcos = 0.0;
6330 int count = 0;
6331 float lastID = t_pulse[i][2];
6332 size_t j = i;
6333 while (j < t_pulse.size() && (t_pulse[j][0] - t0) <= range_resolution) {
6334 const float t = t_pulse[j][0];
6335 const float cosval = t_pulse[j][1];
6336 const float w = t_pulse[j][3];
6337 sum_w += w;
6338 sum_wt += double(w) * t;
6339 sum_wt2 += double(w) * double(t) * t;
6340 sum_wcos += double(w) * cosval;
6341 lastID = t_pulse[j][2];
6342 count++;
6343 j++;
6344 }
6345 i = j;
6346
6347 const bool is_miss = (t0 >= 0.98f * miss_distance);
6348 const float distance = (sum_w > 0.0) ? float(sum_wt / sum_w) : t0;
6349 float intensity;
6350 float echo_width;
6351 if (is_miss) {
6352 // Pure-miss cluster (misses sit at miss_distance and never group with real hits). Preserve the historical
6353 // sentinel: a fully transmitted beam (every sub-ray missed) is flagged with intensity 1, a partial-miss tail 0.
6354 intensity = (count == Npulse) ? 1.0f : 0.0f;
6355 echo_width = 0.f;
6356 } else {
6357 intensity = float(sum_wcos / denom);
6358 double var = (sum_w > 0.0) ? (sum_wt2 / sum_w - double(distance) * distance) : 0.0;
6359 if (var < 0.0) {
6360 var = 0.0; // guard against round-off for a single-member return
6361 }
6362 echo_width = sqrtf(range_resolution * range_resolution + float(var));
6363 }
6364
6365 t_hit.push_back({distance, intensity, float(count), lastID, echo_width});
6366 }
6367
6368 // Detection threshold (noise floor): discard real returns whose echo amplitude is too weak to be detected. Miss
6369 // sentinels are always retained so transmitted beams remain recorded for leaf-area inversion.
6370 if (detection_threshold > 0.f) {
6371 std::vector<std::vector<float>> kept;
6372 kept.reserve(t_hit.size());
6373 for (const auto &h: t_hit) {
6374 const bool h_is_miss = (h[0] >= 0.98f * miss_distance);
6375 if (h_is_miss || fabsf(h[1]) >= detection_threshold) {
6376 kept.push_back(h);
6377 }
6378 }
6379 t_hit.swap(kept);
6380 }
6381
6382 // Limited-return mode (max_returns >= 1): keep at most max_returns real returns per pulse, selected by the policy.
6383 // max_returns <= 0 is unlimited (discrete multi-return): every detected return is reported, including any miss
6384 // sentinel alongside real returns, so this path is left untouched. For the limited case the kept real returns are
6385 // re-ordered nearest-first so the downstream target_index assignment stays in range order. Miss handling: a pulse
6386 // with no real return keeps a single miss sentinel (a transmitted beam, needed for leaf-area inversion); a pulse
6387 // with real returns drops the miss sentinel so only real points are reported (matching a real discrete instrument).
6388 // The SINGLE_RETURN_STRONGEST_PLUS_LAST policy is special: it reports the strongest-plus-last pair (1 or 2 returns)
6389 // and ignores max_returns entirely.
6390 if (max_returns > 0) {
6391 std::vector<size_t> real_idx;
6392 real_idx.reserve(t_hit.size());
6393 for (size_t k = 0; k < t_hit.size(); k++) {
6394 const bool h_is_miss = (t_hit[k][0] >= 0.98f * miss_distance);
6395 if (!h_is_miss) {
6396 real_idx.push_back(k);
6397 }
6398 }
6399
6400 if (real_idx.empty()) {
6401 // No real return: keep exactly the first (nearest) miss sentinel so the transmitted beam is still recorded.
6402 if (t_hit.size() > 1) {
6403 std::vector<std::vector<float>> only_miss{t_hit[0]};
6404 t_hit.swap(only_miss);
6405 }
6406 } else if (single_return_selection == SINGLE_RETURN_STRONGEST_PLUS_LAST) {
6407 // Strongest-plus-last dual return: keep the strongest echo and the last (farthest) return of the pulse,
6408 // deduplicated to one point when they coincide. This is "pick these two specific returns", not a top-N
6409 // ranking, so it bypasses the partial_sort path and ignores max_returns (it yields 1 or 2 returns).
6410 // real_idx is ascending by range, so its last entry is the farthest (last) return.
6411 const size_t last_idx = real_idx.back();
6412 size_t strongest_idx = real_idx.front();
6413 for (const size_t k: real_idx) {
6414 if (fabsf(t_hit[k][1]) > fabsf(t_hit[strongest_idx][1])) {
6415 strongest_idx = k;
6416 }
6417 }
6418 std::vector<size_t> ranked;
6419 if (strongest_idx == last_idx) {
6420 ranked = {last_idx}; // strongest IS the last return: report a single point, not a doubled one.
6421 } else {
6422 ranked = {strongest_idx, last_idx};
6423 // Re-order the kept pair nearest-first so target_index downstream stays in range order.
6424 std::sort(ranked.begin(), ranked.end(), [&](size_t a, size_t b) { return t_hit[a][0] < t_hit[b][0]; });
6425 }
6426 std::vector<std::vector<float>> kept;
6427 kept.reserve(ranked.size());
6428 for (const size_t k: ranked) {
6429 kept.push_back(t_hit[k]);
6430 }
6431 t_hit.swap(kept);
6432 } else {
6433 std::vector<size_t> ranked = real_idx; // misses are never selectable
6434 if (int(ranked.size()) > max_returns) {
6435 // Rank the real returns by the selection policy and keep the strongest/nearest/farthest max_returns.
6436 auto better = [&](size_t a, size_t b) {
6437 if (single_return_selection == SINGLE_RETURN_STRONGEST) {
6438 return fabsf(t_hit[a][1]) > fabsf(t_hit[b][1]);
6439 } else if (single_return_selection == SINGLE_RETURN_FIRST) {
6440 return t_hit[a][0] < t_hit[b][0];
6441 } else { // SINGLE_RETURN_LAST
6442 return t_hit[a][0] > t_hit[b][0];
6443 }
6444 };
6445 std::partial_sort(ranked.begin(), ranked.begin() + max_returns, ranked.end(), better);
6446 ranked.resize(size_t(max_returns));
6447 // Re-order the kept subset nearest-first so target_index downstream stays in range order.
6448 std::sort(ranked.begin(), ranked.end(), [&](size_t a, size_t b) { return t_hit[a][0] < t_hit[b][0]; });
6449 }
6450 // ranked now holds the kept real returns in ascending range order (real_idx was already ascending).
6451 std::vector<std::vector<float>> kept;
6452 kept.reserve(ranked.size());
6453 for (const size_t k: ranked) {
6454 kept.push_back(t_hit[k]);
6455 }
6456 t_hit.swap(kept);
6457 }
6458 }
6459
6460 return t_hit;
6461 }
6462} // namespace
6463
6465 syntheticScan(context, 1, 0, false, false, true);
6466}
6467
6469 syntheticScan(context, 1, 0, false, false, append);
6470}
6471
6472void LiDARcloud::syntheticScan(helios::Context *context, bool scan_grid_only, bool record_misses) {
6473 syntheticScan(context, 1, 0, scan_grid_only, record_misses, true);
6474}
6475
6476void LiDARcloud::syntheticScan(helios::Context *context, bool scan_grid_only, bool record_misses, bool append) {
6477 syntheticScan(context, 1, 0, scan_grid_only, record_misses, append);
6478}
6479
6480void LiDARcloud::syntheticScan(helios::Context *context, int rays_per_pulse, float pulse_distance_threshold) {
6481 syntheticScan(context, rays_per_pulse, pulse_distance_threshold, false, false, true);
6482}
6483
6484void LiDARcloud::syntheticScan(helios::Context *context, int rays_per_pulse, float pulse_distance_threshold, bool append) {
6485 syntheticScan(context, rays_per_pulse, pulse_distance_threshold, false, false, append);
6486}
6487
6488void LiDARcloud::syntheticScan(helios::Context *context, int rays_per_pulse, float pulse_distance_threshold, bool scan_grid_only, bool record_misses) {
6489 syntheticScan(context, rays_per_pulse, pulse_distance_threshold, scan_grid_only, record_misses, true);
6490}
6491
6492void LiDARcloud::syntheticScan(helios::Context *context, int rays_per_pulse, float pulse_distance_threshold, ReturnMode return_mode, bool scan_grid_only, bool record_misses, bool append) {
6493 // Apply the requested return mode to every scan for the duration of this call, then restore the stored per-scan values.
6494 // The master implementation below reads the return mode (and the other waveform parameters) from each scan's metadata,
6495 // so this lets a caller select the mode at the call site without permanently mutating the scan configuration.
6496 std::vector<ReturnMode> saved_modes(scans.size());
6497 for (size_t s = 0; s < scans.size(); s++) {
6498 saved_modes[s] = scans[s].returnMode;
6499 scans[s].returnMode = return_mode;
6500 }
6501 syntheticScan(context, rays_per_pulse, pulse_distance_threshold, scan_grid_only, record_misses, append);
6502 for (size_t s = 0; s < scans.size(); s++) {
6503 scans[s].returnMode = saved_modes[s];
6504 }
6505}
6506
6507void LiDARcloud::syntheticScan(helios::Context *context, int rays_per_pulse, float pulse_distance_threshold, bool scan_grid_only, bool record_misses, bool append) {
6508
6509 // Clear existing hit data if not appending
6510 if (!append) {
6511 clearHits();
6512 Nhits = 0;
6513 // Reset hit tables for each scan
6514 for (auto &hit_table: hit_tables) {
6515 hit_table.resize(hit_table.Ntheta, hit_table.Nphi, -1);
6516 }
6517 hitgridcellcomputed = false;
6518 triangulationcomputed = false;
6519 }
6520
6521 int Npulse;
6522 if (rays_per_pulse < 1) {
6523 Npulse = 1;
6524 } else {
6525 Npulse = rays_per_pulse;
6526 }
6527
6528 if (printmessages) {
6529 if (Npulse > 1) {
6530 std::cout << "Performing multi-return synthetic LiDAR scan..." << std::endl;
6531 } else {
6532 std::cout << "Performing single-return synthetic LiDAR scan..." << std::endl;
6533 }
6534 }
6535
6536 if (getScanCount() == 0) {
6537 std::cout << "WARNING (syntheticScan): No scans added to the point cloud. Exiting.." << std::endl;
6538 return;
6539 }
6540
6541 // Ray-tracer no-hit threshold: a traced ray that intersects nothing returns this t value
6542 // (see performUnifiedRayTracing). Used below to classify whether a beam hit a primitive.
6543 // This is NOT where miss points are placed in the cloud (that is LIDAR_MISS_DISTANCE).
6544 float miss_distance = LIDAR_RAYTRACE_MISS_T;
6545
6546 helios::vec3 bb_center;
6547 helios::vec3 bb_size;
6548
6549 if (scan_grid_only == false) {
6550
6551 // Determine bounding box for Context geometry
6552 helios::vec2 xbounds, ybounds, zbounds;
6553 context->getDomainBoundingBox(xbounds, ybounds, zbounds);
6554 bb_center = helios::make_vec3(xbounds.x + 0.5 * (xbounds.y - xbounds.x), ybounds.x + 0.5 * (ybounds.y - ybounds.x), zbounds.x + 0.5 * (zbounds.y - zbounds.x));
6555 bb_size = helios::make_vec3(xbounds.y - xbounds.x, ybounds.y - ybounds.x, zbounds.y - zbounds.x);
6556
6557 // Pad any degenerate (zero-extent) axis so the AABB slab cull does not reject every ray for planar/flat
6558 // scene geometry (e.g. a single patch or a flat wall with no thickness). A zero-thickness axis forces the
6559 // slab test's entry and exit parameters to coincide (t0 == t1), failing the strict t0 < t1 test below for
6560 // any ray actually pointing at the plane. Giving the axis a tiny finite thickness keeps the slab test (and
6561 // its 1/ray_dir division for axis-aligned rays) well-conditioned without affecting non-degenerate geometry.
6562 const float bb_pad = 1e-4f;
6563 if (bb_size.x < bb_pad)
6564 bb_size.x = bb_pad;
6565 if (bb_size.y < bb_pad)
6566 bb_size.y = bb_pad;
6567 if (bb_size.z < bb_pad)
6568 bb_size.z = bb_pad;
6569
6570 } else {
6571
6572 // Determine bounding box for voxels instead of whole domain
6573 helios::vec3 boxmin, boxmax;
6574 getGridBoundingBox(boxmin, boxmax);
6575 bb_center = helios::make_vec3(boxmin.x + 0.5 * (boxmax.x - boxmin.x), boxmin.y + 0.5 * (boxmax.y - boxmin.y), boxmin.z + 0.5 * (boxmax.z - boxmin.z));
6576 bb_size = helios::make_vec3(boxmax.x - boxmin.x, boxmax.y - boxmin.y, boxmax.z - boxmin.z);
6577
6578 // Pad any degenerate (zero-extent) axis so the AABB slab cull does not reject every ray for a single-layer
6579 // (flat) voxel grid. See the matching note in the domain-bounding-box branch above.
6580 const float bb_pad = 1e-4f;
6581 if (bb_size.x < bb_pad)
6582 bb_size.x = bb_pad;
6583 if (bb_size.y < bb_pad)
6584 bb_size.y = bb_pad;
6585 if (bb_size.z < bb_pad)
6586 bb_size.z = bb_pad;
6587 }
6588
6589 // get geometry information and copy to GPU
6590
6591 size_t c = 0;
6592
6593 std::map<std::string, int> textures;
6594 std::map<std::string, helios::int2> texture_size;
6595 std::map<std::string, std::vector<std::vector<bool>>> texture_data;
6596 int tID = 0;
6597
6598 std::vector<uint> UUIDs_all = context->getAllUUIDs();
6599
6600 std::vector<uint> ID_mapping;
6601
6602 //----- PATCHES ----- //
6603
6604 // figure out how many patches
6605 size_t Npatches = 0;
6606 for (int p = 0; p < UUIDs_all.size(); p++) {
6607 if (context->getPrimitiveType(UUIDs_all.at(p)) == helios::PRIMITIVE_TYPE_PATCH) {
6608 Npatches++;
6609 }
6610 }
6611
6612 ID_mapping.resize(Npatches);
6613
6614 helios::vec3 *patch_vertex = (helios::vec3 *) malloc(4 * Npatches * sizeof(helios::vec3)); // allocate host memory
6615 int *patch_textureID = (int *) malloc(Npatches * sizeof(int)); // allocate host memory
6616 helios::vec2 *patch_uv = (helios::vec2 *) malloc(2 * Npatches * sizeof(helios::vec2)); // allocate host memory
6617
6618 c = 0;
6619 for (int p = 0; p < UUIDs_all.size(); p++) {
6620 uint UUID = UUIDs_all.at(p);
6621 if (context->getPrimitiveType(UUID) == helios::PRIMITIVE_TYPE_PATCH) {
6622 std::vector<helios::vec3> verts = context->getPrimitiveVertices(UUID);
6623 patch_vertex[4 * c] = verts.at(0);
6624 patch_vertex[4 * c + 1] = verts.at(1);
6625 patch_vertex[4 * c + 2] = verts.at(2);
6626 patch_vertex[4 * c + 3] = verts.at(3);
6627
6628 ID_mapping.at(c) = UUIDs_all.at(p);
6629
6630 if (!context->getPrimitiveTextureFile(UUID).empty() && context->primitiveTextureHasTransparencyChannel(UUID)) {
6631 std::string tex = context->getPrimitiveTextureFile(UUID);
6632 std::map<std::string, int>::iterator it = textures.find(tex);
6633 if (it != textures.end()) { // texture already exits
6634 patch_textureID[c] = textures.at(tex);
6635 } else { // new texture
6636 patch_textureID[c] = tID;
6637 textures[tex] = tID;
6638 helios::int2 tsize = context->getPrimitiveTextureSize(UUID);
6639 texture_size[tex] = helios::make_int2(tsize.x, tsize.y);
6640 texture_data[tex] = *context->getPrimitiveTextureTransparencyData(UUID);
6641 tID++;
6642 }
6643
6644 std::vector<helios::vec2> uv = context->getPrimitiveTextureUV(UUID);
6645 if (uv.size() == 4) { // custom uv coordinates
6646 patch_uv[2 * c] = uv.at(1);
6647 patch_uv[2 * c + 1] = uv.at(3);
6648 } else { // default uv coordinates
6649 patch_uv[2 * c] = helios::make_vec2(0, 0);
6650 patch_uv[2 * c + 1] = helios::make_vec2(1, 1);
6651 }
6652
6653 } else {
6654 patch_textureID[c] = -1;
6655 }
6656
6657 c++;
6658 }
6659 }
6660
6661 // GPU allocations removed - performUnifiedRayTracing uses CollisionDetection instead
6662
6663 //----- TRIANGLES ----- //
6664
6665 // figure out how many triangles
6666 size_t Ntriangles = 0;
6667 for (int p = 0; p < UUIDs_all.size(); p++) {
6668 if (context->getPrimitiveType(UUIDs_all.at(p)) == helios::PRIMITIVE_TYPE_TRIANGLE) {
6669 Ntriangles++;
6670 }
6671 }
6672
6673 ID_mapping.resize(Npatches + Ntriangles);
6674
6675 helios::vec3 *tri_vertex = (helios::vec3 *) malloc(3 * Ntriangles * sizeof(helios::vec3)); // allocate host memory
6676 int *tri_textureID = (int *) malloc(Ntriangles * sizeof(int)); // allocate host memory
6677 helios::vec2 *tri_uv = (helios::vec2 *) malloc(3 * Ntriangles * sizeof(helios::vec2)); // allocate host memory
6678
6679 c = 0;
6680 for (int p = 0; p < UUIDs_all.size(); p++) {
6681 uint UUID = UUIDs_all.at(p);
6682 if (context->getPrimitiveType(UUID) == helios::PRIMITIVE_TYPE_TRIANGLE) {
6683 std::vector<helios::vec3> verts = context->getPrimitiveVertices(UUID);
6684 tri_vertex[3 * c] = verts.at(0);
6685 tri_vertex[3 * c + 1] = verts.at(1);
6686 tri_vertex[3 * c + 2] = verts.at(2);
6687
6688 ID_mapping.at(Npatches + c) = UUIDs_all.at(p);
6689
6690 if (!context->getPrimitiveTextureFile(UUID).empty() && context->primitiveTextureHasTransparencyChannel(UUID)) {
6691 std::string tex = context->getPrimitiveTextureFile(UUID);
6692 std::map<std::string, int>::iterator it = textures.find(tex);
6693 if (it != textures.end()) { // texture already exits
6694 tri_textureID[c] = textures.at(tex);
6695 } else { // new texture
6696 tri_textureID[c] = tID;
6697 textures[tex] = tID;
6698 helios::int2 tsize = context->getPrimitiveTextureSize(UUID);
6699 texture_size[tex] = helios::make_int2(tsize.x, tsize.y);
6700 texture_data[tex] = *context->getPrimitiveTextureTransparencyData(UUID);
6701 tID++;
6702 }
6703
6704 std::vector<helios::vec2> uv = context->getPrimitiveTextureUV(UUID);
6705 assert(uv.size() == 3);
6706 tri_uv[3 * c] = uv.at(0);
6707 tri_uv[3 * c + 1] = uv.at(1);
6708 tri_uv[3 * c + 2] = uv.at(2);
6709
6710 } else {
6711 tri_textureID[c] = -1;
6712 }
6713
6714 c++;
6715 }
6716 }
6717
6718 // GPU allocations removed - performUnifiedRayTracing uses CollisionDetection instead
6719
6720 // transfer texture data to GPU
6721 const int Ntextures = textures.size();
6722
6723 helios::int2 masksize_max = helios::make_int2(0, 0);
6724 for (std::map<std::string, helios::int2>::iterator it = texture_size.begin(); it != texture_size.end(); ++it) {
6725 if (it->second.x > masksize_max.x) {
6726 masksize_max.x = it->second.x;
6727 }
6728 if (it->second.y > masksize_max.y) {
6729 masksize_max.y = it->second.y;
6730 }
6731 }
6732
6733 bool *maskdata = (bool *) malloc(Ntextures * masksize_max.x * masksize_max.y * sizeof(bool)); // allocate host memory
6734 helios::int2 *masksize = (helios::int2 *) malloc(Ntextures * sizeof(helios::int2)); // allocate host memory
6735
6736 for (std::map<std::string, helios::int2>::iterator it = texture_size.begin(); it != texture_size.end(); ++it) {
6737 std::string texture_file = it->first;
6738
6739 int ID = textures.at(texture_file);
6740
6741 masksize[ID] = it->second;
6742
6743 int ind = 0;
6744 for (int j = 0; j < masksize_max.y; j++) {
6745 for (int i = 0; i < masksize_max.x; i++) {
6746
6747 if (i < texture_size.at(texture_file).x && j < texture_size.at(texture_file).y) {
6748 maskdata[ID * masksize_max.x * masksize_max.y + ind] = texture_data.at(texture_file).at(j).at(i);
6749 } else {
6750 maskdata[ID * masksize_max.x * masksize_max.y + ind] = false;
6751 }
6752 ind++;
6753 }
6754 }
6755 }
6756
6757 // GPU allocations removed - texture data no longer copied to GPU
6758
6759 // Per-scan cache of decoded texture RGB pixel maps for sampling hit colors.
6760 // Row 0 is the top of the texture (matches readPNG/readJPEG storage); UV y=0 is the bottom.
6761 struct TextureColorMap {
6762 uint width = 0;
6763 uint height = 0;
6764 std::vector<helios::RGBcolor> pixels;
6765 };
6766 std::map<std::string, TextureColorMap> texture_color_cache;
6767
6768 auto load_texture_colors = [&](const std::string &filename) -> const TextureColorMap & {
6769 auto it = texture_color_cache.find(filename);
6770 if (it != texture_color_cache.end()) {
6771 return it->second;
6772 }
6773 TextureColorMap entry;
6774 std::string ext;
6775 size_t dot = filename.find_last_of('.');
6776 if (dot != std::string::npos) {
6777 ext = filename.substr(dot);
6778 for (char &ch: ext) {
6779 ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
6780 }
6781 }
6782 if (ext == ".png") {
6783 std::vector<helios::RGBAcolor> rgba;
6784 helios::readPNG(filename, entry.width, entry.height, rgba);
6785 entry.pixels.resize(rgba.size());
6786 for (size_t i = 0; i < rgba.size(); i++) {
6787 entry.pixels[i] = helios::make_RGBcolor(rgba[i].r, rgba[i].g, rgba[i].b);
6788 }
6789 } else if (ext == ".jpg" || ext == ".jpeg") {
6790 helios::readJPEG(filename, entry.width, entry.height, entry.pixels);
6791 }
6792 return texture_color_cache.emplace(filename, std::move(entry)).first->second;
6793 };
6794
6795 auto sample_hit_color = [&](uint UUID, const helios::vec3 &hit_pos) -> helios::RGBcolor {
6796 const std::string tex_file = context->getPrimitiveTextureFile(UUID);
6797 if (tex_file.empty() || context->isPrimitiveTextureColorOverridden(UUID)) {
6798 return context->getPrimitiveColor(UUID);
6799 }
6800 const TextureColorMap &tex = load_texture_colors(tex_file);
6801 if (tex.pixels.empty()) {
6802 return context->getPrimitiveColor(UUID);
6803 }
6804
6805 std::vector<helios::vec3> verts = context->getPrimitiveVertices(UUID);
6806 std::vector<helios::vec2> uvs = context->getPrimitiveTextureUV(UUID);
6807 helios::vec2 uv;
6808
6809 helios::PrimitiveType ptype = context->getPrimitiveType(UUID);
6810 if (ptype == helios::PRIMITIVE_TYPE_PATCH) {
6811 // Patch corners are (BL, BR, TR, TL); project the hit onto the (BL->BR, BL->TL) basis.
6812 helios::vec3 e1 = verts[1] - verts[0];
6813 helios::vec3 e2 = verts[3] - verts[0];
6814 helios::vec3 d = hit_pos - verts[0];
6815 float e1_sq = e1 * e1;
6816 float e2_sq = e2 * e2;
6817 float s_param = (e1_sq > 0.f) ? (d * e1) / e1_sq : 0.f;
6818 float t_param = (e2_sq > 0.f) ? (d * e2) / e2_sq : 0.f;
6819 if (s_param < 0.f)
6820 s_param = 0.f;
6821 else if (s_param > 1.f)
6822 s_param = 1.f;
6823 if (t_param < 0.f)
6824 t_param = 0.f;
6825 else if (t_param > 1.f)
6826 t_param = 1.f;
6827 if (uvs.size() == 4) {
6828 uv = (1.f - s_param) * (1.f - t_param) * uvs[0] + s_param * (1.f - t_param) * uvs[1] + s_param * t_param * uvs[2] + (1.f - s_param) * t_param * uvs[3];
6829 } else {
6830 uv = helios::make_vec2(s_param, t_param);
6831 }
6832 } else if (ptype == helios::PRIMITIVE_TYPE_TRIANGLE && uvs.size() == 3) {
6833 helios::vec3 e1 = verts[1] - verts[0];
6834 helios::vec3 e2 = verts[2] - verts[0];
6835 helios::vec3 d = hit_pos - verts[0];
6836 float dot11 = e1 * e1;
6837 float dot12 = e1 * e2;
6838 float dot22 = e2 * e2;
6839 float dot1d = e1 * d;
6840 float dot2d = e2 * d;
6841 float denom = dot11 * dot22 - dot12 * dot12;
6842 if (std::fabs(denom) < 1e-20f) {
6843 return context->getPrimitiveColor(UUID);
6844 }
6845 float inv_denom = 1.f / denom;
6846 float beta = (dot22 * dot1d - dot12 * dot2d) * inv_denom;
6847 float gamma = (dot11 * dot2d - dot12 * dot1d) * inv_denom;
6848 uv = uvs[0] + beta * (uvs[1] - uvs[0]) + gamma * (uvs[2] - uvs[0]);
6849 } else {
6850 return context->getPrimitiveColor(UUID);
6851 }
6852
6853 // Wrap UV into [0,1) so repeat-style mappings sample correctly.
6854 uv.x -= std::floor(uv.x);
6855 uv.y -= std::floor(uv.y);
6856
6857 int px = static_cast<int>(uv.x * static_cast<float>(tex.width));
6858 if (px < 0)
6859 px = 0;
6860 if (px >= static_cast<int>(tex.width))
6861 px = static_cast<int>(tex.width) - 1;
6862 // Pixel rows: 0 at top, height-1 at bottom; UV y=0 at bottom.
6863 int py = static_cast<int>((1.f - uv.y) * static_cast<float>(tex.height));
6864 if (py < 0)
6865 py = 0;
6866 if (py >= static_cast<int>(tex.height))
6867 py = static_cast<int>(tex.height) - 1;
6868 return tex.pixels[static_cast<size_t>(py) * tex.width + static_cast<size_t>(px)];
6869 };
6870
6871 helios::WarningAggregator scan_warnings;
6872 scan_warnings.setEnabled(printmessages);
6873
6874 helios::ProgressBar progress_bar(getScanCount(), 50, getScanCount() > 1 && printmessages, "Synthetic scan");
6875 if (progress_callback) {
6876 progress_bar.setCallback(progress_callback);
6877 }
6878
6879 if (synthetic_scan_progress != nullptr) {
6880 *synthetic_scan_progress = 0;
6881 }
6882
6883 for (int s = 0; s < getScanCount(); s++) {
6884
6885 // Surface the current scan index on the caller-registered polling counter (if any) at the very top of the loop,
6886 // before any early-continue, so a host thread watching it advances even for scans whose rays miss the scene.
6887 if (synthetic_scan_progress != nullptr) {
6888 *synthetic_scan_progress = s;
6889 }
6890
6891 // Report progress at the start of each scan iteration. Using the absolute step (number of scans already
6892 // completed) keeps the callback firing on every code path through the loop body, including the early
6893 // "no rays hit the bounding box" continue below; finish() after the loop clamps the bar to 100%.
6894 progress_bar.update(static_cast<size_t>(s));
6895
6896 helios::vec3 scan_origin = getScanOrigin(s);
6897
6898 int Ntheta = getScanSizeTheta(s);
6899 int Nphi = getScanSizePhi(s);
6900
6901 helios::vec2 thetarange = getScanRangeTheta(s);
6902 float thetamin = thetarange.x;
6903 float thetamax = thetarange.y;
6904 helios::vec2 phirange = getScanRangePhi(s);
6905 float phimin = phirange.x;
6906 float phimax = phirange.y;
6907
6908 std::vector<std::string> column_format = getScanColumnFormat(s);
6909
6910 // Global scanner orientation. This models the full roll/pitch/yaw pose of a real terrestrial scanner:
6911 // - roll and pitch are the residual tilt of the scanner spin axis reported by the dual-axis inclinometer,
6912 // - the azimuth offset is the compass heading (yaw) of the instrument about the local vertical.
6913 // The orientation rotates the entire fan of ray directions about the scanner origin using right-hand-rule
6914 // rotations, matching the right-handed, Z-up body frame used by commercial scanners (e.g. RIEGL SOCS).
6915 // The azimuth offset is a right-hand rotation about the world +z axis applied on top of the azimuth sweep; the
6916 // roll/pitch body axes are defined relative to the scan's azimuth-zero (phiMin) facing direction and rotate with
6917 // that heading:
6918 // - the body "forward" axis is the horizontal projection of the phiMin scan direction, after the azimuth offset (Y_body),
6919 // - the body "lateral" (right) axis completes the right-handed frame (X_body = Y_body x Z),
6920 // - roll = right-hand rotation about X_body (the lateral axis),
6921 // - pitch = right-hand rotation about Y_body (the forward / azimuth-zero axis).
6922 // The rotation order is yaw (azimuth) first, then pitch, then roll. A level, north-facing scanner has
6923 // roll = pitch = azimuth = 0. When phiMin - azimuth = 0 the azimuth-zero direction is +y, so X_body = +x and
6924 // Y_body = +y (tilt reduces to roll about world-x, pitch about world-y).
6925 float scanTiltRoll = getScanTiltRoll(s);
6926 float scanTiltPitch = getScanTiltPitch(s);
6927 float scanAzimuthOffset = getScanAzimuthOffset(s);
6928 bool apply_azimuth = (scanAzimuthOffset != 0.f);
6929 bool apply_tilt = (scanTiltRoll != 0.f || scanTiltPitch != 0.f);
6930 const helios::vec3 tilt_pivot = helios::make_vec3(0, 0, 0); // rotate directions about the origin (pure rotation of the unit vector)
6931 const helios::vec3 vertical_axis = helios::make_vec3(0.f, 0.f, 1.f); // world +z: azimuth (yaw) rotation axis
6932 // Body frame from the azimuth-zero (phiMin) direction, offset by the scanner heading. sphere2cart with zero
6933 // elevation gives the horizontal heading; the azimuth offset then rotates that heading about world +z by the
6934 // SAME right-hand rotation applied to the ray directions below. Because phi is measured CW-from-+y while the
6935 // offset is a CCW (right-hand) rotation about +z, advancing the heading by the offset SUBTRACTS it from the
6936 // phi-angle: rotating (sin phiMin, cos phiMin) CCW by az gives (sin(phiMin-az), cos(phiMin-az)). Using +az here
6937 // reflected the body frame about the un-offset heading, so any tilt leaned the wrong way once a heading was set.
6938 const float heading = phimin - scanAzimuthOffset;
6939 const helios::vec3 forward_axis = helios::make_vec3(sinf(heading), cosf(heading), 0.f); // Y_body: azimuth-zero heading (after offset)
6940 const helios::vec3 lateral_axis = helios::make_vec3(cosf(heading), -sinf(heading), 0.f); // X_body = Y_body x (0,0,1)
6941
6942 // Scan pattern determines how the (theta-index, phi-index) grid maps to zenith angles. For a raster scan the zenith is
6943 // uniformly spaced over [thetamin,thetamax]; for a spinning multibeam scan each theta-index is a laser channel fired at
6944 // its own fixed (generally non-uniform) zenith angle. Both patterns share the same azimuth sweep and grid storage.
6945 const ScanMetadata &scan = scans.at(s);
6946 const bool spinning_multibeam = (scan.scanPattern == SCAN_PATTERN_SPINNING_MULTIBEAM);
6947 // A Risley-prism (Livox-style rosette) scan is non-separable: each column is one pulse whose body-frame direction comes
6948 // from the rotating prism optics at that pulse's time. It is stored as a single row (Ntheta=1), so the (i,j) loop below
6949 // visits one direction per column; the per-pulse direction replaces the raster/spinning theta-phi computation.
6950 const bool risley = (scan.scanPattern == SCAN_PATTERN_RISLEY_PRISM);
6951
6952 // Moving-platform support: when the scan carries a 6-DOF trajectory (see addScanMoving), each grid cell's pulse has
6953 // its own acquisition time, emission origin, and orientation. The pulse time is t = t0 + ordinal*pulse_period, where
6954 // the pulse ordinal is the cell's position in the firing sequence (ordinal = Ntheta*j + i), matching the value written
6955 // to data["timestamp"]. For static scans is_moving is false, pulse_period defaults to 1.0 and t0 to 0.0, so the
6956 // timestamp equals the historical pulse ordinal and the per-cell origin equals the single static scan_origin.
6957 const bool is_moving = scan.isMoving;
6958 const double pulse_period = scan.pulse_period;
6959 const double pulse_t0 = scan.t0;
6960 // Fixed sensor boresight misalignment (body frame), applied to every beam direction before the platform quaternion.
6961 const helios::vec4 boresight_quat = quat_from_rpy(scan.boresight_rpy.x, scan.boresight_rpy.y, scan.boresight_rpy.z);
6962
6963 std::vector<helios::vec3> raydir;
6964 raydir.resize(Ntheta * Nphi);
6965
6966 // Per-cell beam emission origin. For static scans every entry is scan_origin (preserving the original single-origin
6967 // behavior); for moving scans each entry is the platform pose origin at that pulse's time.
6968 std::vector<helios::vec3> raygrid_origin;
6969 raygrid_origin.resize(Ntheta * Nphi, scan_origin);
6970
6971 // Inclusive endpoint sampling: Nphi/Ntheta samples spanning [phimin,phimax] / [thetamin,thetamax].
6972 // Guard the (N-1) denominator so a single-row/column scan (N==1) samples once at the minimum angle
6973 // instead of dividing by zero and producing NaN ray directions.
6974 // A continuously-spinning multibeam scan samples a periodic azimuth: the columns are uniformly spaced with no
6975 // duplicated wrap column, so dphi = (phimax-phimin)/Nphi (exclusive endpoint). This matches the periodic
6976 // convention already used by ScanMetadata::rc2direction()/direction2rc(). A raster scan samples inclusive
6977 // endpoints (dphi = (phimax-phimin)/(Nphi-1)); dphi is the column-to-column azimuth step and also sets the
6978 // continuous-azimuth drift applied within each column below.
6979 const float dphi = spinning_multibeam ? (phimax - phimin) / float(Nphi) : ((Nphi > 1) ? (phimax - phimin) / float(Nphi - 1) : 0.f);
6980 const float dtheta = (Ntheta > 1) ? (thetamax - thetamin) / float(Ntheta - 1) : 0.f;
6981
6982 // Continuous-azimuth (skewed-column) raster sweep. A real terrestrial scanner sweeps the beam vertically with a fast
6983 // mirror while the entire head rotates continuously in azimuth, so the azimuth advances during each zenith sweep and the
6984 // zenith columns are slightly skewed (tilted) rather than perfectly vertical. We model this for the raster pattern by
6985 // drifting the azimuth across the inner (zenith) loop: over one full column (i = 0..Ntheta-1) the azimuth advances by
6986 // exactly one column step dphi, so row i of column j ends where row 0 of column j+1 begins (seamless continuous rotation,
6987 // no azimuth gaps or overlap). The per-row increment is therefore dphi/Ntheta. This drift is intentionally NOT reflected
6988 // in the nominal ScanMetadata::rc2direction()/direction2rc() grid mapping: it is sub-cell (dphi/Ntheta per step) and
6989 // hit-point binning uses the nominal grid. The skew is disabled for spinning multibeam (all channels in a column fire at
6990 // one azimuth; azimuth steps between firings) and for Risley (non-separable, no zenith/azimuth grid).
6991 const float dphi_per_row = (!spinning_multibeam && !risley && Ntheta > 0) ? dphi / float(Ntheta) : 0.f;
6992
6993 for (uint j = 0; j < Nphi; j++) {
6994 float phi = phimin + float(j) * dphi;
6995 for (uint i = 0; i < Ntheta; i++) {
6996 helios::vec3 dir;
6997 if (risley) {
6998 // Body-frame beam direction from the rotating prism optics at this pulse's time. With Ntheta=1 the column j
6999 // is the pulse index; the direction then flows into the same is_moving composition as every other pattern.
7000 dir = risleyBodyDirection(scan, j);
7001 } else {
7002 float theta_z = spinning_multibeam ? scan.beamZenithAngles.at(i) : (thetamin + float(i) * dtheta);
7003 float theta_elev = 0.5f * M_PI - theta_z;
7004 // Skewed azimuth for the raster sweep (dphi_per_row is zero for spinning multibeam, leaving fixed-azimuth columns).
7005 float phi_skew = phi + float(i) * dphi_per_row;
7006 dir = sphere2cart(helios::make_SphericalCoord(1.f, theta_elev, phi_skew));
7007 }
7008 if (is_moving) {
7009 // Trajectory-driven pose. The static scanTilt is not applied here (addScanMoving requires it to be zero):
7010 // attitude is composed as dir_world = R(quat) * R(boresight) * dir_body, and the origin includes the lever arm.
7011 const size_t ordinal = size_t(Ntheta) * j + i;
7012 const double t = pulse_t0 + double(ordinal) * pulse_period;
7013 helios::vec3 pos;
7014 helios::vec4 quat;
7015 scan.poseAt(t, pos, quat);
7016 helios::vec3 dir_body = quat_rotate(boresight_quat, dir);
7017 dir = quat_rotate(quat, dir_body);
7018 dir.normalize();
7019 raygrid_origin.at(Ntheta * j + i) = pos + quat_rotate(quat, scan.lever_arm);
7020 } else {
7021 if (apply_azimuth) {
7022 dir = rotatePointAboutLine(dir, tilt_pivot, vertical_axis, scanAzimuthOffset); // yaw about the world +z axis (heading offset)
7023 }
7024 if (apply_tilt) {
7025 dir = rotatePointAboutLine(dir, tilt_pivot, lateral_axis, scanTiltRoll); // roll about the lateral (X_body) axis
7026 dir = rotatePointAboutLine(dir, tilt_pivot, forward_axis, scanTiltPitch); // pitch about the forward (Y_body) axis
7027 }
7028 }
7029 raydir.at(Ntheta * j + i) = dir;
7030 }
7031 }
7032
7033 size_t N = Ntheta * Nphi;
7034
7035 // Bounding box intersection test (CPU version, no CUDA)
7036 std::vector<uint> bb_hit(N, 0);
7037
7038 // Calculate BB bounds once
7039 helios::vec3 bb_min = bb_center - bb_size * 0.5f;
7040 helios::vec3 bb_max = bb_center + bb_size * 0.5f;
7041
7042 // Check if the (static) origin is inside the bounding box. For moving scans each pulse has its own origin, so this
7043 // fast path is only taken for static scans; moving scans evaluate the per-pulse origin inside the loop below.
7044 bool origin_inside_bb = !is_moving && (scan_origin.x >= bb_min.x && scan_origin.x <= bb_max.x && scan_origin.y >= bb_min.y && scan_origin.y <= bb_max.y && scan_origin.z >= bb_min.z && scan_origin.z <= bb_max.z);
7045
7046 for (size_t r = 0; r < N; r++) {
7047 // If origin inside BB, all rays automatically hit
7048 if (origin_inside_bb) {
7049 bb_hit[r] = 1;
7050 continue;
7051 }
7052
7053 // Per-pulse emission origin (equals scan_origin for static scans).
7054 const helios::vec3 cell_origin = raygrid_origin.at(r);
7055
7056 // For a moving scan a pulse whose origin is inside the bounding box always interacts with the grid.
7057 if (is_moving && cell_origin.x >= bb_min.x && cell_origin.x <= bb_max.x && cell_origin.y >= bb_min.y && cell_origin.y <= bb_max.y && cell_origin.z >= bb_min.z && cell_origin.z <= bb_max.z) {
7058 bb_hit[r] = 1;
7059 continue;
7060 }
7061
7062 helios::vec3 ray_dir = raydir.at(r);
7063
7064 // AABB ray intersection using slab method
7065 float tx_min, tx_max, ty_min, ty_max, tz_min, tz_max;
7066
7067 float a = 1.0f / ray_dir.x;
7068 if (a >= 0) {
7069 tx_min = (bb_min.x - cell_origin.x) * a;
7070 tx_max = (bb_max.x - cell_origin.x) * a;
7071 } else {
7072 tx_min = (bb_max.x - cell_origin.x) * a;
7073 tx_max = (bb_min.x - cell_origin.x) * a;
7074 }
7075
7076 float b = 1.0f / ray_dir.y;
7077 if (b >= 0) {
7078 ty_min = (bb_min.y - cell_origin.y) * b;
7079 ty_max = (bb_max.y - cell_origin.y) * b;
7080 } else {
7081 ty_min = (bb_max.y - cell_origin.y) * b;
7082 ty_max = (bb_min.y - cell_origin.y) * b;
7083 }
7084
7085 float c = 1.0f / ray_dir.z;
7086 if (c >= 0) {
7087 tz_min = (bb_min.z - cell_origin.z) * c;
7088 tz_max = (bb_max.z - cell_origin.z) * c;
7089 } else {
7090 tz_min = (bb_max.z - cell_origin.z) * c;
7091 tz_max = (bb_min.z - cell_origin.z) * c;
7092 }
7093
7094 // Find largest entering t value
7095 float t0 = tx_min;
7096 if (ty_min > t0)
7097 t0 = ty_min;
7098 if (tz_min > t0)
7099 t0 = tz_min;
7100
7101 // Find smallest exiting t value
7102 float t1 = tx_max;
7103 if (ty_max < t1)
7104 t1 = ty_max;
7105 if (tz_max < t1)
7106 t1 = tz_max;
7107
7108 if (t0 < t1 && t1 > 1e-6f) {
7109 bb_hit[r] = 1;
7110 }
7111 }
7112
7113 // determine how many rays hit the bounding box
7114 size_t total_scan_rays = Ntheta * Nphi;
7115 N = 0;
7116 float hit_out = 0;
7117 for (int i = 0; i < total_scan_rays; i++) {
7118 if (bb_hit[i] == 1) {
7119 N++;
7120 helios::SphericalCoord dir = cart2sphere(raydir[i]);
7121 hit_out += sin(dir.zenith);
7122 }
7123 }
7124
7125 // Store base beam directions that hit bounding box
7126 std::vector<helios::vec3> base_directions;
7127 base_directions.reserve(N);
7128 std::vector<helios::int2> pulse_scangrid_ij(N);
7129 // Per-beam emission origin (equals scan_origin for static scans; the per-pulse platform origin for moving scans).
7130 std::vector<helios::vec3> pulse_origin(N);
7131
7132 int count = 0;
7133 for (int i = 0; i < Ntheta * Nphi; i++) {
7134 if (bb_hit[i] == 1) {
7135
7136 base_directions.push_back(raydir.at(i));
7137
7138 int jj = floor(i / Ntheta);
7139 int ii = i - jj * Ntheta;
7140 pulse_scangrid_ij[count] = helios::make_int2(ii, jj);
7141 pulse_origin[count] = raygrid_origin.at(i);
7142
7143 count++;
7144 }
7145 // NOTE: Miss recording for rays that don't hit BB removed - those rays can't interact with grid.
7146 // Misses for traced rays are recorded via line 3733 when record_misses=true.
7147 }
7148
7149 // Handle case where no rays hit bounding box
7150 if (N == 0) {
7151 // If record_misses=true, record all rays as misses
7152 if (record_misses) {
7153 // Place miss points just beyond any real target at the ray-tracer no-hit distance.
7154 // The exact placement distance is not significant: the leaf-area inversion classifies
7155 // a miss geometrically as a beam transmitted through the voxel ("after voxel"),
7156 // independent of how far out the point sits. The canonical miss marker is the
7157 // is_miss flag set below.
7158 float miss_dist = LIDAR_RAYTRACE_MISS_T;
7159 for (int i = 0; i < Ntheta * Nphi; i++) {
7160 std::map<std::string, double> data;
7161 data["target_index"] = 0;
7162 data["target_count"] = 1;
7163 data["deviation"] = 0.0;
7164 // Real per-pulse acquisition time. The grid index i is the pulse ordinal (i = Ntheta*j + row), so this
7165 // matches the encoding used on the hit path and unifies miss/hit timestamps at the same scan-grid cell.
7166 data["timestamp"] = pulse_t0 + double(i) * pulse_period;
7167 data["intensity"] = 1.0; // Full miss
7168 data["distance"] = miss_dist;
7169 data["nRaysHit"] = Npulse; // All rays in pulse missed together
7170 data["echo_width"] = 0.0; // no detectable echo for a full miss
7171 data["is_miss"] = 1.0; // canonical miss flag
7172 if (spinning_multibeam) {
7173 data["channel"] = double(i % Ntheta); // laser channel index (scan-table row) that fired this beam
7174 }
7175 if (std::find(column_format.begin(), column_format.end(), "reflectance") != column_format.end()) {
7176 data["reflectance"] = 0.0; // 10*log10(1.0): full-miss sentinel intensity maps to 0 dB
7177 }
7178
7179 helios::vec3 dir = raydir.at(i);
7180 helios::vec3 cell_origin = raygrid_origin.at(i);
7181 if (is_moving) {
7182 data["pulse_id"] = double(i);
7183 data["origin_x"] = cell_origin.x;
7184 data["origin_y"] = cell_origin.y;
7185 data["origin_z"] = cell_origin.z;
7186 }
7187 helios::vec3 p = cell_origin + dir * miss_dist;
7188 addHitPoint(s, p, helios::cart2sphere(dir), helios::RGB::red, data);
7189 }
7190 } else {
7191 scan_warnings.addWarning("synthetic_rays_no_hit", "Synthetic rays did not hit any primitives.");
7192 }
7193 continue; // Move to next scan
7194 }
7195
7196 float exit_diameter = getScanBeamExitDiameter(s);
7197 float beam_divergence = getScanBeamDivergence(s);
7198 float range_noise_stddev = getScanRangeNoiseStdDev(s);
7199 float angle_noise_stddev = getScanAngleNoiseStdDev(s);
7200
7201 // Analytic-waveform return-detection parameters for this scan (see ScanMetadata). The range resolution that merges
7202 // sub-ray hits into discrete returns is the scan's pulse width when set, otherwise the pulse_distance_threshold
7203 // argument (preserving the historical behavior of the multi-return overloads).
7204 const ReturnMode return_mode = scans.at(s).returnMode;
7205 const SingleReturnSelection single_return_selection = scans.at(s).singleReturnSelection;
7206 // Effective per-pulse return cap: RETURN_MODE_MULTI reports every detected return (unlimited, signalled by 0);
7207 // RETURN_MODE_SINGLE limits to maxReturns real returns (1 = single-return, 2 = dual-return, N = N-return).
7208 const int max_returns = (return_mode == RETURN_MODE_MULTI) ? 0 : scans.at(s).maxReturns;
7209 const float detection_threshold = scans.at(s).detectionThreshold;
7210 const float range_resolution = (scans.at(s).pulseWidth > 0.f) ? scans.at(s).pulseWidth : pulse_distance_threshold;
7211
7212 // Apply angular (beam-pointing) jitter to the nominal direction of each beam. This is a per-pulse pointing error of
7213 // the whole beam, distinct from beam divergence (which spreads sub-rays within a beam) and from range noise (which
7214 // perturbs the distance along the beam). It contributes the across-beam component of the positional error, which
7215 // grows with range. The jittered direction becomes the new nominal direction, so the divergence cone, finite
7216 // aperture, and hit-point reconstruction all rotate together with the beam.
7217 std::vector<helios::vec3> nominal_directions(N);
7218 for (size_t beam = 0; beam < N; beam++) {
7219 helios::vec3 base_dir = base_directions[beam];
7220 if (angle_noise_stddev > 0) {
7221 // Build an orthonormal tangent basis {u, v} perpendicular to the beam and apply a small-angle tilt with an
7222 // independent zero-mean Gaussian offset (stddev = angle_noise_stddev) in each tangent direction.
7223 helios::vec3 reference = (fabs(base_dir.z) < 0.9f) ? helios::make_vec3(0, 0, 1) : helios::make_vec3(1, 0, 0);
7224 helios::vec3 u = helios::cross(base_dir, reference);
7225 u.normalize();
7226 helios::vec3 v = helios::cross(base_dir, u); // orthonormal since base_dir and u are orthonormal
7227 float a = context->randn(0.f, angle_noise_stddev);
7228 float b = context->randn(0.f, angle_noise_stddev);
7229 helios::vec3 jittered = base_dir + u * a + v * b;
7230 jittered.normalize();
7231 nominal_directions[beam] = jittered;
7232 } else {
7233 nominal_directions[beam] = base_dir;
7234 }
7235 }
7236
7237 // Determine the beam-chunk size that keeps the live ray-tracing scratch buffers near the configured memory budget.
7238 // The per-beam fan-out into Npulse sub-rays dominates peak memory: each sub-ray costs BYTES_PER_SUBRAY across the
7239 // direction/origin/weight/result/SoA buffers held live during the trace. Processing beams in chunks of chunk_beams
7240 // bounds peak to ~chunk_beams*Npulse*BYTES_PER_SUBRAY regardless of N. The chunk is floored so each trace batch is
7241 // large enough to be efficient (and to reach the collision-detection GPU path when available) and clamped to N so a
7242 // small scan runs in a single chunk (preserving the original single-batch behavior and RNG draw order).
7243 constexpr size_t BYTES_PER_SUBRAY = 40; // direction(12)+origin(12)+weight(4)+hit_t/fnorm/ID(12) + SoA uuid/normal scratch
7244 constexpr size_t MIN_RAYS_PER_CHUNK = 1050000; // keep batches >= ~1M rays (collision-detection GPU threshold)
7245 // Resolve the effective budget. When the user has not set one (auto), default to a larger cap on the GPU path than
7246 // the CPU path, since the GPU ray-tracer handles bigger batches efficiently; users can lower it via
7247 // setSyntheticScanMemoryBudget(). Initialize the collision engine first so its GPU-enabled state is known here
7248 // (idempotent; prepareUnifiedRayTracing() below re-uses the same engine).
7250 size_t effective_budget_bytes = synthetic_scan_memory_budget_bytes;
7251 if (effective_budget_bytes == 0) {
7252 effective_budget_bytes = collision_detection->isGPUAccelerationEnabled() ? SYNTHETIC_SCAN_DEFAULT_BUDGET_GPU : SYNTHETIC_SCAN_DEFAULT_BUDGET_CPU;
7253 }
7254 size_t target_subrays = effective_budget_bytes / BYTES_PER_SUBRAY;
7255 size_t chunk_beams = target_subrays / size_t(Npulse);
7256 size_t min_chunk_beams = (MIN_RAYS_PER_CHUNK + size_t(Npulse) - 1) / size_t(Npulse);
7257 if (chunk_beams < min_chunk_beams) {
7258 chunk_beams = min_chunk_beams;
7259 }
7260 if (chunk_beams < 1) {
7261 chunk_beams = 1;
7262 }
7263 if (chunk_beams > N) {
7264 chunk_beams = N;
7265 }
7266
7267 // Per-scan running totals (accumulated across all chunks).
7268 size_t Nhits = 0;
7269 size_t beams_with_zero_hits = 0;
7270 size_t beams_with_one_hit = 0;
7271 size_t beams_with_multi_hits = 0;
7272
7273 // Prepare the collision-detection engine once for the whole scan so the BVH is built a single time rather than once
7274 // per chunk (geometry is static during a scan). Paired with finishUnifiedRayTracing() after the chunk loop.
7275 prepareUnifiedRayTracing(context);
7276
7277 // Per-chunk scratch buffers, sized for the largest chunk (chunk_beams*Npulse) and reused across chunks. These are the
7278 // buffers whose N*Npulse sizing previously drove the memory explosion; bounding them to chunk_beams*Npulse is the fix.
7279 const size_t chunk_capacity = chunk_beams * size_t(Npulse);
7280 helios::vec3 *direction = (helios::vec3 *) malloc(chunk_capacity * sizeof(helios::vec3));
7281 helios::vec3 *ray_origins = (helios::vec3 *) malloc(chunk_capacity * sizeof(helios::vec3));
7282 float *hit_t = (float *) malloc(chunk_capacity * sizeof(float));
7283 float *hit_fnorm = (float *) malloc(chunk_capacity * sizeof(float));
7284 int *hit_ID = (int *) malloc(chunk_capacity * sizeof(int));
7285 std::vector<float> subray_weight(chunk_capacity, 1.0f);
7286 if (direction == nullptr || ray_origins == nullptr || hit_t == nullptr || hit_fnorm == nullptr || hit_ID == nullptr) {
7287 helios_runtime_error("ERROR (LiDARcloud::syntheticScan): failed to allocate ray-tracing scratch buffers for a beam chunk of " + std::to_string(chunk_capacity) + " sub-rays. Lower the synthetic-scan memory budget (setSyntheticScanMemoryBudget) or reduce rays_per_pulse.");
7288 }
7289
7290 // Pre-warm the texture color cache (serial) so the parallelized per-beam post-processing below performs only
7291 // read-only lookups into it. load_texture_colors() lazily inserts into a shared std::map, which would be a data
7292 // race under OpenMP; warming every used texture up front makes the in-loop sample_hit_color() calls race-free.
7293 // Untextured scenes collect no filenames, so this is a no-op.
7294 {
7295 std::set<std::string> warm_tex_files;
7296 std::vector<uint> all_uuids = context->getAllUUIDs();
7297 for (uint warm_uuid: all_uuids) {
7298 std::string tf = context->getPrimitiveTextureFile(warm_uuid);
7299 if (!tf.empty()) {
7300 warm_tex_files.insert(tf);
7301 }
7302 }
7303 for (const std::string &tf: warm_tex_files) {
7304 load_texture_colors(tf);
7305 }
7306 }
7307
7308 // Salt for the per-beam range-noise RNG seeds (only used when range noise is enabled). Drawn once from the Context
7309 // RNG so the now thread-parallel range noise stays controllable via Context::seedRandomGenerator and reproducible;
7310 // each beam's seed = salt + global beam index, making the realization independent of thread count / scheduling.
7311 const unsigned int range_noise_seed_salt = (range_noise_stddev > 0.f) ? static_cast<unsigned int>(context->randu() * 4294967000.0f) : 0u;
7312
7313 // Per-beam post-processing outputs, collected in parallel then appended to the point cloud in beam order below.
7314 struct SyntheticBeamHit {
7315 helios::vec3 xyz;
7316 helios::RGBcolor color;
7317 std::map<std::string, double> data;
7318 };
7319 struct SyntheticBeamOutput {
7320 std::vector<SyntheticBeamHit> hits;
7321 helios::SphericalCoord dir_sph;
7322 int npulse_hits = 0; // number of sub-ray hits forming this beam's waveform (drives the zero/one/multi counters)
7323 };
7324
7325 // Constants/helpers for the stratified Gaussian footprint sampler (used by the per-sub-ray generation below).
7326 // GOLDEN_ANGLE = pi*(3 - sqrt(5)): the azimuthal increment that spreads successive sub-rays maximally evenly
7327 // around the footprint (Vogel/sunflower spiral), shared by the divergence-cone and exit-aperture samplers.
7328 constexpr float GOLDEN_ANGLE = 2.39996322972865332f; // pi*(3 - sqrt(5))
7329 // Base-2 radical inverse (van der Corput). The aperture radial strata are driven by this low-discrepancy
7330 // sequence while the divergence radial strata use linear strata; using two different sequences decorrelates
7331 // the two independent Gaussian radial dimensions so the joint footprint stays an even disk (no diagonal smear).
7332 auto radicalInverse2 = [](uint32_t i) -> float {
7333 i = (i << 16) | (i >> 16);
7334 i = ((i & 0x55555555u) << 1) | ((i & 0xAAAAAAAAu) >> 1);
7335 i = ((i & 0x33333333u) << 2) | ((i & 0xCCCCCCCCu) >> 2);
7336 i = ((i & 0x0F0F0F0Fu) << 4) | ((i & 0xF0F0F0F0u) >> 4);
7337 i = ((i & 0x00FF00FFu) << 8) | ((i & 0xFF00FF00u) >> 8);
7338 return float(i) * 2.3283064365386963e-10f; // / 2^32
7339 };
7340
7341 for (size_t chunk_begin = 0; chunk_begin < N; chunk_begin += chunk_beams) {
7342 // Cancellation checkpoint between chunks. castRaysUnified() already short-circuits an in-flight trace to all
7343 // misses when the flag is set, but without breaking here the loop would still generate rays and run the
7344 // waveform reduction for every remaining chunk (and, with record_misses=true, record a full grid of miss
7345 // points). Breaking stops further work so the scan aborts promptly with whatever was recorded so far; the
7346 // buffer free and finishUnifiedRayTracing() after the loop still run, and the outer scan loop exits below.
7347 if (cancel_flag != nullptr && *cancel_flag != 0) {
7348 break;
7349 }
7350
7351 const size_t chunk_end = std::min(chunk_begin + chunk_beams, N);
7352 const size_t chunk_N = chunk_end - chunk_begin; // beams in this chunk
7353
7354 // Generate this chunk's chunk_N*Npulse sub-ray directions, origins, and (unit) footprint weights. The
7355 // expensive part is the per-sub-ray trigonometry (cart2sphere/sphere2cart/cos/sin/sqrt/normalize), which is
7356 // parallelized over beams below.
7357 //
7358 // The beam footprint is importance-sampled: rather than drawing sub-rays uniformly over a disk and then
7359 // re-weighting each by a Gaussian envelope (which wastes the outer, near-zero-weight rays and hard-truncates
7360 // the beam at its 1/e^2 radius), each radial offset is drawn FROM the Gaussian via the Rayleigh inverse-CDF
7361 // -- the Gaussian irradiance profile I(r) ~ exp(-2 (r/r0)^2) now lives in the sample DENSITY, so every
7362 // sub-ray carries unit weight and the beam wings are sampled in proportion to their energy. Coverage is made
7363 // even and stripe-free with stratification: linear radial strata + golden-angle azimuth for the divergence
7364 // cone, and a base-2 van der Corput radial sequence + golden-angle azimuth for the exit aperture (the two
7365 // independent radial dimensions use different sequences so the joint footprint does not develop a diagonal
7366 // artifact). The per-beam stratum offsets are Cranley-Patterson rotations (one random radial shift + one
7367 // random azimuth base per beam) so the pattern is decorrelated pulse-to-pulse rather than a fixed lattice.
7368 //
7369 // The random per-beam offsets are drawn FIRST, serially, into divergence_rand/aperture_rand because the
7370 // generation loop below is OpenMP-parallel and Context's mt19937 (context->randu()) is not thread-safe;
7371 // pre-drawing also keeps a seeded Context (Context::seedRandomGenerator) reproducible independent of thread
7372 // count/scheduling. NOTE: this sampler intentionally changes the RNG consumption (two draws per BEAM, not per
7373 // sub-ray) and the sub-ray pattern relative to the previous uniform+weight sampler -- it is NOT bit-identical
7374 // to the old output by design. subray_weight stays at its initialized value of 1 in every new path; the array
7375 // and its plumbing are retained as a fallback for future non-Gaussian beam profiles that need explicit weights.
7376 const bool draw_divergence = (beam_divergence != 0.0f && Npulse > 1);
7377 const bool draw_aperture = (exit_diameter > 0.0f);
7378
7379 // Serial RNG pre-draw: two values per BEAM for each enabled footprint dimension -- a radial stratum shift and
7380 // an azimuth base rotation (the divergence azimuth and aperture azimuth use independent rotations, so only the
7381 // radial dimension needs the separate van der Corput sequence to decorrelate). Buffers stay empty when no
7382 // sampling is needed (rays_per_pulse==1, zero divergence, point source).
7383 std::vector<float> divergence_rand, aperture_rand;
7384 if (draw_divergence) {
7385 divergence_rand.resize(chunk_N * 2, 0.f);
7386 for (size_t local = 0; local < chunk_N; local++) {
7387 divergence_rand[local * 2] = context->randu(); // xi_r: radial stratum jitter
7388 divergence_rand[local * 2 + 1] = 2.0f * float(M_PI) * context->randu(); // phi0: azimuth base rotation
7389 }
7390 }
7391 if (draw_aperture) {
7392 aperture_rand.resize(chunk_N * 2, 0.f);
7393 for (size_t local = 0; local < chunk_N; local++) {
7394 aperture_rand[local * 2] = context->randu(); // xi_ap: aperture radial Cranley-Patterson offset
7395 aperture_rand[local * 2 + 1] = 2.0f * float(M_PI) * context->randu(); // phi0_ap: aperture azimuth rotation
7396 }
7397 }
7398 // Truncate the Gaussian footprint sampling at the detectability radius. The radial irradiance falls off as
7399 // exp(-2 r^2/r0^2); beyond the radius where it drops below the detection threshold, no return there could
7400 // clear detectReturnsFromSubrays' detection_threshold, so sampling further out only spends rays and injects
7401 // tail shot-noise -- with unit weights a lone outer sub-ray would otherwise create a full 1/Npulse-weight
7402 // return at a location the real (attenuated) beam could never illuminate enough to detect. The radial CDF is
7403 // F(r) = 1 - exp(-2 r^2/r0^2), so truncating at that radius means stratifying the uniform CDF variate over
7404 // [0, 1 - thr] rather than [0, 1) -- this also removes the previous hard 3*r0 clamp and its tail pile-up.
7405 // When no detection threshold is set, trim only the negligible outer 0.1% of beam energy (~1.86 r0) as a safe
7406 // default. The 0.5 cap keeps the beam core sampled even if an aggressive (>50%) threshold is requested.
7407 const float footprint_trim = fminf(0.5f, (detection_threshold > 0.f) ? detection_threshold : 1.0e-3f);
7408 const float u_max_foot = 1.0f - footprint_trim; // upper bound of the kept CDF interval (1 - exp(-2 R_max^2/r0^2))
7409
7410 const float aperture_radius = 0.5f * exit_diameter;
7411#pragma omp parallel for schedule(dynamic, 256)
7412 for (int local = 0; local < static_cast<int>(chunk_N); local++) {
7413 const size_t global_r = chunk_begin + local; // length-N array index
7414 const helios::vec3 base_dir = nominal_directions[global_r];
7415 const helios::vec3 beam_origin = pulse_origin[global_r];
7416
7417 // Orthonormal disk basis {u, v} perpendicular to the beam, for finite-aperture origins (constant per beam).
7418 helios::vec3 u, v;
7419 if (draw_aperture) {
7420 const helios::vec3 reference = (fabs(base_dir.z) < 0.9f) ? helios::make_vec3(0, 0, 1) : helios::make_vec3(1, 0, 0);
7421 u = helios::cross(base_dir, reference);
7422 u.normalize();
7423 v = helios::cross(base_dir, u); // already unit (base_dir and u are orthonormal)
7424 }
7425 // Beam-axis spherical coordinates for the divergence perturbation (constant per beam).
7426 const helios::SphericalCoord base_spherical = helios::cart2sphere(base_dir);
7427
7428 for (int p = 0; p < Npulse; p++) {
7429 const size_t idx = local * size_t(Npulse) + size_t(p);
7430 float w = 1.0f;
7431
7432 // Sub-ray direction (stratified Gaussian-warped, importance-sampled): the first ray and zero-divergence
7433 // beams use the nominal axis (exact center ray, no floating-point spread); other sub-rays are drawn
7434 // FROM the divergence-cone Gaussian so each carries unit weight.
7435 if (p == 0 || beam_divergence == 0.0f) {
7436 direction[idx] = base_dir;
7437 } else {
7438 const float xi_r = divergence_rand[local * 2]; // per-beam radial stratum jitter
7439 const float phi0 = divergence_rand[local * 2 + 1]; // per-beam azimuth base rotation
7440 const int j = p - 1; // p==0 is reserved for the center ray
7441 const int M = Npulse - 1; // number of stratified sub-rays
7442 float uu = (float(j) + 0.5f) / float(M) + xi_r; // Cranley-Patterson-rotated radial stratum
7443 uu -= floorf(uu); // wrap into [0,1)
7444 uu *= u_max_foot; // map onto [0, u_max_foot): truncate the undetectable Gaussian tail (no pile-up)
7445 // Rayleigh inverse-CDF for P(R<=theta)=1-exp(-2 theta^2/D^2), D=beam_divergence (the 1/e^2 half-angle):
7446 // density ~ Gaussian irradiance, so the Gaussian lives in the sample density and the weight stays 1.
7447 // 1 - uu >= footprint_trim > 0, so logf is always finite (no separate guard needed).
7448 float theta_offset = beam_divergence * sqrtf(-0.5f * logf(1.0f - uu));
7449 const float phi_offset = phi0 + float(j) * GOLDEN_ANGLE; // golden-angle azimuth
7450 // Perturb in elevation space (SphericalCoord takes elevation, not zenith).
7451 const float new_elevation = base_spherical.elevation + theta_offset * cosf(phi_offset);
7452 const float new_azimuth = base_spherical.azimuth + theta_offset * sinf(phi_offset) / fmaxf(cosf(base_spherical.elevation), 1e-6f);
7453 helios::vec3 perturbed_dir = helios::sphere2cart(helios::SphericalCoord(1.0f, new_elevation, new_azimuth));
7454 perturbed_dir.normalize();
7455 direction[idx] = perturbed_dir;
7456 }
7457
7458 // Sub-ray origin (stratified Gaussian-warped, importance-sampled over the exit aperture): point source
7459 // (exit_diameter==0) emits all rays from beam_origin; otherwise origins are drawn FROM the aperture
7460 // Gaussian so each carries unit weight. The radial dimension uses a base-2 van der Corput sequence
7461 // (radicalInverse2) so it decorrelates from the divergence radial strata above.
7462 if (draw_aperture) {
7463 if (p == 0) {
7464 ray_origins[idx] = beam_origin; // axial ray: aperture center
7465 } else {
7466 const float xi_ap = aperture_rand[local * 2]; // per-beam radial CP offset
7467 const float phi0_ap = aperture_rand[local * 2 + 1]; // per-beam azimuth rotation
7468 const int j = p - 1; // mirror the direction sampler's center-ray reservation
7469 float s = radicalInverse2((uint32_t)(j + 1)) + xi_ap;
7470 s -= floorf(s); // wrap into [0,1)
7471 s *= u_max_foot; // truncate the undetectable aperture tail at the same detectability radius (no pile-up)
7472 // Rayleigh inverse-CDF, aperture_radius = the 1/e^2 radius (matches the existing convention).
7473 float r_sample = aperture_radius * sqrtf(-0.5f * logf(1.0f - s));
7474 const float theta = phi0_ap + float(j) * GOLDEN_ANGLE; // golden-angle azimuth
7475 const float x_disk = r_sample * cosf(theta);
7476 const float y_disk = r_sample * sinf(theta);
7477 const helios::vec3 offset = u * x_disk + v * y_disk;
7478 ray_origins[idx] = beam_origin + offset;
7479 }
7480 } else {
7481 ray_origins[idx] = beam_origin;
7482 }
7483
7484 subray_weight[idx] = w; // w == 1 in every path above; Gaussian is now carried by the sample density
7485 }
7486 }
7487
7488 // Trace this chunk's rays into the scratch result buffers (BVH already built by prepareUnifiedRayTracing).
7489 // The chunk buffers are laid out pulse-contiguously (sub-ray p of beam local at local*Npulse + p), so passing
7490 // Npulse as the packet size lets the collision layer traverse each pulse's coherent sub-rays together.
7491 castRaysUnified(chunk_N * size_t(Npulse), ray_origins, direction, hit_t, hit_fnorm, hit_ID, size_t(Npulse));
7492
7493 // Post-process beams in this chunk. The per-beam waveform reduction, color/primitive-data sampling, and per-hit
7494 // data-map construction are independent and run in parallel into per-beam output slots; the results are then
7495 // merged into the shared point cloud (the merge below pre-sizes the columnar storage and scatters hits into
7496 // distinct rows, so it is also parallel while reproducing the serial recorded values).
7497 std::vector<SyntheticBeamOutput> beam_outputs(chunk_N);
7498#pragma omp parallel for schedule(dynamic, 256)
7499 for (int local = 0; local < static_cast<int>(chunk_N); local++) {
7500 const size_t r = local; // index into the per-chunk scratch buffers
7501 const size_t global_r = chunk_begin + local; // index into the length-N beam arrays
7502 SyntheticBeamOutput &beam_out = beam_outputs[local];
7503
7504 // Sub-ray hits for this beam, each row {t, cos, ID, weight}. The Gaussian footprint weight is carried through
7505 // so the analytic-waveform return detection (detectReturnsFromSubrays) can form energy-weighted ranges and
7506 // intensities. total_pulse_weight is the total emitted beam energy (sum of weights over ALL fired sub-rays,
7507 // including those that missed), so intensity is reported as a fraction of the whole pulse.
7508 std::vector<std::vector<float>> t_pulse;
7509 float total_pulse_weight = 0.f;
7510
7511 // looping over rays in each beam
7512 for (size_t p = 0; p < Npulse; p++) {
7513
7514 float t = hit_t[r * Npulse + p]; // distance to hit (misses t=1001.0)
7515 float i = hit_fnorm[r * Npulse + p]; // dot product between beam direction and primitive normal
7516 float ID = float(hit_ID[r * Npulse + p]); // ID of intersected primitive
7517 float w = subray_weight[r * Npulse + p]; // Gaussian footprint weight
7518
7519 total_pulse_weight += w;
7520
7521 if (record_misses || (!record_misses && t < miss_distance)) {
7522 std::vector<float> v{t, i, ID, w};
7523 t_pulse.push_back(v);
7524 }
7525 }
7526
7527 // Record this beam's sub-ray-hit class; the shared zero/one/multi counters are tallied in the serial merge.
7528 beam_out.npulse_hits = int(t_pulse.size());
7529
7530 // Detect discrete returns from the analytic sum-of-Gaussians waveform. Returns rows
7531 // {distance, intensity, nPulseHit, IDmap, echo_width}.
7532 std::vector<std::vector<float>> t_hit = detectReturnsFromSubrays(t_pulse, total_pulse_weight, Npulse, range_resolution, detection_threshold, max_returns, single_return_selection, miss_distance);
7533
7534 // Count non-miss returns for proper target_index assignment
7535 int non_miss_count = 0;
7536 for (size_t hit = 0; hit < t_hit.size(); hit++) {
7537 if (t_hit.at(hit).at(0) < 0.98f * miss_distance) {
7538 non_miss_count++;
7539 }
7540 }
7541
7542 // Per-beam range-noise RNG, constructed only when noise is enabled and seeded deterministically by the global
7543 // beam index so the (thread-parallel) noise is reproducible and independent of thread count. When
7544 // range_noise_stddev==0 no random numbers are drawn and the output is bit-identical to the serial path.
7545 std::mt19937 beam_rng;
7546 std::normal_distribution<float> range_noise_dist;
7547 if (range_noise_stddev > 0.f) {
7548 beam_rng.seed(range_noise_seed_salt + static_cast<unsigned int>(global_r));
7549 range_noise_dist = std::normal_distribution<float>(0.f, range_noise_stddev);
7550 }
7551
7552 int real_hit_index = 0;
7553 for (size_t hit = 0; hit < t_hit.size(); hit++) {
7554
7555 std::map<std::string, double> data;
7556
7557 // Check if this is a miss point
7558 bool is_miss = (t_hit.at(hit).at(0) >= 0.98f * miss_distance);
7559
7560 // Apply Gaussian range (along-beam) measurement noise to real returns. LiDAR positional error is anisotropic and
7561 // dominated by an error in the measured range, so the noise is added to the scalar distance and the hit point is
7562 // reconstructed along the nominal beam direction below, rather than perturbing (x,y,z) isotropically. Misses keep
7563 // their ray-tracer no-hit distance and are not noise-displaced. Each return draws independently (per-return noise).
7564 float measured_distance = t_hit.at(hit).at(0);
7565 if (!is_miss && range_noise_stddev > 0.f) {
7566 measured_distance += range_noise_dist(beam_rng);
7567 }
7568
7569 // Assign target_index: misses get 99, real hits get sequential index (0, 1, 2...)
7570 if (is_miss) {
7571 data["target_index"] = 99; // Special value to exclude from triangulation
7572 } else {
7573 data["target_index"] = real_hit_index;
7574 real_hit_index++;
7575 }
7576
7577 data["is_miss"] = is_miss ? 1.0 : 0.0; // canonical miss flag
7578 data["target_count"] = t_hit.size();
7579 // Pulse-shape deviation: a dimensionless measure of how distorted (broadened) this return's pulse is
7580 // relative to a clean transmit pulse, analogous to the RIEGL "pulse shape deviation" confidence metric
7581 // (small = clean single-surface return, large = mixed/sloped/blended return). echo_width is the
7582 // pulse-width-convolved range spread, echo_width = sqrt(range_resolution^2 + var), so sqrt(var) is the
7583 // excess broadening beyond the transmit pulse and dividing by range_resolution makes it dimensionless.
7584 // 0 for misses, clean returns, and the degenerate no-pulse-width case (no reference pulse to deviate from).
7585 const float echo_width = t_hit.at(hit).at(4);
7586 float deviation = 0.f;
7587 if (!is_miss && range_resolution > 0.f) {
7588 const float excess_var = echo_width * echo_width - range_resolution * range_resolution;
7589 deviation = (excess_var > 0.f) ? sqrtf(excess_var) / range_resolution : 0.f;
7590 }
7591 data["deviation"] = deviation;
7592 // Real per-pulse acquisition time. The pulse ordinal is its position in the firing sequence
7593 // (ordinal = Ntheta*j + i); scaling by pulse_period and offsetting by t0 turns it into seconds. For static
7594 // scans pulse_period=1 and t0=0, so this equals the historical grid ordinal. All returns of one pulse (this
7595 // loop over `hit` for a fixed beam r) share the identical time, as required by groupHitsByTimestamp.
7596 const size_t pulse_ordinal = size_t(pulse_scangrid_ij.at(global_r).y) * Ntheta + size_t(pulse_scangrid_ij.at(global_r).x);
7597 data["timestamp"] = pulse_t0 + double(pulse_ordinal) * pulse_period;
7598 // Record range-normalized intensity: the range-independent return amplitude rho*cos(theta) with the
7599 // 1/R^2 range loss of the LiDAR range equation normalized out (see applyRangeIntensityCorrection()).
7600 data["intensity"] = applyRangeIntensityCorrection(t_hit.at(hit).at(1), measured_distance);
7601 data["distance"] = measured_distance;
7602 data["nRaysHit"] = t_hit.at(hit).at(2);
7603 // Pulse-width-convolved range spread of the return (the transmit pulse range-extent combined in quadrature
7604 // with the range spread of the surfaces that merged into this return). 0 for misses and zero-spread beams.
7605 data["echo_width"] = t_hit.at(hit).at(4);
7606 if (spinning_multibeam) {
7607 data["channel"] = double(pulse_scangrid_ij.at(global_r).x); // laser channel index (scan-table row) that fired this beam
7608 }
7609
7610 float UUID = t_hit.at(hit).at(3);
7611
7612 // Use base direction for this beam (first ray: r*Npulse+0)
7613 helios::vec3 dir = direction[r * Npulse];
7614 // Reconstruct the hit point along the beam from its own emission origin (the per-pulse platform origin for
7615 // moving scans; scan_origin for static scans). For moving scans, record the per-pulse origin and firing index.
7616 const helios::vec3 beam_origin = pulse_origin[global_r];
7617 helios::vec3 p = beam_origin + dir * measured_distance;
7618 if (is_moving) {
7619 data["pulse_id"] = double(pulse_ordinal);
7620 data["origin_x"] = beam_origin.x;
7621 data["origin_y"] = beam_origin.y;
7622 data["origin_z"] = beam_origin.z;
7623 }
7624
7625 helios::RGBcolor color = helios::RGB::red;
7626
7627 if (UUID >= 0 && context->doesPrimitiveExist(uint(UUID))) {
7628
7629 color = sample_hit_color(uint(UUID), p);
7630
7631 // Sample arbitrary data fields named in the scan's column format onto this hit. The column
7632 // format is the source of truth: add a (non-standard) label to the scan's column format and the
7633 // scanner copies that data here. Each label is resolved from the hit primitive's own primitive
7634 // data first, then (on a miss) from the primitive's parent-object data, so a label may be sourced
7635 // from either primitive or object data.
7636 for (const std::string &label: column_format) {
7637 if (isStandardColumnToken(label)) {
7638 continue;
7639 }
7640
7641 double value;
7642 if (!resolveScalarHitData(context, uint(UUID), label, value)) {
7643 continue;
7644 }
7645
7646 if (label == "reflectivity_lidar") {
7647 // Preserve historical semantics: reflectivity modulates intensity.
7648 data.at("intensity") *= value;
7649 } else if (label == "reflectance") {
7650 // "reflectance" is a computed synthetic output (see below), not a primitive-data field
7651 // to copy. Skip it here so it is not overwritten by (or silently zeroed from) primitive
7652 // data of the same name.
7653 continue;
7654 } else {
7655 data[label] = value;
7656 }
7657 }
7658 }
7659
7660 // If the scan requests "reflectance", record reflectance in decibels. Following the convention used
7661 // by terrestrial laser scanners (e.g. RIEGL), reflectance is reported relative to a perfect diffuse
7662 // (Lambertian) reflector viewed at normal incidence, which corresponds to intensity = 1 (0 dB):
7663 //
7664 // reflectance_dB = 10 * log10( |intensity| )
7665 //
7666 // where intensity is the range-normalized return amplitude rho*cos(theta) (see
7667 // applyRangeIntensityCorrection()). Returns with non-positive intensity (misses, fully grazing or
7668 // back-facing hits) have no detectable signal and are floored at REFLECTANCE_FLOOR_DB rather than
7669 // -infinity, mirroring a scanner's minimum detectable reflectance.
7670 if (std::find(column_format.begin(), column_format.end(), "reflectance") != column_format.end()) {
7671 constexpr double REFLECTANCE_FLOOR_DB = -999.0;
7672 double abs_intensity = fabs(data.at("intensity"));
7673 data["reflectance"] = (abs_intensity > 0.0) ? 10.0 * log10(abs_intensity) : REFLECTANCE_FLOOR_DB;
7674 }
7675
7676 // Stage this hit in the beam's output slot; appended to the cloud in beam order in the serial merge below.
7677 beam_out.dir_sph = helios::cart2sphere(dir);
7678 beam_out.hits.push_back(SyntheticBeamHit{p, color, std::move(data)});
7679 }
7680 } // end per-beam (parallel) loop for this chunk
7681
7682 // Merge the staged per-beam hits into the shared columnar point-cloud storage. The serial equivalent is a
7683 // per-hit addHitPoint()/appendHitData() loop whose cost (string-keyed column lookups + per-hit column
7684 // extension for millions of hits) dominates a GPU-fast scan. Instead: (1) collect the union of hit-data
7685 // labels and pre-create every column once, (2) pre-size `hits` and all columns to their final length, then
7686 // (3) scatter each hit into its final row in parallel. Each hit owns a distinct, precomputed row (beam-order
7687 // prefix sum) and each label a distinct column slot, so the parallel writes are race-free and the recorded
7688 // values are identical to the serial path. Column slot order is the sorted label union rather than strict
7689 // first-appearance order; for a synthetic scan every hit carries the same standard label set, so the first
7690 // hit would create those columns in (alphabetical) std::map order anyway -> identical order in the common
7691 // case. The only observable effect of the difference is the column order of a no-explicit-columnFormat ASCII
7692 // export when sparse per-primitive-data labels appear only on later hits; recorded values are unaffected.
7693 {
7694 // (1) Union of hit-data labels across this chunk (parallel collect into per-thread sets, then merge).
7695 std::vector<std::set<std::string>> thread_label_sets(static_cast<size_t>(omp_get_max_threads()));
7696#pragma omp parallel for schedule(dynamic, 256)
7697 for (int local = 0; local < static_cast<int>(chunk_N); local++) {
7698 std::set<std::string> &my_labels = thread_label_sets[static_cast<size_t>(omp_get_thread_num())];
7699 for (const SyntheticBeamHit &bh: beam_outputs[local].hits) {
7700 for (const auto &kv: bh.data) {
7701 my_labels.insert(kv.first);
7702 }
7703 }
7704 }
7705 std::set<std::string> all_labels;
7706 for (const std::set<std::string> &ts: thread_label_sets) {
7707 all_labels.insert(ts.begin(), ts.end());
7708 }
7709
7710 // Pre-create all columns (sorted order via std::set) and resolve label -> slot once.
7711 std::unordered_map<std::string, size_t> slot_of;
7712 for (const std::string &lbl: all_labels) {
7713 slot_of[lbl] = getOrCreateHitDataColumn(lbl);
7714 }
7715
7716 // (2) Beam-order row offsets + final sizes; tally the beam-class counters in the same pass.
7717 std::vector<size_t> row_offset(chunk_N + 1, 0);
7718 for (size_t local = 0; local < chunk_N; local++) {
7719 const SyntheticBeamOutput &beam_out = beam_outputs[local];
7720 if (beam_out.npulse_hits == 0) {
7721 beams_with_zero_hits++;
7722 } else if (beam_out.npulse_hits == 1) {
7723 beams_with_one_hit++;
7724 } else {
7725 beams_with_multi_hits++;
7726 }
7727 row_offset[local + 1] = row_offset[local] + beam_out.hits.size();
7728 }
7729 const size_t chunk_hits = row_offset[chunk_N];
7730 const size_t old_n = hits.size();
7731 const size_t new_n = old_n + chunk_hits;
7732
7733 hits.resize(new_n); // default-constructed HitPoints (gridcell=-2), overwritten below
7734 for (size_t sl = 0; sl < hit_data_columns.size(); sl++) {
7735 hit_data_columns[sl].resize(new_n, 0.0); // every column stays length-aligned with `hits`
7736 hit_data_present[sl].resize(new_n, char(0));
7737 }
7738
7739 // (3) Scatter each beam's hits into their final rows in parallel (distinct rows/slots => race-free).
7740 const ScanMetadata &scan = scans.at(s);
7741#pragma omp parallel for schedule(dynamic, 256)
7742 for (int local = 0; local < static_cast<int>(chunk_N); local++) {
7743 SyntheticBeamOutput &beam_out = beam_outputs[local];
7744 if (beam_out.hits.empty()) {
7745 continue;
7746 }
7747 const helios::int2 rc = scan.direction2rc(beam_out.dir_sph);
7748 const size_t base = old_n + row_offset[local];
7749 for (size_t h = 0; h < beam_out.hits.size(); h++) {
7750 SyntheticBeamHit &bh = beam_out.hits[h];
7751 const size_t row = base + h;
7752 HitPoint &hp = hits[row];
7753 hp.position = bh.xyz;
7754 hp.direction = beam_out.dir_sph;
7755 hp.row_column = rc;
7756 hp.color = bh.color;
7757 hp.scanID = int(s);
7758 for (const auto &kv: bh.data) {
7759 const size_t sl = slot_of.at(kv.first); // concurrent read-only lookup (no inserts here)
7760 hit_data_columns[sl][row] = kv.second;
7761 hit_data_present[sl][row] = char(1);
7762 }
7763 }
7764 }
7765 Nhits += chunk_hits;
7766 }
7767 } // end chunk loop
7768
7769 // Restore automatic BVH rebuilds now that this scan's batched ray tracing is complete.
7770 finishUnifiedRayTracing();
7771
7772 // Free the per-chunk scratch buffers (allocated once before the chunk loop, reused across chunks).
7773 free(ray_origins);
7774 free(direction);
7775 free(hit_t);
7776 free(hit_fnorm);
7777 free(hit_ID);
7778
7779 if (printmessages) {
7780 std::cout << "Created synthetic scan #" << s << " with " << Nhits << " hit points." << std::endl;
7781 }
7782
7783 // Cancellation checkpoint between scans: this scan's buffers are freed and ray tracing finished above, so a
7784 // cancelled run stops here and falls through to progress_bar.finish() rather than starting the next scan.
7785 if (cancel_flag != nullptr && *cancel_flag != 0) {
7786 break;
7787 }
7788 }
7789
7790 // Signal completion on the polling counter (host also learns this from the call returning).
7791 if (synthetic_scan_progress != nullptr) {
7792 *synthetic_scan_progress = getScanCount();
7793 }
7794
7795 progress_bar.finish();
7796
7797 scan_warnings.report(std::cerr);
7798
7799 // No device memory to free
7800 free(patch_vertex);
7801 free(patch_textureID);
7802 free(patch_uv);
7803 free(tri_vertex);
7804 free(tri_textureID);
7805 free(tri_uv);
7806 free(maskdata);
7807 free(masksize);
7808}