Реструктуризация проекта и генератор синтетических датасетов эхолота.

Перенесены backend/frontend/desktop/engine, добавлены вкладки конструктора сцен и генератора датасета с параметрами лучей и длины сетки рельефа, обновлены API и Docker-сборка.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 12:25:00 +03:00
co-authored by Cursor
parent 18a58f2e85
commit 4f253b860f
134 changed files with 5263 additions and 863 deletions
+551
View File
@@ -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;
}