Перенесены backend/frontend/desktop/engine, добавлены вкладки конструктора сцен и генератора датасета с параметрами лучей и длины сетки рельефа, обновлены API и Docker-сборка. Co-authored-by: Cursor <cursoragent@cursor.com>
552 lines
21 KiB
C++
552 lines
21 KiB
C++
#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;
|
|
}
|