diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ef0178..606f66b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,19 +8,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Repair of non-manifold and self-intersecting roof junctions during arrangement snapping. This prevents certain geometric errors like non-watertight meshes, and highly non-planar faces that could prevriously happen in complex roof arrangements. See #168. +- Repair of non-manifold and self-intersecting roof junctions during arrangement snapping. This prevents certain geometric errors like non-watertight meshes, and highly non-planar faces that could previously happen in complex roof arrangements. See #168. - Optional per-tile triangulated terrain output as CityJSON `TINRelief` features, see the `--terrain` option. - A new `max-nodata-fraction` configuration option to control the maximum fraction of nodata pixels in a building polygon that is allowed before a building pointcloud is considered insufficient. +- A descriptor-backed `roofer::reconstruction::ReconstructionConfig` that contains the configuration for every reconstruction stage. Field declarations are now the single source for defaults, descriptions, validation, nested TOML parsing, generated documentation, and Python bindings. +- A nested C++ `ReconstructOptions` API and corresponding `reconstruct` overload. The nested reconstruction configuration is also available from Python, including each component configuration that exposes public parameters. -## Fixed +### Fixed - More robust handling of OGC WKT payloads in LAS/LAZ files. - Prevent roof-ground bow-ties by clipping roof faces against the terrain before extrusion. - Correct squared-distance tolerance comparisons during extrusion. +- Reject TOML integer values outside the supported `int` range instead of silently narrowing them. -## Changed +### Changed - The pointcloud-insufficient test is now an absolute per-building density floor (`min-building-density`, points/m²) instead of a tile-relative `mean - 2·std` outlier test. The old test depended the entire tile that was being processed, so the same building could be flagged differently between runs with different tiling — potentially skipping point-rich buildings and leaving them without geometry. The decision is now deterministic and depends only on the building's own data. -- Region growing no longer uses a wall-clock time limit, which could make a building reconstruct with success on one run and fall back to LoD 1.1 on the next given identical input due to resource starvation with multithreading. The deterministic plane-count limit (`lod11-fallback-planes`) is now the sole reconstruction-complexity cutoff. The `lod11-fallback-time` parameter is deprecated and removed. -- Use mesh centroid for volume calculation +- Region growing no longer uses a wall-clock time limit, which could make a building reconstruct with success on one run and fall back to LoD 1.1 on the next given identical input due to resource starvation with multithreading. The deterministic plane-count limit (`lod11-fallback-planes`) is now the sole reconstruction-complexity cutoff. The `lod11-fallback-time` behavior is removed; its CLI flag and root configuration key are accepted but ignored with a deprecation warning during 1.x compatibility. +- Reconstruction configuration now uses nested TOML tables such as `[reconstruction.plane-detector]` and `[reconstruction.arrangement-optimiser]`. Core reconstruction stages receive their component configuration aggregates; pipeline-derived settings such as requested LoD and terrain availability are overlaid on local copies. +- Reconstruction parameter descriptions now state physical and angular units where applicable; counts remain unit-free. +- All canonical reconstruction angle parameters now use degrees. + Plane-detection normal, horizontal, and wall thresholds are expressed + directly as angles instead of dot products, and the line-regularisation + angle is no longer in radians. The legacy `plane-detect-normal-angle` alias + and compatibility API properties retain their 1.x dot-product semantics. +- Reconstruction snapping and duplicate-vertex tolerances now use distances + in metres instead of base-10 exponents. Existing defaults retain their + previous effective distances. +- Added global `unit-scale` (metres per input coordinate unit) to convert + metre-based reconstruction, crop, and output parameters for input data that + uses another unit. +- Set the default cityjson scale in the transform object to 0.0001. This helps with improving geometric validity. The previous 0.001 setting regurlarly caused validity issues due to the relatively large quatisation error. +- Descriptor-generated TOML documentation and Python bindings omit reconstruction components that have no public parameters. +- `complexity-factor` now directly controls the optimiser energy balance: the data term is weighted by `complexity-factor` and the smoothness term by `1 - complexity-factor`. The independent `data_multiplier` and `smoothness_multiplier` component options were removed. +- The flattened C++ `roofer::ReconstructionConfig` remains as a 1.x compatibility adapter, while its `reconstruct` overloads are deprecated. Its defaults and validation delegate to the nested configuration. +- Low-level C++ component configuration member names were normalised to `snake_case`, and obsolete members were removed. This is a source-breaking change for code that uses the component configuration structs directly; the flattened `roofer::ReconstructionConfig` adapter does not cover those direct uses. +- Legacy root-level reconstruction TOML keys remain accepted during 1.x, emit a destination-specific deprecation warning, and are applied before nested TOML values. CLI arguments retain the highest precedence. The `--lod12`, `--lod13`, `--lod22`, `--clip-terrain`, and `--complexity-factor` flags remain supported without deprecation warnings. Other supported legacy reconstruction CLI flags remain accepted with deprecation warnings; several older names remain root-TOML-only aliases and are not accepted as CLI flags. +- Use mesh centroid for volume calculation. + +### Reconstruction parameter mapping + +Legacy root keys, and matching legacy CLI flags where available, map as follows: + +| Legacy parameter | Nested TOML parameter | Notes | +|---|---|---| +| `lod12` | `[reconstruction] lod12` | CLI flag remains supported. | +| `lod13` | `[reconstruction] lod13` | CLI flag remains supported. | +| `lod22` | `[reconstruction] lod22` | CLI flag remains supported. | +| `clip-terrain` | `[reconstruction] clip-terrain` | CLI flag remains supported. | +| `lod13-step-height` | `[reconstruction] lod13-step-height` | Relocated; legacy CLI flag is deprecated. | +| `h-terrain-strategy` | `[reconstruction] h-terrain-strategy` | Relocated; legacy CLI flag is deprecated. | +| `complexity-factor` | `[reconstruction.arrangement-optimiser] complexity-factor` | CLI flag remains supported. | +| `plane-detect-k` | `[reconstruction.plane-detector] plane-neighbour-count` | Renamed; legacy CLI flag is deprecated. | +| `plane-detect-min-points` | `[reconstruction.plane-detector] min-plane-points` | Renamed; legacy CLI flag is deprecated. | +| `plane-detect-epsilon` | `[reconstruction.plane-detector] plane-epsilon` | Renamed; legacy CLI flag is deprecated. | +| `plane-detect-normal-angle` | `[reconstruction.plane-detector] normal-angle-threshold` | Renamed; root TOML alias only. Legacy values remain unitless dot products and are converted to degrees. | +| `line-detect-epsilon` | `[reconstruction.line-detector] distance-threshold` | Renamed; root TOML alias only. | +| `thres-alpha` | `[reconstruction.alpha-shaper] alpha` | Renamed; root TOML alias only. | +| `thres-reg-line-dist` | `[reconstruction.line-regulariser] distance-threshold` | Renamed; root TOML alias only. | +| `thres-reg-line-ext` | `[reconstruction.line-regulariser] extension` | Renamed; root TOML alias only. | +| `lod11-fallback-planes` | `[reconstruction.plane-detector] max-plane-count` | Renamed; deprecated legacy CLI flag and deterministic complexity cutoff. | +| `lod11-fallback-time` | `[reconstruction.plane-detector] max-plane-count` | Deprecated CLI flag and root key are accepted but ignored; `max-plane-count` is the deterministic replacement. | ## [1.0.0] - 2026-04-20 diff --git a/apps/example.toml b/apps/example.toml index 2104597b..6f2e8323 100644 --- a/apps/example.toml +++ b/apps/example.toml @@ -60,7 +60,7 @@ optimisation_data_term=8.000000 plane_detect__k=15 plane_detect__epsilon=0.300000 plane_detect__min_points=15 -plane_detect__normal_angle=0.750000 +plane_detect__normal_angle=41.409622 thres_alpha=0.250000 thres_reg_line_dist=0.800000 thres_reg_line_ext=3.000000 diff --git a/apps/ransac_demo.cpp b/apps/ransac_demo.cpp index 2752bbdc..f77c9cf7 100644 --- a/apps/ransac_demo.cpp +++ b/apps/ransac_demo.cpp @@ -14,7 +14,7 @@ float ransac_probability = 0.01; int ransac_min_points = 15; float ransac_epsilon = 0.2; float ransac_cluster_epsilon = 0.5; -float ransac_normal_threshold = 0.8; +float ransac_normal_angle_threshold = 36.869896F; bool bid_changed = true; // int old_bid_changed; @@ -35,7 +35,8 @@ void myCallback() { ImGui::InputInt("min_points", &ransac_min_points); ImGui::InputFloat("epsilon", &ransac_epsilon); ImGui::InputFloat("cluster_epsilon", &ransac_cluster_epsilon); - ImGui::InputFloat("normal_threshold", &ransac_normal_threshold); + ImGui::InputFloat("normal_angle_threshold_degrees", + &ransac_normal_angle_threshold); if (ImGui::Button("Do RANSAC")) { // executes when button is pressed @@ -44,7 +45,7 @@ void myCallback() { roofer::vec3f normals; auto nShapes = ShapeDetector->detectPlanes( pc, normals, labels, ransac_probability, ransac_min_points, - ransac_epsilon, ransac_cluster_epsilon, ransac_normal_threshold); + ransac_epsilon, ransac_cluster_epsilon, ransac_normal_angle_threshold); std::vector> randColor(nShapes); randColor[0] = {{0.0f, 0.0f, 0.0f}}; diff --git a/apps/roofer-app/config.hpp b/apps/roofer-app/config.hpp index 442e615e..dfcbcff2 100644 --- a/apps/roofer-app/config.hpp +++ b/apps/roofer-app/config.hpp @@ -25,6 +25,7 @@ #include #include #include +#include #include "toml.hpp" #include "fmt/format.h" #include "fmt/ranges.h" @@ -33,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -40,15 +42,6 @@ #include #include "version.hpp" -namespace roofer::enums { - enum TerrainStrategy { - BUFFER_TILE = 0, - BUFFER_USER = 1, - USER = 2 - // POLYGON_Z = 3 - }; -} - #include "validators.hpp" #include "parameter.hpp" @@ -110,6 +103,39 @@ inline std::optional select_terrain_pointcloud( struct RooferConfigHandler; struct RooferConfig { + /** + * Metres per input coordinate unit. All app-level metre-based parameters + * are converted to input coordinate units using this factor. + */ + float unit_scale = 1.0F; + + /** + * Convert a distance expressed in metres to the input coordinate units. + * `unit_scale` is the number of metres per input coordinate unit. + */ + [[nodiscard]] float metres_to_input_units(float metres) const { + return metres / unit_scale; + } + + /** Convert an area expressed in square metres to input coordinate units. */ + [[nodiscard]] float square_metres_to_input_units(float square_metres) const { + const auto distance_scale = metres_to_input_units(1.0F); + return square_metres * distance_scale * distance_scale; + } + + /** Convert a density expressed in points per square metre to input units. */ + [[nodiscard]] float points_per_square_metre_to_input_units( + float points_per_square_metre) const { + return points_per_square_metre * unit_scale * unit_scale; + } + + [[nodiscard]] roofer::reconstruction::ReconstructionConfig + reconstruction_in_input_units() const { + auto result = reconstruction; + result.unit_scale = unit_scale; + return result.scaled_to_input_units(); + } + // footprint source parameters std::string source_footprints; std::string id_attribute; // -> attr_building_id @@ -157,7 +183,7 @@ struct RooferConfig { bool split_cjseq = false; bool omit_metadata = false; bool output_terrain = false; - roofer::arr3d cj_scale = {0.001, 0.001, 0.001}; + roofer::arr3d cj_scale = {0.0001, 0.0001, 0.0001}; std::optional cj_translate; std::string building_toml_file_spec = "{path}/objects/{bid}/config_{pc_name}.toml"; @@ -173,26 +199,9 @@ struct RooferConfig { std::string metadata_json_file_spec = "{path}/metadata.json"; std::string output_path; - // reconstruct - roofer::enums::TerrainStrategy h_terrain_strategy = - roofer::enums::TerrainStrategy::BUFFER_TILE; - int lod11_fallback_planes = 900; - float complexity_factor = 0.888; - - bool clip_ground = true; - bool lod_12 = false; - bool lod_13 = false; - bool lod_22 = true; - float lod13_step_height = 3.; - float floor_elevation = 0.; - int plane_detect_k = 15; - int plane_detect_min_points = 15; - float plane_detect_epsilon = 0.300000; - float plane_detect_normal_angle = 0.750000; - float line_detect_epsilon = 1.000000; - float thres_alpha = 0.250000; - float thres_reg_line_dist = 0.800000; - float thres_reg_line_ext = 3.000000; + // reconstruct: defaults and low-level options are owned by the shared + // descriptor-backed aggregate. + roofer::reconstruction::ReconstructionConfig reconstruction; // output attribute names std::string a_success = "rf_success"; @@ -321,6 +330,80 @@ struct CLIArgs { } }; +struct LegacyReconstructionDestination { + std::string_view legacy_key; + std::string_view section; + std::string_view nested_key; + bool cli_deprecated = true; + bool ignored = false; + bool cli_supported = true; +}; + +#define ROOFER_LEGACY_RECONSTRUCTION_ALIASES(X) \ + X("lod12", "reconstruction", "lod12", false, false, true, FIELD, "", \ + cfg_.reconstruction, Reconstruction, lod12) \ + X("lod13", "reconstruction", "lod13", false, false, true, FIELD, "", \ + cfg_.reconstruction, Reconstruction, lod13) \ + X("lod22", "reconstruction", "lod22", false, false, true, FIELD, "", \ + cfg_.reconstruction, Reconstruction, lod22) \ + X("complexity-factor", "reconstruction.arrangement-optimiser", \ + "complexity-factor", false, false, true, FIELD, "", \ + cfg_.reconstruction.arrangement_optimiser, Optimiser, complexity_factor) \ + X("clip-terrain", "reconstruction", "clip-terrain", false, false, true, \ + FIELD, "", cfg_.reconstruction, Reconstruction, clip_terrain) \ + X("lod13-step-height", "reconstruction", "lod13-step-height", true, false, \ + true, FIELD, "", cfg_.reconstruction, Reconstruction, lod13_step_height) \ + X("plane-detect-k", "reconstruction.plane-detector", \ + "plane-neighbour-count", true, false, true, FIELD, "", \ + cfg_.reconstruction.plane_detector, PlaneDetector, plane_neighbour_count) \ + X("plane-detect-min-points", "reconstruction.plane-detector", \ + "min-plane-points", true, false, true, FIELD, "", \ + cfg_.reconstruction.plane_detector, PlaneDetector, min_plane_points) \ + X("plane-detect-epsilon", "reconstruction.plane-detector", "plane-epsilon", \ + true, false, true, FIELD, "", cfg_.reconstruction.plane_detector, \ + PlaneDetector, plane_epsilon) \ + X("plane-detect-normal-angle", "reconstruction.plane-detector", \ + "normal-angle-threshold", true, false, false, NORMAL_DOT_PRODUCT, "", \ + _legacy_plane_detect_normal_angle, void, unused) \ + X("line-detect-epsilon", "reconstruction.line-detector", \ + "distance-threshold", true, false, false, FIELD, "", \ + cfg_.reconstruction.line_detector, LineDetector, distance_threshold) \ + X("thres-alpha", "reconstruction.alpha-shaper", "alpha", true, false, false, \ + FIELD, "", cfg_.reconstruction.alpha_shaper, AlphaShaper, alpha) \ + X("thres-reg-line-dist", "reconstruction.line-regulariser", \ + "distance-threshold", true, false, false, FIELD, "", \ + cfg_.reconstruction.line_regulariser, LineRegulariser, distance_threshold) \ + X("thres-reg-line-ext", "reconstruction.line-regulariser", "extension", \ + true, false, false, FIELD, "", cfg_.reconstruction.line_regulariser, \ + LineRegulariser, extension) \ + X("h-terrain-strategy", "reconstruction", "h-terrain-strategy", true, false, \ + true, FIELD, "\"buffer_tile\"", cfg_.reconstruction, Reconstruction, \ + h_terrain_strategy) \ + X("lod11-fallback-planes", "reconstruction.plane-detector", \ + "max-plane-count", true, false, true, FIELD, "", \ + cfg_.reconstruction.plane_detector, PlaneDetector, max_plane_count) \ + X("lod11-fallback-time", "reconstruction.plane-detector", "max-plane-count", \ + true, true, true, IGNORED, "", _deprecated_lod11_fallback_time, void, \ + unused) + +inline std::optional +legacy_reconstruction_destination(std::string_view key) { +#define ROOFER_LEGACY_DESTINATION(key, section, nested_key, deprecated, \ + ignored, cli_supported, kind, example, \ + owner, type, member) \ + LegacyReconstructionDestination{key, section, nested_key, \ + deprecated, ignored, cli_supported}, + static constexpr std::array destinations{ + ROOFER_LEGACY_RECONSTRUCTION_ALIASES(ROOFER_LEGACY_DESTINATION)}; +#undef ROOFER_LEGACY_DESTINATION + + const auto destination = + std::find_if(destinations.begin(), destinations.end(), + [key](const auto& item) { return item.legacy_key == key; }); + if (destination == destinations.end()) return std::nullopt; + return *destination; +} + struct RooferConfigHandler { RooferConfig cfg_; @@ -332,6 +415,9 @@ struct RooferConfigHandler { DocAttribMap output_attr_; std::unordered_map param_index_; std::unordered_map app_param_index_; + ParameterVector root_only_reconstruction_aliases_; + std::unordered_map + root_only_reconstruction_alias_index_; static int default_jobs() { auto system_threads = std::thread::hardware_concurrency(); @@ -350,10 +436,12 @@ struct RooferConfigHandler { int _trace_interval = 10; std::string _config_path; int _jobs = default_jobs(); + int _deprecated_lod11_fallback_time = 1800000; + float _legacy_plane_detect_normal_angle = 0.75F; // methods RooferConfigHandler() { - ParameterVector input, crop, reconstruction, output; + ParameterVector global, input, crop, reconstruction, output; ParameterVector general; general.add("help", 'h', "Show help message", _print_help); @@ -363,16 +451,23 @@ struct RooferConfigHandler { general.add("jobs", 'j', "Number of worker jobs to use. Reconstruction uses roughly " "jobs - 1 threads.", - _jobs, {check::HigherThan(0)}); + _jobs, {roofer::config::greater_than(0)}); general.add("config", 'c', "Configuration file", _config_path, {check::PathExists, check::DirIsWritable}); general.add("trace-interval", "Interval for tracing in seconds", - _trace_interval, {check::HigherThan(0)}); + _trace_interval, {roofer::config::greater_than(0)}); general.add("loglevel", "Specify loglevel", _loglevel); #ifdef RF_USE_RERUN general.add("rerun", "Log intermediate results to rerun", cfg_.use_rerun); #endif + global.add( + "unit-scale", + "Metres per input coordinate unit. All metre-based reconstruction, " + "crop, and output parameters are converted to input coordinate units " + "using this factor. For feet, use `0.3048`.", + cfg_.unit_scale, {roofer::config::greater_than(0.0F)}); + input.add( "id-attribute", "Building ID attribute to be used as identifier in CityJSONSeq output.", @@ -411,7 +506,9 @@ struct RooferConfigHandler { .add( "box", "Axis aligned bounding box specifying the region of interest. Data " - "outside of this region will be ignored.", + "outside of this region will be ignored. Coordinates are in the " + "input data's coordinate units and are not affected by " + "`unit-scale`.", cfg_.region_of_interest, {[](const std::optional>& box) -> std::optional { @@ -434,10 +531,10 @@ struct RooferConfigHandler { // selection input.add("bld-class", "LAS classification code that contains the building points.", - cfg_.bld_class, {check::HigherOrEqualTo(0)}); + cfg_.bld_class, {roofer::config::at_least(0)}); input.add("grnd-class", "LAS classification code that constains the ground points.", - cfg_.grnd_class, {check::HigherOrEqualTo(0)}); + cfg_.grnd_class, {roofer::config::at_least(0)}); input.add("skip-pc-check", "Disable/enable check if all supplied pointcloud files exist.", _skip_pc_check); @@ -447,28 +544,30 @@ struct RooferConfigHandler { "Simplify input rootprints to remove (nearly) duplicated vertices.", cfg_.simplify); crop.add("ceil-point-density", - "Enforce this point density ceiling on each building pointcloud.", - cfg_.ceil_point_density, {check::HigherThan(0)}); + "Enforce this point density ceiling (points per square metre) on " + "each building pointcloud.", + cfg_.ceil_point_density, {roofer::config::greater_than(0.0F)}); crop.add("cellsize", "Cellsize used for quick pointcloud analysis (eg. point density" - " and nodata regions).", - cfg_.cellsize, {check::HigherThan(0)}); + " and nodata regions), in metres.", + cfg_.cellsize, {roofer::config::greater_than(0.0F)}); crop.add("min-building-density", "Minimum building-class point density (points/m²) below " "which a rootprint's pointcloud is flagged insufficient.", - cfg_.min_building_density, {check::HigherOrEqualTo(0)}); + cfg_.min_building_density, {roofer::config::at_least(0.0F)}); crop.add("max-nodata-fraction", "Maximum fraction of the roofprint area without pointcloud data. " "Above this threshold, a rootprint's pointcloud is flagged " "insufficient.", - cfg_.max_nodata_fraction, {check::InRange(0, 1)}); + cfg_.max_nodata_fraction, {roofer::config::in_range(0.0F, 1.0F)}); crop.add("terrain-grid-cellsize", - "Cellsize used for the crop phase terrain fallback grid.", - cfg_.terrain_grid_cellsize, {check::HigherThan(0)}); + "Cellsize used for the crop phase terrain fallback grid, in " + "metres.", + cfg_.terrain_grid_cellsize, {roofer::config::greater_than(0.0F)}); crop.add("terrain-grid-search-radius", "Number of terrain grid cells to search around a building when " "its local fallback cells do not contain terrain points.", - cfg_.terrain_grid_search_radius, {check::HigherOrEqualTo(0)}); + cfg_.terrain_grid_search_radius, {roofer::config::at_least(0)}); crop.add("terrain-nodata-mode", "How missing terrain grid samples are handled during output " "triangulation: `complete_quads`, `local_triangles`, or " @@ -478,10 +577,10 @@ struct RooferConfigHandler { {"complete_quads", "local_triangles", "fill_small_gaps"})}); crop.add( "lod11-fallback-area", - "LoD 1.1 fallback threshold area in square meters. If the area of the " + "LoD 1.1 fallback threshold area in square metres. If the area of the " "roofprint is larger than this value, the building will be always be " "reconstructed using a LoD 1.1 extrusion.", - cfg_.lod11_fallback_area, {check::HigherThan(0)}); + cfg_.lod11_fallback_area, {roofer::config::greater_than(0)}); crop.add("clear-insufficient", "Do not attempt to reconstruct buildings with insufficient " "pointcloud data." @@ -514,80 +613,60 @@ struct RooferConfigHandler { "crop phase.", cfg_.write_index); - reconstruction.add("lod12", - "Generate LoD 1.2 geometries in CityJSONSeq output.", - cfg_.lod_12); - reconstruction.add("lod13", - "Generate LoD 1.3 geometries in CityJSONSeq output.", - cfg_.lod_13); - reconstruction.add("lod22", - "Generate LoD 2.2 geometries in CityJSONSeq output.", - cfg_.lod_22); - reconstruction.add( - "complexity-factor", - "Complexity factor for building model geometry. " - "A number between 0.0 and 1.0. Higher values lead to more detailed " - "building models, lower values to simpler models.", - cfg_.complexity_factor, {check::InRange(0, 1)}); - reconstruction.add( - "clip-terrain", - "Set to true to activate the procedure that clips parts from the " - "input roofprint wherever patches of ground points are detected." - "May cause irregular outlines in reconstruction result.", - cfg_.clip_ground, {}); - reconstruction.add( - "lod13-step-height", - "Step height in meters, used for LoD 1.3 generalisation." - "Adjacent roofparts with a height discontinuity that is smaller " - "than this value are merged. Only affects LoD 1.3 geometry.", - cfg_.lod13_step_height, {check::HigherThan(0)}); - reconstruction.add("plane-detect-k", - "Number of points used in nearest neighbour queries for " - "plane detection. Higher values will lead to longer " - "processing times, but may help with growing plane " - "regions through areas with a poor point distribution.", - cfg_.plane_detect_k, {check::HigherThan(0)}); - reconstruction.add("plane-detect-min-points", - "Minimum number of points required for " - "detecting a plane in the pointcloud.", - cfg_.plane_detect_min_points, - {check::HigherThan(2)}); - reconstruction.add("plane-detect-epsilon", - "Maximum distance (in meters) from inliers to" - " plane during plane fitting procedure. Higher values " - "offer more robustness " - "against oversegmentation, but may result in less " - "accurate plane detection.", - cfg_.plane_detect_epsilon, - {check::HigherThan(0)}); - reconstruction - .add("h-terrain-strategy", - "Strategy to determine terrain elevation " - "that is used to set the height of building floors. " - "`buffer_tile`: use the 5th percentile lowest elevation point in " - "a 4 meter buffer around the roofprint. If no points are found, " - "we fall back to the local terrain grid minimum. If no local " - "terrain grid cells are found, we fall back to the lowest " - "elevation point in the current tile. " - "This may give undesired results for hilly areas. " - "`buffer_user`: use the same buffered pointcloud value, then " - "fall back to `--h-terrain-attribute`, then the local terrain " - "grid minimum, then the tile minimum. `user`: use " - "`--h-terrain-attribute`, then the local terrain grid minimum, " - "then the tile minimum.", - cfg_.h_terrain_strategy) - .example_ = "\"buffer_tile\""; - reconstruction.add( - "lod11-fallback-planes", - "Number of planes required for LoD 1.1 fallback. When more than this " - "number of planes is detected, abort the reconstruction process and " - "fallback to LoD 1.1 extrusion. This plane count is a deterministic " - "complexity cutoff that bounds reconstruction time per building.", - cfg_.lod11_fallback_planes, {check::HigherThan(0)}); + using Reconstruction = roofer::reconstruction::ReconstructionConfig; + using Optimiser = roofer::reconstruction::ArrangementOptimiserConfig; + using PlaneDetector = roofer::reconstruction::PlaneDetectorConfig; + using AlphaShaper = roofer::reconstruction::AlphaShaperConfig; + using LineDetector = roofer::reconstruction::LineDetectorConfig; + using LineRegulariser = roofer::reconstruction::LineRegulariserConfig; + +#define ROOFER_REGISTER_LEGACY_FIELD(key, cli_supported, example, owner, type, \ + member) \ + do { \ + auto& parameter = \ + (cli_supported) \ + ? reconstruction.add_from_field(key, owner, &type::member) \ + : root_only_reconstruction_aliases_.add_from_field(key, owner, \ + &type::member); \ + parameter.example_ = example; \ + } while (false); +#define ROOFER_REGISTER_LEGACY_IGNORED(key, cli_supported, example, owner, \ + type, member) \ + do { \ + static_assert(cli_supported); \ + auto& parameter = reconstruction.add( \ + key, \ + "Deprecated and ignored. Wall-clock reconstruction limits were " \ + "removed because they are nondeterministic; use max-plane-count " \ + "under [reconstruction.plane-detector] instead.", \ + owner); \ + parameter.example_ = example; \ + } while (false); +#define ROOFER_REGISTER_LEGACY_NORMAL_DOT_PRODUCT(key, cli_supported, example, \ + owner, type, member) \ + do { \ + static_assert(!cli_supported); \ + auto& parameter = root_only_reconstruction_aliases_.add( \ + key, \ + "Minimum normal dot-product similarity within a plane " \ + "(unitless).", \ + owner, {roofer::config::in_range(0.0F, 1.0F)}); \ + parameter.example_ = example; \ + } while (false); +#define ROOFER_REGISTER_LEGACY(key, section, nested_key, deprecated, ignored, \ + cli_supported, kind, example, owner, type, \ + member) \ + ROOFER_REGISTER_LEGACY_##kind(key, cli_supported, example, owner, type, \ + member) + ROOFER_LEGACY_RECONSTRUCTION_ALIASES(ROOFER_REGISTER_LEGACY) +#undef ROOFER_REGISTER_LEGACY +#undef ROOFER_REGISTER_LEGACY_NORMAL_DOT_PRODUCT +#undef ROOFER_REGISTER_LEGACY_IGNORED +#undef ROOFER_REGISTER_LEGACY_FIELD output.add("tiling", "Enable or disable output tiling.", _tiling); - output.add("tilesize", "Tilesize for rectangular output tiles in meters.", - cfg_.tilesize, {check::HigherThan({0, 0})}); + output.add("tilesize", "Tilesize for rectangular output tiles, in metres.", + cfg_.tilesize, {check::AllHigherThan({0, 0})}); output.add("split-cjseq", "Output CityJSONSequence file for each building instead of one " "file per tile.", @@ -599,12 +678,13 @@ struct RooferConfigHandler { "Write one triangulated TINRelief feature per tile from the " "highest-quality pointcloud source.", cfg_.output_terrain); - output.add("cj-scale", "Scaling applied to CityJSON output vertices", + output.add("cj-scale", + "CityJSON output vertex precision, in input map units.", cfg_.cj_scale); output .add("cj-translate", - "Translation applied to CityJSON output vertices. Uses dataset " - "center by default.", + "Translation applied to CityJSON output vertices, in input " + "coordinate units. Uses dataset center by default.", cfg_.cj_translate) .example_ = "[100000, 200000, 0]"; @@ -640,11 +720,13 @@ struct RooferConfigHandler { DocAttrib( &cfg_.a_nodata_r, "Indicates the radius of the largest " - "circle in the roofprint that is not covered by pointcloud data")); + "circle in the roofprint that is not covered by pointcloud data, " + "in input map units")); output_attr_.emplace("pt_density", DocAttrib(&cfg_.a_pt_density, "Indicates the point density inside the " - "roofprint")); + "roofprint, in points per square input map " + "unit")); output_attr_.emplace( "is_mutated", DocAttrib(&cfg_.a_is_mutated, @@ -706,27 +788,27 @@ struct RooferConfigHandler { output_attr_.emplace("rmse_lod12", DocAttrib(&cfg_.a_rmse_lod12, "The Root Mean Square Erorr of " - "the LOD12 geometry")); + "the LOD12 geometry, in input map units")); output_attr_.emplace("rmse_lod13", DocAttrib(&cfg_.a_rmse_lod13, "The Root Mean Square Erorr of " - "the LOD13 geometry")); + "the LOD13 geometry, in input map units")); output_attr_.emplace("rmse_lod22", DocAttrib(&cfg_.a_rmse_lod22, "The Root Mean Square Erorr of " - "the LOD22 geometry")); + "the LOD22 geometry, in input map units")); output_attr_.emplace("volume_lod12", DocAttrib(&cfg_.a_volume_lod12, - "The volume in cubic meters of the LoD 1.2 " - "geometry")); + "The volume of the LoD 1.2 geometry, in " + "cubic input map units")); output_attr_.emplace("volume_lod13", DocAttrib(&cfg_.a_volume_lod13, - "The volume in cubic meters of the LoD 1.3 " - "geometry")); + "The volume of the LoD 1.3 geometry, in " + "cubic input map units")); output_attr_.emplace("volume_lod22", DocAttrib(&cfg_.a_volume_lod22, - "The volume in cubic meters of the LoD 2.2 " - "geometry")); + "The volume of the LoD 2.2 geometry, in " + "cubic input map units")); output_attr_.emplace("h_ground", DocAttrib(&cfg_.a_h_ground, "The elevation of the floor of the " @@ -756,6 +838,7 @@ struct RooferConfigHandler { " By default attribute names are prefixed with `rf_`.", output_attr_); // Move groups into param_group_map + param_groups_.emplace_back("Global", std::move(global)); param_groups_.emplace_back("Input", std::move(input)); param_groups_.emplace_back("Crop", std::move(crop)); param_groups_.emplace_back("Reconstruction", std::move(reconstruction)); @@ -769,6 +852,8 @@ struct RooferConfigHandler { for (auto& [group_name, group] : app_param_groups_) { group.add_to_index(app_param_index_); } + root_only_reconstruction_aliases_.add_to_index( + root_only_reconstruction_alias_index_); }; void validate() { @@ -787,6 +872,9 @@ struct RooferConfigHandler { } } } + if (auto error = cfg_.reconstruction.validate()) { + throw std::runtime_error("Validation error for reconstruction." + *error); + } if (input_pointclouds_.empty()) { throw std::runtime_error("No input pointclouds specified."); @@ -822,6 +910,69 @@ struct RooferConfigHandler { } } + template + bool assign_reconstruction_field(const toml::key& key, const toml::node& node, + const std::string& parent_path, + Config& config) { + bool found = false; + roofer::config::for_each_field(config, [&](auto field, auto& field_value) { + if (found || key != field.toml_name()) return; + found = true; + const auto path = parent_path + "." + field.toml_name(); + if (!assign_toml_value(node, field_value)) { + throw std::runtime_error("Invalid value type or value for " + path + + "."); + } + if (field.validator) { + if (auto error = field.validator(field_value)) { + throw std::runtime_error("Validation error for " + path + ": " + + *error + "."); + } + } + }); + return found; + } + + template + void parse_reconstruction_component(const toml::table& table, + const std::string& path, + Component& component) { + for (const auto& [key, node] : table) { + if (!assign_reconstruction_field(key, node, path, component)) { + throw std::runtime_error("Unknown reconstruction parameter: " + path + + "." + std::string(key.str()) + "."); + } + } + } + + void parse_reconstruction_table(const toml::table& table) { + for (const auto& [key, node] : table) { + if (assign_reconstruction_field(key, node, "reconstruction", + cfg_.reconstruction)) { + continue; + } + + bool found = false; + cfg_.reconstruction.visit_components( + [&](std::string_view component_name, auto& component) { + if (!found && key == component_name) { + found = true; + const auto* component_table = node.as_table(); + const auto path = "reconstruction." + std::string(component_name); + if (!component_table) { + throw std::runtime_error(path + " must be a table."); + } + parse_reconstruction_component(*component_table, path, component); + } + }); + if (!found) { + throw std::runtime_error( + "Unknown reconstruction parameter: reconstruction." + + std::string(key.str()) + "."); + } + } + } + void print_help_header(const std::string& program_name, bool compact) { // see http://docopt.org/ std::cout << "Automatic LoD 2.2 building reconstruction from " @@ -1090,6 +1241,23 @@ struct RooferConfigHandler { } void parse_cli_second_pass(CLIArgs& c) { + auto& logger = roofer::logger::Logger::get_logger(); + const auto warn_if_legacy = [&](const std::string& name) { + if (const auto destination = legacy_reconstruction_destination(name); + destination && destination->cli_deprecated) { + if (destination->ignored) { + logger.warning( + "Option --{} is deprecated and ignored; use {} under [{}] in " + "the configuration file instead. It will be rejected in 2.0.", + name, destination->nested_key, destination->section); + } else { + logger.warning( + "Option --{} is deprecated; use {} under [{}] in the " + "configuration file. It will be removed in 2.0.", + name, destination->nested_key, destination->section); + } + } + }; auto it = c.args.begin(); while (it != c.args.end()) { std::string arg = *it; @@ -1098,6 +1266,7 @@ struct RooferConfigHandler { if (arg.starts_with("--no")) { auto argname = arg.substr(5); if (auto p = param_index_.find(argname); p != param_index_.end()) { + warn_if_legacy(argname); it = c.args.erase(it); p->second->unset(); } else { @@ -1106,6 +1275,7 @@ struct RooferConfigHandler { } else if (arg.starts_with("--")) { auto argname = arg.substr(2); if (auto p = param_index_.find(argname); p != param_index_.end()) { + warn_if_legacy(argname); it = c.args.erase(it); it = p->second->set(c.args, it); } else { @@ -1171,6 +1341,20 @@ struct RooferConfigHandler { // iterate config table for (const auto& [key, value] : config) { try { + if (const auto destination = + legacy_reconstruction_destination(key.str())) { + if (destination->ignored) { + logger.warning( + "Root configuration key {} is deprecated and ignored; use {} " + "under [{}] instead. It will be rejected in 2.0.", + key.data(), destination->nested_key, destination->section); + } else { + logger.warning( + "Root configuration key {} is deprecated; use {} under [{}] " + "instead. It will be removed in 2.0.", + key.data(), destination->nested_key, destination->section); + } + } if (key == "polygon-source") { get_toml_value(config, "polygon-source", cfg_.source_footprints); } else if (key == "output-directory") { @@ -1191,6 +1375,13 @@ struct RooferConfigHandler { } } } + } else if (key == "reconstruction") { + // Parsed after all root-level compatibility aliases so nested values + // deterministically take precedence, independent of table ordering. + } else if (auto p = + root_only_reconstruction_alias_index_.find(key.data()); + p != root_only_reconstruction_alias_index_.end()) { + p->second->set_from_toml(config, key.data()); } else if (auto p = param_index_.find(key.data()); p != param_index_.end()) { p->second->set_from_toml(config, key.data()); @@ -1252,9 +1443,21 @@ struct RooferConfigHandler { key.data(), e.what())); } } + if (config.contains("plane-detect-normal-angle")) { + cfg_.reconstruction.plane_detector.normal_angle_threshold = + roofer::reconstruction::normal_angle_degrees_from_dot_product( + _legacy_plane_detect_normal_angle); + } + if (const auto* reconstruction = config["reconstruction"].as_table()) { + parse_reconstruction_table(*reconstruction); + } else if (config.contains("reconstruction")) { + throw std::runtime_error("reconstruction must be a table."); + } } }; +#undef ROOFER_LEGACY_RECONSTRUCTION_ALIASES + template <> struct fmt::formatter { static constexpr auto parse(format_parse_context& ctx) { return ctx.begin(); } diff --git a/apps/roofer-app/crop_tile.hpp b/apps/roofer-app/crop_tile.hpp index ede7e745..0552fbf1 100644 --- a/apps/roofer-app/crop_tile.hpp +++ b/apps/roofer-app/crop_tile.hpp @@ -74,9 +74,12 @@ bool crop_tile(const roofer::TBox& tile, // simplify + buffer footprints logger.info("Simplifying and buffering footprints..."); - if (cfg.simplify) vector_ops->simplify_polygons(footprints); + if (cfg.simplify) { + vector_ops->simplify_polygons(footprints, cfg.metres_to_input_units(0.01F)); + } auto buffered_footprints = footprints; - vector_ops->buffer_polygons(buffered_footprints); + vector_ops->buffer_polygons(buffered_footprints, + cfg.metres_to_input_units(4.0F)); // compute true extent that includes all buffered footprints roofer::Box polygon_extent; @@ -109,11 +112,15 @@ bool crop_tile(const roofer::TBox& tile, lasfiles, footprints, buffered_footprints, ipc.building_clouds, ipc.ground_elevations, ipc.terrain_grid_elevations, ipc.acquisition_years, ipc.pointcloud_insufficient, polygon_extent, - {.min_building_density = cfg.min_building_density, + {.cellsize = cfg.metres_to_input_units(50.0F), + .buffer = cfg.metres_to_input_units(1.0F), + .min_building_density = cfg.points_per_square_metre_to_input_units( + cfg.min_building_density), .ground_class = ipc.grnd_class, .building_class = ipc.bld_class, .use_acquisition_year = static_cast(yoc_vec), - .terrain_grid_cellsize = cfg.terrain_grid_cellsize, + .terrain_grid_cellsize = + cfg.metres_to_input_units(cfg.terrain_grid_cellsize), .terrain_grid_search_radius = cfg.terrain_grid_search_radius, .retain_terrain_grid = terrain_pointcloud == ipc_index}); ipc.min_ground_elevation = PointCloudCropper->get_min_terrain_elevation(); @@ -172,7 +179,8 @@ bool crop_tile(const roofer::TBox& tile, for (size_t i = 0; i < N_fp; ++i) { force_lod11_vec[i] = *force_lod11_vec[i] || - std::fabs(footprints[i].signed_area()) > cfg.lod11_fallback_area; + std::fabs(footprints[i].signed_area()) > + cfg.square_metres_to_input_units(cfg.lod11_fallback_area); } // compute rasters @@ -194,7 +202,8 @@ bool crop_tile(const roofer::TBox& tile, roofer::arr2f nodata_c; for (unsigned i = 0; i < N_fp; ++i) { roofer::misc::RasterisePointcloud(ipc.building_clouds[i], footprints[i], - ipc.building_rasters[i], cfg.cellsize, + ipc.building_rasters[i], + cfg.metres_to_input_units(cfg.cellsize), ipc.grnd_class, ipc.bld_class); ipc.nodata_fractions[i] = roofer::misc::computeNoDataFraction(ipc.building_rasters[i]); @@ -205,13 +214,15 @@ bool crop_tile(const roofer::TBox& tile, ipc.roof_elevations[i] = roofer::misc::computeRoofElevation(ipc.building_rasters[i], 0.7); - auto target_density = cfg.ceil_point_density; + auto target_density = + cfg.points_per_square_metre_to_input_units(cfg.ceil_point_density); bool do_force_lod11 = *force_lod11_vec[i] || ipc.force_lod11 || ipc.is_glass_roof[i]; ipc.lod11_forced[i] = do_force_lod11; if (do_force_lod11) { - target_density = cfg.lod11_fallback_density; + target_density = cfg.points_per_square_metre_to_input_units( + cfg.lod11_fallback_density); // logger.info( // "Applying extra thinning and skipping nodata circle calculation " // "[force_lod11 = {}]", @@ -268,6 +279,10 @@ bool crop_tile(const roofer::TBox& tile, // compute is_mutated attribute for first 2 pointclouds and add to footprint // attributes roofer::misc::selectPointCloudConfig select_pc_cfg; + select_pc_cfg.threshold_maxcircle = + cfg.metres_to_input_units(select_pc_cfg.threshold_maxcircle); + select_pc_cfg.threshold_mutation_difference = + cfg.metres_to_input_units(select_pc_cfg.threshold_mutation_difference); if (input_pointclouds.size() > 1) { // do roofer::misc::isMutated for each pair of consecutive pointclouds for (unsigned i = 0; i < input_pointclouds.size() - 1; ++i) { @@ -431,7 +446,8 @@ bool crop_tile(const roofer::TBox& tile, input_pointclouds[selected->index].ground_elevations[i]; auto h_ground_terrain_grid = input_pointclouds[selected->index].terrain_grid_elevations[i]; - if (cfg.h_terrain_strategy == TerrainStrategy::BUFFER_TILE) { + if (cfg.reconstruction.h_terrain_strategy == + TerrainStrategy::BUFFER_TILE) { if (h_ground_pc.has_value()) { building.h_ground = h_ground_pc.value(); } else if (h_ground_terrain_grid.has_value()) { @@ -440,7 +456,8 @@ bool crop_tile(const roofer::TBox& tile, building.h_ground = input_pointclouds[selected->index].min_ground_elevation; } - } else if (cfg.h_terrain_strategy == TerrainStrategy::BUFFER_USER) { + } else if (cfg.reconstruction.h_terrain_strategy == + TerrainStrategy::BUFFER_USER) { if (h_ground_pc.has_value()) { building.h_ground = h_ground_pc.value(); } else { @@ -462,7 +479,8 @@ bool crop_tile(const roofer::TBox& tile, exit(1); } } - } else if (cfg.h_terrain_strategy == TerrainStrategy::USER) { + } else if (cfg.reconstruction.h_terrain_strategy == + TerrainStrategy::USER) { if (h_ground_fallback_vec) { if ((*h_ground_fallback_vec)[i].has_value()) { building.h_ground = (*h_ground_fallback_vec)[i].value(); diff --git a/apps/roofer-app/doc-helper.cpp b/apps/roofer-app/doc-helper.cpp index 9870186d..17915cc8 100644 --- a/apps/roofer-app/doc-helper.cpp +++ b/apps/roofer-app/doc-helper.cpp @@ -22,6 +22,42 @@ #include #include "config.hpp" +template +std::string reconstruction_toml_value(const T& value) { + if constexpr (std::is_same_v) { + return value ? "true" : "false"; + } else if constexpr (std::is_same_v) { + return '"' + std::format("{}", value) + '"'; + } else if constexpr (std::is_same_v< + T, roofer::reconstruction::PointCountRange>) { + return std::format("[{}, {}]", value.first, value.second); + } else { + return std::format("{}", value); + } +} + +template +void print_reconstruction_fields(const Config& config) { + roofer::config::for_each_field(config, [](auto field, const auto& value) { + std::cout << "## " << field.description << "\n"; + std::cout << field.toml_name() << " = " << reconstruction_toml_value(value) + << "\n"; + }); +} + +void print_reconstruction_toml( + const roofer::reconstruction::ReconstructionConfig& config) { + std::cout << "### Reconstruction options\n[reconstruction]\n"; + print_reconstruction_fields(config); + config.visit_components([&](std::string_view name, const auto& component) { + using Component = std::remove_cvref_t; + if (!roofer::config::has_public_fields()) return; + std::cout << "\n[reconstruction." << name << "]\n"; + print_reconstruction_fields(component); + }); + std::cout << "\n"; +} + void print_params(RooferConfigHandler::param_group_map& params) { for (const auto& [group_name, param_list] : params) { std::cout << "### " << group_name << " options\n\n"; @@ -34,7 +70,9 @@ void print_params(RooferConfigHandler::param_group_map& params) { } } -void print_params_as_toml(RooferConfigHandler::param_group_map& params) { +void print_params_as_toml( + RooferConfigHandler::param_group_map& params, + const roofer::reconstruction::ReconstructionConfig& reconstruction) { std::cout << "## Path to roofprint polygons source. Can be an OGR supported " "file (eg. GPKG) or database connection string.\n"; std::cout << "# polygon-source = \"\"\n"; @@ -42,6 +80,7 @@ void print_params_as_toml(RooferConfigHandler::param_group_map& params) { "CityJSONSequence file in this directory.\n"; std::cout << "# output-directory = \"\"\n\n"; for (const auto& [group_name, param_list] : params) { + if (group_name == "Reconstruction") continue; std::cout << std::format("### {} options\n", group_name); for (const auto& param : param_list) { std::cout << std::format("## {}\n", param->description()); @@ -67,6 +106,7 @@ void print_params_as_toml(RooferConfigHandler::param_group_map& params) { } std::cout << "\n"; } + print_reconstruction_toml(reconstruction); // print pointcloud block InputPointcloud pc_defaults{}; std::cout << "[[pointclouds]]\n"; @@ -109,7 +149,7 @@ int main(int argc, const char* argv[]) { RooferConfigHandler rch{}; if (format == "config") { - print_params_as_toml(rch.param_groups_); + print_params_as_toml(rch.param_groups_, rch.cfg_.reconstruction); } else if (format == "attr") { print_attributes(rch.output_attr_); } else if (format == "params") { diff --git a/apps/roofer-app/parameter.hpp b/apps/roofer-app/parameter.hpp index 4672e30f..4f465e2b 100644 --- a/apps/roofer-app/parameter.hpp +++ b/apps/roofer-app/parameter.hpp @@ -20,6 +20,7 @@ // Ravi Peters // Balázs Dukai #pragma once +#include #include #include #include @@ -109,6 +110,95 @@ struct std::formatter { } }; +/** Decode the scalar-like TOML types shared by app and reconstruction config. + */ +template +bool assign_toml_value(const toml::node& node, T& result) { + if constexpr (std::is_same_v) { + if (auto value = node.value()) { + result = static_cast(*value); + return true; + } + if (auto value = node.value()) { + result = static_cast(*value); + return true; + } + } else if constexpr (std::is_same_v) { + if (auto value = node.value()) { + result = *value; + return true; + } + if (auto value = node.value()) { + result = static_cast(*value); + return true; + } + } else if constexpr (std::is_same_v) { + if (auto value = node.value()) { + result = *value; + return true; + } + } else if constexpr (std::is_same_v) { + if (auto value = node.value()) { + result = *value; + return true; + } + } else if constexpr (std::is_same_v) { + if (auto value = node.value()) { + result = std::move(*value); + return true; + } + } else if constexpr (std::is_same_v>) { + int value; + if (assign_toml_value(node, value)) { + result = value; + return true; + } + } else if constexpr (std::is_same_v>) { + float value; + if (assign_toml_value(node, value)) { + result = value; + return true; + } + } else if constexpr (std::is_same_v>) { + double value; + if (assign_toml_value(node, value)) { + result = value; + return true; + } + } else if constexpr (std::is_same_v>) { + std::string value; + if (assign_toml_value(node, value)) { + result = std::move(value); + return true; + } + } else if constexpr (std::is_same_v>) { + if (const auto* values = node.as_array(); values && values->size() == 2) { + auto lower = (*values)[0].value(); + auto upper = (*values)[1].value(); + if (lower && upper) { + result = {*lower, *upper}; + return true; + } + } + } else if constexpr (std::is_same_v) { + if (auto value = node.value()) { + if (*value == "buffer_tile") { + result = roofer::enums::TerrainStrategy::BUFFER_TILE; + return true; + } + if (*value == "buffer_user") { + result = roofer::enums::TerrainStrategy::BUFFER_USER; + return true; + } + if (*value == "user") { + result = roofer::enums::TerrainStrategy::USER; + return true; + } + } + } + return false; +} + struct ConfigParameter { std::string help_; std::string longname_; @@ -149,17 +239,18 @@ template struct ConfigParameterByReference : public ConfigParameter { T& value_; T default_value_; - std::vector> _validators; + std::vector> _validators; - ConfigParameterByReference(std::string longname, std::string help, T& value, - std::vector> validators) + ConfigParameterByReference( + std::string longname, std::string help, T& value, + std::vector> validators) : ConfigParameter(longname, help), value_(value), default_value_(value), _validators(validators){}; - ConfigParameterByReference(std::string longname, char shortname, - std::string help, T& value, - std::vector> validators) + ConfigParameterByReference( + std::string longname, char shortname, std::string help, T& value, + std::vector> validators) : ConfigParameter(longname, shortname, help), value_(value), default_value_(value), @@ -375,17 +466,10 @@ struct ConfigParameterByReference : public ConfigParameter { } } } else if constexpr (std::is_same_v) { - if (const toml::value* s = table[name].as_string()) { - if (*s == "buffer_tile") { - value_ = roofer::enums::TerrainStrategy::BUFFER_TILE; - } else if (*s == "buffer_user") { - value_ = roofer::enums::TerrainStrategy::BUFFER_USER; - } else if (*s == "user") { - value_ = roofer::enums::TerrainStrategy::USER; - } else { - throw std::runtime_error("Failed to read value for " + name + - " from config file."); - } + const auto* node = table.get(name); + if (!node || !assign_toml_value(*node, value_)) { + throw std::runtime_error("Failed to read value for " + name + + " from config file."); } } else if constexpr (std::is_same_v) { if (const toml::value* s = table[name].as_string()) { @@ -413,9 +497,8 @@ struct ConfigParameterByReference : public ConfigParameter { } } } else { - if (auto value = table[name].value(); value.has_value()) { - value_ = *value; - } else { + const auto* node = table.get(name); + if (!node || !assign_toml_value(*node, value_)) { std::string expected; if constexpr (std::is_same_v) { expected = "boolean"; @@ -492,22 +575,35 @@ class ParameterVector { ParameterVector& operator=(const ParameterVector&) = delete; template - ConfigParameter& add(const std::string& longname, const std::string& help, - T& value, std::vector> validators = {}) { + ConfigParameter& add( + const std::string& longname, const std::string& help, T& value, + std::vector> validators = {}) { params_.emplace_back(std::make_unique>( longname, help, value, std::move(validators))); return *params_.back(); } template - ConfigParameter& add(const std::string& longname, const char shortname, - const std::string& help, T& value, - std::vector> validators = {}) { + ConfigParameter& add( + const std::string& longname, const char shortname, + const std::string& help, T& value, + std::vector> validators = {}) { params_.emplace_back(std::make_unique>( longname, shortname, help, value, std::move(validators))); return *params_.back(); } + /** Register a CLI/TOML alias while reusing canonical field metadata. */ + template + ConfigParameter& add_from_field(const std::string& longname, Owner& owner, + T Owner::*member) { + const auto field = roofer::config::field_for(member); + std::vector> validators; + if (field.validator) validators.push_back(field.validator); + return add(longname, std::string(field.description), owner.*member, + std::move(validators)); + } + void add_to_index(std::unordered_map& index) { for (auto& param : params_) { index[param->longname_] = param.get(); diff --git a/apps/roofer-app/reconstruct_building.hpp b/apps/roofer-app/reconstruct_building.hpp index d9c86606..f4e64e3c 100644 --- a/apps/roofer-app/reconstruct_building.hpp +++ b/apps/roofer-app/reconstruct_building.hpp @@ -101,8 +101,8 @@ void compute_mesh_properties( add_ms_to_bbox(bbox, multisolid_lod22); } - auto heightmap = MeshPropertyCalculator->get_heightmap(multisolid_lod22, bbox, - cfg->cellsize); + auto heightmap = MeshPropertyCalculator->get_heightmap( + multisolid_lod22, bbox, cfg->metres_to_input_units(cfg->cellsize)); for (auto& [i, mesh22] : multisolid_lod22) { mesh22.get_attributes().resize(mesh22.get_polygons().size()); @@ -200,17 +200,19 @@ std::unordered_map extrude_lod22( #endif auto& logger = roofer::logger::Logger::get_logger(); + const auto reconstruction = cfg->reconstruction_in_input_units(); auto elevation_provider = roofer::reconstruction::createElevationProvider(*building.h_ground); auto ArrangementDissolver = roofer::reconstruction::createArrangementDissolver(); - ArrangementDissolver->compute( - arrangement, SegmentRasteriser->heightfield, *elevation_provider, - {.dissolve_step_edges = dissolve_step_edges, - .dissolve_all_interior = dissolve_all_interior, - .step_height_threshold = cfg->lod13_step_height}); + auto dissolver_config = reconstruction.arrangement_dissolver; + dissolver_config.dissolve_step_edges = dissolve_step_edges; + dissolver_config.dissolve_all_interior = dissolve_all_interior; + dissolver_config.step_height_threshold = reconstruction.lod13_step_height; + ArrangementDissolver->compute(arrangement, SegmentRasteriser->heightfield, + *elevation_provider, dissolver_config); // logger.debug("Completed ArrangementDissolver"); // logger.debug("Roof partition has {} faces", arrangement.number_of_faces()); #ifdef RF_USE_RERUN @@ -221,7 +223,8 @@ std::unordered_map extrude_lod22( } #endif auto ArrangementSnapper = roofer::reconstruction::createArrangementSnapper(); - ArrangementSnapper->compute(arrangement, *elevation_provider); + ArrangementSnapper->compute(arrangement, *elevation_provider, + reconstruction.arrangement_snapper); // logger.debug("Completed ArrangementSnapper"); #ifdef RF_USE_RERUN // rec.log(worldname+"ArrangementSnapper", rerun::LineStrips3D( @@ -230,8 +233,10 @@ std::unordered_map extrude_lod22( auto ArrangementExtruder = roofer::reconstruction::createArrangementExtruder(); + auto extruder_config = reconstruction.arrangement_extruder; + extruder_config.lod2 = extrude_LoD2; ArrangementExtruder->compute(arrangement, *elevation_provider, - {.LoD2 = extrude_LoD2}); + extruder_config); // logger.debug("Completed ArrangementExtruder"); #ifdef RF_USE_RERUN if (cfg->use_rerun) { @@ -280,6 +285,7 @@ void extrude_lod11(BuildingObject& building, float extrusion_h, void reconstruct_building(BuildingObject& building, RooferConfig* cfg) { auto& logger = roofer::logger::Logger::get_logger(); + const auto reconstruction = cfg->reconstruction_in_input_units(); #ifdef RF_USE_RERUN // const auto& rec = rerun::RecordingStream::current(); @@ -335,14 +341,7 @@ void reconstruct_building(BuildingObject& building, RooferConfig* cfg) { auto PlaneDetector = roofer::reconstruction::createPlaneDetector(); auto PlaneDetector_ground = roofer::reconstruction::createPlaneDetector(); try { - auto plane_detector_cfg = roofer::reconstruction::PlaneDetectorConfig{ - .metrics_plane_k = cfg->plane_detect_k, - .metrics_plane_min_points = cfg->plane_detect_min_points, - .metrics_plane_epsilon = cfg->plane_detect_epsilon, - .metrics_plane_normal_threshold = cfg->plane_detect_normal_angle, - .with_limits = true, - .limit_n_regions = cfg->lod11_fallback_planes, - }; + auto plane_detector_cfg = reconstruction.plane_detector; PlaneDetector->detect(building.pointcloud_building, plane_detector_cfg); timings["PlaneDetector"] = std::chrono::high_resolution_clock::now() - t0; t0 = std::chrono::high_resolution_clock::now(); @@ -395,7 +394,7 @@ void reconstruct_building(BuildingObject& building, RooferConfig* cfg) { t0 = std::chrono::high_resolution_clock::now(); auto AlphaShaper = roofer::reconstruction::createAlphaShaper(); AlphaShaper->compute(PlaneDetector->pts_per_roofplane, - {.thres_alpha = cfg->thres_alpha}); + reconstruction.alpha_shaper); timings["AlphaShaper"] = std::chrono::high_resolution_clock::now() - t0; // logger.debug("Completed AlphaShaper (roof), found {} rings, {} labels", // AlphaShaper->alpha_rings.size(), @@ -409,7 +408,8 @@ void reconstruct_building(BuildingObject& building, RooferConfig* cfg) { #endif t0 = std::chrono::high_resolution_clock::now(); auto AlphaShaper_ground = roofer::reconstruction::createAlphaShaper(); - AlphaShaper_ground->compute(PlaneDetector_ground->pts_per_roofplane); + AlphaShaper_ground->compute(PlaneDetector_ground->pts_per_roofplane, + reconstruction.alpha_shaper); timings["AlphaShaper_ground"] = std::chrono::high_resolution_clock::now() - t0; // logger.debug("Completed AlphaShaper (ground), found {} rings, {} labels", @@ -426,7 +426,7 @@ void reconstruct_building(BuildingObject& building, RooferConfig* cfg) { auto LineDetector = roofer::reconstruction::createLineDetector(); LineDetector->detect(AlphaShaper->alpha_rings, AlphaShaper->roofplane_ids, PlaneDetector->pts_per_roofplane, - {.dist_thres = cfg->line_detect_epsilon}); + reconstruction.line_detector); timings["LineDetector"] = std::chrono::high_resolution_clock::now() - t0; // logger.debug("Completed LineDetector"); #ifdef RF_USE_RERUN @@ -439,7 +439,8 @@ void reconstruct_building(BuildingObject& building, RooferConfig* cfg) { t0 = std::chrono::high_resolution_clock::now(); auto PlaneIntersector = roofer::reconstruction::createPlaneIntersector(); PlaneIntersector->compute(PlaneDetector->pts_per_roofplane, - PlaneDetector->plane_adjacencies); + PlaneDetector->plane_adjacencies, + reconstruction.plane_intersector); timings["PlaneIntersector"] = std::chrono::high_resolution_clock::now() - t0; @@ -462,8 +463,7 @@ void reconstruct_building(BuildingObject& building, RooferConfig* cfg) { auto LineRegulariser = roofer::reconstruction::createLineRegulariser(); LineRegulariser->compute(LineDetector->edge_segments, PlaneIntersector->segments, - {.dist_threshold = cfg->thres_reg_line_dist, - .extension = cfg->thres_reg_line_ext}); + reconstruction.line_regulariser); timings["LineRegulariser"] = std::chrono::high_resolution_clock::now() - t0; // logger.debug("Completed LineRegulariser"); #ifdef RF_USE_RERUN @@ -475,10 +475,12 @@ void reconstruct_building(BuildingObject& building, RooferConfig* cfg) { t0 = std::chrono::high_resolution_clock::now(); auto SegmentRasteriser = roofer::reconstruction::createSegmentRasteriser(); - SegmentRasteriser->compute( - AlphaShaper->alpha_triangles, AlphaShaper_ground->alpha_triangles, - {.use_ground = - !building.pointcloud_ground.empty() && cfg->clip_ground}); + auto rasteriser_config = reconstruction.segment_rasteriser; + rasteriser_config.use_ground = + !building.pointcloud_ground.empty() && reconstruction.clip_terrain; + SegmentRasteriser->compute(AlphaShaper->alpha_triangles, + AlphaShaper_ground->alpha_triangles, + rasteriser_config); timings["SegmentRasteriser"] = std::chrono::high_resolution_clock::now() - t0; // logger.debug("Completed SegmentRasteriser"); @@ -500,7 +502,8 @@ void reconstruct_building(BuildingObject& building, RooferConfig* cfg) { auto ArrangementBuilder = roofer::reconstruction::createArrangementBuilder(); ArrangementBuilder->compute(arrangement, building.footprint, - LineRegulariser->exact_regularised_edges); + LineRegulariser->exact_regularised_edges, + reconstruction.arrangement_builder); timings["ArrangementBuilder"] = std::chrono::high_resolution_clock::now() - t0; // logger.debug("Completed ArrangementBuilder"); @@ -517,16 +520,13 @@ void reconstruct_building(BuildingObject& building, RooferConfig* cfg) { t0 = std::chrono::high_resolution_clock::now(); auto ArrangementOptimiser = roofer::reconstruction::createArrangementOptimiser(); - ArrangementOptimiser->compute( - arrangement, SegmentRasteriser->heightfield, - PlaneDetector->pts_per_roofplane, - PlaneDetector_ground->pts_per_roofplane, - { - .data_multiplier = cfg->complexity_factor, - .smoothness_multiplier = float(1. - cfg->complexity_factor), - .use_ground = - !building.pointcloud_ground.empty() && cfg->clip_ground, - }); + auto optimiser_config = reconstruction.arrangement_optimiser; + optimiser_config.use_ground = + !building.pointcloud_ground.empty() && reconstruction.clip_terrain; + ArrangementOptimiser->compute(arrangement, SegmentRasteriser->heightfield, + PlaneDetector->pts_per_roofplane, + PlaneDetector_ground->pts_per_roofplane, + optimiser_config); timings["ArrangementOptimiser"] = std::chrono::high_resolution_clock::now() - t0; // logger.debug("Completed ArrangementOptimiser"); @@ -537,19 +537,19 @@ void reconstruct_building(BuildingObject& building, RooferConfig* cfg) { // attributes to be filled during reconstruction // logger.debug("LoD={}", cfg->lod); t0 = std::chrono::high_resolution_clock::now(); - if (cfg->lod_12) { + if (cfg->reconstruction.lod12) { building.multisolids_lod12 = extrude_lod22( arrangement, building, cfg, SegmentRasteriser.get(), LOD12, building.rmse_lod12, building.volume_lod12, building.val3dity_lod12); } - if (cfg->lod_13) { + if (cfg->reconstruction.lod13) { building.multisolids_lod13 = extrude_lod22( arrangement, building, cfg, SegmentRasteriser.get(), LOD13, building.rmse_lod13, building.volume_lod13, building.val3dity_lod13); } - if (cfg->lod_22) { + if (cfg->reconstruction.lod22) { building.multisolids_lod22 = extrude_lod22( arrangement, building, cfg, SegmentRasteriser.get(), LOD22, building.rmse_lod22, building.volume_lod22, building.val3dity_lod22); diff --git a/apps/roofer-app/roofer-app.cpp b/apps/roofer-app/roofer-app.cpp index 75c6b7a9..179c0cce 100644 --- a/apps/roofer-app/roofer-app.cpp +++ b/apps/roofer-app/roofer-app.cpp @@ -456,8 +456,9 @@ int main(int argc, const char* argv[]) { building_tile.extent = roi; building_tile.proj_helper = roofer::misc::createProjHelper(); } else { - auto tile_extents = - create_tiles(roi, handler.cfg_.tilesize[0], handler.cfg_.tilesize[1]); + auto tile_extents = create_tiles( + roi, handler.cfg_.metres_to_input_units(handler.cfg_.tilesize[0]), + handler.cfg_.metres_to_input_units(handler.cfg_.tilesize[1])); for (std::size_t tid = 0; tid < tile_extents.size(); tid++) { // intersect with roi, to avoid creating buildings outside of the roi @@ -1032,7 +1033,7 @@ int main(int argc, const char* argv[]) { std::unordered_map* ms12 = nullptr; std::unordered_map* ms13 = nullptr; std::unordered_map* ms22 = nullptr; - if (handler.cfg_.lod_12) { + if (handler.cfg_.reconstruction.lod12) { ms12 = &building.multisolids_lod12; if (!handler.cfg_.a_rmse_lod12.empty()) @@ -1047,7 +1048,7 @@ int main(int argc, const char* argv[]) { building.val3dity_lod12); #endif } - if (handler.cfg_.lod_13) { + if (handler.cfg_.reconstruction.lod13) { ms13 = &building.multisolids_lod13; if (!handler.cfg_.a_rmse_lod13.empty()) attrow.insert_optional(handler.cfg_.a_rmse_lod13, @@ -1061,7 +1062,7 @@ int main(int argc, const char* argv[]) { building.val3dity_lod13); #endif } - if (handler.cfg_.lod_22) { + if (handler.cfg_.reconstruction.lod22) { ms22 = &building.multisolids_lod22; if (!handler.cfg_.a_rmse_lod22.empty()) attrow.insert_optional(handler.cfg_.a_rmse_lod22, diff --git a/apps/roofer-app/validators.hpp b/apps/roofer-app/validators.hpp index 62c8941b..a1ba8b59 100644 --- a/apps/roofer-app/validators.hpp +++ b/apps/roofer-app/validators.hpp @@ -20,65 +20,24 @@ // Ravi Peters // Balázs Dukai #pragma once +#include #include #include - -template -using Validator = std::function(const T&)>; +#include namespace roofer::validators { - // Concept to ensure types are comparable - template - concept Comparable = requires(T a, T b) { - { a < b } -> std::convertible_to; - { a > b } -> std::convertible_to; - }; - - // Generator function for range validators - template - requires Comparable - auto InRange(T min, T max) { - return [min, max](const T& val) -> std::optional { - if (val < min || val > max) { - return std::format("Value {} is out of range <{}, {}>.", val, min, max); - } - return std::nullopt; - }; - }; - - // Generator function for validator to check if a value is higher than a given - // value - template - requires Comparable - auto HigherThan(T min) { - return [min](const T& val) -> std::optional { - if constexpr (std::is_same_v) { - if (val[0] <= min[0] || val[1] <= min[1]) { - return std::format( - "One of the values of [{}, {}] is too low. Values must be higher " - "than {} and {} respectively.", - val[0], val[1], min[0], min[1]); - } - } else if (val <= min) { - return std::format("Value must be higher than {}.", min); - } - return std::nullopt; - }; - }; - - // Generator function for validator to check if a value is higher than or - // equal to a given value - template - requires Comparable - auto HigherOrEqualTo(T min) { - return [min](const T& val) -> std::optional { - if (val < min) { + // Array-specific application validation; scalar bounds use roofer::config. + inline auto AllHigherThan(roofer::arr2f min) { + return [min](const roofer::arr2f& val) -> std::optional { + if (val[0] <= min[0] || val[1] <= min[1]) { return std::format( - "Value must be higher than or equal to {}. But is {}.", min, val); + "One of the values of [{}, {}] is too low. Values must be higher " + "than {} and {} respectively.", + val[0], val[1], min[0], min[1]); } return std::nullopt; }; - }; + } // Generator function for validator to check if the value is one of the given // values diff --git a/docs/cli_application.md b/docs/cli_application.md index 8bd8027b..da4df9bf 100644 --- a/docs/cli_application.md +++ b/docs/cli_application.md @@ -61,8 +61,8 @@ For terrain output, `--terrain-nodata-mode` controls whether only complete grid ```{include} output-attributes.md ``` -## Example config file -Below is an example of a [TOML](https://toml.io/en/) configuration file for the {program}`roofer` CLI application. It shows all the available options. Noticed that these options are also available as command line arguments, in case one option is provided both in the configuration file and as a command line argument, the command line argument takes precedence. +## Config file +Below is an example of a [TOML](https://toml.io/en/) configuration file for the {program}`roofer` CLI application. It shows all the available options. Some of these options are also available as command line arguments, in case one option is provided both in the configuration file and as a command line argument, the command line argument takes precedence. ```{literalinclude} example_full.toml :language: toml diff --git a/docs/reconstruct_params.md b/docs/reconstruct_params.md index 6ac7e550..085421bc 100644 --- a/docs/reconstruct_params.md +++ b/docs/reconstruct_params.md @@ -35,7 +35,7 @@ The roofer building reconstruction algorithm is largely data-driven, so the qual