1.3.77
 
Loading...
Searching...
No Matches
fileIO.cpp
Go to the documentation of this file.
1
16#include "LiDAR.h"
17#include "pugixml.hpp"
18
19using namespace helios;
20using namespace std;
21
22// Helper function to ensure output directory exists
23void ensureOutputDirectoryExists(const char *filename) {
24 std::filesystem::path file_path(filename);
25 std::filesystem::path dir_path = file_path.parent_path();
26
27 if (!dir_path.empty() && !std::filesystem::exists(dir_path)) {
28 std::error_code ec;
29 if (!std::filesystem::create_directories(dir_path, ec)) {
30 helios_runtime_error("ERROR: Could not create output directory '" + dir_path.string() + "': " + ec.message());
31 }
32 }
33}
34
35namespace {
36
38
40 helios::vec4 trajQuatFromRPY(float roll, float pitch, float yaw) {
41 const float cr = std::cos(roll * 0.5f), sr = std::sin(roll * 0.5f);
42 const float cp = std::cos(pitch * 0.5f), sp = std::sin(pitch * 0.5f);
43 const float cy = std::cos(yaw * 0.5f), sy = std::sin(yaw * 0.5f);
45 q.w = cr * cp * cy + sr * sp * sy;
46 q.x = sr * cp * cy - cr * sp * sy;
47 q.y = cr * sp * cy + sr * cp * sy;
48 q.z = cr * cp * sy - sr * sp * cy;
49 return q;
50 }
51
53
55 std::vector<float> beamElevationAnglesRad(const std::vector<float> &beamZenithAngles) {
56 std::vector<float> elevation;
57 elevation.reserve(beamZenithAngles.size());
58 for (float zenith: beamZenithAngles) {
59 elevation.push_back(0.5f * float(M_PI) - zenith);
60 }
61 return elevation;
62 }
63
65
71 void parseTrajectoryStream(std::istream &stream, const std::string &context, std::vector<double> &traj_t, std::vector<helios::vec3> &traj_pos, std::vector<helios::vec4> &traj_quat) {
72
73 traj_t.clear();
74 traj_pos.clear();
75 traj_quat.clear();
76
77 int row_width = 0; // 0 = undetermined, otherwise 7 or 8
78 std::string line;
79 size_t line_number = 0;
80 while (std::getline(stream, line)) {
81 line_number++;
82 // Strip a trailing comment and skip blank/comment lines.
83 const size_t hash = line.find('#');
84 if (hash != std::string::npos) {
85 line = line.substr(0, hash);
86 }
87 std::istringstream ls(line);
88 std::vector<double> vals;
89 double v;
90 while (ls >> v) {
91 vals.push_back(v);
92 }
93 if (vals.empty()) {
94 continue; // blank or comment-only line
95 }
96 if (row_width == 0) {
97 if (vals.size() != 7 && vals.size() != 8) {
98 helios_runtime_error("ERROR (LiDARcloud::loadXML): trajectory for " + context + " has a row with " + std::to_string(vals.size()) +
99 " numbers on line " + std::to_string(line_number) + ". Each row must have 8 numbers (t x y z qx qy qz qw) or 7 numbers (t x y z roll pitch yaw, degrees).");
100 }
101 row_width = int(vals.size());
102 } else if (int(vals.size()) != row_width) {
103 helios_runtime_error("ERROR (LiDARcloud::loadXML): trajectory for " + context + " is ragged: line " + std::to_string(line_number) + " has " + std::to_string(vals.size()) + " numbers but earlier rows have " +
104 std::to_string(row_width) + ". All trajectory rows must have the same number of columns.");
105 }
106
107 traj_t.push_back(vals[0]);
108 traj_pos.push_back(helios::make_vec3(float(vals[1]), float(vals[2]), float(vals[3])));
109 if (row_width == 8) {
110 traj_quat.push_back(helios::make_vec4(float(vals[4]), float(vals[5]), float(vals[6]), float(vals[7])));
111 } else {
112 // Euler degrees -> radians -> Hamilton body->world quaternion (intrinsic Z-Y-X).
113 const float roll = float(vals[4]) * float(M_PI) / 180.f;
114 const float pitch = float(vals[5]) * float(M_PI) / 180.f;
115 const float yaw = float(vals[6]) * float(M_PI) / 180.f;
116 traj_quat.push_back(trajQuatFromRPY(roll, pitch, yaw));
117 }
118 }
119
120 if (traj_t.empty()) {
121 helios_runtime_error("ERROR (LiDARcloud::loadXML): trajectory for " + context + " contains no pose rows.");
122 }
123 }
124
125} // namespace
126
127void LiDARcloud::loadXML(const char *filename) {
128 loadXML(filename, false);
129}
130
131void LiDARcloud::loadXML(const char *filename, bool load_grid_only) {
132
133 if (printmessages) {
134 cout << "Reading XML file: " << filename << "..." << flush;
135 }
136
137 // Check if file exists
138 ifstream f(filename);
139 if (!f.good()) {
140 cerr << "failed.\n";
141 helios_runtime_error("ERROR (LiDARcloud::loadXML): XML file does not exist.");
142 }
143
144 // Using "pugixml" parser. See pugixml.org
145 pugi::xml_document xmldoc;
146
147 // Resolve file path using project-based resolution
148 std::filesystem::path resolved_path = resolveProjectFile(filename);
149 std::string resolved_filename = resolved_path.string();
150 std::filesystem::path xml_parent_dir = resolved_path.parent_path();
151
152 // load file
153 pugi::xml_parse_result result = xmldoc.load_file(resolved_filename.c_str());
154
155 // error checking
156 if (!result) {
157 cout << "failed." << endl;
158 cerr << "XML file " << filename << " parsed with errors, attribute value: [" << xmldoc.child("node").attribute("attr").value() << "]\n";
159 helios_runtime_error("ERROR (LiDARcloud::loadXML): Errors were found while parsing XML file. Error description: " + std::string(result.description()));
160 }
161
162 pugi::xml_node helios = xmldoc.child("helios");
163
164 if (helios.empty()) {
165 std::cout << "failed." << std::endl;
166 helios_runtime_error("ERROR (LiDARcloud::loadXML): XML file must have tag '<helios> ... </helios>' bounding all other tags.");
167 }
168
169 //-------------- Scans ---------------//
170
171 uint scan_count = 0; // counter variable for scans
172 size_t total_hits = 0;
173
174 if (load_grid_only == false) {
175
176 // looping over any scans specified in XML file
177 for (pugi::xml_node s = helios.child("scan"); s; s = s.next_sibling("scan")) {
178
179 // ----- scan origin ------//
180 // A scan must define its beam emission origin in one of two ways: a single static <origin> tag (a fixed
181 // scanner), or per-point origin columns (origin_x/origin_y/origin_z) in the ASCII data file (a moving
182 // platform, where each pulse has its own origin). Exactly which one is present is validated below, once the
183 // ASCII column format has been parsed. When only per-point origins are present the static origin is unused
184 // and defaults to (0,0,0).
185 const char *origin_str = s.child_value("origin");
186 const bool has_static_origin = (strlen(origin_str) != 0);
187 vec3 origin = has_static_origin ? string2vec3(origin_str) : make_vec3(0, 0, 0); // note: pugi loads xml as characters; split into 3 floats
188
189 // ----- scan pattern ------//
190 // Optional. Default is a raster scan (uniform angular grid). 'spinning_multibeam' models a rotating multi-channel
191 // sensor (e.g. Velodyne/Ouster/Hesai) whose channels are specified via <beamElevationAngles>.
192 std::string scan_pattern_str = deblank(s.child_value("scanPattern"));
193 if (scan_pattern_str.empty()) {
194 scan_pattern_str = deblank(s.child_value("scanpattern"));
195 }
196 std::transform(scan_pattern_str.begin(), scan_pattern_str.end(), scan_pattern_str.begin(), [](unsigned char ch) { return std::tolower(ch); });
197 const bool spinning_multibeam = (scan_pattern_str == "spinning_multibeam" || scan_pattern_str == "spinning-multibeam" || scan_pattern_str == "spinningmultibeam");
198 // 'risley' / 'risley_prism' models a rotating-Risley-prism (Livox-style rosette) scanner whose prisms are specified
199 // via <prism> children.
200 const bool risley = (scan_pattern_str == "risley" || scan_pattern_str == "risley_prism" || scan_pattern_str == "risley-prism" || scan_pattern_str == "risleyprism");
201 if (!scan_pattern_str.empty() && scan_pattern_str != "raster" && !spinning_multibeam && !risley) {
202 cerr << "failed.\n";
203 helios_runtime_error("ERROR (LiDARcloud::loadXML): Unrecognized scanPattern '" + scan_pattern_str + "' for scan #" + std::to_string(scan_count) + ". Valid values are 'raster', 'spinning_multibeam', and 'risley'.");
204 }
205
206 // ----- Risley prism stack ------//
207 // A risley scan refracts a single beam through a stack of rotating wedge prisms, each given as a <prism> child with
208 // "wedgeAngle(deg) refractiveIndex rotorRate(Hz, signed) [phase(deg)]". The optional <refractiveIndexAir> sets the
209 // surrounding medium index (default 1.0).
210 std::vector<RisleyPrism> risley_prisms;
211 double risley_refractive_index_air = 1.0;
212 if (risley) {
213 const char *air_str = s.child_value("refractiveIndexAir");
214 if (strlen(air_str) == 0) {
215 air_str = s.child_value("refractiveindexair");
216 }
217 if (strlen(air_str) > 0) {
218 risley_refractive_index_air = atof(air_str);
219 }
220 for (pugi::xml_node prism_node = s.child("prism"); prism_node; prism_node = prism_node.next_sibling("prism")) {
221 // NOTE: do not deblank() here - the prism is several space-separated values, and deblank() strips ALL
222 // spaces (it is meant for single tokens), which would concatenate the fields. The istringstream handles
223 // the internal whitespace itself.
224 std::istringstream prism_stream(prism_node.child_value());
225 double wedge_deg, refr_index, rotor_hz;
226 if (!(prism_stream >> wedge_deg >> refr_index >> rotor_hz)) {
227 cerr << "failed.\n";
228 helios_runtime_error("ERROR (LiDARcloud::loadXML): A <prism> of risley scan #" + std::to_string(scan_count) +
229 " must give at least 'wedgeAngle(deg) refractiveIndex rotorRate(Hz)' (an optional fourth value sets the initial phase in degrees).");
230 }
231 double phase_deg = 0.0;
232 prism_stream >> phase_deg; // optional; left at 0 if absent
233 risley_prisms.emplace_back(wedge_deg * M_PI / 180.0, refr_index, rotor_hz * 2.0 * M_PI, phase_deg * M_PI / 180.0);
234 }
235 if (risley_prisms.empty()) {
236 cerr << "failed.\n";
237 helios_runtime_error("ERROR (LiDARcloud::loadXML): A risley scan (#" + std::to_string(scan_count) + ") requires at least one <prism> child (a Livox-style sensor uses two counter-rotating prisms).");
238 }
239 }
240
241 // ----- beam (channel) elevation angles for spinning multibeam ------//
242 std::vector<float> beamZenithAngles;
243 if (spinning_multibeam) {
244 const char *beam_angles_str = s.child_value("beamElevationAngles");
245 if (strlen(beam_angles_str) == 0) {
246 beam_angles_str = s.child_value("beamelevationangles");
247 }
248 if (strlen(beam_angles_str) == 0) {
249 cerr << "failed.\n";
250 helios_runtime_error("ERROR (LiDARcloud::loadXML): A spinning_multibeam scan (#" + std::to_string(scan_count) + ") requires <beamElevationAngles> (space-separated channel elevation angles, in degrees above the horizon).");
251 }
252 std::istringstream beam_stream(beam_angles_str);
253 float elev_deg;
254 while (beam_stream >> elev_deg) {
255 // Manufacturer spec sheets list channel angles as elevation above the horizon; convert to Helios zenith (0 = up).
256 beamZenithAngles.push_back(0.5f * float(M_PI) - elev_deg * float(M_PI) / 180.f);
257 }
258 if (beamZenithAngles.empty()) {
259 cerr << "failed.\n";
260 helios_runtime_error("ERROR (LiDARcloud::loadXML): Could not parse any channel angles from <beamElevationAngles> for scan #" + std::to_string(scan_count) + ".");
261 }
262 }
263
264 // ----- scan size (resolution) ------//
265 // Raster scans require <size> = "Ntheta Nphi". Spinning multibeam scans set Ntheta from the number of channels
266 // (beamElevationAngles) and derive the azimuth-step count Nphi internally from <azimuthStep>, <PRF>, and the
267 // trajectory (see the dispatch below), so they do not specify a <size>.
268 helios::int2 size = make_int2(0, 0);
269 if (spinning_multibeam) {
270 size.x = int(beamZenithAngles.size()); // Ntheta = number of laser channels
271 } else if (risley) {
272 // A risley scan stores a single direction per pulse: Ntheta=1 and Nphi=Npulses are derived in addScanRisley
273 // from the PRF and the trajectory duration, so no <size> is specified.
274 size.x = 1;
275 } else {
276 const char *size_str = s.child_value("size");
277 if (strlen(size_str) == 0) {
278 cerr << "failed.\n";
279 helios_runtime_error("ERROR (LiDARcloud::loadXML): A size was not specified for scan #" + std::to_string(scan_count));
280 } else {
281 size = string2int2(size_str); // note: pugi loads xml data as a character. need to separate it into 2 ints
282 }
283 if (size.x <= 0 || size.y <= 0) {
284 cerr << "failed.\n";
285 helios_runtime_error("ERROR (LiDARcloud::loadXML): The scan size must be positive (check scan #" + std::to_string(scan_count) + ").");
286 }
287 }
288
289 // ----- scan translation ------//
290 const char *offset_str = s.child_value("translation");
291
292 vec3 translation = make_vec3(0, 0, 0);
293 if (strlen(offset_str) > 0) {
294 translation = string2vec3(offset_str); // note: pugi loads xml data as a character. need to separate it into 3 floats
295 }
296
297 // ----- scan rotation ------//
298 const char *rotation_str = s.child_value("rotation");
299
300 SphericalCoord rotation_sphere(0, 0, 0);
301 if (strlen(rotation_str) > 0) {
302 vec2 rotation = string2vec2(rotation_str); // note: pugi loads xml data as a character. need to separate it into 2 floats
303 rotation = rotation * M_PI / 180.f;
304 rotation_sphere = make_SphericalCoord(rotation.x, rotation.y);
305 }
306
307 // ----- thetaMin ------//
308 const char *thetaMin_str = s.child_value("thetaMin");
309
310 float thetaMin;
311 if (strlen(thetaMin_str) == 0) {
312 // cerr << "WARNING (loadXML): A minimum zenithal scan angle was not specified for scan #" << scan_count << "...assuming thetaMin = 0." << flush;
313 thetaMin = 0.f;
314 } else {
315 thetaMin = atof(thetaMin_str) * M_PI / 180.f;
316 }
317
318 if (thetaMin < 0) {
319 helios_runtime_error("ERROR (LiDARcloud::loadXML): thetaMin cannot be less than 0.");
320 }
321
322 // ----- thetaMax ------//
323 const char *thetaMax_str = s.child_value("thetaMax");
324
325 float thetaMax;
326 if (strlen(thetaMax_str) == 0) {
327 thetaMax = M_PI;
328 } else {
329 thetaMax = atof(thetaMax_str) * M_PI / 180.f;
330 }
331
332 if (thetaMax - 1e-5 > M_PI) {
333 helios_runtime_error("ERROR (LiDARcloud::loadXML): thetaMax cannot be greater than 180 degrees.");
334 }
335
336 // ----- phiMin ------//
337 const char *phiMin_str = s.child_value("phiMin");
338
339 float phiMin;
340 if (strlen(phiMin_str) == 0) {
341 phiMin = 0.f;
342 } else {
343 phiMin = atof(phiMin_str) * M_PI / 180.f;
344 }
345
346 if (phiMin < 0) {
347 helios_runtime_error("ERROR (LiDARcloud::loadXML): phiMin cannot be less than 0.");
348 }
349
350 // ----- phiMax ------//
351 const char *phiMax_str = s.child_value("phiMax");
352
353 float phiMax;
354 if (strlen(phiMax_str) == 0) {
355 phiMax = 2.f * M_PI;
356 } else {
357 phiMax = atof(phiMax_str) * M_PI / 180.f;
358 }
359
360 if (phiMax - 1e-5 > 4.f * M_PI) {
361 helios_runtime_error("ERROR (LiDARcloud::loadXML): phiMax cannot be greater than 720 degrees.");
362 }
363
364 // ----- exitDiameter ------//
365 const char *exitDiameter_str_uc = s.child_value("exitDiameter");
366 const char *exitDiameter_str_lc = s.child_value("exitdiameter");
367
368 float exitDiameter;
369 if (strlen(exitDiameter_str_uc) == 0 && strlen(exitDiameter_str_lc) == 0) {
370 exitDiameter = 0;
371 } else if (strlen(exitDiameter_str_uc) > 0) {
372 exitDiameter = fmax(0, atof(exitDiameter_str_uc));
373 } else {
374 exitDiameter = fmax(0, atof(exitDiameter_str_lc));
375 }
376
377 // ----- beamDivergence ------//
378 const char *beamDivergence_str_uc = s.child_value("beamDivergence");
379 const char *beamDivergence_str_lc = s.child_value("beamdivergence");
380
381 float beamDivergence;
382 if (strlen(beamDivergence_str_uc) == 0 && strlen(beamDivergence_str_lc) == 0) {
383 beamDivergence = 0;
384 } else if (strlen(beamDivergence_str_uc) > 0) {
385 beamDivergence = fmax(0, atof(beamDivergence_str_uc));
386 } else {
387 beamDivergence = fmax(0, atof(beamDivergence_str_lc));
388 }
389
390 // ----- rangeNoiseStdDev ------//
391 const char *rangeNoise_str_uc = s.child_value("rangeNoiseStdDev");
392 const char *rangeNoise_str_lc = s.child_value("rangenoisestddev");
393
394 float rangeNoiseStdDev;
395 if (strlen(rangeNoise_str_uc) == 0 && strlen(rangeNoise_str_lc) == 0) {
396 rangeNoiseStdDev = 0;
397 } else if (strlen(rangeNoise_str_uc) > 0) {
398 rangeNoiseStdDev = fmax(0, atof(rangeNoise_str_uc));
399 } else {
400 rangeNoiseStdDev = fmax(0, atof(rangeNoise_str_lc));
401 }
402
403 // ----- angleNoiseStdDev ------//
404 const char *angleNoise_str_uc = s.child_value("angleNoiseStdDev");
405 const char *angleNoise_str_lc = s.child_value("anglenoisestddev");
406
407 float angleNoiseStdDev;
408 if (strlen(angleNoise_str_uc) == 0 && strlen(angleNoise_str_lc) == 0) {
409 angleNoiseStdDev = 0;
410 } else if (strlen(angleNoise_str_uc) > 0) {
411 angleNoiseStdDev = fmax(0, atof(angleNoise_str_uc));
412 } else {
413 angleNoiseStdDev = fmax(0, atof(angleNoise_str_lc));
414 }
415
416 // ----- scanTilt (global scanner tilt: roll pitch, in degrees) ------//
417 const char *scanTilt_str_uc = s.child_value("scanTilt");
418 const char *scanTilt_str_lc = s.child_value("scantilt");
419
420 float scanTiltRoll = 0.f;
421 float scanTiltPitch = 0.f;
422 const char *scanTilt_str = (strlen(scanTilt_str_uc) > 0) ? scanTilt_str_uc : scanTilt_str_lc;
423 if (strlen(scanTilt_str) > 0) {
424 vec2 scanTilt = string2vec2(scanTilt_str); // "roll pitch" in degrees
425 scanTilt = scanTilt * float(M_PI) / 180.f;
426 scanTiltRoll = scanTilt.x;
427 scanTiltPitch = scanTilt.y;
428 }
429
430 // ----- scanAzimuthOffset (global scanner azimuth/heading offset, in degrees) ------//
431 const char *scanAzimuth_str_uc = s.child_value("scanAzimuthOffset");
432 const char *scanAzimuth_str_lc = s.child_value("scanazimuthoffset");
433
434 float scanAzimuthOffset = 0.f;
435 const char *scanAzimuth_str = (strlen(scanAzimuth_str_uc) > 0) ? scanAzimuth_str_uc : scanAzimuth_str_lc;
436 if (strlen(scanAzimuth_str) > 0) {
437 scanAzimuthOffset = atof(scanAzimuth_str) * float(M_PI) / 180.f; // degrees -> radians
438 }
439
440 // ----- returnMode (analytic-waveform return reporting: 'multi' (default) or 'single') ------//
441 std::string returnMode_str = deblank(s.child_value("returnMode"));
442 if (returnMode_str.empty()) {
443 returnMode_str = deblank(s.child_value("returnmode"));
444 }
445 std::transform(returnMode_str.begin(), returnMode_str.end(), returnMode_str.begin(), [](unsigned char ch) { return std::tolower(ch); });
446 ReturnMode returnMode = RETURN_MODE_MULTI;
447 if (returnMode_str == "single") {
448 returnMode = RETURN_MODE_SINGLE;
449 } else if (!returnMode_str.empty() && returnMode_str != "multi") {
450 cerr << "failed.\n";
451 helios_runtime_error("ERROR (LiDARcloud::loadXML): Unrecognized returnMode '" + returnMode_str + "' for scan #" + std::to_string(scan_count) + ". Valid values are 'multi' and 'single'.");
452 }
453
454 // ----- singleReturnSelection ('strongest' (default), 'first', 'last', or 'strongest_plus_last' / 'dual') ------//
455 std::string singleSel_str = deblank(s.child_value("singleReturnSelection"));
456 if (singleSel_str.empty()) {
457 singleSel_str = deblank(s.child_value("singlereturnselection"));
458 }
459 std::transform(singleSel_str.begin(), singleSel_str.end(), singleSel_str.begin(), [](unsigned char ch) { return std::tolower(ch); });
460 SingleReturnSelection singleReturnSelection = SINGLE_RETURN_STRONGEST;
461 if (singleSel_str == "first") {
462 singleReturnSelection = SINGLE_RETURN_FIRST;
463 } else if (singleSel_str == "last") {
464 singleReturnSelection = SINGLE_RETURN_LAST;
465 } else if (singleSel_str == "strongest_plus_last" || singleSel_str == "dual") {
466 singleReturnSelection = SINGLE_RETURN_STRONGEST_PLUS_LAST;
467 } else if (!singleSel_str.empty() && singleSel_str != "strongest") {
468 cerr << "failed.\n";
469 helios_runtime_error("ERROR (LiDARcloud::loadXML): Unrecognized singleReturnSelection '" + singleSel_str + "' for scan #" + std::to_string(scan_count) + ". Valid values are 'strongest', 'first', 'last', and 'strongest_plus_last' (alias 'dual').");
470 }
471
472 // ----- maxReturns (returns per pulse in single/limited mode: 1=single, 2=dual, N=N-return) ------//
473 int maxReturns = 1;
474 const char *maxReturns_str = s.child_value("maxReturns");
475 if (strlen(maxReturns_str) == 0) {
476 maxReturns_str = s.child_value("maxreturns");
477 }
478 if (strlen(maxReturns_str) > 0) {
479 maxReturns = atoi(maxReturns_str);
480 if (maxReturns < 1) {
481 cerr << "failed.\n";
482 helios_runtime_error("ERROR (LiDARcloud::loadXML): maxReturns must be at least 1, but '" + std::string(maxReturns_str) + "' was given for scan #" + std::to_string(scan_count) + ".");
483 }
484 }
485
486 // ----- pulseWidth (range resolution, meters) or pulseDuration (seconds, converted via c*tau/2) ------//
487 float pulseWidth = 0.f;
488 const char *pulseWidth_str = s.child_value("pulseWidth");
489 if (strlen(pulseWidth_str) == 0) {
490 pulseWidth_str = s.child_value("pulsewidth");
491 }
492 const char *pulseDuration_str = s.child_value("pulseDuration");
493 if (strlen(pulseDuration_str) == 0) {
494 pulseDuration_str = s.child_value("pulseduration");
495 }
496 if (strlen(pulseWidth_str) > 0) {
497 pulseWidth = fmax(0, atof(pulseWidth_str));
498 } else if (strlen(pulseDuration_str) > 0) {
499 // Round-trip range extent of a pulse of duration tau: R = c * tau / 2 (c = speed of light in m/s).
500 pulseWidth = fmax(0.f, float(atof(pulseDuration_str)) * 299792458.f * 0.5f);
501 }
502
503 // ----- detectionThreshold (minimum return energy fraction) ------//
504 float detectionThreshold = 0.f;
505 const char *detThresh_str = s.child_value("detectionThreshold");
506 if (strlen(detThresh_str) == 0) {
507 detThresh_str = s.child_value("detectionthreshold");
508 }
509 if (strlen(detThresh_str) > 0) {
510 detectionThreshold = fmax(0, atof(detThresh_str));
511 }
512
513 // ----- distanceFilter ------//
514 const char *dFilter_str = s.child_value("distanceFilter");
515
516 float distanceFilter = -1;
517 if (strlen(dFilter_str) > 0) {
518 distanceFilter = atof(dFilter_str);
519 }
520
521 // ------ ASCII data file format ------- //
522
523 const char *data_format = s.child_value("ASCII_format");
524
525 std::vector<std::string> column_format;
526 if (strlen(data_format) != 0) {
527
528 std::string tmp;
529
530 std::istringstream stream(data_format);
531 while (stream >> tmp) {
532 column_format.push_back(tmp);
533 }
534 }
535
536 // Require an emission origin. A trajectory-driven scan (a spinning scan, or any scan with a <trajectory> /
537 // <trajectoryFile>) supplies the per-pulse origin from the trajectory, so it needs neither a static <origin>
538 // nor per-point origin columns. A static scan must provide one of: a static <origin> tag, or per-point origin
539 // columns (origin_x/origin_y/origin_z) in the ASCII data.
540 const bool has_perpoint_origin = (std::find(column_format.begin(), column_format.end(), "origin_x") != column_format.end() && std::find(column_format.begin(), column_format.end(), "origin_y") != column_format.end() &&
541 std::find(column_format.begin(), column_format.end(), "origin_z") != column_format.end());
542 const bool has_trajectory_tag = (!s.child("trajectory").empty() || strlen(s.child_value("trajectoryFile")) != 0 || strlen(s.child_value("trajectoryfile")) != 0);
543 if (!has_static_origin && !has_perpoint_origin && !spinning_multibeam && !risley && !has_trajectory_tag) {
544 cerr << "failed.\n";
545 helios_runtime_error("ERROR (LiDARcloud::loadXML): Scan #" + std::to_string(scan_count) +
546 " has no beam origin. Specify either a static <origin> tag, or per-point origin columns (origin_x origin_y origin_z) in the <ASCII_format> and data file.");
547 }
548
549 // ----- physical-parameter (moving-platform / spinning) tags ------//
550 // These describe a moving-platform or spinning-multibeam instrument using its physical parameters; when present,
551 // Helios derives the internal sampling grid (see addScanSpinning / addScanMovingRaster). Their presence switches
552 // the scan from the legacy static-grid path to the trajectory-driven path.
553
554 // Azimuth resolution (degrees per firing step) -> radians. Primary azimuth control for a spinning sensor.
555 float azimuthStep_rad = 0.f;
556 const char *azStep_str = s.child_value("azimuthStep");
557 if (strlen(azStep_str) == 0) {
558 azStep_str = s.child_value("azimuthstep");
559 }
560 if (strlen(azStep_str) > 0) {
561 azimuthStep_rad = float(atof(azStep_str)) * float(M_PI) / 180.f;
562 }
563
564 // Pulse repetition frequency (Hz).
565 float PRF = 0.f;
566 const char *prf_str = s.child_value("PRF");
567 if (strlen(prf_str) == 0) {
568 prf_str = s.child_value("pulseRate");
569 }
570 if (strlen(prf_str) == 0) {
571 prf_str = s.child_value("pulserate");
572 }
573 if (strlen(prf_str) > 0) {
574 PRF = float(atof(prf_str));
575 }
576
577 // Lever arm (sensor optical center in the body frame, meters) and boresight (roll pitch yaw, degrees).
578 vec3 lever_arm = make_vec3(0, 0, 0);
579 const char *lever_str = s.child_value("leverArm");
580 if (strlen(lever_str) == 0) {
581 lever_str = s.child_value("leverarm");
582 }
583 if (strlen(lever_str) > 0) {
584 lever_arm = string2vec3(lever_str);
585 }
586 vec3 boresight_rpy = make_vec3(0, 0, 0);
587 const char *boresight_str = s.child_value("boresight");
588 if (strlen(boresight_str) > 0) {
589 boresight_rpy = string2vec3(boresight_str) * float(M_PI) / 180.f; // degrees -> radians
590 }
591
592 // t0 (time of first pulse, seconds). Optional; defaults to the trajectory start.
593 double t0 = 0.0;
594 bool t0_specified = false;
595 const char *t0_str = s.child_value("t0");
596 if (strlen(t0_str) > 0) {
597 t0 = atof(t0_str);
598 t0_specified = true;
599 }
600
601 // Trajectory: either an inline <trajectory> block of <pose> children, or a referenced <trajectoryFile>.
602 std::vector<double> traj_t;
603 std::vector<vec3> traj_pos;
604 std::vector<vec4> traj_quat;
605 bool has_trajectory = false;
606 pugi::xml_node traj_node = s.child("trajectory");
607 const char *trajFile_str = s.child_value("trajectoryFile");
608 if (strlen(trajFile_str) == 0) {
609 trajFile_str = s.child_value("trajectoryfile");
610 }
611 if (!traj_node.empty()) {
612 // Inline trajectory: concatenate the text of all <pose> children into a stream of rows.
613 std::ostringstream poses;
614 for (pugi::xml_node p = traj_node.child("pose"); p; p = p.next_sibling("pose")) {
615 poses << p.child_value() << "\n";
616 }
617 std::istringstream traj_stream(poses.str());
618 parseTrajectoryStream(traj_stream, "scan #" + std::to_string(scan_count), traj_t, traj_pos, traj_quat);
619 has_trajectory = true;
620 } else if (strlen(trajFile_str) > 0) {
621 // Referenced trajectory file: resolve relative to the XML file, then cwd.
622 std::string resolved_traj;
623 std::vector<std::string> candidates;
624 candidates.emplace_back(trajFile_str);
625 if (!xml_parent_dir.empty()) {
626 candidates.push_back((xml_parent_dir / trajFile_str).string());
627 }
628 candidates.push_back("input/" + std::string(trajFile_str));
629 for (const std::string &candidate: candidates) {
630 ifstream tf(candidate);
631 if (tf.good()) {
632 resolved_traj = candidate;
633 break;
634 }
635 }
636 if (resolved_traj.empty()) {
637 cerr << "failed.\n";
638 helios_runtime_error("ERROR (LiDARcloud::loadXML): trajectory file `" + std::string(trajFile_str) + "' given for scan #" + std::to_string(scan_count) + " does not exist.");
639 }
640 ifstream tf(resolved_traj);
641 parseTrajectoryStream(tf, "scan #" + std::to_string(scan_count), traj_t, traj_pos, traj_quat);
642 has_trajectory = true;
643 }
644
645 // Dispatch by scan type. A spinning_multibeam scan is always set up from its physical parameters (channel
646 // elevations + <azimuthStep> + <PRF> + a trajectory); there is no static azimuth-grid form. A stationary "spin
647 // in place" capture is just a trajectory of two coincident poses whose time gap sets the acquisition duration.
648 // A non-spinning scan with a trajectory is a moving raster; otherwise it is a static raster.
649 const bool physical_moving_raster = !spinning_multibeam && !risley && has_trajectory && (PRF > 0.f);
650
651 uint scanID;
652
653 if (risley) {
654
655 if (PRF <= 0.f) {
656 cerr << "failed.\n";
657 helios_runtime_error("ERROR (LiDARcloud::loadXML): risley scan #" + std::to_string(scan_count) + " requires a pulse repetition rate given as <PRF> (Hz).");
658 }
659 if (!has_trajectory) {
660 cerr << "failed.\n";
661 helios_runtime_error("ERROR (LiDARcloud::loadXML): risley scan #" + std::to_string(scan_count) +
662 " requires a <trajectory> or <trajectoryFile>. For a stationary capture, give two coincident poses with the same position and orientation, separated in time by the acquisition duration.");
663 }
664
665 scanID = addScanRisley(risley_prisms, risley_refractive_index_air, PRF, traj_t, traj_pos, traj_quat, lever_arm, boresight_rpy, exitDiameter, beamDivergence, rangeNoiseStdDev, angleNoiseStdDev, column_format,
666 t0_specified ? t0 : (traj_t.empty() ? 0.0 : traj_t.front()));
667
668 // Apply analytic-waveform return parameters (not constructor arguments of the new entry points).
669 setScanReturnMode(scanID, returnMode);
670 setScanSingleReturnSelection(scanID, singleReturnSelection);
671 setScanMaxReturns(scanID, maxReturns);
672 setScanPulseWidth(scanID, pulseWidth);
673 setScanDetectionThreshold(scanID, detectionThreshold);
674
675 } else if (spinning_multibeam) {
676
677 if (azimuthStep_rad <= 0.f) {
678 cerr << "failed.\n";
679 helios_runtime_error("ERROR (LiDARcloud::loadXML): spinning_multibeam scan #" + std::to_string(scan_count) + " requires an azimuth resolution given as <azimuthStep> (degrees per firing step).");
680 }
681 if (PRF <= 0.f) {
682 cerr << "failed.\n";
683 helios_runtime_error("ERROR (LiDARcloud::loadXML): spinning_multibeam scan #" + std::to_string(scan_count) + " requires a pulse repetition rate given as <PRF> (Hz).");
684 }
685 if (!has_trajectory) {
686 cerr << "failed.\n";
687 helios_runtime_error("ERROR (LiDARcloud::loadXML): spinning_multibeam scan #" + std::to_string(scan_count) +
688 " requires a <trajectory> or <trajectoryFile>. For a stationary spin in place, give two coincident poses with the same position and orientation, separated in time by the acquisition duration.");
689 }
690
691 scanID = addScanSpinning(beamElevationAnglesRad(beamZenithAngles), azimuthStep_rad, PRF, traj_t, traj_pos, traj_quat, lever_arm, boresight_rpy, exitDiameter, beamDivergence, rangeNoiseStdDev, angleNoiseStdDev, column_format,
692 t0_specified ? t0 : (traj_t.empty() ? 0.0 : traj_t.front()));
693
694 // Apply analytic-waveform return parameters (not constructor arguments of the new entry points).
695 setScanReturnMode(scanID, returnMode);
696 setScanSingleReturnSelection(scanID, singleReturnSelection);
697 setScanMaxReturns(scanID, maxReturns);
698 setScanPulseWidth(scanID, pulseWidth);
699 setScanDetectionThreshold(scanID, detectionThreshold);
700
701 } else if (physical_moving_raster) {
702
703 scanID = addScanMovingRaster(size.x, thetaMin, thetaMax, size.y, phiMin, phiMax, PRF, traj_t, traj_pos, traj_quat, lever_arm, boresight_rpy, exitDiameter, beamDivergence, rangeNoiseStdDev, angleNoiseStdDev, column_format,
704 t0_specified ? t0 : (traj_t.empty() ? 0.0 : traj_t.front()));
705
706 setScanReturnMode(scanID, returnMode);
707 setScanSingleReturnSelection(scanID, singleReturnSelection);
708 setScanMaxReturns(scanID, maxReturns);
709 setScanPulseWidth(scanID, pulseWidth);
710 setScanDetectionThreshold(scanID, detectionThreshold);
711
712 } else {
713
714 // Static raster scan (single fixed origin, uniform Ntheta x Nphi angular grid).
715 ScanMetadata scan(origin, size.x, thetaMin, thetaMax, size.y, phiMin, phiMax, exitDiameter, beamDivergence, rangeNoiseStdDev, angleNoiseStdDev, column_format, scanTiltRoll, scanTiltPitch, scanAzimuthOffset);
716
717 // Analytic-waveform return parameters (not constructor arguments)
718 scan.returnMode = returnMode;
719 scan.singleReturnSelection = singleReturnSelection;
720 scan.maxReturns = maxReturns;
721 scan.pulseWidth = pulseWidth;
722 scan.detectionThreshold = detectionThreshold;
723
724 addScan(scan);
725
726 scanID = getScanCount() - 1;
727 }
728
729 // ----- ASCII data file name ------//
730 std::string data_filename = deblank(s.child_value("filename"));
731
732 if (!data_filename.empty()) {
733
734 // Resolve the data file. Try in order:
735 // 1. input/<data_filename> (legacy convention)
736 // 2. <data_filename> (cwd-relative or absolute)
737 // 3. <xml_parent_dir>/<data_filename> (sibling of the XML file)
738 std::string resolved_data_file;
739 std::vector<std::string> candidates;
740 candidates.push_back("input/" + data_filename);
741 candidates.push_back(data_filename);
742 if (!xml_parent_dir.empty()) {
743 candidates.push_back((xml_parent_dir / data_filename).string());
744 }
745 for (const std::string &candidate: candidates) {
746 ifstream f(candidate);
747 if (f.good()) {
748 resolved_data_file = candidate;
749 break;
750 }
751 }
752 if (resolved_data_file.empty()) {
753 cout << "failed.\n";
754 helios_runtime_error("ERROR (LiDARcloud::loadXML): Data file `" + data_filename + "' given for scan #" + std::to_string(scan_count) + " does not exist.");
755 }
756
757 scans.at(scanID).data_file = resolved_data_file; // set the data file for the registered scan
758
759 // add hit points to scan if data file was given
760
761 total_hits += loadASCIIFile(scanID, scans.at(scanID).data_file);
762
763 if (translation.magnitude() > 0.f) {
764 coordinateShift(scanID, translation);
765 }
766 if (rotation_sphere.elevation != 0 || rotation_sphere.azimuth != 0) {
767 coordinateRotation(scanID, rotation_sphere);
768 }
769 }
770
771 scan_count++;
772 }
773 }
774
775 //------------ Grids ------------//
776
777 uint cell_count = 0; // counter variable for scans
778
779 // looping over any grids specified in XML file
780 for (pugi::xml_node s = helios.child("grid"); s; s = s.next_sibling("grid")) {
781
782 // ----- grid center ------//
783 const char *center_str = s.child_value("center");
784
785 if (strlen(center_str) == 0) {
786 cerr << "failed.\n";
787 helios_runtime_error("ERROR (LiDARcloud::loadXML): A center was not specified for grid #" + std::to_string(cell_count));
788 }
789
790 vec3 center = string2vec3(center_str); // note: pugi loads xml data as a character. need to separate it into 3 floats
791
792 // ----- grid size ------//
793 const char *gsize_str = s.child_value("size");
794
795 if (strlen(gsize_str) == 0) {
796 cerr << "failed.\n";
797 helios_runtime_error("ERROR (LiDARcloud::loadXML): A size was not specified for grid cell #" + std::to_string(cell_count));
798 }
799
800 vec3 gsize = string2vec3(gsize_str); // note: pugi loads xml data as a character. need to separate it into 3 floats
801
802 if (gsize.x <= 0 || gsize.y <= 0 || gsize.z <= 0) {
803 cerr << "failed.\n";
804 helios_runtime_error("ERROR (LiDARcloud::loadXML): The grid cell size must be positive.");
805 }
806
807 // ----- grid rotation ------//
808 float rotation;
809 const char *grot_str = s.child_value("rotation");
810
811 if (strlen(grot_str) == 0) {
812 rotation = 0; // if no rotation specified, assume = 0
813 } else {
814 rotation = atof(grot_str);
815 }
816
817 // ----- grid cells ------//
818 uint Nx, Ny, Nz;
819
820 const char *Nx_str = s.child_value("Nx");
821
822 if (strlen(Nx_str) == 0) { // If no Nx specified, assume Nx=1;
823 Nx = 1;
824 } else {
825 Nx = atof(Nx_str);
826 }
827 if (Nx <= 0) {
828 cerr << "failed.\n";
829 helios_runtime_error("ERROR (LiDARcloud::loadXML): The number of grid cells must be positive.");
830 }
831
832 const char *Ny_str = s.child_value("Ny");
833
834 if (strlen(Ny_str) == 0) { // If no Ny specified, assume Ny=1;
835 Ny = 1;
836 } else {
837 Ny = atof(Ny_str);
838 }
839 if (Ny <= 0) {
840 cerr << "failed.\n";
841 helios_runtime_error("ERROR (LiDARcloud::loadXML): The number of grid cells must be positive.");
842 }
843
844 const char *Nz_str = s.child_value("Nz");
845
846 if (strlen(Nz_str) == 0) { // If no Nz specified, assume Nz=1;
847 Nz = 1;
848 } else {
849 Nz = atof(Nz_str);
850 }
851 if (Nz <= 0) {
852 cerr << "failed.\n";
853 helios_runtime_error("ERROR (LiDARcloud::loadXML): The number of grid cells must be positive.");
854 }
855
856 int3 gridDivisions = helios::make_int3(Nx, Ny, Nz);
857
858 // add cells to grid
859
860 vec3 gsubsize = make_vec3(float(gsize.x) / float(Nx), float(gsize.y) / float(Ny), float(gsize.z) / float(Nz));
861
862 float x, y, z;
863 uint count = 0;
864 for (int k = 0; k < Nz; k++) {
865 z = -0.5f * float(gsize.z) + (float(k) + 0.5f) * float(gsubsize.z);
866 for (int j = 0; j < Ny; j++) {
867 y = -0.5f * float(gsize.y) + (float(j) + 0.5f) * float(gsubsize.y);
868 for (int i = 0; i < Nx; i++) {
869 x = -0.5f * float(gsize.x) + (float(i) + 0.5f) * float(gsubsize.x);
870
871 vec3 subcenter = make_vec3(x, y, z);
872
873 vec3 subcenter_rot = rotatePoint(subcenter, make_SphericalCoord(0, rotation * M_PI / 180.f));
874
875 if (printmessages) {
876 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 "
877 << gsubsize.z << endl;
878 }
879
880 addGridCell(subcenter + center, center, gsubsize, gsize, rotation * M_PI / 180.f, make_int3(i, j, k), make_int3(Nx, Ny, Nz));
881
882 count++;
883 }
884 }
885 }
886 }
887
888 if (printmessages) {
889
890 cout << "done." << endl;
891
892 cout << "Successfully read " << getScanCount() << " scan(s), which contain " << total_hits << " total hit points." << endl;
893 }
894}
895
896size_t LiDARcloud::loadASCIIFile(uint scanID, const std::string &ASCII_data_file) {
897
898 // Resolve file path using project-based resolution
899 std::filesystem::path resolved_path = resolveProjectFile(ASCII_data_file);
900 std::string resolved_filename = resolved_path.string();
901
902 ifstream datafile(resolved_filename); // open the file
903
904 if (!datafile.is_open()) { // check that file exists
905 helios_runtime_error("ERROR (LiDARcloud::loadASCIIFile): ASCII data file '" + ASCII_data_file + "' does not exist.");
906 }
907
908 if (scanID >= getScanCount()) {
909 helios_runtime_error("ERROR (LiDARcloud::loadASCIIFile): Scan #" + std::to_string(scanID) + " does not exist.");
910 }
911 const ScanMetadata &scan_data = scans.at(scanID);
912
913 vec3 temp_xyz;
914 float temp_zenith, temp_azimuth;
915 RGBcolor temp_rgb;
916 float temp_row, temp_column;
917 double temp_data;
918 std::map<std::string, double> data;
919
920 std::size_t hit_count = 0;
921 while (datafile.good()) { // loop through file to read scan data
922
923 temp_xyz = make_vec3(-9999, -9999, -9999);
924 temp_rgb = make_RGBcolor(1, 0, 0); // default color: red
925 temp_row = -1;
926 temp_column = -1;
927 temp_zenith = -9999;
928 temp_azimuth = -9999;
929
930 // Skip comment/header lines (e.g. the '#'-prefixed column-name header written by
931 // exportPointCloud). The loader keys columns off the XML ASCII_format, so the header is
932 // informational and is simply discarded.
933 datafile >> std::ws;
934 if (datafile.peek() == '#') {
935 std::string discard;
936 std::getline(datafile, discard);
937 continue;
938 }
939 if (!datafile.good()) { // EOF reached after trailing whitespace
940 break;
941 }
942
943 for (uint i = 0; i < scan_data.columnFormat.size(); i++) {
944 if (scan_data.columnFormat.at(i) == "row") {
945 datafile >> temp_row;
946 } else if (scan_data.columnFormat.at(i) == "column") {
947 datafile >> temp_column;
948 } else if (scan_data.columnFormat.at(i) == "zenith") {
949 datafile >> temp_zenith;
950 temp_zenith = deg2rad(temp_zenith);
951 } else if (scan_data.columnFormat.at(i) == "azimuth") {
952 datafile >> temp_azimuth;
953 temp_azimuth = deg2rad(temp_azimuth);
954 } else if (scan_data.columnFormat.at(i) == "zenith_rad") {
955 datafile >> temp_zenith;
956 } else if (scan_data.columnFormat.at(i) == "azimuth_rad") {
957 datafile >> temp_azimuth;
958 } else if (scan_data.columnFormat.at(i) == "x") {
959 datafile >> temp_xyz.x;
960 } else if (scan_data.columnFormat.at(i) == "y") {
961 datafile >> temp_xyz.y;
962 } else if (scan_data.columnFormat.at(i) == "z") {
963 datafile >> temp_xyz.z;
964 } else if (scan_data.columnFormat.at(i) == "r") {
965 datafile >> temp_rgb.r;
966 } else if (scan_data.columnFormat.at(i) == "g") {
967 datafile >> temp_rgb.g;
968 } else if (scan_data.columnFormat.at(i) == "b") {
969 datafile >> temp_rgb.b;
970 } else if (scan_data.columnFormat.at(i) == "r255") {
971 datafile >> temp_rgb.r;
972 temp_rgb.r /= 255.f;
973 } else if (scan_data.columnFormat.at(i) == "g255") {
974 datafile >> temp_rgb.g;
975 temp_rgb.g /= 255.f;
976 } else if (scan_data.columnFormat.at(i) == "b255") {
977 datafile >> temp_rgb.b;
978 temp_rgb.b /= 255.f;
979 } else { // assume that rest is data
980 datafile >> temp_data;
981 data[scan_data.columnFormat.at(i)] = temp_data;
982 }
983 }
984
985 if (!datafile.good()) { // if the whole line was not read successfully, stop
986 if (hit_count == 0) {
987 std::cerr << "WARNING: Something is likely wrong with the data file " << ASCII_data_file << ". Check that the format is consistent with that specified in the XML metadata file." << std::endl;
988 }
989 break;
990 }
991
992 // -- Checks to make sure everything was specified correctly -- //
993
994 // hit point
995 if (temp_xyz.x == -9999) {
996 helios_runtime_error("ERROR (LiDARcloud::loadASCIIFile): x-coordinate not specified for hit point #" + std::to_string(hit_count) + " of scan #" + std::to_string(scanID));
997 } else if (temp_xyz.y == -9999) {
998 helios_runtime_error("ERROR (LiDARcloud::loadASCIIFile): y-coordinate not specified for hit point #" + std::to_string(hit_count) + " of scan #" + std::to_string(scanID));
999 } else if (temp_xyz.z == -9999) {
1000 helios_runtime_error("ERROR (LiDARcloud::loadASCIIFile): z-coordinate not specified for hit point #" + std::to_string(hit_count) + " of scan #" + std::to_string(scanID));
1001 }
1002
1003 // direction
1004 SphericalCoord temp_direction(1.f, 0.5f * M_PI - temp_zenith, temp_azimuth);
1005 if (temp_direction.elevation == -9999 || temp_direction.azimuth == -9999) {
1006 temp_direction = cart2sphere(temp_xyz - scan_data.origin);
1007 }
1008
1009 // Carry the native scan-grid row/column indices onto the hit as hit data so that
1010 // the row/column-based gap filler (gapfillMisses) can reconstruct miss directions
1011 // without timestamps. Only stored when the columns were actually present and read.
1012 if (temp_row >= 0) {
1013 data["row"] = temp_row;
1014 }
1015 if (temp_column >= 0) {
1016 data["column"] = temp_column;
1017 }
1018
1019 // add hit point to the scan
1020 addHitPoint(scanID, temp_xyz, temp_direction, temp_rgb, data);
1021
1022 hit_count++;
1023 }
1024
1025 datafile.close();
1026
1027 return hit_count;
1028}
1029
1030void LiDARcloud::exportTriangleNormals(const char *filename) {
1031
1032 ensureOutputDirectoryExists(filename);
1033
1034 ofstream file;
1035
1036 file.open(filename);
1037
1038 if (!file.is_open()) {
1039 helios_runtime_error("ERROR (LiDARcloud::exportTriangleNormals): Could not open file '" + std::string(filename) + "' for writing.");
1040 }
1041
1042 for (std::size_t t = 0; t < triangles.size(); t++) {
1043
1044 Triangulation tri = triangles.at(t);
1045
1046 vec3 v0 = tri.vertex0;
1047 vec3 v1 = tri.vertex1;
1048 vec3 v2 = tri.vertex2;
1049
1050 vec3 normal = cross(v1 - v0, v2 - v0);
1051 normal.normalize();
1052
1053 file << normal.x << " " << normal.y << " " << normal.z << std::endl;
1054 }
1055
1056 file.close();
1057}
1058
1059void LiDARcloud::exportTriangleNormals(const char *filename, int gridcell) {
1060
1061 ensureOutputDirectoryExists(filename);
1062
1063 ofstream file;
1064
1065 file.open(filename);
1066
1067 if (!file.is_open()) {
1068 helios_runtime_error("ERROR (LiDARcloud::exportTriangleNormals): Could not open file '" + std::string(filename) + "' for writing.");
1069 }
1070
1071 for (std::size_t t = 0; t < triangles.size(); t++) {
1072
1073 Triangulation tri = triangles.at(t);
1074
1075 if (tri.gridcell == gridcell) {
1076
1077 vec3 v0 = tri.vertex0;
1078 vec3 v1 = tri.vertex1;
1079 vec3 v2 = tri.vertex2;
1080
1081 vec3 normal = cross(v1 - v0, v2 - v0);
1082 normal.normalize();
1083
1084 file << normal.x << " " << normal.y << " " << normal.z << std::endl;
1085 }
1086 }
1087
1088 file.close();
1089}
1090
1091void LiDARcloud::exportTriangleAreas(const char *filename) {
1092
1093 ensureOutputDirectoryExists(filename);
1094
1095 ofstream file;
1096
1097 file.open(filename);
1098
1099 if (!file.is_open()) {
1100 helios_runtime_error("ERROR (LiDARcloud::exportTriangleAreas): Could not open file '" + std::string(filename) + "' for writing.");
1101 }
1102
1103 for (std::size_t t = 0; t < triangles.size(); t++) {
1104
1105 Triangulation tri = triangles.at(t);
1106
1107 file << tri.area << std::endl;
1108 }
1109
1110 file.close();
1111}
1112
1113void LiDARcloud::exportTriangleAreas(const char *filename, int gridcell) {
1114
1115 ensureOutputDirectoryExists(filename);
1116
1117 ofstream file;
1118
1119 file.open(filename);
1120
1121 if (!file.is_open()) {
1122 helios_runtime_error("ERROR (LiDARcloud::exportTriangleAreas): Could not open file '" + std::string(filename) + "' for writing.");
1123 }
1124
1125 for (std::size_t t = 0; t < triangles.size(); t++) {
1126
1127 Triangulation tri = triangles.at(t);
1128
1129 if (tri.gridcell == gridcell) {
1130
1131 file << tri.area << std::endl;
1132 }
1133 }
1134
1135 file.close();
1136}
1137
1139
1140 ensureOutputDirectoryExists(filename);
1141
1142 std::vector<std::vector<float>> inclinations(getGridCellCount());
1143 for (int i = 0; i < getGridCellCount(); i++) {
1144 inclinations.at(i).resize(Nbins);
1145 }
1146 std::vector<float> cell_area(inclinations.size(), 0);
1147
1148 float db = 0.5f * M_PI / float(Nbins); // bin width
1149
1150 for (std::size_t t = 0; t < triangles.size(); t++) {
1151
1152 Triangulation tri = triangles.at(t);
1153
1154 int cell = tri.gridcell;
1155
1156 if (cell < 0) {
1157 continue;
1158 }
1159
1160 vec3 v0 = tri.vertex0;
1161 vec3 v1 = tri.vertex1;
1162 vec3 v2 = tri.vertex2;
1163
1164 vec3 normal = cross(v1 - v0, v2 - v0);
1165 normal.normalize();
1166
1167 float angle = acos_safe(fabs(normal.z));
1168
1169 float area = tri.area;
1170
1171 uint bin = floor(angle / db);
1172 if (bin >= Nbins) {
1173 bin = Nbins - 1;
1174 }
1175
1176 inclinations.at(cell).at(bin) += area;
1177
1178 cell_area.at(cell) += area;
1179 }
1180
1181 ofstream file;
1182
1183 file.open(filename);
1184
1185 if (!file.is_open()) {
1186 helios_runtime_error("ERROR (LiDARcloud::exportTriangleInclinationDistribution): Could not open file '" + std::string(filename) + "' for writing.");
1187 }
1188
1189 for (int cell = 0; cell < getGridCellCount(); cell++) {
1190 for (int bin = 0; bin < Nbins; bin++) {
1191 file << inclinations.at(cell).at(bin) / cell_area.at(cell) << " ";
1192 }
1193 file << std::endl;
1194 }
1195
1196 file.close();
1197}
1198
1199void LiDARcloud::exportTriangleAzimuthDistribution(const char *filename, uint Nbins) {
1200
1201 ensureOutputDirectoryExists(filename);
1202
1203 std::vector<std::vector<float>> azimuths(getGridCellCount());
1204 for (int i = 0; i < getGridCellCount(); i++) {
1205 azimuths.at(i).resize(Nbins);
1206 }
1207 std::vector<float> cell_area(azimuths.size(), 0);
1208
1209 float db = 2 * M_PI / float(Nbins); // bin width
1210
1211 for (std::size_t t = 0; t < triangles.size(); t++) {
1212
1213 Triangulation tri = triangles.at(t);
1214
1215 int cell = tri.gridcell;
1216
1217 if (cell < 0) {
1218 continue;
1219 }
1220
1221 vec3 v0 = tri.vertex0;
1222 vec3 v1 = tri.vertex1;
1223 vec3 v2 = tri.vertex2;
1224
1225 vec3 normal = cross(v1 - v0, v2 - v0);
1226 normal.normalize();
1227 SphericalCoord n_sph = cart2sphere(normal);
1228
1229 float azimuth = n_sph.azimuth;
1230
1231 if (normal.z < 0) {
1232 azimuth = azimuth + M_PI;
1233 if (azimuth > M_PI * 2) {
1234 azimuth = azimuth - M_PI * 2;
1235 }
1236 }
1237
1238
1239 float area = tri.area;
1240
1241 uint bin = floor(azimuth / db);
1242 if (bin >= Nbins) {
1243 bin = Nbins - 1;
1244 }
1245
1246 azimuths.at(cell).at(bin) += area;
1247 cell_area.at(cell) += area;
1248 }
1249
1250 ofstream file;
1251
1252 file.open(filename);
1253
1254 if (!file.is_open()) {
1255 helios_runtime_error("ERROR (LiDARcloud::exportTriangleAzimuthDistribution): Could not open file '" + std::string(filename) + "' for writing.");
1256 }
1257
1258 for (int cell = 0; cell < getGridCellCount(); cell++) {
1259 for (int bin = 0; bin < Nbins; bin++) {
1260 file << azimuths.at(cell).at(bin) / cell_area.at(cell) << " ";
1261 }
1262 file << std::endl;
1263 }
1264
1265 file.close();
1266}
1267
1268void LiDARcloud::exportLeafAreas(const char *filename) {
1269
1270 ensureOutputDirectoryExists(filename);
1271
1272 ofstream file;
1273
1274 file.open(filename);
1275
1276 if (!file.is_open()) {
1277 helios_runtime_error("ERROR (LiDARcloud::exportLeafAreas): Could not open file '" + std::string(filename) + "' for writing.");
1278 }
1279
1280 for (uint i = 0; i < getGridCellCount(); i++) {
1281
1282 file << getCellLeafArea(i) << std::endl;
1283 }
1284
1285 file.close();
1286}
1287
1288void LiDARcloud::exportLeafAreaDensities(const char *filename) {
1289
1290 ensureOutputDirectoryExists(filename);
1291
1292 ofstream file;
1293
1294 file.open(filename);
1295
1296 if (!file.is_open()) {
1297 helios_runtime_error("ERROR (LiDARcloud::exportLeafAreaDensities): Could not open file '" + std::string(filename) + "' for writing.");
1298 }
1299
1300 for (uint i = 0; i < getGridCellCount(); i++) {
1301
1302 file << getCellLeafAreaDensity(i) << std::endl;
1303 }
1304
1305 file.close();
1306}
1307
1308void LiDARcloud::exportGtheta(const char *filename) {
1309
1310 ensureOutputDirectoryExists(filename);
1311
1312 ofstream file;
1313
1314 file.open(filename);
1315
1316 if (!file.is_open()) {
1317 helios_runtime_error("ERROR (LiDARcloud::exportGtheta): Could not open file '" + std::string(filename) + "' for writing.");
1318 }
1319
1320 for (uint i = 0; i < getGridCellCount(); i++) {
1321
1322 file << getCellGtheta(i) << std::endl;
1323 }
1324
1325 file.close();
1326}
1327
1328void LiDARcloud::exportLeafAreaUncertainty(const char *filename) {
1329
1330 ensureOutputDirectoryExists(filename);
1331
1332 ofstream file;
1333
1334 file.open(filename);
1335
1336 if (!file.is_open()) {
1337 helios_runtime_error("ERROR (LiDARcloud::exportLeafAreaUncertainty): Could not open file '" + std::string(filename) + "' for writing.");
1338 }
1339
1340 // SAMPLING uncertainty of the leaf-area inversion (Pimont et al. 2018), conditional on the
1341 // beams that entered each voxel; does NOT capture occlusion/coverage bias. Undefined values
1342 // are written as the sentinel -1.
1343 file << "# cell_index leaf_area beam_count I_rdi LAD_std_error ci_valid" << std::endl;
1344 for (uint i = 0; i < getGridCellCount(); i++) {
1345 const float lad_variance = getCellLADVariance(i);
1346 const float lad_std_error = (lad_variance >= 0.f) ? std::sqrt(lad_variance) : -1.f;
1347 file << i << " " << getCellLeafArea(i) << " " << getCellBeamCount(i) << " " << grid_cells.at(i).I_rdi << " " << lad_std_error << " " << (grid_cells.at(i).ci_valid ? 1 : 0) << std::endl;
1348 }
1349
1350 file.close();
1351}
1352
1353void LiDARcloud::exportPointCloud(const char *filename, bool write_header) {
1354
1355 if (getScanCount() == 1) {
1356 exportPointCloud(filename, 0, write_header);
1357 } else {
1358
1359 for (int i = 0; i < getScanCount(); i++) {
1360
1361 std::string filename_a = filename;
1362 char scan[20];
1363 snprintf(scan, sizeof(scan), "%d", i);
1364
1365 size_t dotindex = filename_a.find_last_of(".");
1366 if (dotindex == filename_a.size() - 1 || filename_a.size() - 1 - dotindex > 4) { // no file extension was provided
1367 filename_a = filename_a + "_" + scan;
1368 } else { // has file extension
1369 std::string ext = filename_a.substr(dotindex, filename_a.size() - 1);
1370 filename_a = filename_a.substr(0, dotindex) + "_" + scan + ext;
1371 }
1372
1373 exportPointCloud(filename_a.c_str(), i, write_header);
1374 }
1375 }
1376}
1377
1378
1379void LiDARcloud::exportPointCloud(const char *filename, uint scanID, bool write_header) {
1380
1381 ensureOutputDirectoryExists(filename);
1382
1383 if (scanID > getScanCount()) {
1384 std::cerr << "ERROR (LiDARcloud::exportPointCloud): Cannot export scan " << scanID << " because this scan does not exist." << std::endl;
1385 throw 1;
1386 }
1387
1388 ofstream file;
1389
1390 file.open(filename);
1391
1392 if (!file.is_open()) {
1393 helios_runtime_error("ERROR (LiDARcloud::exportPointCloud): Could not open file '" + std::string(filename) + "' for writing.");
1394 }
1395
1396 // The union of per-hit scalar-data labels is exactly the set of columns in the columnar store. This
1397 // used to be computed by copying every hit's whole std::map and scanning it (an O(N*keys) pass that
1398 // was a second hidden export hot spot); with columnar storage it is just the label list. (This
1399 // collected list is presently unused downstream — the export columns come from getScanColumnFormat
1400 // below — but is kept for parity with the prior behavior.)
1401 std::vector<std::string> hit_data = hit_data_labels;
1402
1403 std::vector<std::string> ASCII_format = getScanColumnFormat(scanID);
1404
1405 if (ASCII_format.size() == 0) {
1406 ASCII_format.push_back("x");
1407 ASCII_format.push_back("y");
1408 ASCII_format.push_back("z");
1409 }
1410
1411 // Write a leading comment-line header listing the column field names. This follows the
1412 // conventional '#'-prefixed ASCII point-cloud header (e.g. accepted by CloudCompare). The
1413 // tokens are the resolved ASCII_format columns, so the header always matches the data
1414 // columns, including any user-defined scalar fields. loadASCIIFile() skips '#' lines.
1415 if (write_header) {
1416 file << "#";
1417 for (const std::string &col: ASCII_format) {
1418 file << " " << col;
1419 }
1420 file << std::endl;
1421 }
1422
1423 // Resolve every output column ONCE to either a built-in field code or a scalar-data column slot, so
1424 // the per-hit inner loop does no string comparisons and no per-cell map/hash lookups. A scalar column
1425 // resolves to its slot index in the columnar store (>= 0), or COL_ABSENT if the label is not a known
1426 // column at all. Built-ins (x/y/z/r/g/b/...) get distinct negative codes.
1427 enum ColCode {
1428 COL_X = -1,
1429 COL_Y = -2,
1430 COL_Z = -3,
1431 COL_R = -4,
1432 COL_G = -5,
1433 COL_B = -6,
1434 COL_R255 = -7,
1435 COL_G255 = -8,
1436 COL_B255 = -9,
1437 COL_ZENITH = -10,
1438 COL_AZIMUTH = -11,
1439 COL_ABSENT = -12
1440 };
1441 std::vector<int> col_resolved(ASCII_format.size());
1442 for (size_t c = 0; c < ASCII_format.size(); c++) {
1443 const std::string &tok = ASCII_format[c];
1444 if (tok == "x") {
1445 col_resolved[c] = COL_X;
1446 } else if (tok == "y") {
1447 col_resolved[c] = COL_Y;
1448 } else if (tok == "z") {
1449 col_resolved[c] = COL_Z;
1450 } else if (tok == "r") {
1451 col_resolved[c] = COL_R;
1452 } else if (tok == "g") {
1453 col_resolved[c] = COL_G;
1454 } else if (tok == "b") {
1455 col_resolved[c] = COL_B;
1456 } else if (tok == "r255") {
1457 col_resolved[c] = COL_R255;
1458 } else if (tok == "g255") {
1459 col_resolved[c] = COL_G255;
1460 } else if (tok == "b255") {
1461 col_resolved[c] = COL_B255;
1462 } else if (tok == "zenith") {
1463 col_resolved[c] = COL_ZENITH;
1464 } else if (tok == "azimuth") {
1465 col_resolved[c] = COL_AZIMUTH;
1466 } else {
1467 int slot = getHitDataColumnIndex(tok.c_str());
1468 col_resolved[c] = (slot >= 0) ? slot : COL_ABSENT;
1469 }
1470 }
1471
1472 for (int r = 0; r < getHitCount(); r++) {
1473
1474 if (getHitScanID(r) != scanID) {
1475 continue;
1476 }
1477
1478 vec3 xyz = getHitXYZ(r);
1479 RGBcolor color = getHitColor(r);
1480
1481 for (int c = 0; c < ASCII_format.size(); c++) {
1482
1483 const int code = col_resolved[c];
1484 if (code >= 0) { // scalar-data column slot
1485 if (hit_data_present[code][r] != char(0)) {
1486 file << hit_data_columns[code][r];
1487 } else {
1488 file << -9999;
1489 }
1490 } else if (code == COL_X) {
1491 file << xyz.x;
1492 } else if (code == COL_Y) {
1493 file << xyz.y;
1494 } else if (code == COL_Z) {
1495 file << xyz.z;
1496 } else if (code == COL_R) {
1497 file << color.r;
1498 } else if (code == COL_G) {
1499 file << color.g;
1500 } else if (code == COL_B) {
1501 file << color.b;
1502 } else if (code == COL_R255) {
1503 file << round(color.r * 255);
1504 } else if (code == COL_G255) {
1505 file << round(color.g * 255);
1506 } else if (code == COL_B255) {
1507 file << round(color.b * 255);
1508 } else if (code == COL_ZENITH) {
1509 file << getHitRaydir(r).zenith;
1510 } else if (code == COL_AZIMUTH) {
1511 file << getHitRaydir(r).azimuth;
1512 } else { // COL_ABSENT
1513 file << -9999;
1514 }
1515
1516 if (c < ASCII_format.size() - 1) {
1517 file << " ";
1518 }
1519 }
1520
1521 file << std::endl;
1522 }
1523
1524 file.close();
1525}
1526
1527void LiDARcloud::exportScans(const char *filename) {
1528
1529 if (getScanCount() == 0) {
1530 helios_runtime_error("ERROR (LiDARcloud::exportScans): No scans to export.");
1531 }
1532
1533 ensureOutputDirectoryExists(filename);
1534
1535 std::filesystem::path xml_path(filename);
1536 std::filesystem::path parent_dir = xml_path.parent_path();
1537 std::string stem = xml_path.stem().string();
1538
1539 pugi::xml_document xmldoc;
1540 pugi::xml_node helios_node = xmldoc.append_child("helios");
1541
1542 for (uint i = 0; i < getScanCount(); i++) {
1543
1544 std::string xyz_basename = stem + "_" + std::to_string(i) + ".xyz";
1545 std::filesystem::path xyz_path = parent_dir / xyz_basename;
1546 std::string xyz_path_str = xyz_path.string();
1547
1548 // Write the ASCII point cloud for this scan using the existing exporter
1549 exportPointCloud(xyz_path_str.c_str(), i);
1550
1551 // Build the <scan> entry
1552 pugi::xml_node scan_node = helios_node.append_child("scan");
1553
1554 vec3 origin = getScanOrigin(i);
1555 uint Ntheta = getScanSizeTheta(i);
1556 uint Nphi = getScanSizePhi(i);
1557 vec2 theta_range = getScanRangeTheta(i);
1558 vec2 phi_range = getScanRangePhi(i);
1559 float exit_diameter = getScanBeamExitDiameter(i);
1560 float beam_divergence = getScanBeamDivergence(i);
1561 float range_noise_stddev = getScanRangeNoiseStdDev(i);
1562 float angle_noise_stddev = getScanAngleNoiseStdDev(i);
1563 float scan_tilt_roll = getScanTiltRoll(i);
1564 float scan_tilt_pitch = getScanTiltPitch(i);
1565 float scan_azimuth_offset = getScanAzimuthOffset(i);
1566 std::vector<std::string> column_format = getScanColumnFormat(i);
1567 if (column_format.empty()) {
1568 column_format = {"x", "y", "z"};
1569 }
1570
1571 auto append_text_child = [&](const char *tag, const std::string &text) {
1572 pugi::xml_node child = scan_node.append_child(tag);
1573 child.append_child(pugi::node_pcdata).set_value(text.c_str());
1574 };
1575
1576 // Write a static <origin> only when the scan has no per-point origin columns. A moving-platform scan records a
1577 // per-pulse origin (origin_x/origin_y/origin_z) in the data file, so a single static origin would be misleading;
1578 // it is omitted and the per-point origins are the source of truth (loadXML accepts either, but requires one).
1579 const bool has_perpoint_origin = (std::find(column_format.begin(), column_format.end(), "origin_x") != column_format.end() && std::find(column_format.begin(), column_format.end(), "origin_y") != column_format.end() &&
1580 std::find(column_format.begin(), column_format.end(), "origin_z") != column_format.end());
1581 if (!has_perpoint_origin) {
1582 std::ostringstream origin_ss;
1583 origin_ss << origin.x << " " << origin.y << " " << origin.z;
1584 append_text_child("origin", origin_ss.str());
1585 }
1586
1587 const ScanMode scan_mode = getScanMode(i);
1588 const bool is_spinning = (scan_mode == SCAN_MODE_SPINNING);
1589 const bool is_moving_raster = (scan_mode == SCAN_MODE_MOVING_RASTER);
1590 const bool is_risley = (scan_mode == SCAN_MODE_RISLEY_PRISM);
1591
1592 // Spinning multibeam scans store the per-channel zenith angles rather than a uniform theta range, so write the pattern
1593 // and channel elevation angles (in degrees above the horizon) to round-trip the scan geometry on re-import. A Risley
1594 // scan stores its rotating prism stack instead; both derive their grid internally and emit no <size>.
1596 append_text_child("scanPattern", "spinning_multibeam");
1597 std::ostringstream elev_ss;
1598 const std::vector<float> beam_zenith_angles = getScanBeamZenithAngles(i);
1599 for (size_t c = 0; c < beam_zenith_angles.size(); c++) {
1600 if (c > 0) {
1601 elev_ss << " ";
1602 }
1603 elev_ss << (0.5f * float(M_PI) - beam_zenith_angles[c]) * 180.f / float(M_PI); // zenith -> elevation (deg)
1604 }
1605 append_text_child("beamElevationAngles", elev_ss.str());
1606 // A spinning scan derives Nphi (and the azimuth sweep) internally from the azimuth resolution, PRF, and
1607 // trajectory, which are written below; no <size>/<Nphi>/<phiMax> is emitted.
1608 } else if (is_risley) {
1609 append_text_child("scanPattern", "risley");
1610 if (getScanRisleyRefractiveIndexAir(i) != 1.0) {
1611 append_text_child("refractiveIndexAir", std::to_string(getScanRisleyRefractiveIndexAir(i)));
1612 }
1613 // One <prism> child per rotating wedge: "wedgeAngle(deg) refractiveIndex rotorRate(Hz, signed) phase(deg)". The
1614 // grid (Ntheta=1, Nphi=Npulses) and circular FoV are derived internally from these on reload; no <size> is emitted.
1615 const std::vector<RisleyPrism> prisms = getScanRisleyPrisms(i);
1616 for (const RisleyPrism &prism : prisms) {
1617 std::ostringstream prism_ss;
1618 prism_ss << std::setprecision(12) << prism.wedge_angle * 180.0 / M_PI << " " << prism.refractive_index << " " << prism.rotor_rate / (2.0 * M_PI) << " " << prism.phase * 180.0 / M_PI;
1619 append_text_child("prism", prism_ss.str());
1620 }
1621 } else if (!is_moving_raster) {
1622 std::ostringstream size_ss;
1623 size_ss << Ntheta << " " << Nphi;
1624 append_text_child("size", size_ss.str());
1625 }
1626
1627 // The angular bounds describe the per-frame fan. For a physical spinning or Risley scan they are derived and must NOT
1628 // be written (the geometry comes from the channels / prisms); for a moving raster the fan resolution is written via
1629 // <size> below.
1630 if (is_moving_raster) {
1631 std::ostringstream size_ss;
1632 size_ss << Ntheta << " " << Nphi;
1633 append_text_child("size", size_ss.str());
1634 }
1635 if (!is_spinning && !is_risley) {
1636 append_text_child("thetaMin", std::to_string(theta_range.x * 180.f / float(M_PI)));
1637 append_text_child("thetaMax", std::to_string(theta_range.y * 180.f / float(M_PI)));
1638 append_text_child("phiMin", std::to_string(phi_range.x * 180.f / float(M_PI)));
1639 append_text_child("phiMax", std::to_string(phi_range.y * 180.f / float(M_PI)));
1640 }
1641
1642 // ----- physical-parameter (moving / spinning / Risley) round-trip ------//
1643 // Emit the physical instrument parameters and a trajectory sidecar CSV so a moving-platform, spinning, or Risley scan
1644 // reloads through the same physical-parameter path it was created with (addScanSpinning / addScanMovingRaster /
1645 // addScanRisley).
1646 if (is_spinning || is_moving_raster || is_risley) {
1647 const ScanMetadata &sm = scans.at(i);
1648
1649 // PRF (Hz) from the per-pulse period.
1650 if (sm.pulse_period > 0.0) {
1651 append_text_child("PRF", std::to_string(1.0 / sm.pulse_period));
1652 }
1653 if (is_spinning && sm.steps_per_rev > 0) {
1654 // Azimuth resolution in degrees per step (360 / steps_per_rev).
1655 append_text_child("azimuthStep", std::to_string(360.0 / double(sm.steps_per_rev)));
1656 }
1657 if (sm.lever_arm.x != 0.f || sm.lever_arm.y != 0.f || sm.lever_arm.z != 0.f) {
1658 std::ostringstream lever_ss;
1659 lever_ss << sm.lever_arm.x << " " << sm.lever_arm.y << " " << sm.lever_arm.z;
1660 append_text_child("leverArm", lever_ss.str());
1661 }
1662 if (sm.boresight_rpy.x != 0.f || sm.boresight_rpy.y != 0.f || sm.boresight_rpy.z != 0.f) {
1663 std::ostringstream boresight_ss;
1664 boresight_ss << sm.boresight_rpy.x * 180.f / float(M_PI) << " " << sm.boresight_rpy.y * 180.f / float(M_PI) << " " << sm.boresight_rpy.z * 180.f / float(M_PI);
1665 append_text_child("boresight", boresight_ss.str());
1666 }
1667 if (sm.t0 != 0.0) {
1668 append_text_child("t0", std::to_string(sm.t0));
1669 }
1670
1671 // Trajectory sidecar CSV: "<stem>_<i>_traj.csv" alongside the XML, columns t x y z qx qy qz qw.
1672 std::string traj_basename = stem + "_" + std::to_string(i) + "_traj.csv";
1673 std::filesystem::path traj_path = parent_dir / traj_basename;
1674 std::ofstream traj_file(traj_path.string());
1675 if (!traj_file.is_open()) {
1676 helios_runtime_error("ERROR (LiDARcloud::exportScans): Could not write trajectory file '" + traj_path.string() + "'.");
1677 }
1678 traj_file << "# t x y z qx qy qz qw\n";
1679 for (size_t k = 0; k < sm.traj_t.size(); k++) {
1680 const vec3 &p = sm.traj_pos.at(k);
1681 const vec4 &q = sm.traj_quat.at(k);
1682 traj_file << sm.traj_t.at(k) << " " << p.x << " " << p.y << " " << p.z << " " << q.x << " " << q.y << " " << q.z << " " << q.w << "\n";
1683 }
1684 traj_file.close();
1685 append_text_child("trajectoryFile", traj_basename);
1686 }
1687
1688 append_text_child("exitDiameter", std::to_string(exit_diameter));
1689 append_text_child("beamDivergence", std::to_string(beam_divergence));
1690 append_text_child("rangeNoiseStdDev", std::to_string(range_noise_stddev));
1691 append_text_child("angleNoiseStdDev", std::to_string(angle_noise_stddev));
1692
1693 // Analytic-waveform return parameters: write only non-default values to keep the metadata file uncluttered.
1695 append_text_child("returnMode", "single");
1697 if (sel == SINGLE_RETURN_FIRST) {
1698 append_text_child("singleReturnSelection", "first");
1699 } else if (sel == SINGLE_RETURN_LAST) {
1700 append_text_child("singleReturnSelection", "last");
1701 } else if (sel == SINGLE_RETURN_STRONGEST_PLUS_LAST) {
1702 append_text_child("singleReturnSelection", "strongest_plus_last");
1703 } else {
1704 append_text_child("singleReturnSelection", "strongest");
1705 }
1706 if (getScanMaxReturns(i) != 1) {
1707 append_text_child("maxReturns", std::to_string(getScanMaxReturns(i)));
1708 }
1709 }
1710 if (getScanPulseWidth(i) > 0.f) {
1711 append_text_child("pulseWidth", std::to_string(getScanPulseWidth(i)));
1712 }
1713 if (getScanDetectionThreshold(i) > 0.f) {
1714 append_text_child("detectionThreshold", std::to_string(getScanDetectionThreshold(i)));
1715 }
1716
1717 if (scan_tilt_roll != 0.f || scan_tilt_pitch != 0.f) {
1718 std::ostringstream tilt_ss;
1719 tilt_ss << scan_tilt_roll * 180.f / float(M_PI) << " " << scan_tilt_pitch * 180.f / float(M_PI);
1720 append_text_child("scanTilt", tilt_ss.str());
1721 }
1722
1723 if (scan_azimuth_offset != 0.f) {
1724 append_text_child("scanAzimuthOffset", std::to_string(scan_azimuth_offset * 180.f / float(M_PI)));
1725 }
1726
1727 std::ostringstream format_ss;
1728 for (size_t c = 0; c < column_format.size(); c++) {
1729 if (c > 0) {
1730 format_ss << " ";
1731 }
1732 format_ss << column_format[c];
1733 }
1734 append_text_child("ASCII_format", format_ss.str());
1735
1736 append_text_child("filename", xyz_basename);
1737 }
1738
1739 if (!xmldoc.save_file(filename)) {
1740 helios_runtime_error("ERROR (LiDARcloud::exportScans): Could not write XML metadata file '" + std::string(filename) + "'.");
1741 }
1742}
1743
1744void LiDARcloud::exportPointCloudPTX(const char *filename, uint scanID) {
1745
1746 ensureOutputDirectoryExists(filename);
1747
1748 if (scanID > getScanCount()) {
1749 std::cerr << "ERROR (LiDARcloud::exportPointCloudPTX): Cannot export scan " << scanID << " because this scan does not exist." << std::endl;
1750 throw 1;
1751 }
1752
1753 // The PTX format encodes a single scanner position/transform per scan, which cannot represent a scanner that moved
1754 // during acquisition. Fail fast rather than write a file with a misleading single origin. Use exportScans() /
1755 // exportPointCloud() (which write the per-pulse origin columns) for a moving-platform scan instead.
1756 if (scanID < scans.size() && scans.at(scanID).isMoving) {
1757 helios_runtime_error("ERROR (LiDARcloud::exportPointCloudPTX): the PTX format cannot represent a moving-platform scan (see addScanMoving), which has no single scanner origin. Use exportScans() or exportPointCloud() instead.");
1758 }
1759
1760 ofstream file;
1761
1762 file.open(filename);
1763
1764 if (!file.is_open()) {
1765 helios_runtime_error("ERROR (LiDARcloud::exportPointCloudPTX): Could not open file '" + std::string(filename) + "' for writing.");
1766 }
1767
1768 std::vector<std::string> ASCII_format = getScanColumnFormat(scanID);
1769
1770 uint Nx = getScanSizeTheta(scanID);
1771 uint Ny = getScanSizePhi(scanID);
1772
1773 file << Nx << std::endl;
1774 file << Ny << std::endl;
1775 file << "0 0 0" << std::endl;
1776 file << "1 0 0" << std::endl;
1777 file << "0 1 0" << std::endl;
1778 file << "0 0 1" << std::endl;
1779 file << "1 0 0 0" << std::endl;
1780 file << "0 1 0 0" << std::endl;
1781 file << "0 0 1 0" << std::endl;
1782 file << "0 0 0 1" << std::endl;
1783
1784 std::vector<std::vector<vec4>> xyzi(Ny);
1785 for (int j = 0; j < Ny; j++) {
1786 xyzi.at(j).resize(Nx);
1787 for (int i = 0; i < Nx; i++) {
1788 xyzi.at(j).at(i) = make_vec4(0, 0, 0, 1);
1789 }
1790 }
1791
1792 vec3 origin = getScanOrigin(scanID);
1793
1794 for (int r = 0; r < getHitCount(); r++) {
1795
1796 if (getHitScanID(r) != scanID) {
1797 continue;
1798 }
1799
1800 SphericalCoord raydir = getHitRaydir(r);
1801
1802 int2 row_column = scans.at(scanID).direction2rc(raydir);
1803
1804 assert(row_column.x >= 0 && row_column.x < Nx && row_column.y >= 0 && row_column.y < Ny);
1805
1806 vec3 xyz = getHitXYZ(r);
1807
1808 if ((xyz - origin).magnitude() >= 1e4) {
1809 continue;
1810 }
1811
1812 float intensity = 1.f;
1813 if (doesHitDataExist(r, "intensity")) {
1814 intensity = getHitData(r, "intensity");
1815 }
1816
1817 xyzi.at(row_column.y).at(row_column.x) = make_vec4(xyz.x, xyz.y, xyz.z, intensity);
1818 }
1819
1820 for (int j = 0; j < Ny; j++) {
1821 for (int i = 0; i < Nx; i++) {
1822 file << xyzi.at(j).at(i).x << " " << xyzi.at(j).at(i).y << " " << xyzi.at(j).at(i).z << " " << xyzi.at(j).at(i).w << std::endl;
1823 }
1824 }
1825
1826 file.close();
1827}
1828
1829std::vector<uint> LiDARcloud::loadTreeQSM(helios::Context *context, const std::string &filename, uint radial_subdivisions, const std::string &texture_file) {
1830 return loadTreeQSM_impl(context, filename, radial_subdivisions, false, texture_file);
1831}
1832
1833std::vector<uint> LiDARcloud::loadTreeQSMColormap(helios::Context *context, const std::string &filename, uint radial_subdivisions, const std::string &colormap_name) {
1834 return loadTreeQSM_impl(context, filename, radial_subdivisions, true, colormap_name);
1835}
1836
1837std::vector<uint> LiDARcloud::loadTreeQSM_impl(helios::Context *context, const std::string &filename, uint radial_subdivisions, bool use_colormap, const std::string &colormap_or_texture) {
1838
1839 if (printmessages) {
1840 if (use_colormap) {
1841 std::cout << "Loading TreeQSM cylinder file with colormap: " << filename << " (colormap: " << colormap_or_texture << ")" << std::endl;
1842 } else {
1843 std::cout << "Loading TreeQSM cylinder file: " << filename << std::endl;
1844 }
1845 }
1846
1847 std::vector<uint> tube_UUIDs;
1848
1849 // Open the file
1850 std::ifstream file(filename);
1851 if (!file.is_open()) {
1852 helios_runtime_error("ERROR (LiDARcloud::loadTreeQSM): Could not open TreeQSM file: " + filename);
1853 }
1854
1855 // Structure to hold cylinder data
1856 struct CylinderData {
1857 float radius;
1858 float length;
1859 helios::vec3 start_point;
1860 helios::vec3 axis_direction;
1861 int parent;
1862 int extension;
1863 int branch_id;
1864 int branch_order;
1865 int position_in_branch;
1866 float mad;
1867 float surf_cov;
1868 int added;
1869 float unmod_radius;
1870 };
1871
1872 std::vector<CylinderData> cylinders;
1873 std::string line;
1874
1875 // Skip the header line
1876 if (!std::getline(file, line)) {
1877 helios_runtime_error("ERROR (LiDARcloud::loadTreeQSM): Empty file or failed to read header: " + filename);
1878 }
1879
1880 // Read cylinder data
1881 while (std::getline(file, line)) {
1882 if (line.empty())
1883 continue;
1884
1885 std::istringstream iss(line);
1886 CylinderData cylinder;
1887
1888 // Parse the tab-separated values
1889 if (!(iss >> cylinder.radius >> cylinder.length >> cylinder.start_point.x >> cylinder.start_point.y >> cylinder.start_point.z >> cylinder.axis_direction.x >> cylinder.axis_direction.y >> cylinder.axis_direction.z >> cylinder.parent >>
1890 cylinder.extension >> cylinder.branch_id >> cylinder.branch_order >> cylinder.position_in_branch >> cylinder.mad >> cylinder.surf_cov >> cylinder.added >> cylinder.unmod_radius)) {
1891 std::cerr << "WARNING (LiDARcloud::loadTreeQSM): Failed to parse line: " << line << std::endl;
1892 continue;
1893 }
1894
1895 cylinders.push_back(cylinder);
1896 }
1897
1898 file.close();
1899
1900 if (printmessages) {
1901 std::cout << "Read " << cylinders.size() << " cylinders from TreeQSM file" << std::endl;
1902 }
1903
1904 // Group cylinders by branch ID
1905 std::map<int, std::vector<CylinderData>> branches;
1906 for (const auto &cylinder: cylinders) {
1907 branches[cylinder.branch_id].push_back(cylinder);
1908 }
1909
1910 if (printmessages) {
1911 std::cout << "Found " << branches.size() << " branches" << std::endl;
1912 }
1913
1914 // Generate the colormap if needed
1915 std::vector<helios::RGBcolor> colormap;
1916 if (use_colormap) {
1917 uint num_colors = std::max(static_cast<uint>(branches.size()), 10u); // At least 10 colors for variety
1918 try {
1919 colormap = context->generateColormap(colormap_or_texture, num_colors);
1920 } catch (const std::exception &e) {
1921 helios_runtime_error("ERROR (LiDARcloud::loadTreeQSM): Invalid colormap name '" + colormap_or_texture + "'. Valid options are: hot, cool, rainbow, lava, parula, gray, green");
1922 }
1923 }
1924
1925 // Create tube objects for each branch
1926 for (const auto &branch_pair: branches) {
1927 int branch_id = branch_pair.first;
1928 const auto &branch_cylinders = branch_pair.second;
1929
1930 if (branch_cylinders.empty())
1931 continue;
1932
1933 // Sort cylinders by position in branch
1934 std::vector<CylinderData> sorted_cylinders = branch_cylinders;
1935 std::sort(sorted_cylinders.begin(), sorted_cylinders.end(), [](const CylinderData &a, const CylinderData &b) { return a.position_in_branch < b.position_in_branch; });
1936
1937 // Create nodes and radii for the tube
1938 std::vector<helios::vec3> nodes;
1939 std::vector<float> radii;
1940
1941 for (const auto &cylinder: sorted_cylinders) {
1942 // Add start point
1943 nodes.push_back(cylinder.start_point);
1944 radii.push_back(cylinder.radius);
1945
1946 // Add end point (start + length * axis_direction)
1947 helios::vec3 end_point = cylinder.start_point + cylinder.length * cylinder.axis_direction;
1948 nodes.push_back(end_point);
1949 radii.push_back(cylinder.radius);
1950 }
1951
1952 // Remove duplicate consecutive nodes (where end of one cylinder = start of next)
1953 std::vector<helios::vec3> final_nodes;
1954 std::vector<float> final_radii;
1955
1956 if (!nodes.empty()) {
1957 final_nodes.push_back(nodes[0]);
1958 final_radii.push_back(radii[0]);
1959
1960 for (size_t i = 1; i < nodes.size(); i++) {
1961 // Check if this node is significantly different from the previous
1962 if ((nodes[i] - final_nodes.back()).magnitude() > 1e-6) {
1963 final_nodes.push_back(nodes[i]);
1964 final_radii.push_back(radii[i]);
1965 }
1966 }
1967 }
1968
1969 // Create the tube object
1970 if (final_nodes.size() >= 2) {
1971 uint tube_UUID;
1972
1973 if (use_colormap) {
1974 // Sample color from colormap based on branch ID
1975 // Use branch_id modulo colormap size for deterministic color selection
1976 int color_index = std::abs(branch_id) % colormap.size();
1977 helios::RGBcolor branch_color = colormap[color_index];
1978
1979 // Create a color vector for all nodes in this branch
1980 std::vector<helios::RGBcolor> tube_colors(final_nodes.size(), branch_color);
1981 tube_UUID = context->addTubeObject(radial_subdivisions, final_nodes, final_radii, tube_colors);
1982
1983 // if( printmessages ){
1984 // std::cout << "Created tube for branch " << branch_id << " with " << final_nodes.size()
1985 // << " nodes, branch_order " << sorted_cylinders[0].branch_order << ", color RGB("
1986 // << branch_color.r << "," << branch_color.g << "," << branch_color.b << ")" << std::endl;
1987 // }
1988 } else {
1989 // Use texture file or solid color
1990 if (colormap_or_texture.empty()) {
1991 // Create a color vector for the tube (red color for all nodes)
1992 std::vector<helios::RGBcolor> tube_colors(final_nodes.size(), helios::RGB::red);
1993 tube_UUID = context->addTubeObject(radial_subdivisions, final_nodes, final_radii, tube_colors);
1994 } else {
1995 tube_UUID = context->addTubeObject(radial_subdivisions, final_nodes, final_radii, colormap_or_texture.c_str());
1996 }
1997
1998 if (printmessages) {
1999 std::cout << "Created tube for branch " << branch_id << " with " << final_nodes.size() << " nodes, branch_order " << sorted_cylinders[0].branch_order << std::endl;
2000 }
2001 }
2002
2003 // Add object data for branch rank (branch_order)
2004 int branch_order = sorted_cylinders[0].branch_order; // All cylinders in a branch should have same order
2005 context->setObjectData(tube_UUID, "branch_order", branch_order);
2006 context->setObjectData(tube_UUID, "branch_id", branch_id);
2007
2008 tube_UUIDs.push_back(tube_UUID);
2009 }
2010 }
2011
2012 if (printmessages) {
2013 if (use_colormap) {
2014 std::cout << "Successfully created " << tube_UUIDs.size() << " tube objects from TreeQSM file using colormap" << std::endl;
2015 } else {
2016 std::cout << "Successfully created " << tube_UUIDs.size() << " tube objects from TreeQSM file" << std::endl;
2017 }
2018 }
2019
2020 return tube_UUIDs;
2021}