Сформирован пресет для отрисовки

This commit is contained in:
2026-04-24 15:56:43 +03:00
parent 64b54bbc56
commit 98afe5af5a
16 changed files with 1505 additions and 399 deletions
+10
View File
@@ -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 \
+8
View File
@@ -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 \
+101
View File
@@ -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).
## Демо
При запуске приложение:
+509
View File
@@ -0,0 +1,509 @@
#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;
}
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("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<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(",");
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;
}
+6
View File
@@ -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
+48
View File
@@ -0,0 +1,48 @@
#include "hole_aware_score.h"
#include <QtMath>
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
+28
View File
@@ -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
+79
View File
@@ -0,0 +1,79 @@
#include "pipeline_config_validation.h"
#include <QSet>
#include <QStringList>
namespace core
{
bool normalizeAndValidatePipelineConfig(PipelineConfig &config, QString &errorText)
{
QSet<QString> 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
+13
View File
@@ -0,0 +1,13 @@
#ifndef PIPELINE_CONFIG_VALIDATION_H
#define PIPELINE_CONFIG_VALIDATION_H
#include <QString>
#include "pipeline_config.h"
namespace core
{
bool normalizeAndValidatePipelineConfig(PipelineConfig &config, QString &errorText);
} // namespace core
#endif // PIPELINE_CONFIG_VALIDATION_H
+341
View File
@@ -0,0 +1,341 @@
#include "pipeline_stage_defaults.h"
#include <QMap>
#include <QStringList>
namespace
{
QMap<QString, QString> parseDefaultsMap(const QString &defaultsText)
{
QMap<QString, QString> 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<QString, QString> &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<QString, QString> &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<QString, QString> &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<QString, QString> 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
+17
View File
@@ -0,0 +1,17 @@
#ifndef PIPELINE_STAGE_DEFAULTS_H
#define PIPELINE_STAGE_DEFAULTS_H
#include <QString>
#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
+109
View File
@@ -0,0 +1,109 @@
#include "tuning_service.h"
#include <algorithm>
#include <limits>
#include "pipeline_config_validation.h"
#include "../factories/pipeline/desktop_pipeline_factory.h"
namespace
{
QVector<float> withFallback(const QVector<float> &values, const float fallback)
{
if (!values.isEmpty()) {
return values;
}
return QVector<float>{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<float> leafValues = withFallback(searchSpace.pclVoxelLeafSizes, baseConfig.preprocess.pclVoxelLeafSize);
const QVector<float> greedyValues = withFallback(searchSpace.pclGreedySearchRadii, baseConfig.reconstruction.pclGreedySearchRadius);
const QVector<float> 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<int>::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
+53
View File
@@ -0,0 +1,53 @@
#ifndef TUNING_SERVICE_H
#define TUNING_SERVICE_H
#include <QVector>
#include "hole_aware_score.h"
#include "pipeline_config.h"
#include "point_cloud_types.h"
namespace core
{
struct AutotuneSearchSpace
{
QVector<float> pclVoxelLeafSizes;
QVector<float> pclGreedySearchRadii;
QVector<float> 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<AutotuneCandidateResult> topCandidates;
};
AutotuneResult runGridAutotune(
const PointCloudFrame &frame,
const PipelineConfig &baseConfig,
const AutotuneSearchSpace &searchSpace,
const HoleAwareScoreWeights &weights,
const AutotuneLimits &limits);
} // namespace core
#endif // TUNING_SERVICE_H
+9
View File
@@ -1,9 +1,18 @@
#include <QApplication>
#include <QCoreApplication>
#include <QString>
#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;
+170
View File
@@ -1,9 +1,15 @@
#include "pipeline_smoke_tests.h"
#include <QtMath>
#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<float>{0.02f, 0.05f};
searchSpace.pclGreedySearchRadii = QVector<float>{0.06f, 0.10f};
searchSpace.neighborRadiusScales = QVector<float>{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<float>{0.02f};
searchSpace.pclGreedySearchRadii = QVector<float>{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
+4 -399
View File
@@ -29,6 +29,8 @@
#include <QtMath>
#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<QString, QString> parseDefaultsMap(const QString &defaultsText)
{
QMap<QString, QString> 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<QString, QString> &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<QString, QString> &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<QString, QString> &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<QString, QString> 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<QString> 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