#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; } void appendGeometryToJson(const core::PipelineResult &result, QJsonObject &root) { QJsonArray pointsArray; for (const core::Point3f &point : result.frame.points) { QJsonArray xyz; xyz.push_back(static_cast(point.x)); xyz.push_back(static_cast(point.y)); xyz.push_back(static_cast(point.z)); pointsArray.push_back(xyz); } root["points"] = pointsArray; QJsonArray trianglesArray; for (const core::Triangle &triangle : result.triangles) { QJsonArray indices; indices.push_back(triangle.i0); indices.push_back(triangle.i1); indices.push_back(triangle.i2); trianglesArray.push_back(indices); } root["triangleIndices"] = trianglesArray; } void appendRunStatsToJson(const core::PipelineResult &result, QJsonObject &root) { QJsonArray stepMetrics; for (const core::PipelineStats::PreprocessStepMetric &metric : result.stats.preprocessStepMetrics) { QJsonObject row; row["stageId"] = metric.stageId; row["inputPoints"] = metric.inputPoints; row["outputPoints"] = metric.outputPoints; row["removedPoints"] = metric.removedPoints; row["elapsedMs"] = static_cast(metric.elapsedMs); stepMetrics.push_back(row); } root["preprocessStepMetrics"] = stepMetrics; root["clusters"] = result.stats.detectedClusters; root["removedPoints"] = result.stats.removedClusterPoints + result.stats.removedDownsamplePoints; } 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("DotsToSurface"); QCommandLineParser parser; parser.setApplicationDescription("DotsToSurface 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(","); appendGeometryToJson(result, root); appendRunStatsToJson(result, root); 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; }