From 98afe5af5a80e52d019cbe0ef55e8bd35247f858 Mon Sep 17 00:00:00 2001 From: rusprus Date: Fri, 24 Apr 2026 15:56:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=84=D0=BE=D1=80=D0=BC=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BF=D1=80=D0=B5=D1=81=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82=D1=80=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DotsToSirface.pro | 10 + DotsToSirfaceTests.pro | 8 + README.md | 101 +++++ src/cli/pipeline_cli_runner.cpp | 509 ++++++++++++++++++++++++ src/cli/pipeline_cli_runner.h | 6 + src/core/hole_aware_score.cpp | 48 +++ src/core/hole_aware_score.h | 28 ++ src/core/pipeline_config_validation.cpp | 79 ++++ src/core/pipeline_config_validation.h | 13 + src/core/pipeline_stage_defaults.cpp | 341 ++++++++++++++++ src/core/pipeline_stage_defaults.h | 17 + src/core/tuning_service.cpp | 109 +++++ src/core/tuning_service.h | 53 +++ src/main.cpp | 9 + src/tests/pipeline_smoke_tests.cpp | 170 ++++++++ src/ui/mainwindow.cpp | 403 +------------------ 16 files changed, 1505 insertions(+), 399 deletions(-) create mode 100644 src/cli/pipeline_cli_runner.cpp create mode 100644 src/cli/pipeline_cli_runner.h create mode 100644 src/core/hole_aware_score.cpp create mode 100644 src/core/hole_aware_score.h create mode 100644 src/core/pipeline_config_validation.cpp create mode 100644 src/core/pipeline_config_validation.h create mode 100644 src/core/pipeline_stage_defaults.cpp create mode 100644 src/core/pipeline_stage_defaults.h create mode 100644 src/core/tuning_service.cpp create mode 100644 src/core/tuning_service.h diff --git a/DotsToSirface.pro b/DotsToSirface.pro index 085d434..15cfe13 100644 --- a/DotsToSirface.pro +++ b/DotsToSirface.pro @@ -39,6 +39,7 @@ contains(DEFINES, PCL_ENABLED) { SOURCES += \ src/main.cpp \ + src/cli/pipeline_cli_runner.cpp \ src/algorithms/preprocess/preprocess_algorithms.cpp \ src/algorithms/reconstruction/surface_reconstruction.cpp \ src/adapters/reconstruction/reconstruction_adapter.cpp \ @@ -49,6 +50,10 @@ SOURCES += \ src/adapters/sources/file_point_cloud_source.cpp \ src/adapters/sources/ros2_point_cloud_source.cpp \ src/core/pipeline_config.cpp \ + src/core/pipeline_config_validation.cpp \ + src/core/hole_aware_score.cpp \ + src/core/pipeline_stage_defaults.cpp \ + src/core/tuning_service.cpp \ src/core/pipeline_executor.cpp \ src/core/pipeline_plugin_registry.cpp \ src/factories/pipeline/desktop_pipeline_factory.cpp \ @@ -61,6 +66,7 @@ SOURCES += \ src/ui/glview.cpp HEADERS += \ + src/cli/pipeline_cli_runner.h \ src/algorithms/preprocess/preprocess_algorithms.h \ src/algorithms/reconstruction/surface_reconstruction.h \ src/adapters/reconstruction/reconstruction_adapter.h \ @@ -73,6 +79,10 @@ HEADERS += \ src/core/data_source.h \ src/core/point_cloud_types.h \ src/core/pipeline_config.h \ + src/core/pipeline_config_validation.h \ + src/core/hole_aware_score.h \ + src/core/pipeline_stage_defaults.h \ + src/core/tuning_service.h \ src/core/pipeline_stage.h \ src/core/pipeline_executor.h \ src/core/pipeline_plugin_registry.h \ diff --git a/DotsToSirfaceTests.pro b/DotsToSirfaceTests.pro index 59aa915..17b35b4 100644 --- a/DotsToSirfaceTests.pro +++ b/DotsToSirfaceTests.pro @@ -48,6 +48,10 @@ SOURCES += \ src/adapters/sources/file_point_cloud_source.cpp \ src/adapters/sources/ros2_point_cloud_source.cpp \ src/core/pipeline_config.cpp \ + src/core/pipeline_config_validation.cpp \ + src/core/hole_aware_score.cpp \ + src/core/pipeline_stage_defaults.cpp \ + src/core/tuning_service.cpp \ src/core/pipeline_executor.cpp \ src/core/pipeline_plugin_registry.cpp \ src/factories/pipeline/desktop_pipeline_factory.cpp \ @@ -70,6 +74,10 @@ HEADERS += \ src/core/data_source.h \ src/core/point_cloud_types.h \ src/core/pipeline_config.h \ + src/core/pipeline_config_validation.h \ + src/core/hole_aware_score.h \ + src/core/pipeline_stage_defaults.h \ + src/core/tuning_service.h \ src/core/pipeline_stage.h \ src/core/pipeline_executor.h \ src/core/pipeline_plugin_registry.h \ diff --git a/README.md b/README.md index 96576f6..85df501 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,15 @@ - `strategies`: конкретные стадии пайплайна (preprocess, transform, registration, reconstruction). - `factories`: сборка `PipelineExecutor` из plugin-id и профиля. +## Этап 1 (backend-first, без CLI) + +- Парсинг и применение `defaults` параметров стадий вынесены из UI в `core`: + `src/core/pipeline_stage_defaults.h` и `src/core/pipeline_stage_defaults.cpp`. +- `MainWindow` больше не содержит backend-логику разбора параметров, а вызывает + `core::applyStageDefaultsToConfig(...)`. +- Это позволяет использовать один и тот же backend API из GUI сейчас и из будущего + CLI на этапе 2 без дублирования логики. + ## Pipeline Dashboard: UX и анализ цепочки В dashboard реализованы инструменты для пошаговой настройки всей pipeline-цепочки (preprocess + reconstruction): @@ -103,6 +112,98 @@ make При успешном запуске runner печатает `Smoke tests passed.` и завершаетcя с кодом `0`. +## CLI режим (этап 2) + +Для headless-прогона без GUI добавлен CLI-режим в основном бинарнике через `--cli`. + +Пример: + +```bash +cd /mnt/d/yakupov/Projects/DotsToSirface/build-wsl +qmake ../DotsToSirface.pro 'DEFINES+=PCL_ENABLED' +make -j4 +./DotsToSirface --cli \ + --input /mnt/d/path/to/cloud.xyz \ + --profile desktop_debug \ + --preprocess pcl_remove_nan,pcl_voxel_grid,pcl_statistical_outlier \ + --reconstruction pcl_greedy_triangulation \ + --stage-default pcl_voxel_grid:leaf=0.03 \ + --stage-default pcl_greedy_triangulation:searchRadius=0.08,mu=2.5,maxNearest=100,maxSurfaceAngle=0.8 \ + --output-json /mnt/d/path/to/result.json +``` + +CLI печатает краткую сводку по метрикам (`input`, `after preprocess`, `triangles`, `reconstruction ms`). + +### JSON-конфиг для CLI (этап 2.1) + +Чтобы не передавать длинную команду, можно задать пайплайн через `--config-json`. + +Пример `pipeline_config.json`: + +```json +{ + "profile": "desktop_debug", + "preprocessPlugins": ["pcl_remove_nan", "pcl_voxel_grid", "pcl_statistical_outlier"], + "reconstructionPlugin": "pcl_greedy_triangulation", + "stageDefaults": { + "pcl_voxel_grid": "leaf=0.03", + "pcl_greedy_triangulation": "searchRadius=0.08,mu=2.5,maxNearest=100,maxSurfaceAngle=0.8" + } +} +``` + +Запуск: + +```bash +./DotsToSirface --cli --input /mnt/d/path/to/cloud.xyz --config-json /mnt/d/path/to/pipeline_config.json +``` + +Если одновременно переданы `--config-json` и обычные CLI-флаги (`--preprocess`, `--reconstruction`, `--stage-default`), флаги командной строки имеют приоритет. + +### Autotune Grid Search (этап 3) + +Для подбора параметров с целью уменьшения дыр на поверхности добавлен режим: +`--autotune-config`. + +Пример `autotune_config.json`: + +```json +{ + "baseConfigJson": "/mnt/d/path/to/pipeline_config.json", + "searchSpace": { + "pclVoxelLeafSize": [0.02, 0.03, 0.04], + "pclGreedySearchRadius": [0.06, 0.08, 0.10], + "neighborRadiusScale": [2.5, 3.0] + }, + "objective": { + "connectivityWeight": 0.45, + "coverageWeight": 0.45, + "speedPenaltyWeight": 0.10 + }, + "limits": { + "maxCandidates": 20, + "topK": 5 + }, + "output": { + "resultJsonPath": "/mnt/d/path/to/autotune_result.json" + } +} +``` + +Запуск: + +```bash +./DotsToSirface --cli \ + --input /mnt/d/path/to/cloud.xyz \ + --autotune-config /mnt/d/path/to/autotune_config.json +``` + +Опционально можно переопределить путь результата через `--output-json`. + +Файл `autotune_result.json` теперь содержит поля `title` и `stages` в формате GUI-пресета, +поэтому его можно загрузить напрямую кнопкой `Загрузить пресет`. +Дополнительно рядом сохраняется `*_best_preset.json` (чистый preset-JSON). + ## Демо При запуске приложение: diff --git a/src/cli/pipeline_cli_runner.cpp b/src/cli/pipeline_cli_runner.cpp new file mode 100644 index 0000000..56f3171 --- /dev/null +++ b/src/cli/pipeline_cli_runner.cpp @@ -0,0 +1,509 @@ +#include "pipeline_cli_runner.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../adapters/sources/file_point_cloud_source.h" +#include "../core/pipeline_config.h" +#include "../core/pipeline_config_validation.h" +#include "../core/pipeline_executor.h" +#include "../core/pipeline_stage_defaults.h" +#include "../core/tuning_service.h" +#include "../factories/pipeline/desktop_pipeline_factory.h" + +namespace +{ +core::PipelineConfig configForProfile(const QString &profile) +{ + if (profile == "rpi4_runtime") { + return core::makeRpi4RuntimeConfig(); + } + return core::makeDesktopDebugConfig(); +} + +bool applyStageDefaultsMap( + const QJsonObject &defaultsObj, + core::PipelineConfig &config, + QString &errorText) +{ + for (auto it = defaultsObj.constBegin(); it != defaultsObj.constEnd(); ++it) { + const QString stageId = it.key().trimmed(); + const QString defaultsText = it.value().toString().trimmed(); + if (stageId.isEmpty()) { + errorText = "Empty stage id in stageDefaults map."; + return false; + } + if (!core::applyStageDefaultsToConfig(stageId, defaultsText, config, errorText)) { + errorText = QString("Invalid defaults for stage '%1': %2").arg(stageId, errorText); + return false; + } + } + return true; +} + +bool loadConfigFromJsonFile( + const QString &path, + core::PipelineConfig &config, + QString &errorText) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + errorText = QString("Cannot open config JSON: %1").arg(path); + return false; + } + + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + errorText = QString("Invalid config JSON: %1").arg(parseError.errorString()); + return false; + } + + const QJsonObject root = doc.object(); + if (root.contains("profile")) { + config = configForProfile(root.value("profile").toString().trimmed()); + } + if (root.contains("preprocessPlugins")) { + config.preprocessPlugins.clear(); + const QJsonArray arr = root.value("preprocessPlugins").toArray(); + for (const QJsonValue &v : arr) { + const QString id = v.toString().trimmed(); + if (!id.isEmpty()) { + config.preprocessPlugins.push_back(id); + } + } + } + if (root.contains("reconstructionPlugin")) { + config.reconstructionPlugin = root.value("reconstructionPlugin").toString().trimmed(); + } + if (root.contains("stageDefaults")) { + if (!root.value("stageDefaults").isObject()) { + errorText = "Field 'stageDefaults' must be an object: {\"stageId\":\"k=v,...\"}."; + return false; + } + QString defaultsError; + if (!applyStageDefaultsMap(root.value("stageDefaults").toObject(), config, defaultsError)) { + errorText = defaultsError; + return false; + } + } + return true; +} + +struct AutotuneCliConfig +{ + QString baseConfigJsonPath; + core::AutotuneSearchSpace searchSpace; + core::HoleAwareScoreWeights weights; + core::AutotuneLimits limits; + QString outputPathFromConfig; +}; + +bool parseFloatArray(const QJsonValue &value, QVector &out, QString &errorText, const QString &fieldName) +{ + if (!value.isArray()) { + errorText = QString("Field '%1' must be an array of numbers.").arg(fieldName); + return false; + } + const QJsonArray arr = value.toArray(); + out.clear(); + for (const QJsonValue &v : arr) { + if (!v.isDouble()) { + errorText = QString("Field '%1' must contain only numbers.").arg(fieldName); + return false; + } + out.push_back(static_cast(v.toDouble())); + } + return true; +} + +bool loadAutotuneConfigFromJson( + const QString &path, + AutotuneCliConfig &cfg, + QString &errorText) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + errorText = QString("Cannot open autotune JSON: %1").arg(path); + return false; + } + + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + errorText = QString("Invalid autotune JSON: %1").arg(parseError.errorString()); + return false; + } + + const QJsonObject root = doc.object(); + cfg = AutotuneCliConfig(); + cfg.limits.topK = 5; + + if (root.contains("baseConfigJson")) { + cfg.baseConfigJsonPath = root.value("baseConfigJson").toString().trimmed(); + } + + if (root.contains("searchSpace")) { + if (!root.value("searchSpace").isObject()) { + errorText = "Field 'searchSpace' must be an object."; + return false; + } + const QJsonObject searchObj = root.value("searchSpace").toObject(); + if (searchObj.contains("pclVoxelLeafSize") + && !parseFloatArray( + searchObj.value("pclVoxelLeafSize"), + cfg.searchSpace.pclVoxelLeafSizes, + errorText, + "searchSpace.pclVoxelLeafSize")) { + return false; + } + if (searchObj.contains("pclGreedySearchRadius") + && !parseFloatArray( + searchObj.value("pclGreedySearchRadius"), + cfg.searchSpace.pclGreedySearchRadii, + errorText, + "searchSpace.pclGreedySearchRadius")) { + return false; + } + if (searchObj.contains("neighborRadiusScale") + && !parseFloatArray( + searchObj.value("neighborRadiusScale"), + cfg.searchSpace.neighborRadiusScales, + errorText, + "searchSpace.neighborRadiusScale")) { + return false; + } + } + + if (root.contains("objective")) { + if (!root.value("objective").isObject()) { + errorText = "Field 'objective' must be an object."; + return false; + } + const QJsonObject objective = root.value("objective").toObject(); + if (objective.contains("connectivityWeight")) { + cfg.weights.connectivityWeight = objective.value("connectivityWeight").toDouble(cfg.weights.connectivityWeight); + } + if (objective.contains("coverageWeight")) { + cfg.weights.coverageWeight = objective.value("coverageWeight").toDouble(cfg.weights.coverageWeight); + } + if (objective.contains("speedPenaltyWeight")) { + cfg.weights.speedPenaltyWeight = objective.value("speedPenaltyWeight").toDouble(cfg.weights.speedPenaltyWeight); + } + } + + if (root.contains("limits")) { + if (!root.value("limits").isObject()) { + errorText = "Field 'limits' must be an object."; + return false; + } + const QJsonObject limitsObj = root.value("limits").toObject(); + if (limitsObj.contains("maxCandidates")) { + cfg.limits.maxCandidates = limitsObj.value("maxCandidates").toInt(cfg.limits.maxCandidates); + } + if (limitsObj.contains("topK")) { + cfg.limits.topK = limitsObj.value("topK").toInt(cfg.limits.topK); + } + } + + if (root.contains("output")) { + if (!root.value("output").isObject()) { + errorText = "Field 'output' must be an object."; + return false; + } + const QJsonObject outputObj = root.value("output").toObject(); + cfg.outputPathFromConfig = outputObj.value("resultJsonPath").toString().trimmed(); + } + + return true; +} + +QString defaultsForStageInPreset(const QString &stageId, const core::PipelineConfig &config) +{ + if (stageId == "pcl_voxel_grid") { + return QString("leaf=%1").arg(config.preprocess.pclVoxelLeafSize); + } + if (stageId == "pcl_greedy_triangulation") { + return QString("searchRadius=%1,mu=%2,maxNearest=%3,maxSurfaceAngle=%4") + .arg(config.reconstruction.pclGreedySearchRadius) + .arg(config.reconstruction.pclGreedyMu) + .arg(config.reconstruction.pclGreedyMaxNearest) + .arg(config.reconstruction.pclGreedyMaxSurfaceAngle); + } + if (stageId == "surface_fallback") { + return QString("neighborRadiusScale=%1,runEveryNthFrame=%2") + .arg(config.reconstruction.neighborRadiusScale) + .arg(config.reconstruction.runEveryNthFrame); + } + if (stageId == "pcl_statistical_outlier") { + return QString("meanK=%1,stddev=%2") + .arg(config.preprocess.pclSorMeanK) + .arg(config.preprocess.pclSorStdDevMul); + } + if (stageId == "pcl_radius_outlier") { + return QString("radius=%1,minNeighbors=%2") + .arg(config.preprocess.pclRorRadius) + .arg(config.preprocess.pclRorMinNeighbors); + } + return QString(); +} + +QJsonObject makePresetFromConfig(const core::PipelineConfig &config, const QString &title) +{ + QJsonArray stages; + for (const QString &stageId : config.preprocessPlugins) { + QJsonObject stage; + stage["id"] = stageId; + stage["enabled"] = true; + stage["family"] = "preprocess"; + const QString defaults = defaultsForStageInPreset(stageId, config); + if (!defaults.isEmpty()) { + stage["defaults"] = defaults; + } + stages.push_back(stage); + } + + QJsonObject reconstruction; + reconstruction["id"] = config.reconstructionPlugin; + reconstruction["enabled"] = true; + reconstruction["family"] = "reconstruction"; + const QString reconstructionDefaults = defaultsForStageInPreset(config.reconstructionPlugin, config); + if (!reconstructionDefaults.isEmpty()) { + reconstruction["defaults"] = reconstructionDefaults; + } + stages.push_back(reconstruction); + + QJsonObject preset; + preset["title"] = title; + preset["idValue"] = "user:autotune_best"; + preset["stages"] = stages; + return preset; +} +} // namespace + +int runCliPipeline(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + app.setApplicationName("DotsToSirface"); + + QCommandLineParser parser; + parser.setApplicationDescription("DotsToSirface CLI pipeline runner"); + parser.addHelpOption(); + parser.addOption(QCommandLineOption("cli", "Run in CLI mode.")); + parser.addOption(QCommandLineOption(QStringList() << "i" << "input", "Input point cloud file path.", "path")); + parser.addOption(QCommandLineOption("config-json", "Path to pipeline JSON config file.", "path")); + parser.addOption(QCommandLineOption("autotune-config", "Path to autotune JSON config file.", "path")); + parser.addOption(QCommandLineOption("profile", "Pipeline profile: desktop_debug or rpi4_runtime.", "name", "desktop_debug")); + parser.addOption(QCommandLineOption("preprocess", "Comma-separated preprocess plugin ids.", "ids")); + parser.addOption(QCommandLineOption("reconstruction", "Reconstruction plugin id.", "id")); + parser.addOption(QCommandLineOption( + "stage-default", + "Stage parameter override as stageId:key=value,key2=value2. Can be repeated.", + "spec")); + parser.addOption(QCommandLineOption("output-json", "Write run summary JSON to this file.", "path")); + parser.process(app); + + QTextStream out(stdout); + QTextStream err(stderr); + + const QString inputPath = parser.value("input").trimmed(); + if (inputPath.isEmpty()) { + err << "--input is required in --cli mode.\n"; + return 2; + } + + core::PipelineConfig config = configForProfile(parser.value("profile").trimmed()); + const QString configJsonPath = parser.value("config-json").trimmed(); + if (!configJsonPath.isEmpty()) { + QString jsonError; + if (!loadConfigFromJsonFile(configJsonPath, config, jsonError)) { + err << jsonError << "\n"; + return 2; + } + } + + const QString autotuneConfigPath = parser.value("autotune-config").trimmed(); + if (!autotuneConfigPath.isEmpty()) { + AutotuneCliConfig autotuneCfg; + QString autotuneError; + if (!loadAutotuneConfigFromJson(autotuneConfigPath, autotuneCfg, autotuneError)) { + err << autotuneError << "\n"; + return 2; + } + + if (!autotuneCfg.baseConfigJsonPath.isEmpty()) { + QString baseError; + if (!loadConfigFromJsonFile(autotuneCfg.baseConfigJsonPath, config, baseError)) { + err << baseError << "\n"; + return 2; + } + } + + QString configError; + if (!core::normalizeAndValidatePipelineConfig(config, configError)) { + err << "Invalid base config: " << configError << "\n"; + return 2; + } + + adapters::sources::FilePointCloudSource source(inputPath); + core::PointCloudFrame frame; + QString ioError; + if (!source.nextFrame(frame, ioError)) { + err << "Failed to read input point cloud: " << ioError << "\n"; + return 2; + } + + const core::AutotuneResult tuneResult = core::runGridAutotune( + frame, + config, + autotuneCfg.searchSpace, + autotuneCfg.weights, + autotuneCfg.limits); + if (!tuneResult.ok) { + err << "Autotune failed: " << tuneResult.errorText << "\n"; + return 2; + } + + out << "Autotune candidates evaluated: " << tuneResult.evaluatedCandidates << "\n"; + out << "Best score: " << tuneResult.best.score << "\n"; + out << "Best pipeline: PP=[" << tuneResult.best.config.preprocessPlugins.join("->") + << "] REC=" << tuneResult.best.config.reconstructionPlugin << "\n"; + out << "Best params: leaf=" << tuneResult.best.config.preprocess.pclVoxelLeafSize + << " greedyRadius=" << tuneResult.best.config.reconstruction.pclGreedySearchRadius + << " neighborRadiusScale=" << tuneResult.best.config.reconstruction.neighborRadiusScale << "\n"; + + QString outputJsonPath = parser.value("output-json").trimmed(); + if (outputJsonPath.isEmpty()) { + outputJsonPath = autotuneCfg.outputPathFromConfig; + } + if (!outputJsonPath.isEmpty()) { + QJsonObject bestObj; + bestObj["score"] = tuneResult.best.score; + bestObj["pclVoxelLeafSize"] = tuneResult.best.config.preprocess.pclVoxelLeafSize; + bestObj["pclGreedySearchRadius"] = tuneResult.best.config.reconstruction.pclGreedySearchRadius; + bestObj["neighborRadiusScale"] = tuneResult.best.config.reconstruction.neighborRadiusScale; + bestObj["inputPoints"] = tuneResult.best.inputPoints; + bestObj["afterPreprocess"] = tuneResult.best.afterPreprocessPoints; + bestObj["triangles"] = tuneResult.best.outputTriangles; + bestObj["reconstructionMs"] = static_cast(tuneResult.best.reconstructionMs); + + QJsonArray topArray; + for (const core::AutotuneCandidateResult &candidate : tuneResult.topCandidates) { + QJsonObject c; + c["score"] = candidate.score; + c["pclVoxelLeafSize"] = candidate.config.preprocess.pclVoxelLeafSize; + c["pclGreedySearchRadius"] = candidate.config.reconstruction.pclGreedySearchRadius; + c["neighborRadiusScale"] = candidate.config.reconstruction.neighborRadiusScale; + c["triangles"] = candidate.outputTriangles; + c["afterPreprocess"] = candidate.afterPreprocessPoints; + c["reconstructionMs"] = static_cast(candidate.reconstructionMs); + topArray.push_back(c); + } + + const QJsonObject bestPreset = makePresetFromConfig(tuneResult.best.config, "AutoTune Best"); + QJsonObject root; + root["title"] = bestPreset.value("title"); + root["idValue"] = bestPreset.value("idValue"); + root["stages"] = bestPreset.value("stages"); + root["evaluatedCandidates"] = tuneResult.evaluatedCandidates; + root["best"] = bestObj; + root["topK"] = topArray; + root["bestPreset"] = bestPreset; + + QFile file(outputJsonPath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + err << "Failed to open output JSON file: " << outputJsonPath << "\n"; + return 2; + } + file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + + const QFileInfo info(outputJsonPath); + const QString presetPath = info.path() + "/" + info.completeBaseName() + "_best_preset.json"; + QFile presetFile(presetPath); + if (presetFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + presetFile.write(QJsonDocument(bestPreset).toJson(QJsonDocument::Indented)); + } + } + return 0; + } + + const QString preprocessRaw = parser.value("preprocess").trimmed(); + if (!preprocessRaw.isEmpty()) { + config.preprocessPlugins = preprocessRaw.split(',', Qt::SkipEmptyParts); + for (int i = 0; i < config.preprocessPlugins.size(); ++i) { + config.preprocessPlugins[i] = config.preprocessPlugins[i].trimmed(); + } + } + + const QString reconstruction = parser.value("reconstruction").trimmed(); + if (!reconstruction.isEmpty()) { + config.reconstructionPlugin = reconstruction; + } + + const QStringList defaultsSpecs = parser.values("stage-default"); + for (const QString &specRaw : defaultsSpecs) { + const QString spec = specRaw.trimmed(); + const int colonPos = spec.indexOf(':'); + if (colonPos <= 0 || colonPos >= spec.size() - 1) { + err << "Invalid --stage-default format, expected stageId:key=value,... got: " << spec << "\n"; + return 2; + } + const QString stageId = spec.left(colonPos).trimmed(); + const QString defaultsText = spec.mid(colonPos + 1).trimmed(); + QString errorText; + if (!core::applyStageDefaultsToConfig(stageId, defaultsText, config, errorText)) { + err << "Invalid defaults for stage '" << stageId << "': " << errorText << "\n"; + return 2; + } + } + + QString configError; + if (!core::normalizeAndValidatePipelineConfig(config, configError)) { + err << "Invalid pipeline config: " << configError << "\n"; + return 2; + } + + adapters::sources::FilePointCloudSource source(inputPath); + core::PointCloudFrame frame; + QString ioError; + if (!source.nextFrame(frame, ioError)) { + err << "Failed to read input point cloud: " << ioError << "\n"; + return 2; + } + + core::PipelineExecutor executor = factories::pipeline::createDesktopPipelineExecutor(config); + const core::PipelineResult result = executor.run(frame); + + out << "Input points: " << result.stats.inputPoints << "\n"; + out << "After preprocess: " << result.stats.afterPreprocessingPoints << "\n"; + out << "Triangles: " << result.stats.outputTriangles << "\n"; + out << "Reconstruction ms: " << result.stats.reconstructionMs << "\n"; + out << "Pipeline: PP=[" << config.preprocessPlugins.join("->") << "] REC=" << config.reconstructionPlugin << "\n"; + + const QString outputJsonPath = parser.value("output-json").trimmed(); + if (!outputJsonPath.isEmpty()) { + QJsonObject root; + root["inputPoints"] = result.stats.inputPoints; + root["afterPreprocess"] = result.stats.afterPreprocessingPoints; + root["triangles"] = result.stats.outputTriangles; + root["reconstructionMs"] = static_cast(result.stats.reconstructionMs); + root["reconstruction"] = config.reconstructionPlugin; + root["preprocessChain"] = config.preprocessPlugins.join(","); + + QFile file(outputJsonPath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + err << "Failed to open output JSON file: " << outputJsonPath << "\n"; + return 2; + } + file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + } + + return 0; +} diff --git a/src/cli/pipeline_cli_runner.h b/src/cli/pipeline_cli_runner.h new file mode 100644 index 0000000..9a5ad83 --- /dev/null +++ b/src/cli/pipeline_cli_runner.h @@ -0,0 +1,6 @@ +#ifndef PIPELINE_CLI_RUNNER_H +#define PIPELINE_CLI_RUNNER_H + +int runCliPipeline(int argc, char *argv[]); + +#endif // PIPELINE_CLI_RUNNER_H diff --git a/src/core/hole_aware_score.cpp b/src/core/hole_aware_score.cpp new file mode 100644 index 0000000..4c70847 --- /dev/null +++ b/src/core/hole_aware_score.cpp @@ -0,0 +1,48 @@ +#include "hole_aware_score.h" + +#include + +namespace +{ +double clamp01(const double v) +{ + if (v < 0.0) { + return 0.0; + } + if (v > 1.0) { + return 1.0; + } + return v; +} +} // namespace + +namespace core +{ +HoleAwareScoreBreakdown computeHoleAwareScore( + const PipelineResult &result, + const HoleAwareScoreWeights &weights) +{ + HoleAwareScoreBreakdown score; + const double inputPoints = qMax(1, result.stats.inputPoints); + const double processedPoints = qMax(1, result.stats.afterPreprocessingPoints); + const double triangles = qMax(0, result.stats.outputTriangles); + + // Proxy 1: low triangle density often corresponds to sparse surfaces and visible holes. + const double triangleDensity = triangles / processedPoints; + const double targetDensity = 0.80; + score.connectivityScore = clamp01(triangleDensity / targetDensity); + + // Proxy 2: aggressive filtering can remove support points and increase hole probability. + score.coverageScore = clamp01(processedPoints / inputPoints); + + // Secondary: discourage very slow reconstructions, but keep it lower priority. + const double targetMs = 120.0; + score.speedPenalty = clamp01(result.stats.reconstructionMs / targetMs); + + score.totalScore = + weights.connectivityWeight * score.connectivityScore + + weights.coverageWeight * score.coverageScore + - weights.speedPenaltyWeight * score.speedPenalty; + return score; +} +} // namespace core diff --git a/src/core/hole_aware_score.h b/src/core/hole_aware_score.h new file mode 100644 index 0000000..70bcbca --- /dev/null +++ b/src/core/hole_aware_score.h @@ -0,0 +1,28 @@ +#ifndef HOLE_AWARE_SCORE_H +#define HOLE_AWARE_SCORE_H + +#include "point_cloud_types.h" + +namespace core +{ +struct HoleAwareScoreWeights +{ + double connectivityWeight = 0.45; + double coverageWeight = 0.45; + double speedPenaltyWeight = 0.10; +}; + +struct HoleAwareScoreBreakdown +{ + double connectivityScore = 0.0; + double coverageScore = 0.0; + double speedPenalty = 0.0; + double totalScore = 0.0; +}; + +HoleAwareScoreBreakdown computeHoleAwareScore( + const PipelineResult &result, + const HoleAwareScoreWeights &weights); +} // namespace core + +#endif // HOLE_AWARE_SCORE_H diff --git a/src/core/pipeline_config_validation.cpp b/src/core/pipeline_config_validation.cpp new file mode 100644 index 0000000..d8e1e86 --- /dev/null +++ b/src/core/pipeline_config_validation.cpp @@ -0,0 +1,79 @@ +#include "pipeline_config_validation.h" + +#include +#include + +namespace core +{ +bool normalizeAndValidatePipelineConfig(PipelineConfig &config, QString &errorText) +{ + QSet seen; + QStringList normalized; + const QStringList allowed = QStringList() + << "keep_largest_cluster" + << "pcl_remove_nan" + << "pcl_remove_nan_normals" + << "pcl_pass_through" + << "pcl_crop_box" + << "pcl_crop_hull" + << "pcl_frustum_culling" + << "pcl_plane_clipper_3d" + << "pcl_conditional_removal" + << "pcl_extract_indices" + << "pcl_functor_filter" + << "pcl_project_inliers" + << "pcl_normal_refinement" + << "pcl_bilateral_filter" + << "pcl_fast_bilateral_filter" + << "pcl_fast_bilateral_filter_omp" + << "pcl_convolution" + << "pcl_gaussian_kernel" + << "pcl_gaussian_kernel_rgb" + << "pcl_voxel_grid_occlusion" + << "downsample_dense" + << "pcl_voxel_grid" + << "pcl_statistical_outlier" + << "pcl_radius_outlier" + << "pcl_model_outlier" + << "pcl_shadow_points" + << "pcl_approximate_voxel_grid" + << "pcl_voxel_grid_label" + << "pcl_voxel_grid_covariance" + << "pcl_grid_minimum" + << "pcl_farthest_point_sampling" + << "pcl_normal_space_sampling" + << "pcl_sampling_surface_normal"; + for (const QString &id : config.preprocessPlugins) { + if (id.isEmpty() || seen.contains(id)) { + continue; + } + if (!allowed.contains(id)) { + errorText = QString("Unknown preprocess plugin: %1").arg(id); + return false; + } + seen.insert(id); + normalized.push_back(id); + } + if (normalized.isEmpty()) { + normalized = QStringList() << "keep_largest_cluster" << "downsample_dense"; + } + config.preprocessPlugins = normalized; + + const QStringList reconAllowed = QStringList() << "surface_fallback" << "pcl_greedy_triangulation" << "pcl_poisson_reconstruction"; + if (!reconAllowed.contains(config.reconstructionPlugin)) { + errorText = QString("Unknown reconstruction plugin: %1").arg(config.reconstructionPlugin); + return false; + } +#ifndef PCL_ENABLED + for (int i = 0; i < config.preprocessPlugins.size(); ++i) { + if (config.preprocessPlugins[i].startsWith("pcl_")) { + config.preprocessPlugins[i] = "downsample_dense"; + } + } + if (config.reconstructionPlugin.startsWith("pcl_")) { + config.reconstructionPlugin = "surface_fallback"; + } +#endif + return true; +} +} // namespace core diff --git a/src/core/pipeline_config_validation.h b/src/core/pipeline_config_validation.h new file mode 100644 index 0000000..f3ab124 --- /dev/null +++ b/src/core/pipeline_config_validation.h @@ -0,0 +1,13 @@ +#ifndef PIPELINE_CONFIG_VALIDATION_H +#define PIPELINE_CONFIG_VALIDATION_H + +#include + +#include "pipeline_config.h" + +namespace core +{ +bool normalizeAndValidatePipelineConfig(PipelineConfig &config, QString &errorText); +} // namespace core + +#endif // PIPELINE_CONFIG_VALIDATION_H diff --git a/src/core/pipeline_stage_defaults.cpp b/src/core/pipeline_stage_defaults.cpp new file mode 100644 index 0000000..4cf65ba --- /dev/null +++ b/src/core/pipeline_stage_defaults.cpp @@ -0,0 +1,341 @@ +#include "pipeline_stage_defaults.h" + +#include +#include + +namespace +{ +QMap parseDefaultsMap(const QString &defaultsText) +{ + QMap parsed; + const QStringList chunks = defaultsText.split(',', Qt::SkipEmptyParts); + for (const QString &chunkRaw : chunks) { + const QString chunk = chunkRaw.trimmed(); + if (chunk.isEmpty()) { + continue; + } + const int pos = chunk.indexOf('='); + if (pos <= 0) { + continue; + } + const QString key = chunk.left(pos).trimmed(); + const QString value = chunk.mid(pos + 1).trimmed(); + if (!key.isEmpty()) { + parsed.insert(key, value); + } + } + return parsed; +} + +bool parseIntParam(const QMap &parsed, const QString &key, int &target, QString &errorText) +{ + if (!parsed.contains(key)) { + return true; + } + bool ok = false; + const int value = parsed.value(key).toInt(&ok); + if (!ok) { + errorText = QString("Параметр '%1' должен быть целым числом.").arg(key); + return false; + } + target = value; + return true; +} + +bool parseFloatParam(const QMap &parsed, const QString &key, float &target, QString &errorText) +{ + if (!parsed.contains(key)) { + return true; + } + bool ok = false; + const float value = parsed.value(key).toFloat(&ok); + if (!ok) { + errorText = QString("Параметр '%1' должен быть числом.").arg(key); + return false; + } + target = value; + return true; +} + +bool parseDoubleParam(const QMap &parsed, const QString &key, double &target, QString &errorText) +{ + if (!parsed.contains(key)) { + return true; + } + bool ok = false; + const double value = parsed.value(key).toDouble(&ok); + if (!ok) { + errorText = QString("Параметр '%1' должен быть числом.").arg(key); + return false; + } + target = value; + return true; +} +} // namespace + +namespace core +{ +bool applyStageDefaultsToConfig( + const QString &stageId, + const QString &defaultsText, + PipelineConfig &cfg, + QString &errorText) +{ + const QMap parsed = parseDefaultsMap(defaultsText); + if (parsed.isEmpty()) { + return true; + } + + if (stageId == "keep_largest_cluster") { + return parseDoubleParam(parsed, "clusterJoinDistanceScale", cfg.preprocess.clusterJoinDistanceScale, errorText); + } + if (stageId == "downsample_dense") { + return parseDoubleParam(parsed, "downsampleCellScale", cfg.preprocess.downsampleCellScale, errorText); + } + if (stageId == "pcl_voxel_grid") { + float leaf = cfg.preprocess.pclVoxelLeafSize; + if (!parseFloatParam(parsed, "leaf", leaf, errorText)) { + return false; + } + if (!parseFloatParam(parsed, "pclVoxelLeafSize", leaf, errorText)) { + return false; + } + cfg.preprocess.pclVoxelLeafSize = leaf; + return true; + } + if (stageId == "pcl_statistical_outlier") { + if (!parseIntParam(parsed, "meanK", cfg.preprocess.pclSorMeanK, errorText)) { + return false; + } + if (!parseDoubleParam(parsed, "stddev", cfg.preprocess.pclSorStdDevMul, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_radius_outlier") { + if (!parseFloatParam(parsed, "radius", cfg.preprocess.pclRorRadius, errorText)) { + return false; + } + if (!parseIntParam(parsed, "minNeighbors", cfg.preprocess.pclRorMinNeighbors, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_model_outlier") { + if (!parseFloatParam(parsed, "threshold", cfg.preprocess.pclMorThreshold, errorText)) { + return false; + } + if (!parseFloatParam(parsed, "pclMorThreshold", cfg.preprocess.pclMorThreshold, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_shadow_points") { + if (!parseFloatParam(parsed, "shadowThreshold", cfg.preprocess.pclShadowThreshold, errorText)) { + return false; + } + if (!parseFloatParam(parsed, "pclShadowThreshold", cfg.preprocess.pclShadowThreshold, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_approximate_voxel_grid") { + if (!parseFloatParam(parsed, "leaf", cfg.preprocess.pclApproxLeafSize, errorText)) { + return false; + } + if (!parseFloatParam(parsed, "pclApproxLeafSize", cfg.preprocess.pclApproxLeafSize, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_voxel_grid_label") { + if (!parseFloatParam(parsed, "leaf", cfg.preprocess.pclLabelLeafSize, errorText)) { + return false; + } + if (!parseFloatParam(parsed, "pclLabelLeafSize", cfg.preprocess.pclLabelLeafSize, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_voxel_grid_covariance") { + if (!parseFloatParam(parsed, "leaf", cfg.preprocess.pclCovLeafSize, errorText)) { + return false; + } + if (!parseFloatParam(parsed, "pclCovLeafSize", cfg.preprocess.pclCovLeafSize, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_grid_minimum") { + if (!parseFloatParam(parsed, "resolution", cfg.preprocess.pclGridMinimumResolution, errorText)) { + return false; + } + if (!parseFloatParam(parsed, "pclGridMinimumResolution", cfg.preprocess.pclGridMinimumResolution, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_farthest_point_sampling") { + if (!parseIntParam(parsed, "sample", cfg.preprocess.pclFpsSampleCount, errorText)) { + return false; + } + if (!parseIntParam(parsed, "pclFpsSampleCount", cfg.preprocess.pclFpsSampleCount, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_normal_space_sampling") { + if (!parseIntParam(parsed, "sample", cfg.preprocess.pclNormalSpaceSampleCount, errorText)) { + return false; + } + if (!parseIntParam(parsed, "pclNormalSpaceSampleCount", cfg.preprocess.pclNormalSpaceSampleCount, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_sampling_surface_normal") { + if (!parseIntParam(parsed, "sample", cfg.preprocess.pclSurfaceNormalSampleCount, errorText)) { + return false; + } + if (!parseIntParam(parsed, "pclSurfaceNormalSampleCount", cfg.preprocess.pclSurfaceNormalSampleCount, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_pass_through") { + if (parsed.contains("axis")) { + const QString axis = parsed.value("axis").toLower(); + if (axis != "x" && axis != "y" && axis != "z") { + errorText = "Параметр 'axis' должен быть x, y или z."; + return false; + } + cfg.preprocess.pclPassAxis = axis; + } + if (!parseFloatParam(parsed, "min", cfg.preprocess.pclPassMin, errorText)) { + return false; + } + if (!parseFloatParam(parsed, "max", cfg.preprocess.pclPassMax, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_crop_box" || stageId == "pcl_crop_hull") { + if (!parseFloatParam(parsed, "minX", cfg.preprocess.pclCropBoxMinX, errorText) + || !parseFloatParam(parsed, "minY", cfg.preprocess.pclCropBoxMinY, errorText) + || !parseFloatParam(parsed, "minZ", cfg.preprocess.pclCropBoxMinZ, errorText) + || !parseFloatParam(parsed, "maxX", cfg.preprocess.pclCropBoxMaxX, errorText) + || !parseFloatParam(parsed, "maxY", cfg.preprocess.pclCropBoxMaxY, errorText) + || !parseFloatParam(parsed, "maxZ", cfg.preprocess.pclCropBoxMaxZ, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_frustum_culling") { + if (!parseFloatParam(parsed, "near", cfg.preprocess.pclFrustumNear, errorText) + || !parseFloatParam(parsed, "far", cfg.preprocess.pclFrustumFar, errorText) + || !parseFloatParam(parsed, "hfov", cfg.preprocess.pclFrustumHfovDeg, errorText) + || !parseFloatParam(parsed, "vfov", cfg.preprocess.pclFrustumVfovDeg, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_plane_clipper_3d") { + if (!parseFloatParam(parsed, "a", cfg.preprocess.pclClipPlaneA, errorText) + || !parseFloatParam(parsed, "b", cfg.preprocess.pclClipPlaneB, errorText) + || !parseFloatParam(parsed, "c", cfg.preprocess.pclClipPlaneC, errorText) + || !parseFloatParam(parsed, "d", cfg.preprocess.pclClipPlaneD, errorText)) { + return false; + } + if (parsed.contains("keepPositive")) { + const QString v = parsed.value("keepPositive").trimmed().toLower(); + if (v != "true" && v != "false") { + errorText = "Параметр 'keepPositive' должен быть true или false."; + return false; + } + cfg.preprocess.pclClipKeepPositive = (v == "true"); + } + return true; + } + if (stageId == "pcl_conditional_removal") { + if (!parseFloatParam(parsed, "zMin", cfg.preprocess.pclConditionalZMin, errorText) + || !parseFloatParam(parsed, "zMax", cfg.preprocess.pclConditionalZMax, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_extract_indices") { + if (!parseIntParam(parsed, "nth", cfg.preprocess.pclExtractNth, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_functor_filter") { + if (!parseFloatParam(parsed, "radiusMax", cfg.preprocess.pclFunctorRadiusMax, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_project_inliers") { + if (!parseFloatParam(parsed, "a", cfg.preprocess.pclProjectPlaneA, errorText) + || !parseFloatParam(parsed, "b", cfg.preprocess.pclProjectPlaneB, errorText) + || !parseFloatParam(parsed, "c", cfg.preprocess.pclProjectPlaneC, errorText) + || !parseFloatParam(parsed, "d", cfg.preprocess.pclProjectPlaneD, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_normal_refinement") { + if (!parseFloatParam(parsed, "radius", cfg.preprocess.pclNormalRefineRadius, errorText) + || !parseIntParam(parsed, "iterations", cfg.preprocess.pclNormalRefineIterations, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_bilateral_filter" || stageId == "pcl_fast_bilateral_filter" || stageId == "pcl_fast_bilateral_filter_omp") { + if (!parseFloatParam(parsed, "sigmaS", cfg.preprocess.pclBilateralSigmaS, errorText) + || !parseFloatParam(parsed, "sigmaR", cfg.preprocess.pclBilateralSigmaR, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_convolution" || stageId == "pcl_gaussian_kernel" || stageId == "pcl_gaussian_kernel_rgb") { + if (!parseFloatParam(parsed, "sigma", cfg.preprocess.pclConvolutionKernelSigma, errorText) + || !parseIntParam(parsed, "kernel", cfg.preprocess.pclConvolutionKernelSize, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_voxel_grid_occlusion") { + if (!parseFloatParam(parsed, "leaf", cfg.preprocess.pclVoxelOccLeaf, errorText) + || !parseIntParam(parsed, "minHits", cfg.preprocess.pclVoxelOccMinHits, errorText)) { + return false; + } + return true; + } + if (stageId == "surface_fallback") { + if (!parseFloatParam(parsed, "neighborRadiusScale", cfg.reconstruction.neighborRadiusScale, errorText) + || !parseIntParam(parsed, "runEveryNthFrame", cfg.reconstruction.runEveryNthFrame, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_greedy_triangulation") { + if (!parseFloatParam(parsed, "searchRadius", cfg.reconstruction.pclGreedySearchRadius, errorText) + || !parseFloatParam(parsed, "mu", cfg.reconstruction.pclGreedyMu, errorText) + || !parseIntParam(parsed, "maxNearest", cfg.reconstruction.pclGreedyMaxNearest, errorText) + || !parseFloatParam(parsed, "maxSurfaceAngle", cfg.reconstruction.pclGreedyMaxSurfaceAngle, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_poisson_reconstruction") { + if (!parseIntParam(parsed, "poissonDepth", cfg.reconstruction.pclPoissonDepth, errorText) + || !parseFloatParam(parsed, "samplesPerNode", cfg.reconstruction.pclPoissonSamplesPerNode, errorText)) { + return false; + } + return true; + } + + return true; +} +} // namespace core diff --git a/src/core/pipeline_stage_defaults.h b/src/core/pipeline_stage_defaults.h new file mode 100644 index 0000000..7a5962d --- /dev/null +++ b/src/core/pipeline_stage_defaults.h @@ -0,0 +1,17 @@ +#ifndef PIPELINE_STAGE_DEFAULTS_H +#define PIPELINE_STAGE_DEFAULTS_H + +#include + +#include "pipeline_config.h" + +namespace core +{ +bool applyStageDefaultsToConfig( + const QString &stageId, + const QString &defaultsText, + PipelineConfig &cfg, + QString &errorText); +} // namespace core + +#endif // PIPELINE_STAGE_DEFAULTS_H diff --git a/src/core/tuning_service.cpp b/src/core/tuning_service.cpp new file mode 100644 index 0000000..d46aec6 --- /dev/null +++ b/src/core/tuning_service.cpp @@ -0,0 +1,109 @@ +#include "tuning_service.h" + +#include +#include + +#include "pipeline_config_validation.h" +#include "../factories/pipeline/desktop_pipeline_factory.h" + +namespace +{ +QVector withFallback(const QVector &values, const float fallback) +{ + if (!values.isEmpty()) { + return values; + } + return QVector{fallback}; +} +} // namespace + +namespace core +{ +AutotuneResult runGridAutotune( + const PointCloudFrame &frame, + const PipelineConfig &baseConfig, + const AutotuneSearchSpace &searchSpace, + const HoleAwareScoreWeights &weights, + const AutotuneLimits &limits) +{ + AutotuneResult out; + if (frame.points.isEmpty()) { + out.errorText = "Autotune input frame is empty."; + return out; + } + + const QVector leafValues = withFallback(searchSpace.pclVoxelLeafSizes, baseConfig.preprocess.pclVoxelLeafSize); + const QVector greedyValues = withFallback(searchSpace.pclGreedySearchRadii, baseConfig.reconstruction.pclGreedySearchRadius); + const QVector neighborValues = withFallback(searchSpace.neighborRadiusScales, baseConfig.reconstruction.neighborRadiusScale); + + const int requestedTopK = qMax(1, limits.topK); + const int maxCandidates = limits.maxCandidates > 0 ? limits.maxCandidates : std::numeric_limits::max(); + + bool hasBest = false; + int evaluated = 0; + + for (const float leaf : leafValues) { + for (const float greedyRadius : greedyValues) { + for (const float neighborRadius : neighborValues) { + if (evaluated >= maxCandidates) { + break; + } + + PipelineConfig cfg = baseConfig; + cfg.preprocess.pclVoxelLeafSize = leaf; + cfg.reconstruction.pclGreedySearchRadius = greedyRadius; + cfg.reconstruction.neighborRadiusScale = neighborRadius; + + QString errorText; + if (!normalizeAndValidatePipelineConfig(cfg, errorText)) { + out.errorText = QString("Autotune candidate rejected: %1").arg(errorText); + return out; + } + + const PipelineExecutor executor = factories::pipeline::createDesktopPipelineExecutor(cfg); + const PipelineResult result = executor.run(frame); + const HoleAwareScoreBreakdown breakdown = computeHoleAwareScore(result, weights); + + AutotuneCandidateResult candidate; + candidate.config = cfg; + candidate.score = breakdown.totalScore; + candidate.breakdown = breakdown; + candidate.inputPoints = result.stats.inputPoints; + candidate.afterPreprocessPoints = result.stats.afterPreprocessingPoints; + candidate.outputTriangles = result.stats.outputTriangles; + candidate.reconstructionMs = result.stats.reconstructionMs; + + out.topCandidates.push_back(candidate); + if (!hasBest || candidate.score > out.best.score) { + out.best = candidate; + hasBest = true; + } + ++evaluated; + } + if (evaluated >= maxCandidates) { + break; + } + } + if (evaluated >= maxCandidates) { + break; + } + } + + std::sort( + out.topCandidates.begin(), + out.topCandidates.end(), + [](const AutotuneCandidateResult &a, const AutotuneCandidateResult &b) { + return a.score > b.score; + }); + if (out.topCandidates.size() > requestedTopK) { + out.topCandidates.resize(requestedTopK); + } + + out.evaluatedCandidates = evaluated; + out.ok = hasBest; + if (!out.ok) { + out.errorText = "No candidates evaluated during autotune."; + } + return out; +} +} // namespace core diff --git a/src/core/tuning_service.h b/src/core/tuning_service.h new file mode 100644 index 0000000..e7d4ab9 --- /dev/null +++ b/src/core/tuning_service.h @@ -0,0 +1,53 @@ +#ifndef TUNING_SERVICE_H +#define TUNING_SERVICE_H + +#include + +#include "hole_aware_score.h" +#include "pipeline_config.h" +#include "point_cloud_types.h" + +namespace core +{ +struct AutotuneSearchSpace +{ + QVector pclVoxelLeafSizes; + QVector pclGreedySearchRadii; + QVector neighborRadiusScales; +}; + +struct AutotuneLimits +{ + int maxCandidates = 0; + int topK = 5; +}; + +struct AutotuneCandidateResult +{ + PipelineConfig config; + double score = 0.0; + HoleAwareScoreBreakdown breakdown; + int inputPoints = 0; + int afterPreprocessPoints = 0; + int outputTriangles = 0; + qint64 reconstructionMs = 0; +}; + +struct AutotuneResult +{ + bool ok = false; + QString errorText; + int evaluatedCandidates = 0; + AutotuneCandidateResult best; + QVector topCandidates; +}; + +AutotuneResult runGridAutotune( + const PointCloudFrame &frame, + const PipelineConfig &baseConfig, + const AutotuneSearchSpace &searchSpace, + const HoleAwareScoreWeights &weights, + const AutotuneLimits &limits); +} // namespace core + +#endif // TUNING_SERVICE_H diff --git a/src/main.cpp b/src/main.cpp index 25298b5..3c18bd5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,9 +1,18 @@ #include +#include +#include +#include "cli/pipeline_cli_runner.h" #include "ui/mainwindow.h" int main(int argc, char *argv[]) { + for (int i = 1; i < argc; ++i) { + if (QString::fromLocal8Bit(argv[i]) == "--cli") { + return runCliPipeline(argc, argv); + } + } + QCoreApplication::setAttribute(Qt::AA_UseSoftwareOpenGL); QApplication app(argc, argv); MainWindow window; diff --git a/src/tests/pipeline_smoke_tests.cpp b/src/tests/pipeline_smoke_tests.cpp index 3c72ad5..a88741d 100644 --- a/src/tests/pipeline_smoke_tests.cpp +++ b/src/tests/pipeline_smoke_tests.cpp @@ -1,9 +1,15 @@ #include "pipeline_smoke_tests.h" +#include + #include "../algorithms/reconstruction/surface_reconstruction.h" #include "../adapters/sources/ros2_point_cloud_source.h" #include "../core/pipeline_config.h" +#include "../core/pipeline_config_validation.h" #include "../core/pipeline_executor.h" +#include "../core/hole_aware_score.h" +#include "../core/pipeline_stage_defaults.h" +#include "../core/tuning_service.h" #include "../factories/pipeline/desktop_pipeline_factory.h" namespace @@ -168,6 +174,152 @@ bool runReconstructionPluginSwitchCase(QString &failureReason) } return true; } + +bool runStageDefaultsApplyCase(QString &failureReason) +{ + core::PipelineConfig config = core::makeDesktopDebugConfig(); + QString errorText; + const bool ok = core::applyStageDefaultsToConfig( + "pcl_voxel_grid", + "leaf=0.06", + config, + errorText); + if (!ok) { + failureReason = QString("Stage defaults apply failed unexpectedly: %1").arg(errorText); + return false; + } + if (qAbs(config.preprocess.pclVoxelLeafSize - 0.06f) > 0.0001f) { + failureReason = "pclVoxelLeafSize was not updated by backend defaults parser."; + return false; + } + return true; +} + +bool runStageDefaultsValidationCase(QString &failureReason) +{ + core::PipelineConfig config = core::makeDesktopDebugConfig(); + QString errorText; + const bool ok = core::applyStageDefaultsToConfig( + "pcl_radius_outlier", + "radius=bad,minNeighbors=8", + config, + errorText); + if (ok) { + failureReason = "Invalid defaults were accepted unexpectedly."; + return false; + } + if (!errorText.contains("Параметр 'radius'")) { + failureReason = QString("Unexpected validation message: %1").arg(errorText); + return false; + } + return true; +} + +bool runPipelineConfigValidationCase(QString &failureReason) +{ + core::PipelineConfig config = core::makeDesktopDebugConfig(); + config.preprocessPlugins = QStringList() << "pcl_voxel_grid" << "pcl_voxel_grid" << "pcl_statistical_outlier"; + config.reconstructionPlugin = "pcl_greedy_triangulation"; + QString errorText; + if (!core::normalizeAndValidatePipelineConfig(config, errorText)) { + failureReason = QString("Config validation failed unexpectedly: %1").arg(errorText); + return false; + } + if (config.preprocessPlugins.size() != 2) { + failureReason = "Config validation did not de-duplicate preprocess plugins."; + return false; + } + return true; +} + +bool runHoleAwareScoreCase(QString &failureReason) +{ + core::PipelineResult result; + result.stats.inputPoints = 100; + result.stats.afterPreprocessingPoints = 90; + result.stats.outputTriangles = 120; + result.stats.reconstructionMs = 20; + const core::HoleAwareScoreBreakdown score = core::computeHoleAwareScore( + result, + core::HoleAwareScoreWeights()); + if (score.totalScore <= 0.0) { + failureReason = "Hole-aware score should be positive for a dense reconstructed surface."; + return false; + } + if (score.connectivityScore <= 0.0 || score.coverageScore <= 0.0) { + failureReason = "Hole-aware score breakdown has invalid non-positive components."; + return false; + } + return true; +} + +bool runGridAutotuneCase(QString &failureReason) +{ + core::PointCloudFrame frame; + frame.points = buildDemoCloud(); + frame.sourceLabel = "smoke-autotune"; + + core::PipelineConfig config = core::makeDesktopDebugConfig(); + config.preprocessPlugins = QStringList() << "pcl_voxel_grid" << "pcl_statistical_outlier"; + config.reconstructionPlugin = "pcl_greedy_triangulation"; + + core::AutotuneSearchSpace searchSpace; + searchSpace.pclVoxelLeafSizes = QVector{0.02f, 0.05f}; + searchSpace.pclGreedySearchRadii = QVector{0.06f, 0.10f}; + searchSpace.neighborRadiusScales = QVector{2.5f}; + + core::AutotuneLimits limits; + limits.maxCandidates = 10; + limits.topK = 3; + + const core::AutotuneResult result = core::runGridAutotune( + frame, + config, + searchSpace, + core::HoleAwareScoreWeights(), + limits); + if (!result.ok) { + failureReason = QString("Autotune failed unexpectedly: %1").arg(result.errorText); + return false; + } + if (result.evaluatedCandidates != 4) { + failureReason = QString("Autotune evaluated %1 candidates instead of 4.").arg(result.evaluatedCandidates); + return false; + } + if (result.topCandidates.isEmpty()) { + failureReason = "Autotune topCandidates is empty."; + return false; + } + if (result.best.score < result.topCandidates.first().score) { + failureReason = "Autotune best candidate is not the top-scoring candidate."; + return false; + } + return true; +} + +bool runGridAutotuneValidationCase(QString &failureReason) +{ + core::PointCloudFrame emptyFrame; + core::PipelineConfig config = core::makeDesktopDebugConfig(); + core::AutotuneSearchSpace searchSpace; + searchSpace.pclVoxelLeafSizes = QVector{0.02f}; + searchSpace.pclGreedySearchRadii = QVector{0.08f}; + const core::AutotuneResult result = core::runGridAutotune( + emptyFrame, + config, + searchSpace, + core::HoleAwareScoreWeights(), + core::AutotuneLimits()); + if (result.ok) { + failureReason = "Autotune accepted empty input frame unexpectedly."; + return false; + } + if (!result.errorText.contains("empty")) { + failureReason = QString("Unexpected autotune validation message: %1").arg(result.errorText); + return false; + } + return true; +} } // namespace namespace tests @@ -195,6 +347,24 @@ bool runPipelineSmokeTests(QString &failureReason) if (!runReconstructionPluginSwitchCase(failureReason)) { return false; } + if (!runStageDefaultsApplyCase(failureReason)) { + return false; + } + if (!runStageDefaultsValidationCase(failureReason)) { + return false; + } + if (!runPipelineConfigValidationCase(failureReason)) { + return false; + } + if (!runHoleAwareScoreCase(failureReason)) { + return false; + } + if (!runGridAutotuneCase(failureReason)) { + return false; + } + if (!runGridAutotuneValidationCase(failureReason)) { + return false; + } return true; } } // namespace tests diff --git a/src/ui/mainwindow.cpp b/src/ui/mainwindow.cpp index b72805b..427cacc 100644 --- a/src/ui/mainwindow.cpp +++ b/src/ui/mainwindow.cpp @@ -29,6 +29,8 @@ #include #include "../adapters/sources/file_point_cloud_source.h" +#include "../core/pipeline_config_validation.h" +#include "../core/pipeline_stage_defaults.h" #include "../factories/pipeline/desktop_pipeline_factory.h" #include "glview.h" @@ -159,336 +161,6 @@ const StageMeta *findMeta(const QString &id) return nullptr; } -QMap parseDefaultsMap(const QString &defaultsText) -{ - QMap parsed; - const QStringList chunks = defaultsText.split(',', Qt::SkipEmptyParts); - for (const QString &chunkRaw : chunks) { - const QString chunk = chunkRaw.trimmed(); - if (chunk.isEmpty()) { - continue; - } - const int pos = chunk.indexOf('='); - if (pos <= 0) { - continue; - } - const QString key = chunk.left(pos).trimmed(); - const QString value = chunk.mid(pos + 1).trimmed(); - if (!key.isEmpty()) { - parsed.insert(key, value); - } - } - return parsed; -} - -bool parseIntParam(const QMap &parsed, const QString &key, int &target, QString &errorText) -{ - if (!parsed.contains(key)) { - return true; - } - bool ok = false; - const int value = parsed.value(key).toInt(&ok); - if (!ok) { - errorText = QString("Параметр '%1' должен быть целым числом.").arg(key); - return false; - } - target = value; - return true; -} - -bool parseFloatParam(const QMap &parsed, const QString &key, float &target, QString &errorText) -{ - if (!parsed.contains(key)) { - return true; - } - bool ok = false; - const float value = parsed.value(key).toFloat(&ok); - if (!ok) { - errorText = QString("Параметр '%1' должен быть числом.").arg(key); - return false; - } - target = value; - return true; -} - -bool parseDoubleParam(const QMap &parsed, const QString &key, double &target, QString &errorText) -{ - if (!parsed.contains(key)) { - return true; - } - bool ok = false; - const double value = parsed.value(key).toDouble(&ok); - if (!ok) { - errorText = QString("Параметр '%1' должен быть числом.").arg(key); - return false; - } - target = value; - return true; -} - -bool applyStageDefaultsToConfig( - const QString &stageId, - const QString &defaultsText, - core::PipelineConfig &cfg, - QString &errorText) -{ - const QMap parsed = parseDefaultsMap(defaultsText); - if (parsed.isEmpty()) { - return true; - } - - if (stageId == "keep_largest_cluster") { - return parseDoubleParam(parsed, "clusterJoinDistanceScale", cfg.preprocess.clusterJoinDistanceScale, errorText); - } - if (stageId == "downsample_dense") { - return parseDoubleParam(parsed, "downsampleCellScale", cfg.preprocess.downsampleCellScale, errorText); - } - if (stageId == "pcl_voxel_grid") { - float leaf = cfg.preprocess.pclVoxelLeafSize; - if (!parseFloatParam(parsed, "leaf", leaf, errorText)) { - return false; - } - if (!parseFloatParam(parsed, "pclVoxelLeafSize", leaf, errorText)) { - return false; - } - cfg.preprocess.pclVoxelLeafSize = leaf; - return true; - } - if (stageId == "pcl_statistical_outlier") { - if (!parseIntParam(parsed, "meanK", cfg.preprocess.pclSorMeanK, errorText)) { - return false; - } - if (!parseDoubleParam(parsed, "stddev", cfg.preprocess.pclSorStdDevMul, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_radius_outlier") { - if (!parseFloatParam(parsed, "radius", cfg.preprocess.pclRorRadius, errorText)) { - return false; - } - if (!parseIntParam(parsed, "minNeighbors", cfg.preprocess.pclRorMinNeighbors, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_model_outlier") { - if (!parseFloatParam(parsed, "threshold", cfg.preprocess.pclMorThreshold, errorText)) { - return false; - } - if (!parseFloatParam(parsed, "pclMorThreshold", cfg.preprocess.pclMorThreshold, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_shadow_points") { - if (!parseFloatParam(parsed, "shadowThreshold", cfg.preprocess.pclShadowThreshold, errorText)) { - return false; - } - if (!parseFloatParam(parsed, "pclShadowThreshold", cfg.preprocess.pclShadowThreshold, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_approximate_voxel_grid") { - if (!parseFloatParam(parsed, "leaf", cfg.preprocess.pclApproxLeafSize, errorText)) { - return false; - } - if (!parseFloatParam(parsed, "pclApproxLeafSize", cfg.preprocess.pclApproxLeafSize, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_voxel_grid_label") { - if (!parseFloatParam(parsed, "leaf", cfg.preprocess.pclLabelLeafSize, errorText)) { - return false; - } - if (!parseFloatParam(parsed, "pclLabelLeafSize", cfg.preprocess.pclLabelLeafSize, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_voxel_grid_covariance") { - if (!parseFloatParam(parsed, "leaf", cfg.preprocess.pclCovLeafSize, errorText)) { - return false; - } - if (!parseFloatParam(parsed, "pclCovLeafSize", cfg.preprocess.pclCovLeafSize, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_grid_minimum") { - if (!parseFloatParam(parsed, "resolution", cfg.preprocess.pclGridMinimumResolution, errorText)) { - return false; - } - if (!parseFloatParam(parsed, "pclGridMinimumResolution", cfg.preprocess.pclGridMinimumResolution, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_farthest_point_sampling") { - if (!parseIntParam(parsed, "sample", cfg.preprocess.pclFpsSampleCount, errorText)) { - return false; - } - if (!parseIntParam(parsed, "pclFpsSampleCount", cfg.preprocess.pclFpsSampleCount, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_normal_space_sampling") { - if (!parseIntParam(parsed, "sample", cfg.preprocess.pclNormalSpaceSampleCount, errorText)) { - return false; - } - if (!parseIntParam(parsed, "pclNormalSpaceSampleCount", cfg.preprocess.pclNormalSpaceSampleCount, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_sampling_surface_normal") { - if (!parseIntParam(parsed, "sample", cfg.preprocess.pclSurfaceNormalSampleCount, errorText)) { - return false; - } - if (!parseIntParam(parsed, "pclSurfaceNormalSampleCount", cfg.preprocess.pclSurfaceNormalSampleCount, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_pass_through") { - if (parsed.contains("axis")) { - const QString axis = parsed.value("axis").toLower(); - if (axis != "x" && axis != "y" && axis != "z") { - errorText = "Параметр 'axis' должен быть x, y или z."; - return false; - } - cfg.preprocess.pclPassAxis = axis; - } - if (!parseFloatParam(parsed, "min", cfg.preprocess.pclPassMin, errorText)) { - return false; - } - if (!parseFloatParam(parsed, "max", cfg.preprocess.pclPassMax, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_crop_box" || stageId == "pcl_crop_hull") { - if (!parseFloatParam(parsed, "minX", cfg.preprocess.pclCropBoxMinX, errorText) - || !parseFloatParam(parsed, "minY", cfg.preprocess.pclCropBoxMinY, errorText) - || !parseFloatParam(parsed, "minZ", cfg.preprocess.pclCropBoxMinZ, errorText) - || !parseFloatParam(parsed, "maxX", cfg.preprocess.pclCropBoxMaxX, errorText) - || !parseFloatParam(parsed, "maxY", cfg.preprocess.pclCropBoxMaxY, errorText) - || !parseFloatParam(parsed, "maxZ", cfg.preprocess.pclCropBoxMaxZ, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_frustum_culling") { - if (!parseFloatParam(parsed, "near", cfg.preprocess.pclFrustumNear, errorText) - || !parseFloatParam(parsed, "far", cfg.preprocess.pclFrustumFar, errorText) - || !parseFloatParam(parsed, "hfov", cfg.preprocess.pclFrustumHfovDeg, errorText) - || !parseFloatParam(parsed, "vfov", cfg.preprocess.pclFrustumVfovDeg, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_plane_clipper_3d") { - if (!parseFloatParam(parsed, "a", cfg.preprocess.pclClipPlaneA, errorText) - || !parseFloatParam(parsed, "b", cfg.preprocess.pclClipPlaneB, errorText) - || !parseFloatParam(parsed, "c", cfg.preprocess.pclClipPlaneC, errorText) - || !parseFloatParam(parsed, "d", cfg.preprocess.pclClipPlaneD, errorText)) { - return false; - } - if (parsed.contains("keepPositive")) { - const QString v = parsed.value("keepPositive").trimmed().toLower(); - if (v != "true" && v != "false") { - errorText = "Параметр 'keepPositive' должен быть true или false."; - return false; - } - cfg.preprocess.pclClipKeepPositive = (v == "true"); - } - return true; - } - if (stageId == "pcl_conditional_removal") { - if (!parseFloatParam(parsed, "zMin", cfg.preprocess.pclConditionalZMin, errorText) - || !parseFloatParam(parsed, "zMax", cfg.preprocess.pclConditionalZMax, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_extract_indices") { - if (!parseIntParam(parsed, "nth", cfg.preprocess.pclExtractNth, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_functor_filter") { - if (!parseFloatParam(parsed, "radiusMax", cfg.preprocess.pclFunctorRadiusMax, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_project_inliers") { - if (!parseFloatParam(parsed, "a", cfg.preprocess.pclProjectPlaneA, errorText) - || !parseFloatParam(parsed, "b", cfg.preprocess.pclProjectPlaneB, errorText) - || !parseFloatParam(parsed, "c", cfg.preprocess.pclProjectPlaneC, errorText) - || !parseFloatParam(parsed, "d", cfg.preprocess.pclProjectPlaneD, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_normal_refinement") { - if (!parseFloatParam(parsed, "radius", cfg.preprocess.pclNormalRefineRadius, errorText) - || !parseIntParam(parsed, "iterations", cfg.preprocess.pclNormalRefineIterations, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_bilateral_filter" || stageId == "pcl_fast_bilateral_filter" || stageId == "pcl_fast_bilateral_filter_omp") { - if (!parseFloatParam(parsed, "sigmaS", cfg.preprocess.pclBilateralSigmaS, errorText) - || !parseFloatParam(parsed, "sigmaR", cfg.preprocess.pclBilateralSigmaR, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_convolution" || stageId == "pcl_gaussian_kernel" || stageId == "pcl_gaussian_kernel_rgb") { - if (!parseFloatParam(parsed, "sigma", cfg.preprocess.pclConvolutionKernelSigma, errorText) - || !parseIntParam(parsed, "kernel", cfg.preprocess.pclConvolutionKernelSize, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_voxel_grid_occlusion") { - if (!parseFloatParam(parsed, "leaf", cfg.preprocess.pclVoxelOccLeaf, errorText) - || !parseIntParam(parsed, "minHits", cfg.preprocess.pclVoxelOccMinHits, errorText)) { - return false; - } - return true; - } - if (stageId == "surface_fallback") { - if (!parseFloatParam(parsed, "neighborRadiusScale", cfg.reconstruction.neighborRadiusScale, errorText) - || !parseIntParam(parsed, "runEveryNthFrame", cfg.reconstruction.runEveryNthFrame, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_greedy_triangulation") { - if (!parseFloatParam(parsed, "searchRadius", cfg.reconstruction.pclGreedySearchRadius, errorText) - || !parseFloatParam(parsed, "mu", cfg.reconstruction.pclGreedyMu, errorText) - || !parseIntParam(parsed, "maxNearest", cfg.reconstruction.pclGreedyMaxNearest, errorText) - || !parseFloatParam(parsed, "maxSurfaceAngle", cfg.reconstruction.pclGreedyMaxSurfaceAngle, errorText)) { - return false; - } - return true; - } - if (stageId == "pcl_poisson_reconstruction") { - if (!parseIntParam(parsed, "poissonDepth", cfg.reconstruction.pclPoissonDepth, errorText) - || !parseFloatParam(parsed, "samplesPerNode", cfg.reconstruction.pclPoissonSamplesPerNode, errorText)) { - return false; - } - return true; - } - - return true; -} } // namespace MainWindow::MainWindow(QWidget *parent) @@ -629,7 +301,7 @@ void MainWindow::rebuildExecutorFromConfig() if (card.family == "reconstruction") { cfg.reconstructionPlugin = card.id; } - if (!applyStageDefaultsToConfig(card.id, card.defaults, cfg, errorText)) { + if (!core::applyStageDefaultsToConfig(card.id, card.defaults, cfg, errorText)) { QMessageBox::warning(this, "Параметры фильтра", errorText); return; } @@ -1468,74 +1140,7 @@ void MainWindow::loadSnapshot(const QString &slotName) bool MainWindow::normalizeAndValidateConfig(core::PipelineConfig &config, QString &errorText) const { - QSet seen; - QStringList normalized; - const QStringList allowed = QStringList() - << "keep_largest_cluster" - << "pcl_remove_nan" - << "pcl_remove_nan_normals" - << "pcl_pass_through" - << "pcl_crop_box" - << "pcl_crop_hull" - << "pcl_frustum_culling" - << "pcl_plane_clipper_3d" - << "pcl_conditional_removal" - << "pcl_extract_indices" - << "pcl_functor_filter" - << "pcl_project_inliers" - << "pcl_normal_refinement" - << "pcl_bilateral_filter" - << "pcl_fast_bilateral_filter" - << "pcl_fast_bilateral_filter_omp" - << "pcl_convolution" - << "pcl_gaussian_kernel" - << "pcl_gaussian_kernel_rgb" - << "pcl_voxel_grid_occlusion" - << "downsample_dense" - << "pcl_voxel_grid" - << "pcl_statistical_outlier" - << "pcl_radius_outlier" - << "pcl_model_outlier" - << "pcl_shadow_points" - << "pcl_approximate_voxel_grid" - << "pcl_voxel_grid_label" - << "pcl_voxel_grid_covariance" - << "pcl_grid_minimum" - << "pcl_farthest_point_sampling" - << "pcl_normal_space_sampling" - << "pcl_sampling_surface_normal"; - for (const QString &id : config.preprocessPlugins) { - if (id.isEmpty() || seen.contains(id)) { - continue; - } - if (!allowed.contains(id)) { - errorText = QString("Unknown preprocess plugin: %1").arg(id); - return false; - } - seen.insert(id); - normalized.push_back(id); - } - if (normalized.isEmpty()) { - normalized = QStringList() << "keep_largest_cluster" << "downsample_dense"; - } - config.preprocessPlugins = normalized; - - const QStringList reconAllowed = QStringList() << "surface_fallback" << "pcl_greedy_triangulation" << "pcl_poisson_reconstruction"; - if (!reconAllowed.contains(config.reconstructionPlugin)) { - errorText = QString("Unknown reconstruction plugin: %1").arg(config.reconstructionPlugin); - return false; - } -#ifndef PCL_ENABLED - for (int i = 0; i < config.preprocessPlugins.size(); ++i) { - if (config.preprocessPlugins[i].startsWith("pcl_")) { - config.preprocessPlugins[i] = "downsample_dense"; - } - } - if (config.reconstructionPlugin.startsWith("pcl_")) { - config.reconstructionPlugin = "surface_fallback"; - } -#endif - return true; + return core::normalizeAndValidatePipelineConfig(config, errorText); } QString MainWindow::pipelineSummary() const