Реструктуризация проекта и генератор синтетических датасетов эхолота.
Перенесены backend/frontend/desktop/engine, добавлены вкладки конструктора сцен и генератора датасета с параметрами лучей и длины сетки рельефа, обновлены API и Docker-сборка. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
#include "pipeline_cli_runner.h"
|
||||
|
||||
#include <QCommandLineOption>
|
||||
#include <QCommandLineParser>
|
||||
#include <QCoreApplication>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QTextStream>
|
||||
|
||||
#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<double>(point.x));
|
||||
xyz.push_back(static_cast<double>(point.y));
|
||||
xyz.push_back(static_cast<double>(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<qint64>(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<float> &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<float>(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<qint64>(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<qint64>(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<qint64>(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;
|
||||
}
|
||||
Reference in New Issue
Block a user