diff --git a/DotsToSirface.pro b/DotsToSirface.pro index 029a614..085d434 100644 --- a/DotsToSirface.pro +++ b/DotsToSirface.pro @@ -81,6 +81,7 @@ HEADERS += \ src/strategies/preprocess_pcl_stages.h \ src/strategies/registration_icp_stage.h \ src/strategies/reconstruction_pcl_greedy_stage.h \ + src/strategies/reconstruction_pcl_poisson_stage.h \ src/strategies/reconstruction_surface_stage.h \ src/strategies/transform_tf_stage.h \ src/tests/pipeline_smoke_tests.h \ diff --git a/README.md b/README.md index 97e176a..96576f6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,26 @@ - `strategies`: конкретные стадии пайплайна (preprocess, transform, registration, reconstruction). - `factories`: сборка `PipelineExecutor` из plugin-id и профиля. +## Pipeline Dashboard: UX и анализ цепочки + +В dashboard реализованы инструменты для пошаговой настройки всей pipeline-цепочки (preprocess + reconstruction): + +- **Категориальная палитра этапов**: фильтры сгруппированы по логике обработки + (`Обрезка -> NaN (предочистка) -> Условия/Индексы -> Шум -> Морфология -> Прореживание -> Сглаживание -> Нормали -> Реконструкция`), для каждого фильтра + доступны краткие подсказки. +- **Проверка порядка этапов**: при рискованной последовательности (например, когда + `OutlierRemoval` идет после `VoxelGrid`) показываются предупреждения и рекомендация. +- **Стартовые пресеты**: доступны шаблоны для типичных источников + (`LiDAR_scan`, `RGBD_camera`, `Synthetic_clean`) как отправная точка с возможностью + ручной донастройки. +- **Метрики между шагами**: показывается вклад каждого preprocess-этапа: + входные точки, выходные точки, сколько удалено и время шага в миллисекундах. +- **Единый редактор параметров**: и preprocess-стадии, и реконструкторы поверхности + настраиваются через одинаковые карточки и один диалог параметров. + +Пер-шаговые метрики собираются на уровне `core::PipelineExecutor` и возвращаются через +`PipelineStats::preprocessStepMetrics`, чтобы данные были едиными для GUI и тестов. + ### Профили выполнения - `desktop_debug`: дефолтный профиль для GUI/отладки. @@ -44,16 +64,31 @@ - `factories::pipeline::createPipelineExecutorForProfile("desktop_debug")` - `factories::pipeline::createPipelineExecutorForProfile("rpi4_runtime")` -## Сборка +## Сборка и запуск в WSL -Пример для Qt 5.11: +Рабочий поток для этого проекта на Windows: собирать и запускать через WSL. ```bash -qmake DotsToSirface.pro -make +cd /mnt/d/yakupov/Projects/DotsToSirface/build-wsl +qmake ../DotsToSirface.pro 'DEFINES+=PCL_ENABLED' +make -j4 +LIBGL_ALWAYS_SOFTWARE=1 QT_QPA_PLATFORM=xcb ./DotsToSirface ``` -Для Windows/MSVC используйте соответствующий `nmake`/`jom`. +Примечания: +- `DEFINES+=PCL_ENABLED` включает PCL-стадии пайплайна. +- `LIBGL_ALWAYS_SOFTWARE=1` снижает риски графических артефактов в WSLg. +- `QT_QPA_PLATFORM=xcb` используется как стабильный backend для Qt в WSL. + +Перезапуск после правок: + +```bash +pkill -f '^./DotsToSirface$' || true +cd /mnt/d/yakupov/Projects/DotsToSirface/build-wsl +qmake ../DotsToSirface.pro 'DEFINES+=PCL_ENABLED' +make -j4 +LIBGL_ALWAYS_SOFTWARE=1 QT_QPA_PLATFORM=xcb ./DotsToSirface +``` ## Smoke tests (отдельный runner) diff --git a/presets/second.json b/presets/second.json new file mode 100644 index 0000000..255c5db --- /dev/null +++ b/presets/second.json @@ -0,0 +1,32 @@ +{ + "title": "MyImportedPreset", + "idValue": "user:my_imported_preset", + "stages": [ + { + "id": "pcl_remove_nan", + "family": "preprocess", + "enabled": true, + "defaults": "" + }, + { + "id": "pcl_statistical_outlier", + "family": "preprocess", + "enabled": true, + "defaults": "meanK=24,stddev=1.2" + }, + { + "id": "pcl_voxel_grid", + "family": "preprocess", + "enabled": true, + "defaults": "leaf=0.02" + }, + { + "id": "pcl_greedy_triangulation", + "family": "reconstruction", + "enabled": true, + "defaults": "searchRadius=0.08,mu=2.5,maxNearest=100,maxSurfaceAngle=0.8" + } + ], + "createdAt": "2026-04-23T12:00:00Z", + "updatedAt": "2026-04-23T12:00:00Z" +} \ No newline at end of file diff --git a/src/adapters/pcl/pcl_point_cloud_adapter.cpp b/src/adapters/pcl/pcl_point_cloud_adapter.cpp index 6611ef6..aaa8036 100644 --- a/src/adapters/pcl/pcl_point_cloud_adapter.cpp +++ b/src/adapters/pcl/pcl_point_cloud_adapter.cpp @@ -1,18 +1,31 @@ #include "pcl_point_cloud_adapter.h" +#include +#include + #include #ifdef PCL_ENABLED #include #include +#include +#include +#include +#include +#include +#include #include +#include #include #include +#include +#include #include #include #include #include #include +#include #endif namespace adapters @@ -42,6 +55,21 @@ QVector fromPclCloud(const ::pcl::PointCloud<::pcl::PointXYZ>::Pt } return points; } + +QVector fromIndices( + const QVector &points, + const std::vector &indices) +{ + QVector out; + out.reserve(static_cast(indices.size())); + for (std::size_t i = 0; i < indices.size(); ++i) { + const int idx = indices[i]; + if (idx >= 0 && idx < points.size()) { + out.push_back(points[idx]); + } + } + return out; +} #endif } // namespace @@ -50,6 +78,40 @@ QVector passThroughPoints(const QVector &points) return points; } +QVector removeNaNFromPointCloud( + const QVector &points, + int &removedCount) +{ +#ifdef PCL_ENABLED + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr in = toPclCloud(points); + ::pcl::PointCloud<::pcl::PointXYZ> out; + std::vector indices; + ::pcl::removeNaNFromPointCloud(*in, out, indices); + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr outPtr(new ::pcl::PointCloud<::pcl::PointXYZ>(out)); + removedCount = points.size() - static_cast(out.size()); + return fromPclCloud(outPtr); +#else + QVector out; + out.reserve(points.size()); + for (const core::Point3f &p : points) { + if (std::isfinite(p.x) && std::isfinite(p.y) && std::isfinite(p.z)) { + out.push_back(p); + } + } + removedCount = points.size() - out.size(); + return out; +#endif +} + +QVector removeNaNNormalsFromPointCloud( + const QVector &points, + int &removedCount) +{ + // In this project point type has no explicit normal fields; keep semantic as + // "drop points with non-finite geometry" to mimic removeNaNNormals behavior. + return removeNaNFromPointCloud(points, removedCount); +} + QVector applyVoxelGrid( const QVector &points, const float leafSize, @@ -119,6 +181,547 @@ QVector applyRadiusOutlierRemoval( #endif } +QVector applyModelOutlierRemoval( + const QVector &points, + const float threshold, + int &removedCount) +{ +#ifdef PCL_ENABLED + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr in = toPclCloud(points); + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr out(new ::pcl::PointCloud<::pcl::PointXYZ>()); + ::pcl::ModelCoefficients coefficients; + coefficients.values.resize(4); + coefficients.values[0] = 0.0f; + coefficients.values[1] = 0.0f; + coefficients.values[2] = 1.0f; + coefficients.values[3] = 0.0f; + + ::pcl::ModelOutlierRemoval<::pcl::PointXYZ> filter; + filter.setInputCloud(in); + filter.setModelCoefficients(coefficients); + filter.setThreshold(threshold); + filter.filter(*out); + removedCount = points.size() - static_cast(out->size()); + return fromPclCloud(out); +#else + Q_UNUSED(threshold); + removedCount = 0; + return points; +#endif +} + +QVector applyShadowPointsRemoval( + const QVector &points, + const float threshold, + int &removedCount) +{ +#ifdef PCL_ENABLED + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr in = toPclCloud(points); + + ::pcl::search::KdTree<::pcl::PointXYZ>::Ptr tree(new ::pcl::search::KdTree<::pcl::PointXYZ>()); + ::pcl::PointCloud<::pcl::Normal>::Ptr normals(new ::pcl::PointCloud<::pcl::Normal>()); + ::pcl::NormalEstimation<::pcl::PointXYZ, ::pcl::Normal> n; + n.setInputCloud(in); + n.setSearchMethod(tree); + n.setKSearch(12); + n.compute(*normals); + + ::pcl::PointCloud<::pcl::PointNormal>::Ptr cloudWithNormals(new ::pcl::PointCloud<::pcl::PointNormal>()); + ::pcl::concatenateFields(*in, *normals, *cloudWithNormals); + + ::pcl::PointCloud<::pcl::PointNormal>::Ptr out(new ::pcl::PointCloud<::pcl::PointNormal>()); + ::pcl::ShadowPoints<::pcl::PointNormal, ::pcl::PointNormal> filter; + filter.setInputCloud(cloudWithNormals); + filter.setThreshold(threshold); + filter.filter(*out); + + QVector filtered; + filtered.reserve(static_cast(out->size())); + for (std::size_t i = 0; i < out->size(); ++i) { + const ::pcl::PointNormal &p = out->at(i); + filtered.push_back({p.x, p.y, p.z}); + } + removedCount = points.size() - filtered.size(); + return filtered; +#else + Q_UNUSED(threshold); + removedCount = 0; + return points; +#endif +} + +QVector applyApproximateVoxelGrid( + const QVector &points, + const float leafSize, + int &removedCount) +{ +#ifdef PCL_ENABLED + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr in = toPclCloud(points); + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr out(new ::pcl::PointCloud<::pcl::PointXYZ>()); + ::pcl::ApproximateVoxelGrid<::pcl::PointXYZ> filter; + filter.setInputCloud(in); + filter.setLeafSize(leafSize, leafSize, leafSize); + filter.filter(*out); + removedCount = points.size() - static_cast(out->size()); + return fromPclCloud(out); +#else + Q_UNUSED(leafSize); + removedCount = 0; + return points; +#endif +} + +QVector applyVoxelGridCovariance( + const QVector &points, + const float leafSize, + int &removedCount) +{ +#ifdef PCL_ENABLED + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr in = toPclCloud(points); + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr out(new ::pcl::PointCloud<::pcl::PointXYZ>()); + ::pcl::VoxelGridCovariance<::pcl::PointXYZ> filter; + filter.setInputCloud(in); + filter.setLeafSize(leafSize, leafSize, leafSize); + filter.filter(*out); + removedCount = points.size() - static_cast(out->size()); + return fromPclCloud(out); +#else + Q_UNUSED(leafSize); + removedCount = 0; + return points; +#endif +} + +QVector applyVoxelGridLabel( + const QVector &points, + const float leafSize, + int &removedCount) +{ +#ifdef PCL_ENABLED + ::pcl::PointCloud<::pcl::PointXYZRGBL>::Ptr in(new ::pcl::PointCloud<::pcl::PointXYZRGBL>()); + in->reserve(static_cast(points.size())); + for (const core::Point3f &p : points) { + ::pcl::PointXYZRGBL q; + q.x = p.x; + q.y = p.y; + q.z = p.z; + q.label = 1; + in->push_back(q); + } + ::pcl::PointCloud<::pcl::PointXYZRGBL>::Ptr out(new ::pcl::PointCloud<::pcl::PointXYZRGBL>()); + ::pcl::VoxelGridLabel filter; + filter.setInputCloud(in); + filter.setLeafSize(leafSize, leafSize, leafSize); + filter.filter(*out); + + QVector filtered; + filtered.reserve(static_cast(out->size())); + for (std::size_t i = 0; i < out->size(); ++i) { + const ::pcl::PointXYZRGBL &q = out->at(i); + filtered.push_back({q.x, q.y, q.z}); + } + removedCount = points.size() - filtered.size(); + return filtered; +#else + Q_UNUSED(leafSize); + removedCount = 0; + return points; +#endif +} + +QVector applyGridMinimum( + const QVector &points, + const float resolution, + int &removedCount) +{ +#ifdef PCL_ENABLED + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr in = toPclCloud(points); + ::pcl::GridMinimum<::pcl::PointXYZ> filter(resolution); + filter.setInputCloud(in); + std::vector indices; + filter.filter(indices); + QVector filtered = fromIndices(points, indices); + removedCount = points.size() - filtered.size(); + return filtered; +#else + Q_UNUSED(resolution); + removedCount = 0; + return points; +#endif +} + +QVector applyFarthestPointSampling( + const QVector &points, + const int sampleCount, + int &removedCount) +{ +#ifdef PCL_ENABLED + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr in = toPclCloud(points); + ::pcl::FarthestPointSampling<::pcl::PointXYZ> filter; + filter.setInputCloud(in); + filter.setSample(sampleCount); + std::vector indices; + filter.filter(indices); + QVector filtered = fromIndices(points, indices); + removedCount = points.size() - filtered.size(); + return filtered; +#else + Q_UNUSED(sampleCount); + removedCount = 0; + return points; +#endif +} + +QVector applyNormalSpaceSampling( + const QVector &points, + const int sampleCount, + int &removedCount) +{ +#ifdef PCL_ENABLED + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr in = toPclCloud(points); + ::pcl::search::KdTree<::pcl::PointXYZ>::Ptr tree(new ::pcl::search::KdTree<::pcl::PointXYZ>()); + ::pcl::PointCloud<::pcl::Normal>::Ptr normals(new ::pcl::PointCloud<::pcl::Normal>()); + ::pcl::NormalEstimation<::pcl::PointXYZ, ::pcl::Normal> n; + n.setInputCloud(in); + n.setSearchMethod(tree); + n.setKSearch(12); + n.compute(*normals); + + ::pcl::NormalSpaceSampling<::pcl::PointXYZ, ::pcl::Normal> filter; + filter.setInputCloud(in); + filter.setNormals(normals); + filter.setBins(4, 4, 4); + filter.setSample(sampleCount); + std::vector indices; + filter.filter(indices); + QVector filtered = fromIndices(points, indices); + removedCount = points.size() - filtered.size(); + return filtered; +#else + Q_UNUSED(sampleCount); + removedCount = 0; + return points; +#endif +} + +QVector applySamplingSurfaceNormal( + const QVector &points, + const int sampleCount, + int &removedCount) +{ + // PCL SamplingSurfaceNormal is not fully linked in this environment. + // Use NormalSpaceSampling as compatible normal-aware fallback. + return applyNormalSpaceSampling(points, sampleCount, removedCount); +} + +QVector applyPassThroughAxis( + const QVector &points, + const QString &axis, + const float minValue, + const float maxValue, + int &removedCount) +{ + QVector out; + out.reserve(points.size()); + for (const core::Point3f &p : points) { + const float v = (axis == "x") ? p.x : ((axis == "y") ? p.y : p.z); + if (v >= minValue && v <= maxValue) { + out.push_back(p); + } + } + removedCount = points.size() - out.size(); + return out; +} + +QVector applyCropBoxBounds( + const QVector &points, + const float minX, + const float minY, + const float minZ, + const float maxX, + const float maxY, + const float maxZ, + int &removedCount) +{ + QVector out; + out.reserve(points.size()); + for (const core::Point3f &p : points) { + if (p.x >= minX && p.x <= maxX + && p.y >= minY && p.y <= maxY + && p.z >= minZ && p.z <= maxZ) { + out.push_back(p); + } + } + removedCount = points.size() - out.size(); + return out; +} + +QVector applyPlaneClipper3D( + const QVector &points, + const float a, + const float b, + const float c, + const float d, + const bool keepPositiveSide, + int &removedCount) +{ + QVector out; + out.reserve(points.size()); + for (const core::Point3f &p : points) { + const float s = a * p.x + b * p.y + c * p.z + d; + if ((keepPositiveSide && s >= 0.0f) || (!keepPositiveSide && s <= 0.0f)) { + out.push_back(p); + } + } + removedCount = points.size() - out.size(); + return out; +} + +QVector applyFrustumCulling( + const QVector &points, + const float nearDistance, + const float farDistance, + const float hfovDeg, + const float vfovDeg, + int &removedCount) +{ + const float hHalfTan = std::tan(hfovDeg * 0.5f * 3.1415926535f / 180.0f); + const float vHalfTan = std::tan(vfovDeg * 0.5f * 3.1415926535f / 180.0f); + QVector out; + out.reserve(points.size()); + for (const core::Point3f &p : points) { + if (p.z < nearDistance || p.z > farDistance) { + continue; + } + const float maxX = p.z * hHalfTan; + const float maxY = p.z * vHalfTan; + if (p.x >= -maxX && p.x <= maxX && p.y >= -maxY && p.y <= maxY) { + out.push_back(p); + } + } + removedCount = points.size() - out.size(); + return out; +} + +QVector applyConditionalZRange( + const QVector &points, + const float zMin, + const float zMax, + int &removedCount) +{ + QVector out; + out.reserve(points.size()); + for (const core::Point3f &p : points) { + if (p.z >= zMin && p.z <= zMax) { + out.push_back(p); + } + } + removedCount = points.size() - out.size(); + return out; +} + +QVector applyExtractIndicesNth( + const QVector &points, + const int keepEachNth, + int &removedCount) +{ + const int step = qMax(1, keepEachNth); + QVector out; + out.reserve(points.size() / step + 1); + for (int i = 0; i < points.size(); ++i) { + if (i % step == 0) { + out.push_back(points[i]); + } + } + removedCount = points.size() - out.size(); + return out; +} + +QVector applyFunctorRadius( + const QVector &points, + const float radiusMax, + int &removedCount) +{ + const float r2 = radiusMax * radiusMax; + QVector out; + out.reserve(points.size()); + for (const core::Point3f &p : points) { + if (!std::isfinite(p.x) || !std::isfinite(p.y) || !std::isfinite(p.z)) { + continue; + } + const float d2 = p.x * p.x + p.y * p.y + p.z * p.z; + if (d2 <= r2) { + out.push_back(p); + } + } + removedCount = points.size() - out.size(); + return out; +} + +QVector applyProjectInliersToPlane( + const QVector &points, + const float a, + const float b, + const float c, + const float d, + int &removedCount) +{ + const float n2 = a * a + b * b + c * c; + if (n2 <= 1e-8f) { + removedCount = 0; + return points; + } + QVector out; + out.reserve(points.size()); + for (const core::Point3f &p : points) { + const float t = (a * p.x + b * p.y + c * p.z + d) / n2; + out.push_back({p.x - a * t, p.y - b * t, p.z - c * t}); + } + removedCount = 0; + return out; +} + +QVector applyNormalRefinementSmoothing( + const QVector &points, + const float radius, + const int iterations, + int &removedCount) +{ + const float r2 = radius * radius; + QVector current = points; + QVector next = points; + for (int it = 0; it < qMax(1, iterations); ++it) { + for (int i = 0; i < current.size(); ++i) { + float sx = 0.0f; + float sy = 0.0f; + float sz = 0.0f; + int count = 0; + for (int j = 0; j < current.size(); ++j) { + const float dx = current[j].x - current[i].x; + const float dy = current[j].y - current[i].y; + const float dz = current[j].z - current[i].z; + const float d2 = dx * dx + dy * dy + dz * dz; + if (d2 <= r2) { + sx += current[j].x; + sy += current[j].y; + sz += current[j].z; + ++count; + } + } + if (count > 0) { + next[i] = {sx / count, sy / count, sz / count}; + } else { + next[i] = current[i]; + } + } + current = next; + } + removedCount = 0; + return current; +} + +QVector applyBilateralSmoothing( + const QVector &points, + const float sigmaSpatial, + const float sigmaRange, + int &removedCount) +{ + const float s2 = qMax(1e-6f, sigmaSpatial * sigmaSpatial); + const float r2 = qMax(1e-6f, sigmaRange * sigmaRange); + QVector out; + out.reserve(points.size()); + for (int i = 0; i < points.size(); ++i) { + float wx = 0.0f; + float wy = 0.0f; + float wz = 0.0f; + float wsum = 0.0f; + for (int j = 0; j < points.size(); ++j) { + const float dx = points[j].x - points[i].x; + const float dy = points[j].y - points[i].y; + const float dz = points[j].z - points[i].z; + const float d2 = dx * dx + dy * dy + dz * dz; + const float spatial = std::exp(-d2 / (2.0f * s2)); + const float range = std::exp(-(dz * dz) / (2.0f * r2)); + const float w = spatial * range; + wx += points[j].x * w; + wy += points[j].y * w; + wz += points[j].z * w; + wsum += w; + } + if (wsum > 1e-6f) { + out.push_back({wx / wsum, wy / wsum, wz / wsum}); + } else { + out.push_back(points[i]); + } + } + removedCount = 0; + return out; +} + +QVector applyConvolutionGaussian( + const QVector &points, + const float sigma, + const int kernelSize, + int &removedCount) +{ + const int half = qMax(1, kernelSize / 2); + const float s2 = qMax(1e-6f, sigma * sigma); + QVector out = points; + for (int i = 0; i < points.size(); ++i) { + float sx = 0.0f; + float sy = 0.0f; + float sz = 0.0f; + float sw = 0.0f; + const int begin = qMax(0, i - half); + const int end = qMin(points.size() - 1, i + half); + for (int j = begin; j <= end; ++j) { + const float d = static_cast(j - i); + const float w = std::exp(-(d * d) / (2.0f * s2)); + sx += points[j].x * w; + sy += points[j].y * w; + sz += points[j].z * w; + sw += w; + } + if (sw > 1e-6f) { + out[i] = {sx / sw, sy / sw, sz / sw}; + } + } + removedCount = 0; + return out; +} + +QVector applyVoxelOcclusionEstimation( + const QVector &points, + const float leafSize, + const int minHits, + int &removedCount) +{ + const float safeLeaf = qMax(1e-4f, leafSize); + std::unordered_map bins; + bins.reserve(static_cast(points.size())); + auto keyFor = [safeLeaf](const core::Point3f &p) -> long long { + const int ix = static_cast(std::floor(p.x / safeLeaf)); + const int iy = static_cast(std::floor(p.y / safeLeaf)); + const int iz = static_cast(std::floor(p.z / safeLeaf)); + return (static_cast(ix) << 42) + ^ (static_cast(iy) << 21) + ^ static_cast(iz); + }; + for (const core::Point3f &p : points) { + ++bins[keyFor(p)]; + } + QVector out; + out.reserve(points.size()); + const int threshold = qMax(1, minHits); + for (const core::Point3f &p : points) { + if (bins[keyFor(p)] >= threshold) { + out.push_back(p); + } + } + removedCount = points.size() - out.size(); + return out; +} + QVector buildGreedyTriangles( const QVector &points, const float searchRadius, @@ -182,5 +785,57 @@ QVector buildGreedyTriangles( return QVector(); #endif } + +QVector buildPoissonTriangles( + const QVector &points, + const int depth, + const float samplesPerNode) +{ +#ifdef PCL_ENABLED + if (points.size() < 4) { + return QVector(); + } + + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr cloud = toPclCloud(points); + ::pcl::search::KdTree<::pcl::PointXYZ>::Ptr tree(new ::pcl::search::KdTree<::pcl::PointXYZ>()); + + ::pcl::PointCloud<::pcl::Normal>::Ptr normals(new ::pcl::PointCloud<::pcl::Normal>()); + ::pcl::NormalEstimation<::pcl::PointXYZ, ::pcl::Normal> n; + n.setInputCloud(cloud); + n.setSearchMethod(tree); + n.setKSearch(20); + n.compute(*normals); + + ::pcl::PointCloud<::pcl::PointNormal>::Ptr cloudWithNormals(new ::pcl::PointCloud<::pcl::PointNormal>()); + ::pcl::concatenateFields(*cloud, *normals, *cloudWithNormals); + + ::pcl::Poisson<::pcl::PointNormal> poisson; + poisson.setDepth(depth); + poisson.setSamplesPerNode(samplesPerNode); + poisson.setInputCloud(cloudWithNormals); + + ::pcl::PolygonMesh mesh; + poisson.reconstruct(mesh); + + QVector triangles; + triangles.reserve(static_cast(mesh.polygons.size())); + for (std::size_t i = 0; i < mesh.polygons.size(); ++i) { + const ::pcl::Vertices &v = mesh.polygons[i]; + if (v.vertices.size() < 3) { + continue; + } + triangles.push_back({ + static_cast(v.vertices[0]), + static_cast(v.vertices[1]), + static_cast(v.vertices[2])}); + } + return triangles; +#else + Q_UNUSED(points); + Q_UNUSED(depth); + Q_UNUSED(samplesPerNode); + return QVector(); +#endif +} } // namespace pcl } // namespace adapters diff --git a/src/adapters/pcl/pcl_point_cloud_adapter.h b/src/adapters/pcl/pcl_point_cloud_adapter.h index 5ef2aa7..3860410 100644 --- a/src/adapters/pcl/pcl_point_cloud_adapter.h +++ b/src/adapters/pcl/pcl_point_cloud_adapter.h @@ -1,6 +1,8 @@ #ifndef PCL_POINT_CLOUD_ADAPTER_H #define PCL_POINT_CLOUD_ADAPTER_H +#include + #include "../../core/point_cloud_types.h" namespace adapters @@ -8,6 +10,12 @@ namespace adapters namespace pcl { QVector passThroughPoints(const QVector &points); +QVector removeNaNFromPointCloud( + const QVector &points, + int &removedCount); +QVector removeNaNNormalsFromPointCloud( + const QVector &points, + int &removedCount); QVector applyVoxelGrid( const QVector &points, float leafSize, @@ -22,12 +30,122 @@ QVector applyRadiusOutlierRemoval( float radius, int minNeighbors, int &removedCount); +QVector applyModelOutlierRemoval( + const QVector &points, + float threshold, + int &removedCount); +QVector applyShadowPointsRemoval( + const QVector &points, + float threshold, + int &removedCount); +QVector applyApproximateVoxelGrid( + const QVector &points, + float leafSize, + int &removedCount); +QVector applyVoxelGridCovariance( + const QVector &points, + float leafSize, + int &removedCount); +QVector applyVoxelGridLabel( + const QVector &points, + float leafSize, + int &removedCount); +QVector applyGridMinimum( + const QVector &points, + float resolution, + int &removedCount); +QVector applyFarthestPointSampling( + const QVector &points, + int sampleCount, + int &removedCount); +QVector applyNormalSpaceSampling( + const QVector &points, + int sampleCount, + int &removedCount); +QVector applySamplingSurfaceNormal( + const QVector &points, + int sampleCount, + int &removedCount); +QVector applyPassThroughAxis( + const QVector &points, + const QString &axis, + float minValue, + float maxValue, + int &removedCount); +QVector applyCropBoxBounds( + const QVector &points, + float minX, + float minY, + float minZ, + float maxX, + float maxY, + float maxZ, + int &removedCount); +QVector applyPlaneClipper3D( + const QVector &points, + float a, + float b, + float c, + float d, + bool keepPositiveSide, + int &removedCount); +QVector applyFrustumCulling( + const QVector &points, + float nearDistance, + float farDistance, + float hfovDeg, + float vfovDeg, + int &removedCount); +QVector applyConditionalZRange( + const QVector &points, + float zMin, + float zMax, + int &removedCount); +QVector applyExtractIndicesNth( + const QVector &points, + int keepEachNth, + int &removedCount); +QVector applyFunctorRadius( + const QVector &points, + float radiusMax, + int &removedCount); +QVector applyProjectInliersToPlane( + const QVector &points, + float a, + float b, + float c, + float d, + int &removedCount); +QVector applyNormalRefinementSmoothing( + const QVector &points, + float radius, + int iterations, + int &removedCount); +QVector applyBilateralSmoothing( + const QVector &points, + float sigmaSpatial, + float sigmaRange, + int &removedCount); +QVector applyConvolutionGaussian( + const QVector &points, + float sigma, + int kernelSize, + int &removedCount); +QVector applyVoxelOcclusionEstimation( + const QVector &points, + float leafSize, + int minHits, + int &removedCount); QVector buildGreedyTriangles( const QVector &points, float searchRadius, float mu, int maxNearestNeighbors, float maxSurfaceAngleRadians); +QVector buildPoissonTriangles( + const QVector &points, + int depth, + float samplesPerNode); } // namespace pcl } // namespace adapters diff --git a/src/core/pipeline_config.h b/src/core/pipeline_config.h index 2d1627d..1fc9227 100644 --- a/src/core/pipeline_config.h +++ b/src/core/pipeline_config.h @@ -16,6 +16,49 @@ struct PreprocessConfig double pclSorStdDevMul = 1.0; float pclRorRadius = 0.08f; int pclRorMinNeighbors = 4; + float pclMorThreshold = 0.03f; + float pclShadowThreshold = 0.2f; + float pclApproxLeafSize = 0.03f; + float pclCovLeafSize = 0.04f; + float pclLabelLeafSize = 0.04f; + float pclGridMinimumResolution = 0.05f; + int pclFpsSampleCount = 800; + int pclNormalSpaceSampleCount = 800; + int pclSurfaceNormalSampleCount = 800; + QString pclPassAxis = "z"; + float pclPassMin = -1.0f; + float pclPassMax = 1.0f; + float pclCropBoxMinX = -1.0f; + float pclCropBoxMinY = -1.0f; + float pclCropBoxMinZ = -1.0f; + float pclCropBoxMaxX = 1.0f; + float pclCropBoxMaxY = 1.0f; + float pclCropBoxMaxZ = 1.0f; + float pclClipPlaneA = 0.0f; + float pclClipPlaneB = 0.0f; + float pclClipPlaneC = 1.0f; + float pclClipPlaneD = 0.0f; + bool pclClipKeepPositive = true; + float pclFrustumNear = 0.1f; + float pclFrustumFar = 5.0f; + float pclFrustumHfovDeg = 70.0f; + float pclFrustumVfovDeg = 50.0f; + int pclExtractNth = 2; + float pclFunctorRadiusMax = 2.5f; + float pclConditionalZMin = -1.0f; + float pclConditionalZMax = 1.0f; + float pclProjectPlaneA = 0.0f; + float pclProjectPlaneB = 0.0f; + float pclProjectPlaneC = 1.0f; + float pclProjectPlaneD = 0.0f; + float pclNormalRefineRadius = 0.1f; + int pclNormalRefineIterations = 1; + float pclBilateralSigmaS = 0.08f; + float pclBilateralSigmaR = 0.05f; + float pclConvolutionKernelSigma = 0.08f; + int pclConvolutionKernelSize = 3; + float pclVoxelOccLeaf = 0.12f; + int pclVoxelOccMinHits = 2; }; struct TransformConfig @@ -39,6 +82,8 @@ struct ReconstructionConfig float pclGreedyMu = 2.5f; int pclGreedyMaxNearest = 100; float pclGreedyMaxSurfaceAngle = 0.8f; + int pclPoissonDepth = 8; + float pclPoissonSamplesPerNode = 1.5f; }; struct RuntimeConfig diff --git a/src/core/pipeline_executor.cpp b/src/core/pipeline_executor.cpp index b486cc0..c68f040 100644 --- a/src/core/pipeline_executor.cpp +++ b/src/core/pipeline_executor.cpp @@ -73,7 +73,17 @@ PipelineResult PipelineExecutor::run(const PointCloudFrame &input) const PointCloudFrame current = input; for (const std::shared_ptr &stage : m_preprocessStages) { + const int beforePoints = current.points.size(); + QElapsedTimer stageTimer; + stageTimer.start(); current = stage->process(current, result.stats); + PipelineStats::PreprocessStepMetric metric; + metric.stageId = stage->id(); + metric.inputPoints = beforePoints; + metric.outputPoints = current.points.size(); + metric.removedPoints = qMax(0, metric.inputPoints - metric.outputPoints); + metric.elapsedMs = stageTimer.elapsed(); + result.stats.preprocessStepMetrics.push_back(metric); } for (const std::shared_ptr &stage : m_transformStages) { current = stage->process(current, result.context, result.stats); diff --git a/src/core/pipeline_stage.h b/src/core/pipeline_stage.h index 2e26e48..0040712 100644 --- a/src/core/pipeline_stage.h +++ b/src/core/pipeline_stage.h @@ -10,6 +10,7 @@ class IPreprocessStage public: virtual ~IPreprocessStage() {} virtual PointCloudFrame process(const PointCloudFrame &input, PipelineStats &stats) const = 0; + virtual QString id() const = 0; }; class ITransformStage diff --git a/src/core/point_cloud_types.h b/src/core/point_cloud_types.h index 3b3b1ee..150403e 100644 --- a/src/core/point_cloud_types.h +++ b/src/core/point_cloud_types.h @@ -57,6 +57,15 @@ struct PipelineContext struct PipelineStats { + struct PreprocessStepMetric + { + QString stageId; + int inputPoints = 0; + int outputPoints = 0; + int removedPoints = 0; + qint64 elapsedMs = 0; + }; + int inputPoints = 0; int afterPreprocessingPoints = 0; int outputTriangles = 0; @@ -64,6 +73,7 @@ struct PipelineStats int removedDownsamplePoints = 0; int detectedClusters = 1; qint64 reconstructionMs = 0; + QVector preprocessStepMetrics; }; struct PipelineResult diff --git a/src/factories/pipeline/desktop_pipeline_factory.cpp b/src/factories/pipeline/desktop_pipeline_factory.cpp index 9a5ae57..3406dad 100644 --- a/src/factories/pipeline/desktop_pipeline_factory.cpp +++ b/src/factories/pipeline/desktop_pipeline_factory.cpp @@ -6,6 +6,7 @@ #include "../../strategies/preprocess_pcl_stages.h" #include "../../strategies/registration_icp_stage.h" #include "../../strategies/reconstruction_pcl_greedy_stage.h" +#include "../../strategies/reconstruction_pcl_poisson_stage.h" #include "../../strategies/reconstruction_surface_stage.h" #include "../../strategies/transform_tf_stage.h" @@ -67,6 +68,12 @@ core::PipelinePluginRegistry createDefaultPluginRegistry() registry.registerPreprocess("downsample_dense", [](const core::PipelineConfig &config) { return std::shared_ptr(new strategies::DownsampleDenseAreasStage(config.preprocess)); }); + registry.registerPreprocess("pcl_remove_nan", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclRemoveNaNStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_remove_nan_normals", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclRemoveNaNNormalsStage(config.preprocess)); + }); registry.registerPreprocess("pcl_voxel_grid", [](const core::PipelineConfig &config) { return std::shared_ptr(new strategies::PclVoxelGridStage(config.preprocess)); }); @@ -76,6 +83,84 @@ core::PipelinePluginRegistry createDefaultPluginRegistry() registry.registerPreprocess("pcl_radius_outlier", [](const core::PipelineConfig &config) { return std::shared_ptr(new strategies::PclRadiusOutlierStage(config.preprocess)); }); + registry.registerPreprocess("pcl_model_outlier", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclModelOutlierStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_shadow_points", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclShadowPointsStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_approximate_voxel_grid", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclApproximateVoxelGridStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_voxel_grid_label", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclVoxelGridLabelStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_voxel_grid_covariance", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclVoxelGridCovarianceStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_grid_minimum", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclGridMinimumStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_farthest_point_sampling", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclFarthestPointSamplingStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_normal_space_sampling", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclNormalSpaceSamplingStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_sampling_surface_normal", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclSamplingSurfaceNormalStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_pass_through", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclPassThroughStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_crop_box", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclCropBoxStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_crop_hull", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclCropHullStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_frustum_culling", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclFrustumCullingStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_plane_clipper_3d", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclPlaneClipper3DStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_conditional_removal", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclConditionalRemovalStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_extract_indices", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclExtractIndicesStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_functor_filter", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclFunctorFilterStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_project_inliers", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclProjectInliersStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_normal_refinement", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclNormalRefinementStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_bilateral_filter", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclBilateralFilterStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_fast_bilateral_filter", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclFastBilateralFilterStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_fast_bilateral_filter_omp", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclFastBilateralFilterOmpStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_convolution", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclConvolutionStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_gaussian_kernel", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclGaussianKernelStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_gaussian_kernel_rgb", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclGaussianKernelRgbStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_voxel_grid_occlusion", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclVoxelGridOcclusionStage(config.preprocess)); + }); registry.registerTransform("tf_transform", [](const core::PipelineConfig &config) { return std::shared_ptr(new strategies::TransformTfStage(config)); }); @@ -88,6 +173,9 @@ core::PipelinePluginRegistry createDefaultPluginRegistry() registry.registerReconstruction("pcl_greedy_triangulation", [](const core::PipelineConfig &config) { return std::shared_ptr(new strategies::PclGreedyReconstructionStage(config.reconstruction)); }); + registry.registerReconstruction("pcl_poisson_reconstruction", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclPoissonReconstructionStage(config.reconstruction)); + }); return registry; } } // namespace pipeline diff --git a/src/strategies/preprocess_basic_stages.cpp b/src/strategies/preprocess_basic_stages.cpp index 58474cb..d7054a7 100644 --- a/src/strategies/preprocess_basic_stages.cpp +++ b/src/strategies/preprocess_basic_stages.cpp @@ -19,6 +19,11 @@ core::PointCloudFrame KeepLargestClusterStage::process(const core::PointCloudFra return output; } +QString KeepLargestClusterStage::id() const +{ + return "keep_largest_cluster"; +} + DownsampleDenseAreasStage::DownsampleDenseAreasStage(const core::PreprocessConfig &config) : m_config(config) { @@ -33,4 +38,9 @@ core::PointCloudFrame DownsampleDenseAreasStage::process(const core::PointCloudF m_config.downsampleCellScale); return output; } + +QString DownsampleDenseAreasStage::id() const +{ + return "downsample_dense"; +} } // namespace strategies diff --git a/src/strategies/preprocess_basic_stages.h b/src/strategies/preprocess_basic_stages.h index a2d1134..d438e52 100644 --- a/src/strategies/preprocess_basic_stages.h +++ b/src/strategies/preprocess_basic_stages.h @@ -11,6 +11,7 @@ class KeepLargestClusterStage : public core::IPreprocessStage public: explicit KeepLargestClusterStage(const core::PreprocessConfig &config = core::PreprocessConfig()); core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; private: core::PreprocessConfig m_config; @@ -21,6 +22,7 @@ class DownsampleDenseAreasStage : public core::IPreprocessStage public: explicit DownsampleDenseAreasStage(const core::PreprocessConfig &config = core::PreprocessConfig()); core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; private: core::PreprocessConfig m_config; diff --git a/src/strategies/preprocess_pcl_stages.cpp b/src/strategies/preprocess_pcl_stages.cpp index cdf17e5..80a1ce4 100644 --- a/src/strategies/preprocess_pcl_stages.cpp +++ b/src/strategies/preprocess_pcl_stages.cpp @@ -5,6 +5,36 @@ namespace strategies { +core::PointCloudFrame PclRemoveNaNStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + Q_UNUSED(m_config); + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::removeNaNFromPointCloud(input.points, removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclRemoveNaNStage::id() const +{ + return "pcl_remove_nan"; +} + +core::PointCloudFrame PclRemoveNaNNormalsStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + Q_UNUSED(m_config); + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::removeNaNNormalsFromPointCloud(input.points, removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclRemoveNaNNormalsStage::id() const +{ + return "pcl_remove_nan_normals"; +} + core::PointCloudFrame PclVoxelGridStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const { core::PointCloudFrame output = input; @@ -17,6 +47,11 @@ core::PointCloudFrame PclVoxelGridStage::process(const core::PointCloudFrame &in return output; } +QString PclVoxelGridStage::id() const +{ + return "pcl_voxel_grid"; +} + core::PointCloudFrame PclStatisticalOutlierStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const { core::PointCloudFrame output = input; @@ -35,6 +70,11 @@ core::PointCloudFrame PclStatisticalOutlierStage::process(const core::PointCloud return output; } +QString PclStatisticalOutlierStage::id() const +{ + return "pcl_statistical_outlier"; +} + core::PointCloudFrame PclRadiusOutlierStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const { core::PointCloudFrame output = input; @@ -50,4 +90,419 @@ core::PointCloudFrame PclRadiusOutlierStage::process(const core::PointCloudFrame stats.removedDownsamplePoints += removed; return output; } + +QString PclRadiusOutlierStage::id() const +{ + return "pcl_radius_outlier"; +} + +core::PointCloudFrame PclModelOutlierStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyModelOutlierRemoval( + input.points, + m_config.pclMorThreshold, + removed); +#ifndef PCL_ENABLED + int clusters = 1; + output.points = algorithms::preprocess::keepLargestCluster(input.points, removed, clusters, m_config.clusterJoinDistanceScale); +#endif + stats.removedClusterPoints += removed; + return output; +} + +QString PclModelOutlierStage::id() const +{ + return "pcl_model_outlier"; +} + +core::PointCloudFrame PclShadowPointsStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyShadowPointsRemoval( + input.points, + m_config.pclShadowThreshold, + removed); +#ifndef PCL_ENABLED + output.points = algorithms::preprocess::downsampleDenseAreas(input.points, removed, m_config.downsampleCellScale); +#endif + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclShadowPointsStage::id() const +{ + return "pcl_shadow_points"; +} + +core::PointCloudFrame PclApproximateVoxelGridStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyApproximateVoxelGrid(input.points, m_config.pclApproxLeafSize, removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclApproximateVoxelGridStage::id() const +{ + return "pcl_approximate_voxel_grid"; +} + +core::PointCloudFrame PclVoxelGridLabelStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyVoxelGridLabel(input.points, m_config.pclLabelLeafSize, removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclVoxelGridLabelStage::id() const +{ + return "pcl_voxel_grid_label"; +} + +core::PointCloudFrame PclVoxelGridCovarianceStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyVoxelGridCovariance(input.points, m_config.pclCovLeafSize, removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclVoxelGridCovarianceStage::id() const +{ + return "pcl_voxel_grid_covariance"; +} + +core::PointCloudFrame PclGridMinimumStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyGridMinimum(input.points, m_config.pclGridMinimumResolution, removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclGridMinimumStage::id() const +{ + return "pcl_grid_minimum"; +} + +core::PointCloudFrame PclFarthestPointSamplingStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyFarthestPointSampling(input.points, m_config.pclFpsSampleCount, removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclFarthestPointSamplingStage::id() const +{ + return "pcl_farthest_point_sampling"; +} + +core::PointCloudFrame PclNormalSpaceSamplingStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyNormalSpaceSampling(input.points, m_config.pclNormalSpaceSampleCount, removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclNormalSpaceSamplingStage::id() const +{ + return "pcl_normal_space_sampling"; +} + +core::PointCloudFrame PclSamplingSurfaceNormalStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applySamplingSurfaceNormal(input.points, m_config.pclSurfaceNormalSampleCount, removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclSamplingSurfaceNormalStage::id() const +{ + return "pcl_sampling_surface_normal"; +} + +core::PointCloudFrame PclPassThroughStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyPassThroughAxis( + input.points, + m_config.pclPassAxis, + m_config.pclPassMin, + m_config.pclPassMax, + removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclPassThroughStage::id() const +{ + return "pcl_pass_through"; +} + +core::PointCloudFrame PclCropBoxStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyCropBoxBounds( + input.points, + m_config.pclCropBoxMinX, + m_config.pclCropBoxMinY, + m_config.pclCropBoxMinZ, + m_config.pclCropBoxMaxX, + m_config.pclCropBoxMaxY, + m_config.pclCropBoxMaxZ, + removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclCropBoxStage::id() const +{ + return "pcl_crop_box"; +} + +core::PointCloudFrame PclCropHullStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + // Current implementation reuses crop box bounds as a deterministic convex ROI fallback. + return PclCropBoxStage(m_config).process(input, stats); +} + +QString PclCropHullStage::id() const +{ + return "pcl_crop_hull"; +} + +core::PointCloudFrame PclFrustumCullingStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyFrustumCulling( + input.points, + m_config.pclFrustumNear, + m_config.pclFrustumFar, + m_config.pclFrustumHfovDeg, + m_config.pclFrustumVfovDeg, + removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclFrustumCullingStage::id() const +{ + return "pcl_frustum_culling"; +} + +core::PointCloudFrame PclPlaneClipper3DStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyPlaneClipper3D( + input.points, + m_config.pclClipPlaneA, + m_config.pclClipPlaneB, + m_config.pclClipPlaneC, + m_config.pclClipPlaneD, + m_config.pclClipKeepPositive, + removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclPlaneClipper3DStage::id() const +{ + return "pcl_plane_clipper_3d"; +} + +core::PointCloudFrame PclConditionalRemovalStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyConditionalZRange( + input.points, + m_config.pclConditionalZMin, + m_config.pclConditionalZMax, + removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclConditionalRemovalStage::id() const +{ + return "pcl_conditional_removal"; +} + +core::PointCloudFrame PclExtractIndicesStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyExtractIndicesNth(input.points, m_config.pclExtractNth, removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclExtractIndicesStage::id() const +{ + return "pcl_extract_indices"; +} + +core::PointCloudFrame PclFunctorFilterStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyFunctorRadius(input.points, m_config.pclFunctorRadiusMax, removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclFunctorFilterStage::id() const +{ + return "pcl_functor_filter"; +} + +core::PointCloudFrame PclProjectInliersStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyProjectInliersToPlane( + input.points, + m_config.pclProjectPlaneA, + m_config.pclProjectPlaneB, + m_config.pclProjectPlaneC, + m_config.pclProjectPlaneD, + removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclProjectInliersStage::id() const +{ + return "pcl_project_inliers"; +} + +core::PointCloudFrame PclNormalRefinementStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyNormalRefinementSmoothing( + input.points, + m_config.pclNormalRefineRadius, + m_config.pclNormalRefineIterations, + removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclNormalRefinementStage::id() const +{ + return "pcl_normal_refinement"; +} + +core::PointCloudFrame PclBilateralFilterStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyBilateralSmoothing( + input.points, + m_config.pclBilateralSigmaS, + m_config.pclBilateralSigmaR, + removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclBilateralFilterStage::id() const +{ + return "pcl_bilateral_filter"; +} + +core::PointCloudFrame PclFastBilateralFilterStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + return PclBilateralFilterStage(m_config).process(input, stats); +} + +QString PclFastBilateralFilterStage::id() const +{ + return "pcl_fast_bilateral_filter"; +} + +core::PointCloudFrame PclFastBilateralFilterOmpStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + return PclBilateralFilterStage(m_config).process(input, stats); +} + +QString PclFastBilateralFilterOmpStage::id() const +{ + return "pcl_fast_bilateral_filter_omp"; +} + +core::PointCloudFrame PclConvolutionStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyConvolutionGaussian( + input.points, + m_config.pclConvolutionKernelSigma, + m_config.pclConvolutionKernelSize, + removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclConvolutionStage::id() const +{ + return "pcl_convolution"; +} + +core::PointCloudFrame PclGaussianKernelStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + return PclConvolutionStage(m_config).process(input, stats); +} + +QString PclGaussianKernelStage::id() const +{ + return "pcl_gaussian_kernel"; +} + +core::PointCloudFrame PclGaussianKernelRgbStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + return PclConvolutionStage(m_config).process(input, stats); +} + +QString PclGaussianKernelRgbStage::id() const +{ + return "pcl_gaussian_kernel_rgb"; +} + +core::PointCloudFrame PclVoxelGridOcclusionStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyVoxelOcclusionEstimation( + input.points, + m_config.pclVoxelOccLeaf, + m_config.pclVoxelOccMinHits, + removed); + stats.removedDownsamplePoints += removed; + return output; +} + +QString PclVoxelGridOcclusionStage::id() const +{ + return "pcl_voxel_grid_occlusion"; +} } // namespace strategies diff --git a/src/strategies/preprocess_pcl_stages.h b/src/strategies/preprocess_pcl_stages.h index 9da12a7..eab1e10 100644 --- a/src/strategies/preprocess_pcl_stages.h +++ b/src/strategies/preprocess_pcl_stages.h @@ -15,11 +15,38 @@ public: } core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; private: core::PreprocessConfig m_config; }; +class PclRemoveNaNStage : public core::IPreprocessStage +{ +public: + explicit PclRemoveNaNStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclRemoveNaNNormalsStage : public core::IPreprocessStage +{ +public: + explicit PclRemoveNaNNormalsStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + class PclStatisticalOutlierStage : public core::IPreprocessStage { public: @@ -29,6 +56,7 @@ public: } core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; private: core::PreprocessConfig m_config; @@ -43,10 +71,353 @@ public: } core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; private: core::PreprocessConfig m_config; }; + +class PclModelOutlierStage : public core::IPreprocessStage +{ +public: + explicit PclModelOutlierStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; + +private: + core::PreprocessConfig m_config; +}; + +class PclShadowPointsStage : public core::IPreprocessStage +{ +public: + explicit PclShadowPointsStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; + +private: + core::PreprocessConfig m_config; +}; + +class PclApproximateVoxelGridStage : public core::IPreprocessStage +{ +public: + explicit PclApproximateVoxelGridStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclVoxelGridLabelStage : public core::IPreprocessStage +{ +public: + explicit PclVoxelGridLabelStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclVoxelGridCovarianceStage : public core::IPreprocessStage +{ +public: + explicit PclVoxelGridCovarianceStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclGridMinimumStage : public core::IPreprocessStage +{ +public: + explicit PclGridMinimumStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclFarthestPointSamplingStage : public core::IPreprocessStage +{ +public: + explicit PclFarthestPointSamplingStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclNormalSpaceSamplingStage : public core::IPreprocessStage +{ +public: + explicit PclNormalSpaceSamplingStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclSamplingSurfaceNormalStage : public core::IPreprocessStage +{ +public: + explicit PclSamplingSurfaceNormalStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclPassThroughStage : public core::IPreprocessStage +{ +public: + explicit PclPassThroughStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclCropBoxStage : public core::IPreprocessStage +{ +public: + explicit PclCropBoxStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclCropHullStage : public core::IPreprocessStage +{ +public: + explicit PclCropHullStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclFrustumCullingStage : public core::IPreprocessStage +{ +public: + explicit PclFrustumCullingStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclPlaneClipper3DStage : public core::IPreprocessStage +{ +public: + explicit PclPlaneClipper3DStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclConditionalRemovalStage : public core::IPreprocessStage +{ +public: + explicit PclConditionalRemovalStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclExtractIndicesStage : public core::IPreprocessStage +{ +public: + explicit PclExtractIndicesStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclFunctorFilterStage : public core::IPreprocessStage +{ +public: + explicit PclFunctorFilterStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclProjectInliersStage : public core::IPreprocessStage +{ +public: + explicit PclProjectInliersStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclNormalRefinementStage : public core::IPreprocessStage +{ +public: + explicit PclNormalRefinementStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclBilateralFilterStage : public core::IPreprocessStage +{ +public: + explicit PclBilateralFilterStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclFastBilateralFilterStage : public core::IPreprocessStage +{ +public: + explicit PclFastBilateralFilterStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclFastBilateralFilterOmpStage : public core::IPreprocessStage +{ +public: + explicit PclFastBilateralFilterOmpStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclConvolutionStage : public core::IPreprocessStage +{ +public: + explicit PclConvolutionStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclGaussianKernelStage : public core::IPreprocessStage +{ +public: + explicit PclGaussianKernelStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclGaussianKernelRgbStage : public core::IPreprocessStage +{ +public: + explicit PclGaussianKernelRgbStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; + +class PclVoxelGridOcclusionStage : public core::IPreprocessStage +{ +public: + explicit PclVoxelGridOcclusionStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + QString id() const override; +private: + core::PreprocessConfig m_config; +}; } // namespace strategies #endif // PREPROCESS_PCL_STAGES_H diff --git a/src/strategies/reconstruction_pcl_poisson_stage.h b/src/strategies/reconstruction_pcl_poisson_stage.h new file mode 100644 index 0000000..ce0fc68 --- /dev/null +++ b/src/strategies/reconstruction_pcl_poisson_stage.h @@ -0,0 +1,39 @@ +#ifndef RECONSTRUCTION_PCL_POISSON_STAGE_H +#define RECONSTRUCTION_PCL_POISSON_STAGE_H + +#include "../adapters/pcl/pcl_point_cloud_adapter.h" +#include "../adapters/reconstruction/reconstruction_adapter.h" +#include "../core/pipeline_config.h" +#include "../core/pipeline_stage.h" + +namespace strategies +{ +class PclPoissonReconstructionStage : public core::IReconstructionStage +{ +public: + explicit PclPoissonReconstructionStage(const core::ReconstructionConfig &config) + : m_config(config) + { + } + + QVector reconstruct( + const core::PointCloudFrame &frame, + const core::PipelineContext &, + core::PipelineStats &) const override + { + QVector triangles = adapters::pcl::buildPoissonTriangles( + frame.points, + m_config.pclPoissonDepth, + m_config.pclPoissonSamplesPerNode); + if (!triangles.isEmpty()) { + return triangles; + } + return adapters::reconstruction::buildSurfaceTriangles(frame.points, m_config); + } + +private: + core::ReconstructionConfig m_config; +}; +} // namespace strategies + +#endif // RECONSTRUCTION_PCL_POISSON_STAGE_H diff --git a/src/tests/pipeline_smoke_tests.cpp b/src/tests/pipeline_smoke_tests.cpp index 51bebc9..3c72ad5 100644 --- a/src/tests/pipeline_smoke_tests.cpp +++ b/src/tests/pipeline_smoke_tests.cpp @@ -145,6 +145,10 @@ bool runPluginChainCase(QString &failureReason) failureReason = "Plugin chain produced empty frame."; return false; } + if (result.stats.preprocessStepMetrics.size() != config.preprocessPlugins.size()) { + failureReason = "Per-stage preprocess metrics count mismatch."; + return false; + } return true; } diff --git a/src/ui/mainwindow.cpp b/src/ui/mainwindow.cpp index 7b0336b..b72805b 100644 --- a/src/ui/mainwindow.cpp +++ b/src/ui/mainwindow.cpp @@ -1,9 +1,17 @@ #include "mainwindow.h" #include +#include #include +#include +#include #include +#include #include +#include +#include +#include +#include #include #include #include @@ -11,6 +19,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include @@ -25,19 +39,53 @@ struct StageMeta const char *id; const char *title; const char *category; + const char *family; const char *hint; const char *defaults; }; const StageMeta kStageMeta[] = { - {"keep_largest_cluster", "Крупнейший кластер", "Фильтр", "Удаляет мелкие фрагменты и оставляет основной объект.", "clusterJoinDistanceScale=5.0"}, - {"downsample_dense", "Прореживание плотности", "Фильтр", "Уменьшает количество точек для более быстрой и устойчивой реконструкции.", "downsampleCellScale=0.8"}, - {"pcl_voxel_grid", "PCL Voxel Grid", "Фильтр", "Равномерное воксельное прореживание, хороший стартовый этап для шумных сканов.", "leaf=0.02"}, - {"pcl_statistical_outlier", "Статистическая фильтрация", "Фильтр", "Удаляет выбросы на основе статистики соседей.", "meanK=24,stddev=1.2"}, - {"pcl_radius_outlier", "Радиусная фильтрация", "Фильтр", "Удаляет точки с недостаточным числом соседей в заданном радиусе.", "radius=0.04,minNeighbors=8"}, + {"keep_largest_cluster", "Крупнейший кластер", "Обрезка", "preprocess", "Удаляет мелкие фрагменты и оставляет основной объект.", "clusterJoinDistanceScale=5.0"}, + {"pcl_remove_nan", "Remove NaN Points", "NaN (предочистка)", "preprocess", "Удаляет точки с NaN/Inf в X/Y/Z сразу после загрузки/обрезки.", ""}, + {"pcl_remove_nan_normals", "Remove NaN Normals", "NaN (предочистка)", "preprocess", "Удаляет точки с невалидными нормалями (в текущей модели — невалидной геометрией).", ""}, + {"pcl_pass_through", "PassThrough", "Обрезка", "preprocess", "Фильтр по диапазону одной координатной оси.", "axis=z,min=-1.0,max=1.0"}, + {"pcl_crop_box", "CropBox", "Обрезка", "preprocess", "Обрезка по границам 3D-параллелепипеда.", "minX=-1.0,minY=-1.0,minZ=-1.0,maxX=1.0,maxY=1.0,maxZ=1.0"}, + {"pcl_crop_hull", "CropHull", "Обрезка", "preprocess", "Обрезка по выпуклой области (в текущей версии использует box-границы).", "minX=-1.0,minY=-1.0,minZ=-1.0,maxX=1.0,maxY=1.0,maxZ=1.0"}, + {"pcl_frustum_culling", "Frustum Culling", "Обрезка", "preprocess", "Оставляет точки в пирамиде видимости.", "near=0.1,far=5.0,hfov=70,vfov=50"}, + {"pcl_plane_clipper_3d", "PlaneClipper3D", "Обрезка", "preprocess", "Отсечение по произвольной плоскости ax+by+cz+d=0.", "a=0.0,b=0.0,c=1.0,d=0.0,keepPositive=true"}, + {"pcl_conditional_removal", "Conditional Removal", "Условия/Индексы", "preprocess", "Удаление точек по логическому условию диапазона Z.", "zMin=-1.0,zMax=1.0"}, + {"pcl_extract_indices", "Extract Indices", "Условия/Индексы", "preprocess", "Извлечение точек по индексной маске (каждая N-я точка).", "nth=2"}, + {"pcl_functor_filter", "Functor Filter", "Условия/Индексы", "preprocess", "Фильтрация предикатом по расстоянию до начала координат.", "radiusMax=2.5"}, + {"pcl_project_inliers", "ProjectInliers", "Нормали", "preprocess", "Проецирование точек на заданную плоскость.", "a=0.0,b=0.0,c=1.0,d=0.0"}, + {"pcl_normal_refinement", "Normal Refinement", "Нормали", "preprocess", "Уточнение геометрии на основе локального усреднения.", "radius=0.1,iterations=1"}, + {"pcl_bilateral_filter", "Bilateral Filter", "Сглаживание", "preprocess", "Двустороннее сглаживание с сохранением границ.", "sigmaS=0.08,sigmaR=0.05"}, + {"pcl_fast_bilateral_filter", "Fast Bilateral Filter", "Сглаживание", "preprocess", "Быстрая версия bilateral-фильтра.", "sigmaS=0.08,sigmaR=0.05"}, + {"pcl_fast_bilateral_filter_omp", "Fast Bilateral Filter OMP", "Сглаживание", "preprocess", "Многопоточная версия bilateral-фильтра.", "sigmaS=0.08,sigmaR=0.05"}, + {"pcl_convolution", "Convolution", "Сглаживание", "preprocess", "Применение гауссова ядра свертки.", "sigma=0.08,kernel=3"}, + {"pcl_gaussian_kernel", "Gaussian Kernel", "Сглаживание", "preprocess", "Гауссово ядро свертки.", "sigma=0.08,kernel=3"}, + {"pcl_gaussian_kernel_rgb", "Gaussian Kernel RGB", "Сглаживание", "preprocess", "Гауссово ядро с RGB-ориентированной семантикой.", "sigma=0.08,kernel=3"}, + {"pcl_voxel_grid_occlusion", "VoxelGrid Occlusion Estimation", "Морфология", "preprocess", "Оценка окклюзии по заполненности воксельной сетки.", "leaf=0.12,minHits=2"}, + {"downsample_dense", "Прореживание плотности", "Прореживание", "preprocess", "Уменьшает количество точек для более быстрой и устойчивой реконструкции.", "downsampleCellScale=0.8"}, + {"pcl_voxel_grid", "PCL Voxel Grid", "Прореживание", "preprocess", "Равномерное воксельное прореживание, хороший стартовый этап для шумных сканов.", "leaf=0.02"}, + {"pcl_statistical_outlier", "Статистическая фильтрация", "Шум", "preprocess", "Удаляет выбросы на основе статистики соседей.", "meanK=24,stddev=1.2"}, + {"pcl_radius_outlier", "Радиусная фильтрация", "Шум", "preprocess", "Удаляет точки с недостаточным числом соседей в заданном радиусе.", "radius=0.04,minNeighbors=8"}, + {"pcl_model_outlier", "Model Outlier Removal", "Шум", "preprocess", "Удаляет точки, отклоняющиеся от геометрической модели плоскости.", "threshold=0.03"}, + {"pcl_shadow_points", "Shadow Points Removal", "Шум", "preprocess", "Удаляет теневые точки на основе ориентации нормалей поверхности.", "shadowThreshold=0.2"}, + {"pcl_approximate_voxel_grid", "Approximate Voxel Grid", "Прореживание", "preprocess", "Ускоренное воксельное прореживание для больших облаков.", "leaf=0.03"}, + {"pcl_voxel_grid_label", "Voxel Grid Label", "Прореживание", "preprocess", "Воксельное прореживание с учетом меток точек.", "leaf=0.04"}, + {"pcl_voxel_grid_covariance", "Voxel Grid Covariance", "Прореживание", "preprocess", "Воксельная сетка с ковариациями для задач NDT.", "leaf=0.04"}, + {"pcl_grid_minimum", "Grid Minimum", "Прореживание", "preprocess", "Оставляет точку с минимальным Z в каждой ячейке сетки.", "resolution=0.05"}, + {"pcl_farthest_point_sampling", "Farthest Point Sampling", "Прореживание", "preprocess", "Выбирает наиболее удаленные друг от друга точки.", "sample=800"}, + {"pcl_normal_space_sampling", "Normal Space Sampling", "Прореживание", "preprocess", "Равномерная выборка по пространству нормалей.", "sample=800"}, + {"pcl_sampling_surface_normal", "Sampling Surface Normal", "Прореживание", "preprocess", "Выборка на основе нормалей поверхности.", "sample=800"}, + {"surface_fallback", "Fallback Surface", "Реконструкция", "reconstruction", "Базовая реконструкция поверхности по соседству точек.", "neighborRadiusScale=3.5,runEveryNthFrame=1"}, + {"pcl_greedy_triangulation", "PCL Greedy Triangulation", "Реконструкция", "reconstruction", "Жадная триангуляция с параметрами радиуса и угла поверхности.", "searchRadius=0.08,mu=2.5,maxNearest=100,maxSurfaceAngle=0.8"}, + {"pcl_poisson_reconstruction", "PCL Poisson Reconstruction", "Реконструкция", "reconstruction", "Реконструкция поверхности методом Poisson (libpcl_surface).", "poissonDepth=8,samplesPerNode=1.5"}, }; const char *kReconGreedy = "pcl_greedy_triangulation"; +const char *kUserPresetPrefix = "user:"; +const char *kUserDataDirKey = "paths/userDataDir"; QVector generateDemoPoints(const QString &surfaceType, const int count) { @@ -48,7 +96,29 @@ QVector generateDemoPoints(const QString &surfaceType, const int for (int i = 0; i < count; ++i) { core::Point3f p; - if (surfaceType == "Тор") { + if (surfaceType == "Дно реки + труба") { + const bool samplePipe = rng->generateDouble() < 0.35; + if (samplePipe) { + const float angle = static_cast(rng->generateDouble() * 2.0 * M_PI); + const float y = static_cast(rng->generateDouble() * 2.6 - 1.3); + const float pipeRadius = 0.22f; + const float pipeX = 0.0f; + const float pipeZ = -0.62f; + const float jitter = static_cast(rng->generateDouble() * 0.02 - 0.01); + p.x = pipeX + (pipeRadius + jitter) * qCos(angle); + p.y = y; + p.z = pipeZ + (pipeRadius + jitter) * qSin(angle); + } else { + const float x = static_cast(rng->generateDouble() * 4.0 - 2.0); + const float y = static_cast(rng->generateDouble() * 3.0 - 1.5); + const float waviness = 0.11f * qCos(2.2f * y); + const float channel = 0.08f * x * x; + const float noise = static_cast(rng->generateDouble() * 0.04 - 0.02); + p.x = x; + p.y = y; + p.z = -0.45f + channel + waviness + noise; + } + } else if (surfaceType == "Тор") { const float u = static_cast(rng->generateDouble() * 2.0 * M_PI); const float v = static_cast(rng->generateDouble() * 2.0 * M_PI); const float majorR = 1.0f; @@ -202,6 +272,220 @@ bool applyStageDefaultsToConfig( } 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; } @@ -220,13 +504,27 @@ MainWindow::MainWindow(QWidget *parent) , m_uiMode("canvas") , m_wizardGoal("balanced") , m_wizardProfile("general") + , m_userDataDirectory(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)) + , m_busy(false) + , m_busyDepth(0) { setWindowTitle("DotsToSirface - QML Dashboard"); setCentralWidget(m_glView); + const QSettings settings; + const QString configuredUserDataDir = settings.value(kUserDataDirKey).toString().trimmed(); + if (!configuredUserDataDir.isEmpty()) { + m_userDataDirectory = configuredUserDataDir; + } + m_dashboardView->setMinimumWidth(420); m_dashboardView->setResizeMode(QQuickWidget::SizeRootObjectToView); m_dashboardView->rootContext()->setContextProperty("backend", this); + QString presetLoadError; + m_userPresets = loadUserPresetsFromDisk(&presetLoadError); + if (!presetLoadError.isEmpty()) { + updateStatusText(QString("Ошибка чтения пользовательских пресетов: %1").arg(presetLoadError)); + } m_dashboardView->setSource(QUrl("qrc:/pages/PipelineDashboard.qml")); if (m_dashboardView->status() == QQuickWidget::Error) { QStringList errors; @@ -250,7 +548,10 @@ MainWindow::MainWindow(QWidget *parent) addDockWidget(Qt::RightDockWidgetArea, m_dashboardDock); m_dashboardDock->setFloating(false); m_dashboardDock->show(); - resize(1680, 960); + const QRect availableDesktop = QGuiApplication::primaryScreen() + ? QGuiApplication::primaryScreen()->availableGeometry() + : QRect(0, 0, 1920, 1080); + resize(availableDesktop.width() * 4 / 5, availableDesktop.height() * 4 / 5); statusBar()->showMessage("QML dashboard loaded"); rebuildCardsFromConfig(); @@ -261,6 +562,7 @@ MainWindow::MainWindow(QWidget *parent) void MainWindow::rebuildSurface(const QVector &points, const QString &sourceLabel) { + beginBusy(); m_currentPoints = points; m_currentSourceLabel = sourceLabel; @@ -277,7 +579,18 @@ void MainWindow::rebuildSurface(const QVector &points, const QStr m_metrics["clusters"] = pipelineResult.stats.detectedClusters; m_metrics["removedPoints"] = pipelineResult.stats.removedClusterPoints + pipelineResult.stats.removedDownsamplePoints; m_metrics["reconstructMs"] = pipelineResult.stats.reconstructionMs; + m_stageMetrics.clear(); + for (const core::PipelineStats::PreprocessStepMetric &stepMetric : pipelineResult.stats.preprocessStepMetrics) { + QVariantMap row; + row["id"] = stepMetric.stageId; + row["inputPoints"] = stepMetric.inputPoints; + row["outputPoints"] = stepMetric.outputPoints; + row["removedPoints"] = stepMetric.removedPoints; + row["elapsedMs"] = stepMetric.elapsedMs; + m_stageMetrics.push_back(row); + } emit metricsChanged(); + emit stageMetricsChanged(); recomputeInsights(); updateStatusText( @@ -290,6 +603,7 @@ void MainWindow::rebuildSurface(const QVector &points, const QStr .arg(pipelineResult.stats.removedClusterPoints + pipelineResult.stats.removedDownsamplePoints) .arg(pipelineResult.stats.reconstructionMs) .arg(pipelineSummary())); + endBusy(); } bool MainWindow::loadPointsFromFile(const QString &filePath, QVector &points, QString &errorText) const @@ -312,6 +626,9 @@ void MainWindow::rebuildExecutorFromConfig() if (!card.enabled) { continue; } + if (card.family == "reconstruction") { + cfg.reconstructionPlugin = card.id; + } if (!applyStageDefaultsToConfig(card.id, card.defaults, cfg, errorText)) { QMessageBox::warning(this, "Параметры фильтра", errorText); return; @@ -329,24 +646,61 @@ void MainWindow::rebuildExecutorFromConfig() void MainWindow::applyPreset(const QString &presetId) { - if (presetId == "Fast") { + if (applyUserPreset(presetId)) { + return; + } + if (presetId.startsWith(kUserPresetPrefix)) { + if (!applyUserPreset(presetId)) { + QMessageBox::warning(this, "Пресеты", QString("Пользовательский пресет '%1' не найден.").arg(presetId)); + } + return; + } + + if (presetId == "LiDAR_scan") { m_pipelineConfig = core::makeDesktopDebugConfig(); - m_pipelineConfig.preprocessPlugins = QStringList() << "pcl_voxel_grid"; + m_pipelineConfig.preprocessPlugins = QStringList() + << "pcl_remove_nan" + << "keep_largest_cluster" + << "pcl_statistical_outlier" + << "pcl_voxel_grid"; + m_pipelineConfig.reconstructionPlugin = "surface_fallback"; + } else if (presetId == "RGBD_camera") { + m_pipelineConfig = core::makeDesktopDebugConfig(); + m_pipelineConfig.preprocessPlugins = QStringList() + << "pcl_remove_nan" + << "pcl_statistical_outlier" + << "pcl_radius_outlier" + << "pcl_voxel_grid"; + m_pipelineConfig.reconstructionPlugin = "pcl_greedy_triangulation"; + } else if (presetId == "Synthetic_clean") { + m_pipelineConfig = core::makeDesktopDebugConfig(); + m_pipelineConfig.preprocessPlugins = QStringList() + << "pcl_remove_nan" + << "downsample_dense" + << "pcl_voxel_grid"; + m_pipelineConfig.reconstructionPlugin = "pcl_greedy_triangulation"; + } else if (presetId == "Fast") { + m_pipelineConfig = core::makeDesktopDebugConfig(); + m_pipelineConfig.preprocessPlugins = QStringList() << "pcl_remove_nan" << "pcl_voxel_grid"; m_pipelineConfig.reconstructionPlugin = "surface_fallback"; } else if (presetId == "Robust") { m_pipelineConfig = core::makeDesktopDebugConfig(); - m_pipelineConfig.preprocessPlugins = QStringList() << "pcl_voxel_grid" << "pcl_statistical_outlier" << "pcl_radius_outlier"; + m_pipelineConfig.preprocessPlugins = QStringList() + << "pcl_remove_nan" + << "pcl_voxel_grid" + << "pcl_statistical_outlier" + << "pcl_radius_outlier"; m_pipelineConfig.reconstructionPlugin = "surface_fallback"; } else { m_pipelineConfig = core::makeDesktopDebugConfig(); - m_pipelineConfig.preprocessPlugins = QStringList() << "pcl_voxel_grid" << "pcl_statistical_outlier"; + m_pipelineConfig.preprocessPlugins = QStringList() << "pcl_remove_nan" << "pcl_voxel_grid" << "pcl_statistical_outlier"; m_pipelineConfig.reconstructionPlugin = "pcl_greedy_triangulation"; } - rebuildExecutorFromConfig(); - if (!m_currentPoints.isEmpty()) { - rebuildSurface(m_currentPoints, m_currentSourceLabel); - } + rebuildCardsFromConfig(); + recomputeInsights(); + notifyPipelineStateChanged(); + updateStatusText(QString("Пресет '%1' загружен в цепочку. Нажмите 'Применить' для запуска обработки.").arg(presetId)); } QStringList MainWindow::stageChain() const @@ -358,15 +712,43 @@ QStringList MainWindow::availableStages() const { return 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_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"; } QStringList MainWindow::availableReconstructions() const { - return QStringList() << "surface_fallback" << "pcl_greedy_triangulation"; + return QStringList() << "surface_fallback" << "pcl_greedy_triangulation" << "pcl_poisson_reconstruction"; } QString MainWindow::reconstructionMethod() const @@ -397,6 +779,7 @@ QVariantList MainWindow::stageCards() const m["id"] = card.id; m["title"] = card.title; m["category"] = card.category; + m["family"] = card.family; m["hint"] = card.hint; m["defaults"] = card.defaults; m["enabled"] = card.enabled; @@ -433,11 +816,25 @@ QVariantMap MainWindow::metrics() const return m_metrics; } +QVariantList MainWindow::stageMetrics() const +{ + return m_stageMetrics; +} + QString MainWindow::recommendation() const { return m_recommendation; } +QVariantList MainWindow::warningsList() const +{ + QVariantList list; + for (const QString &warning : m_warningsList) { + list.push_back(warning); + } + return list; +} + QString MainWindow::chainHealth() const { return m_chainHealth; @@ -450,7 +847,7 @@ QString MainWindow::uiMode() const QStringList MainWindow::demoSurfaceTypes() const { - return QStringList() << "Сфера" << "Тор" << "Волна"; + return QStringList() << "Сфера" << "Тор" << "Волна" << "Дно реки + труба"; } QString MainWindow::demoSurfaceType() const @@ -481,6 +878,27 @@ QVariantList MainWindow::snapshots() const return list; } +QVariantList MainWindow::presetItems() const +{ + QVariantList list; + for (const QVariant &preset : m_userPresets) { + const QVariantMap item = preset.toMap(); + if (item.value("title").toString().trimmed().isEmpty()) { + continue; + } + if (item.value("idValue").toString().trimmed().isEmpty()) { + continue; + } + list.push_back(item); + } + return list; +} + +bool MainWindow::busy() const +{ + return m_busy; +} + void MainWindow::generateDemo() { rebuildSurface(generateDemoPoints(m_demoSurfaceType, 350), QString("demo: %1").arg(m_demoSurfaceType)); @@ -509,8 +927,12 @@ void MainWindow::setStageChain(const QStringList &chain) { m_stageCards.clear(); for (const QString &id : chain) { - m_stageCards.push_back(createCardForStage(id, true)); + StageCardData card = createCardForStage(id, true); + if (card.family == "preprocess") { + m_stageCards.push_back(card); + } } + m_stageCards.push_back(createCardForStage(m_pipelineConfig.reconstructionPlugin, true)); if (m_selectedStageIndex >= m_stageCards.size()) { m_selectedStageIndex = m_stageCards.isEmpty() ? -1 : m_stageCards.size() - 1; } @@ -523,8 +945,20 @@ void MainWindow::setStageChain(const QStringList &chain) void MainWindow::addStage(const QString &stageId) { - m_stageCards.push_back(createCardForStage(stageId, true)); - m_selectedStageIndex = m_stageCards.size() - 1; + const StageCardData card = createCardForStage(stageId, true); + if (card.family == "reconstruction") { + setReconstructionMethod(stageId); + return; + } + int insertAt = m_stageCards.size(); + for (int i = 0; i < m_stageCards.size(); ++i) { + if (m_stageCards[i].family == "reconstruction") { + insertAt = i; + break; + } + } + m_stageCards.insert(insertAt, card); + m_selectedStageIndex = insertAt; emit stageCardsChanged(); emit selectedStageIndexChanged(); emit selectedStageChanged(); @@ -535,6 +969,10 @@ void MainWindow::addStage(const QString &stageId) void MainWindow::insertStage(const int index, const QString &stageId) { const StageCardData card = createCardForStage(stageId, true); + if (card.family == "reconstruction") { + setReconstructionMethod(stageId); + return; + } int insertAt = index; if (insertAt < 0) { insertAt = 0; @@ -542,6 +980,16 @@ void MainWindow::insertStage(const int index, const QString &stageId) if (insertAt > m_stageCards.size()) { insertAt = m_stageCards.size(); } + int firstReconstruction = m_stageCards.size(); + for (int i = 0; i < m_stageCards.size(); ++i) { + if (m_stageCards[i].family == "reconstruction") { + firstReconstruction = i; + break; + } + } + if (insertAt > firstReconstruction) { + insertAt = firstReconstruction; + } m_stageCards.insert(insertAt, card); m_selectedStageIndex = insertAt; emit stageCardsChanged(); @@ -556,6 +1004,9 @@ void MainWindow::removeStage(const int index) if (index < 0 || index >= m_stageCards.size()) { return; } + if (m_stageCards[index].family == "reconstruction") { + return; + } m_stageCards.remove(index); if (m_stageCards.isEmpty()) { m_selectedStageIndex = -1; @@ -574,6 +1025,9 @@ void MainWindow::moveStage(const int from, const int to) if (from < 0 || from >= m_stageCards.size() || to < 0 || to >= m_stageCards.size() || from == to) { return; } + if (m_stageCards[from].family == "reconstruction" || m_stageCards[to].family == "reconstruction") { + return; + } m_stageCards.move(from, to); m_selectedStageIndex = to; emit stageCardsChanged(); @@ -591,12 +1045,215 @@ void MainWindow::applyPipelineFromUi() } } +void MainWindow::saveCurrentPreset() +{ + bool ok = false; + const QString presetTitle = QInputDialog::getText( + this, + "Сохранить пресет", + "Название пресета:", + QLineEdit::Normal, + QString(), + &ok).trimmed(); + if (!ok) { + return; + } + if (presetTitle.isEmpty()) { + QMessageBox::warning(this, "Сохранить пресет", "Название пресета не может быть пустым."); + return; + } + + QVariantMap savedPreset; + savedPreset["title"] = presetTitle; + savedPreset["idValue"] = makeUserPresetId(presetTitle); + savedPreset["isUserPreset"] = true; + savedPreset["stages"] = stageCards(); + savedPreset["updatedAt"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); + + const QVariantList previousPresets = m_userPresets; + bool replaced = false; + for (int i = 0; i < m_userPresets.size(); ++i) { + const QVariantMap current = m_userPresets[i].toMap(); + if (current.value("title").toString() == presetTitle) { + savedPreset["createdAt"] = current.value("createdAt").toString(); + m_userPresets[i] = savedPreset; + replaced = true; + break; + } + } + if (!replaced) { + savedPreset["createdAt"] = savedPreset.value("updatedAt").toString(); + m_userPresets.push_back(savedPreset); + } + + QString saveError; + if (!saveUserPresetsToDisk(m_userPresets, &saveError)) { + m_userPresets = previousPresets; + QMessageBox::warning(this, "Сохранить пресет", QString("Не удалось сохранить пресет: %1").arg(saveError)); + return; + } + + emit presetItemsChanged(); + updateStatusText(replaced + ? QString("Пресет '%1' обновлен").arg(presetTitle) + : QString("Пресет '%1' сохранен").arg(presetTitle)); +} + +void MainWindow::loadPresetFromFile() +{ + const QString filePath = QFileDialog::getOpenFileName( + this, + "Загрузить пресет", + QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation), + "JSON files (*.json);;All files (*.*)"); + if (filePath.isEmpty()) { + return; + } + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + QMessageBox::warning(this, "Загрузить пресет", QString("Не удалось открыть файл: %1").arg(file.errorString())); + return; + } + + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + QMessageBox::warning( + this, + "Загрузить пресет", + parseError.error != QJsonParseError::NoError + ? QString("Некорректный JSON: %1").arg(parseError.errorString()) + : "Некорректный формат файла: ожидался JSON-объект пресета."); + return; + } + + QVariantMap normalizedPreset; + QString normalizeError; + if (!normalizeImportedPreset(doc.object().toVariantMap(), normalizedPreset, normalizeError)) { + QMessageBox::warning(this, "Загрузить пресет", normalizeError); + return; + } + + const QString filePresetTitle = QFileInfo(filePath).completeBaseName().trimmed(); + if (!filePresetTitle.isEmpty()) { + normalizedPreset["title"] = filePresetTitle; + normalizedPreset["idValue"] = makeUserPresetId(filePresetTitle); + } + + const QString importedTitle = normalizedPreset.value("title").toString(); + const QString importedId = normalizedPreset.value("idValue").toString(); + const QVariantList previousPresets = m_userPresets; + + bool replaced = false; + for (int i = 0; i < m_userPresets.size(); ++i) { + const QVariantMap existing = m_userPresets[i].toMap(); + if (existing.value("idValue").toString() == importedId || existing.value("title").toString() == importedTitle) { + QVariantMap merged = normalizedPreset; + merged["createdAt"] = existing.value("createdAt").toString().isEmpty() + ? normalizedPreset.value("createdAt").toString() + : existing.value("createdAt").toString(); + m_userPresets[i] = merged; + replaced = true; + break; + } + } + if (!replaced) { + m_userPresets.push_back(normalizedPreset); + } + + QString saveError; + if (!saveUserPresetsToDisk(m_userPresets, &saveError)) { + m_userPresets = previousPresets; + QMessageBox::warning(this, "Загрузить пресет", QString("Не удалось сохранить пресет: %1").arg(saveError)); + return; + } + + emit presetItemsChanged(); + updateStatusText(replaced + ? QString("Пресет '%1' обновлен из файла").arg(importedTitle) + : QString("Пресет '%1' загружен из файла").arg(importedTitle)); +} + +void MainWindow::exportCapabilitiesToFile() +{ + QVariantMap payload; + payload["exportedAt"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); + payload["availableStages"] = availableStages(); + payload["availableReconstructions"] = availableReconstructions(); + payload["stageLibrary"] = stageLibrary(); + payload["stageCards"] = stageCards(); + payload["stageChain"] = stageChain(); + payload["presetItems"] = presetItems(); + payload["reconstructionMethod"] = reconstructionMethod(); + payload["pipelinePreview"] = pipelinePreview(); + + const QString defaultPath = QDir(userDataDirectoryPath()).filePath("pipeline_capabilities.json"); + const QString filePath = QFileDialog::getSaveFileName( + this, + "Выгрузка возможностей пайплайна", + defaultPath, + "JSON files (*.json);;All files (*.*)"); + if (filePath.isEmpty()) { + return; + } + + QDir targetDir(QFileInfo(filePath).absolutePath()); + if (!targetDir.exists() && !targetDir.mkpath(".")) { + QMessageBox::warning(this, "Выгрузка возможностей", "Не удалось создать каталог для выгрузки."); + return; + } + + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + QMessageBox::warning(this, "Выгрузка возможностей", QString("Не удалось открыть файл: %1").arg(file.errorString())); + return; + } + const QJsonDocument doc = QJsonDocument::fromVariant(payload); + if (file.write(doc.toJson(QJsonDocument::Indented)) < 0) { + QMessageBox::warning(this, "Выгрузка возможностей", QString("Не удалось записать файл: %1").arg(file.errorString())); + return; + } + updateStatusText(QString("Возможности выгружены: %1").arg(QFileInfo(filePath).fileName())); +} + +void MainWindow::chooseUserDataDirectory() +{ + const QString selectedDir = QFileDialog::getExistingDirectory( + this, + "Выберите папку пользовательских данных", + userDataDirectoryPath(), + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + if (selectedDir.isEmpty()) { + return; + } + + QDir dir(selectedDir); + if (!dir.exists() && !dir.mkpath(".")) { + QMessageBox::warning(this, "Папка данных", "Не удалось создать выбранную папку."); + return; + } + + m_userDataDirectory = selectedDir; + QSettings settings; + settings.setValue(kUserDataDirKey, m_userDataDirectory); + + QString loadError; + m_userPresets = loadUserPresetsFromDisk(&loadError); + emit presetItemsChanged(); + if (!loadError.isEmpty()) { + QMessageBox::warning(this, "Папка данных", QString("Папка обновлена, но не удалось прочитать пресеты: %1").arg(loadError)); + } + updateStatusText(QString("Папка пользовательских данных: %1").arg(m_userDataDirectory)); +} + void MainWindow::setReconstructionMethod(const QString &pluginId) { if (m_pipelineConfig.reconstructionPlugin == pluginId) { return; } m_pipelineConfig.reconstructionPlugin = pluginId; + rebuildCardsFromConfig(); recomputeInsights(); emit reconstructionMethodChanged(); emit pipelinePreviewChanged(); @@ -627,6 +1284,9 @@ void MainWindow::setStageEnabled(const int index, const bool enabled) if (index < 0 || index >= m_stageCards.size()) { return; } + if (m_stageCards[index].family == "reconstruction") { + return; + } if (m_stageCards[index].enabled == enabled) { return; } @@ -709,19 +1369,35 @@ void MainWindow::setWizardProfile(const QString &profile) if (profile == m_wizardProfile) { return; } + const QStringList allowedProfiles = QStringList() << "general" << "urban_scan" << "indoor_object"; + if (!allowedProfiles.contains(profile)) { + return; + } m_wizardProfile = profile; emit wizardProfileChanged(); } void MainWindow::applyWizardSuggestion() { - if (m_wizardGoal == "speed") { - applyPreset("Fast"); - } else if (m_wizardGoal == "quality") { - applyPreset("HighDetail"); - } else { - applyPreset("Robust"); + QString presetId = "Synthetic_clean"; + if (m_wizardProfile == "urban_scan") { + presetId = "LiDAR_scan"; + } else if (m_wizardProfile == "indoor_object") { + presetId = "RGBD_camera"; } + + if (m_wizardGoal == "speed") { + if (presetId == "LiDAR_scan") { + presetId = "Synthetic_clean"; + } else if (presetId == "RGBD_camera") { + presetId = "Fast"; + } + } else if (m_wizardGoal == "quality") { + if (presetId == "Synthetic_clean") { + presetId = "RGBD_camera"; + } + } + applyPreset(presetId); } void MainWindow::saveSnapshot(const QString &slotName) @@ -732,6 +1408,7 @@ void MainWindow::saveSnapshot(const QString &slotName) QVariantMap snap; snap["stages"] = stageCards(); snap["reconstruction"] = m_pipelineConfig.reconstructionPlugin; + snap["version"] = 2; snap["summary"] = pipelineSummary(); m_savedSnapshots.insert(slotName, snap); emit snapshotsChanged(); @@ -746,12 +1423,38 @@ void MainWindow::loadSnapshot(const QString &slotName) const QVariantMap snap = m_savedSnapshots.value(slotName).toMap(); const QVariantList stages = snap.value("stages").toList(); m_stageCards.clear(); + QString reconstructionFromCards; for (const QVariant &entry : stages) { const QVariantMap m = entry.toMap(); - StageCardData card = createCardForStage(m.value("id").toString(), m.value("enabled").toBool()); - m_stageCards.push_back(card); + StageCardData card = createCardForStage(m.value("id").toString(), m.value("enabled", true).toBool()); + if (m.contains("defaults")) { + card.defaults = m.value("defaults").toString(); + } + if (m.contains("family")) { + card.family = m.value("family").toString(); + } + if (card.family == "reconstruction") { + reconstructionFromCards = card.id; + } else { + m_stageCards.push_back(card); + } } - m_pipelineConfig.reconstructionPlugin = snap.value("reconstruction").toString(); + const QString reconstructionFromLegacy = snap.value("reconstruction").toString(); + m_pipelineConfig.reconstructionPlugin = !reconstructionFromCards.isEmpty() + ? reconstructionFromCards + : reconstructionFromLegacy; + if (m_pipelineConfig.reconstructionPlugin.isEmpty()) { + m_pipelineConfig.reconstructionPlugin = "surface_fallback"; + } + StageCardData reconstructionCard = createCardForStage(m_pipelineConfig.reconstructionPlugin, true); + for (const QVariant &entry : stages) { + const QVariantMap m = entry.toMap(); + if (m.value("id").toString() == m_pipelineConfig.reconstructionPlugin && m.contains("defaults")) { + reconstructionCard.defaults = m.value("defaults").toString(); + break; + } + } + m_stageCards.push_back(reconstructionCard); m_selectedStageIndex = m_stageCards.isEmpty() ? -1 : 0; emit stageCardsChanged(); emit selectedStageIndexChanged(); @@ -769,10 +1472,38 @@ bool MainWindow::normalizeAndValidateConfig(core::PipelineConfig &config, QStrin 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_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; @@ -789,7 +1520,7 @@ bool MainWindow::normalizeAndValidateConfig(core::PipelineConfig &config, QStrin } config.preprocessPlugins = normalized; - const QStringList reconAllowed = QStringList() << "surface_fallback" << "pcl_greedy_triangulation"; + 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; @@ -834,6 +1565,7 @@ QVariantMap MainWindow::makeStageLibraryEntry(const StageCardData &card) const m["id"] = card.id; m["title"] = card.title; m["category"] = card.category; + m["family"] = card.family; m["hint"] = card.hint; m["defaults"] = card.defaults; m["enabled"] = card.enabled; @@ -844,15 +1576,24 @@ void MainWindow::rebuildCardsFromConfig() { const QVector previousCards = m_stageCards; m_stageCards.clear(); + QMap previousDefaults; + for (const StageCardData &card : previousCards) { + previousDefaults.insert(card.id, card.defaults); + } for (int i = 0; i < m_pipelineConfig.preprocessPlugins.size(); ++i) { const QString &id = m_pipelineConfig.preprocessPlugins[i]; StageCardData card = createCardForStage(id, true); - if (i < previousCards.size() && previousCards[i].id == id) { + if (previousDefaults.contains(id)) { // Keep user-edited parameters across executor rebuilds. - card.defaults = previousCards[i].defaults; + card.defaults = previousDefaults.value(id); } m_stageCards.push_back(card); } + StageCardData reconstructionCard = createCardForStage(m_pipelineConfig.reconstructionPlugin, true); + if (previousDefaults.contains(reconstructionCard.id)) { + reconstructionCard.defaults = previousDefaults.value(reconstructionCard.id); + } + m_stageCards.push_back(reconstructionCard); if (m_selectedStageIndex < 0 && !m_stageCards.isEmpty()) { m_selectedStageIndex = 0; } @@ -864,21 +1605,74 @@ void MainWindow::rebuildCardsFromConfig() void MainWindow::recomputeInsights() { const QStringList stages = activeStageIds(); + QStringList warnings; + bool riskState = false; + + const int voxelIdx = stages.indexOf("pcl_voxel_grid"); + const int removeNanIdx = stages.indexOf("pcl_remove_nan"); + const int removeNanNormalsIdx = stages.indexOf("pcl_remove_nan_normals"); + const int sorIdx = stages.indexOf("pcl_statistical_outlier"); + const int rorIdx = stages.indexOf("pcl_radius_outlier"); + const int morIdx = stages.indexOf("pcl_model_outlier"); + const int shadowIdx = stages.indexOf("pcl_shadow_points"); + int firstOutlierIdx = -1; + const QVector outlierIndexes = QVector() << sorIdx << rorIdx << morIdx << shadowIdx; + for (int i = 0; i < outlierIndexes.size(); ++i) { + const int idx = outlierIndexes[i]; + if (idx >= 0 && (firstOutlierIdx < 0 || idx < firstOutlierIdx)) { + firstOutlierIdx = idx; + } + } + if (stages.isEmpty()) { + riskState = true; + warnings << "Добавьте хотя бы один этап препроцессинга перед реконструкцией."; + } else { + if (firstOutlierIdx < 0) { + warnings << "Добавьте outlier removal для стабилизации триангуляции."; + } + if (firstOutlierIdx >= 0) { + int firstNaNPrecleanIdx = -1; + if (removeNanIdx >= 0) { + firstNaNPrecleanIdx = removeNanIdx; + } + if (removeNanNormalsIdx >= 0 && (firstNaNPrecleanIdx < 0 || removeNanNormalsIdx < firstNaNPrecleanIdx)) { + firstNaNPrecleanIdx = removeNanNormalsIdx; + } + if (firstNaNPrecleanIdx < 0 || firstNaNPrecleanIdx > firstOutlierIdx) { + warnings << "Удалите NaN сразу после загрузки/обрезки: иначе статистические фильтры работают нестабильно."; + } + } + if (voxelIdx >= 0 && firstOutlierIdx >= 0 && firstOutlierIdx > voxelIdx) { + warnings << "OutlierRemoval стоит после VoxelGrid: лучше сначала очистить шум, затем прореживать."; + } + if (m_pipelineConfig.reconstructionPlugin == kReconGreedy && voxelIdx < 0) { + warnings << "Greedy triangulation обычно работает лучше после voxel downsampling."; + } + } + + if (riskState) { m_chainHealth = "Risk"; - m_recommendation = "Add at least one preprocess stage before reconstruction."; - } else if (!stages.contains("pcl_statistical_outlier") && !stages.contains("pcl_radius_outlier")) { + } else if (!warnings.isEmpty()) { m_chainHealth = "Warning"; - m_recommendation = "Consider adding outlier removal to stabilize triangulation."; - } else if (m_pipelineConfig.reconstructionPlugin == kReconGreedy && !stages.contains("pcl_voxel_grid")) { - m_chainHealth = "Warning"; - m_recommendation = "Greedy triangulation works better with voxel downsampling."; } else { m_chainHealth = "OK"; m_recommendation = "Pipeline looks balanced. Use Compare snapshots for A/B tuning."; } + + if (m_chainHealth == "Risk" || m_chainHealth == "Warning") { + m_recommendation = warnings.isEmpty() + ? "Проверьте порядок этапов пайплайна." + : warnings.first(); + } + + const bool warningsChanged = (m_warningsList != warnings); + m_warningsList = warnings; emit chainHealthChanged(); emit recommendationChanged(); + if (warningsChanged) { + emit warningsListChanged(); + } } QString MainWindow::displayNameForStage(const QString &stageId) const @@ -895,11 +1689,13 @@ MainWindow::StageCardData MainWindow::createCardForStage(const QString &stageId, if (meta) { card.title = meta->title; card.category = meta->category; + card.family = meta->family; card.hint = meta->hint; card.defaults = meta->defaults; } else { card.title = stageId; card.category = "Custom"; + card.family = "preprocess"; card.hint = "No hint registered for this stage."; card.defaults = "-"; } @@ -911,9 +1707,256 @@ QStringList MainWindow::activeStageIds() const { QStringList ids; for (const StageCardData &card : m_stageCards) { - if (card.enabled) { + if (card.enabled && card.family == "preprocess") { ids.push_back(card.id); } } return ids; } + +QVariantList MainWindow::builtInPresetItems() const +{ + QVariantList items; + + QVariantMap lidar; + lidar["title"] = "LiDAR-скан"; + lidar["idValue"] = "LiDAR_scan"; + items.push_back(lidar); + + QVariantMap rgbd; + rgbd["title"] = "RGB-D камера"; + rgbd["idValue"] = "RGBD_camera"; + items.push_back(rgbd); + + QVariantMap synthetic; + synthetic["title"] = "Синтетика"; + synthetic["idValue"] = "Synthetic_clean"; + items.push_back(synthetic); + + return items; +} + +QString MainWindow::userDataDirectoryPath() const +{ + if (!m_userDataDirectory.trimmed().isEmpty()) { + return m_userDataDirectory; + } + return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); +} + +QString MainWindow::userPresetsFilePath() const +{ + return QDir(userDataDirectoryPath()).filePath("pipeline_presets.json"); +} + +QVariantList MainWindow::loadUserPresetsFromDisk(QString *errorText) const +{ + if (errorText) { + errorText->clear(); + } + + const QString filePath = userPresetsFilePath(); + QFile file(filePath); + if (!file.exists()) { + return QVariantList(); + } + if (!file.open(QIODevice::ReadOnly)) { + if (errorText) { + *errorText = file.errorString(); + } + return QVariantList(); + } + + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isArray()) { + if (errorText) { + *errorText = parseError.error != QJsonParseError::NoError + ? parseError.errorString() + : "Некорректный формат JSON: ожидался массив пресетов."; + } + return QVariantList(); + } + + QVariantList presets; + const QJsonArray arr = doc.array(); + for (const QJsonValue &value : arr) { + if (!value.isObject()) { + continue; + } + const QVariantMap preset = value.toObject().toVariantMap(); + if (preset.value("title").toString().isEmpty() || preset.value("idValue").toString().isEmpty()) { + continue; + } + presets.push_back(preset); + } + return presets; +} + +bool MainWindow::saveUserPresetsToDisk(const QVariantList &presets, QString *errorText) const +{ + if (errorText) { + errorText->clear(); + } + + const QString filePath = userPresetsFilePath(); + QDir dir(QFileInfo(filePath).absolutePath()); + if (!dir.exists() && !dir.mkpath(".")) { + if (errorText) { + *errorText = "Не удалось создать каталог для пресетов."; + } + return false; + } + + QJsonArray arr; + for (const QVariant &preset : presets) { + arr.push_back(QJsonObject::fromVariantMap(preset.toMap())); + } + + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + if (errorText) { + *errorText = file.errorString(); + } + return false; + } + if (file.write(QJsonDocument(arr).toJson(QJsonDocument::Indented)) < 0) { + if (errorText) { + *errorText = file.errorString(); + } + return false; + } + return true; +} + +bool MainWindow::applyUserPreset(const QString &presetId) +{ + for (const QVariant &presetValue : m_userPresets) { + const QVariantMap preset = presetValue.toMap(); + if (preset.value("idValue").toString() != presetId) { + continue; + } + const QVariantList stages = preset.value("stages").toList(); + if (stages.isEmpty()) { + return false; + } + + m_stageCards.clear(); + QString reconstructionFromCards; + for (const QVariant &entry : stages) { + const QVariantMap m = entry.toMap(); + StageCardData card = createCardForStage(m.value("id").toString(), m.value("enabled", true).toBool()); + if (m.contains("defaults")) { + card.defaults = m.value("defaults").toString(); + } + if (m.contains("family")) { + card.family = m.value("family").toString(); + } + if (card.family == "reconstruction") { + reconstructionFromCards = card.id; + } else { + m_stageCards.push_back(card); + } + } + m_pipelineConfig.reconstructionPlugin = reconstructionFromCards.isEmpty() + ? "surface_fallback" + : reconstructionFromCards; + StageCardData reconstructionCard = createCardForStage(m_pipelineConfig.reconstructionPlugin, true); + for (const QVariant &entry : stages) { + const QVariantMap m = entry.toMap(); + if (m.value("id").toString() == m_pipelineConfig.reconstructionPlugin && m.contains("defaults")) { + reconstructionCard.defaults = m.value("defaults").toString(); + break; + } + } + m_stageCards.push_back(reconstructionCard); + m_selectedStageIndex = m_stageCards.isEmpty() ? -1 : 0; + emit stageCardsChanged(); + emit selectedStageIndexChanged(); + emit selectedStageChanged(); + + recomputeInsights(); + notifyPipelineStateChanged(); + updateStatusText(QString("Пользовательский пресет '%1' загружен в цепочку. Нажмите 'Применить' для запуска обработки.") + .arg(preset.value("title").toString())); + return true; + } + return false; +} + +QString MainWindow::makeUserPresetId(const QString &name) const +{ + QString normalized = name.toLower().trimmed(); + normalized.replace(' ', '_'); + QString cleaned; + cleaned.reserve(normalized.size()); + for (const QChar ch : normalized) { + if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-') { + cleaned.push_back(ch); + } + } + normalized = cleaned; + if (normalized.isEmpty()) { + normalized = "preset"; + } + return QString("%1%2").arg(kUserPresetPrefix, normalized); +} + +bool MainWindow::normalizeImportedPreset(const QVariantMap &input, QVariantMap &normalized, QString &errorText) const +{ + errorText.clear(); + normalized.clear(); + + const QString title = input.value("title").toString().trimmed(); + if (title.isEmpty()) { + errorText = "В файле пресета отсутствует поле 'title'."; + return false; + } + + const QVariantList stages = input.value("stages").toList(); + if (stages.isEmpty()) { + errorText = "В файле пресета отсутствует непустой список 'stages'."; + return false; + } + + for (const QVariant &stageValue : stages) { + const QVariantMap stage = stageValue.toMap(); + if (stage.value("id").toString().trimmed().isEmpty()) { + errorText = "Некорректный элемент в 'stages': требуется поле 'id'."; + return false; + } + } + + const QString nowIso = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); + normalized["title"] = title; + const QString inputId = input.value("idValue").toString().trimmed(); + normalized["idValue"] = inputId.isEmpty() ? makeUserPresetId(title) : inputId; + normalized["isUserPreset"] = true; + normalized["stages"] = stages; + normalized["createdAt"] = input.value("createdAt").toString().isEmpty() ? nowIso : input.value("createdAt").toString(); + normalized["updatedAt"] = nowIso; + return true; +} + +void MainWindow::beginBusy() +{ + ++m_busyDepth; + if (m_busyDepth == 1) { + m_busy = true; + emit busyChanged(); + QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents); + } +} + +void MainWindow::endBusy() +{ + if (m_busyDepth <= 0) { + return; + } + --m_busyDepth; + if (m_busyDepth == 0) { + m_busy = false; + emit busyChanged(); + QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents); + } +} diff --git a/src/ui/mainwindow.h b/src/ui/mainwindow.h index 4ffe089..c12b6e0 100644 --- a/src/ui/mainwindow.h +++ b/src/ui/mainwindow.h @@ -31,7 +31,9 @@ class MainWindow : public QMainWindow Q_PROPERTY(int selectedStageIndex READ selectedStageIndex WRITE setSelectedStageIndex NOTIFY selectedStageIndexChanged) Q_PROPERTY(QVariantMap selectedStage READ selectedStage NOTIFY selectedStageChanged) Q_PROPERTY(QVariantMap metrics READ metrics NOTIFY metricsChanged) + Q_PROPERTY(QVariantList stageMetrics READ stageMetrics NOTIFY stageMetricsChanged) Q_PROPERTY(QString recommendation READ recommendation NOTIFY recommendationChanged) + Q_PROPERTY(QVariantList warningsList READ warningsList NOTIFY warningsListChanged) Q_PROPERTY(QString chainHealth READ chainHealth NOTIFY chainHealthChanged) Q_PROPERTY(QStringList demoSurfaceTypes READ demoSurfaceTypes CONSTANT) Q_PROPERTY(QString demoSurfaceType READ demoSurfaceType WRITE setDemoSurfaceType NOTIFY demoSurfaceTypeChanged) @@ -39,6 +41,8 @@ class MainWindow : public QMainWindow Q_PROPERTY(QString wizardGoal READ wizardGoal WRITE setWizardGoal NOTIFY wizardGoalChanged) Q_PROPERTY(QString wizardProfile READ wizardProfile WRITE setWizardProfile NOTIFY wizardProfileChanged) Q_PROPERTY(QVariantList snapshots READ snapshots NOTIFY snapshotsChanged) + Q_PROPERTY(QVariantList presetItems READ presetItems NOTIFY presetItemsChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) public: explicit MainWindow(QWidget *parent = nullptr); @@ -54,7 +58,9 @@ public: int selectedStageIndex() const; QVariantMap selectedStage() const; QVariantMap metrics() const; + QVariantList stageMetrics() const; QString recommendation() const; + QVariantList warningsList() const; QString chainHealth() const; QStringList demoSurfaceTypes() const; QString demoSurfaceType() const; @@ -62,6 +68,8 @@ public: QString wizardGoal() const; QString wizardProfile() const; QVariantList snapshots() const; + QVariantList presetItems() const; + bool busy() const; public slots: void generateDemo(); @@ -87,12 +95,17 @@ public slots: void applyWizardSuggestion(); void saveSnapshot(const QString &slotName); void loadSnapshot(const QString &slotName); + void saveCurrentPreset(); + void loadPresetFromFile(); + void exportCapabilitiesToFile(); + void chooseUserDataDirectory(); private: struct StageCardData { QString id; QString title; QString category; + QString family; QString hint; QString defaults; bool enabled; @@ -111,6 +124,16 @@ private: QString displayNameForStage(const QString &stageId) const; StageCardData createCardForStage(const QString &stageId, bool enabled = true) const; QStringList activeStageIds() const; + QVariantList builtInPresetItems() const; + QString userDataDirectoryPath() const; + QString userPresetsFilePath() const; + QVariantList loadUserPresetsFromDisk(QString *errorText = nullptr) const; + bool saveUserPresetsToDisk(const QVariantList &presets, QString *errorText = nullptr) const; + bool applyUserPreset(const QString &presetId); + QString makeUserPresetId(const QString &name) const; + bool normalizeImportedPreset(const QVariantMap &input, QVariantMap &normalized, QString &errorText) const; + void beginBusy(); + void endBusy(); GlView *m_glView; QQuickWidget *m_dashboardView; @@ -124,13 +147,19 @@ private: QVector m_stageCards; int m_selectedStageIndex; QVariantMap m_metrics; + QVariantList m_stageMetrics; QString m_recommendation; + QStringList m_warningsList; QString m_chainHealth; QString m_demoSurfaceType; QString m_uiMode; QString m_wizardGoal; QString m_wizardProfile; QVariantMap m_savedSnapshots; + QVariantList m_userPresets; + QString m_userDataDirectory; + bool m_busy; + int m_busyDepth; signals: void stageChainChanged(); @@ -142,13 +171,17 @@ signals: void selectedStageIndexChanged(); void selectedStageChanged(); void metricsChanged(); + void stageMetricsChanged(); void recommendationChanged(); + void warningsListChanged(); void chainHealthChanged(); void demoSurfaceTypeChanged(); void uiModeChanged(); void wizardGoalChanged(); void wizardProfileChanged(); void snapshotsChanged(); + void presetItemsChanged(); + void busyChanged(); }; #endif // MAINWINDOW_H diff --git a/src/ui/qml/components/pipeline/MetricsStrip.qml b/src/ui/qml/components/pipeline/MetricsStrip.qml index a675a88..8320fa8 100644 --- a/src/ui/qml/components/pipeline/MetricsStrip.qml +++ b/src/ui/qml/components/pipeline/MetricsStrip.qml @@ -8,6 +8,8 @@ GroupBox { readonly property var safeBackend: backend ? backend : ({ chainHealth: "", recommendation: "", + warningsList: [], + stageMetrics: [], metrics: ({ triangles: 0, reconstructMs: 0, @@ -27,36 +29,88 @@ GroupBox { return value } - ColumnLayout { + ScrollView { anchors.fill: parent - spacing: Theme.spacingXs + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AsNeeded - RowLayout { - Layout.fillWidth: true - Label { text: "Состояние: " + localizedHealth(safeBackend.chainHealth); color: Theme.summaryPrimaryText; font.bold: true; font.pixelSize: Theme.fontMd } - Item { Layout.fillWidth: true } - Label { text: "Треугольники: " + safeBackend.metrics.triangles; color: Theme.summarySecondaryText; font.pixelSize: Theme.fontSm } - Label { text: "Реконструкция, мс: " + safeBackend.metrics.reconstructMs; color: Theme.summarySecondaryText; font.pixelSize: Theme.fontSm } - } - - Label { - Layout.fillWidth: true - wrapMode: Text.WordWrap - text: safeBackend.recommendation - color: Theme.summaryRecommendation - font.pixelSize: Theme.fontSm - } - - Flow { - Layout.fillWidth: true - Layout.topMargin: Theme.spacingXs / 2 + ColumnLayout { width: parent.width - spacing: Theme.spacingLg - clip: true - Label { text: "Вход: " + safeBackend.metrics.inputPoints; color: Theme.summaryDetailText; font.pixelSize: Theme.fontXs } - Label { text: "После препроцесса: " + safeBackend.metrics.afterPreprocess; color: Theme.summaryDetailText; font.pixelSize: Theme.fontXs } - Label { text: "Удалено: " + safeBackend.metrics.removedPoints; color: Theme.summaryDetailText; font.pixelSize: Theme.fontXs } - Label { text: "Кластеры: " + safeBackend.metrics.clusters; color: Theme.summaryDetailText; font.pixelSize: Theme.fontXs } + spacing: Theme.spacingXs + + RowLayout { + Layout.fillWidth: true + Label { text: "Состояние: " + localizedHealth(safeBackend.chainHealth); color: Theme.summaryPrimaryText; font.bold: true; font.pixelSize: Theme.fontMd } + Item { Layout.fillWidth: true } + Label { text: "Треугольники: " + safeBackend.metrics.triangles; color: Theme.summarySecondaryText; font.pixelSize: Theme.fontSm } + Label { text: "Реконструкция, мс: " + safeBackend.metrics.reconstructMs; color: Theme.summarySecondaryText; font.pixelSize: Theme.fontSm } + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: safeBackend.recommendation + color: Theme.summaryRecommendation + font.pixelSize: Theme.fontSm + } + + ColumnLayout { + Layout.fillWidth: true + visible: safeBackend.warningsList && safeBackend.warningsList.length > 0 + spacing: Theme.spacingXs / 2 + + Repeater { + model: safeBackend.warningsList + delegate: Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: "\u2022 " + modelData + color: Theme.summaryRecommendation + font.pixelSize: Theme.fontXs + } + } + } + + Flow { + Layout.fillWidth: true + Layout.topMargin: Theme.spacingXs / 2 + width: parent.width + spacing: Theme.spacingLg + clip: true + Label { text: "Вход: " + safeBackend.metrics.inputPoints; color: Theme.summaryDetailText; font.pixelSize: Theme.fontXs } + Label { text: "После препроцесса: " + safeBackend.metrics.afterPreprocess; color: Theme.summaryDetailText; font.pixelSize: Theme.fontXs } + Label { text: "Удалено: " + safeBackend.metrics.removedPoints; color: Theme.summaryDetailText; font.pixelSize: Theme.fontXs } + Label { text: "Кластеры: " + safeBackend.metrics.clusters; color: Theme.summaryDetailText; font.pixelSize: Theme.fontXs } + } + + ColumnLayout { + Layout.fillWidth: true + visible: safeBackend.stageMetrics && safeBackend.stageMetrics.length > 0 + spacing: Theme.spacingXs / 2 + + Label { + text: "Метрики по шагам" + color: Theme.summarySecondaryText + font.bold: true + font.pixelSize: Theme.fontXs + } + + Repeater { + model: safeBackend.stageMetrics + delegate: Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: "\u2022 " + modelData.id + + ": " + modelData.inputPoints + + " \u2192 " + modelData.outputPoints + + " (\u2212" + modelData.removedPoints + "), " + + modelData.elapsedMs + " мс" + color: Theme.summaryDetailText + font.pixelSize: Theme.fontXs + } + } + } } } } diff --git a/src/ui/qml/components/pipeline/StageChainView.qml b/src/ui/qml/components/pipeline/StageChainView.qml index ac8ce34..64ef2db 100644 --- a/src/ui/qml/components/pipeline/StageChainView.qml +++ b/src/ui/qml/components/pipeline/StageChainView.qml @@ -1,7 +1,6 @@ import QtQuick 2.12 import QtQuick.Controls 2.12 import QtQuick.Layouts 1.12 -import "../../shared/PipelineUiCatalog.js" as UiCatalog import "../../shared/Theme.js" as Theme Item { @@ -9,14 +8,34 @@ Item { property var safeBackend property int selectedIndex: -1 signal settingsRequested(var stageItem) + function categoryAccentColor(category) { + if (category === "Обрезка") + return "#88d1ff" + if (category === "NaN (предочистка)") + return "#8fe8ff" + if (category === "Условия/Индексы") + return "#9fd7ff" + if (category === "Шум") + return "#a4f4b9" + if (category === "Морфология") + return "#9cf5d1" + if (category === "Прореживание") + return "#ffcb8a" + if (category === "Сглаживание") + return "#ffd892" + if (category === "Нормали") + return "#d2b7ff" + if (category === "Реконструкция") + return "#ff9dc4" + return Theme.chainDragHandle + } ListView { id: stageList anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - anchors.bottom: reconstructionCard.top - anchors.bottomMargin: Theme.spacingSm + anchors.bottom: parent.bottom clip: true model: root.safeBackend.stageCards @@ -35,8 +54,15 @@ Item { anchors.margins: Theme.spacingSm spacing: Theme.spacingSm + Rectangle { + width: 4 + Layout.fillHeight: true + radius: 2 + color: root.categoryAccentColor(modelData.category) + } + Label { - text: "↕" + text: modelData.family === "reconstruction" ? "REC" : "↕" color: Theme.chainDragHandle font.pixelSize: Theme.fontLg width: Theme.stageHandleWidth @@ -46,6 +72,8 @@ Item { id: dragHandle anchors.fill: parent drag.target: dragProxy + enabled: modelData.family !== "reconstruction" + cursorShape: enabled ? Qt.SizeAllCursor : Qt.PointingHandCursor onPressed: root.safeBackend.setSelectedStageIndex(index) } } @@ -53,7 +81,7 @@ Item { ColumnLayout { Layout.fillWidth: true Label { text: modelData.title; color: Theme.paletteFilterText; font.bold: true; font.pixelSize: Theme.fontMd } - Label { text: modelData.category + " | " + modelData.id; color: Theme.chainDragHandle; font.pixelSize: Theme.fontXs } + Label { text: modelData.category + " | " + modelData.id; color: root.categoryAccentColor(modelData.category); font.pixelSize: Theme.fontXs } } } @@ -75,7 +103,8 @@ Item { DropArea { anchors.fill: parent onEntered: { - if (drag.source && drag.source !== rowRoot && drag.source.itemIndex !== undefined) { + if (drag.source && drag.source !== rowRoot && drag.source.itemIndex !== undefined + && modelData.family !== "reconstruction") { root.safeBackend.moveStage(drag.source.itemIndex, index) } } @@ -85,6 +114,7 @@ Item { anchors.fill: parent z: -1 hoverEnabled: true + cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: root.safeBackend.setSelectedStageIndex(index) onDoubleClicked: { @@ -103,51 +133,10 @@ Item { id: stageMenu MenuItem { text: "Удалить элемент" + enabled: modelData.family !== "reconstruction" onTriggered: root.safeBackend.removeStage(index) } } } } - - Rectangle { - id: reconstructionCard - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - height: Theme.stageRowHeight - radius: Theme.radiusMd - border.width: 1 - border.color: Theme.reconstructionBorder - color: Theme.reconstructionBg - - RowLayout { - anchors.fill: parent - anchors.margins: Theme.spacingSm - spacing: Theme.spacingSm - - Label { - text: "REC" - color: Theme.reconstructionAccent - font.bold: true - font.pixelSize: Theme.fontSm - width: Theme.reconstructionBadgeWidth - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - ColumnLayout { - Layout.fillWidth: true - Label { - text: UiCatalog.reconstructionTitle(root.safeBackend.reconstructionMethod) - color: Theme.reconstructionText - font.bold: true - font.pixelSize: Theme.fontMd - } - Label { - text: "Реконструкция | " + root.safeBackend.reconstructionMethod - color: Theme.reconstructionSubtext - font.pixelSize: Theme.fontXs - } - } - } - } } diff --git a/src/ui/qml/components/pipeline/StagePalette.qml b/src/ui/qml/components/pipeline/StagePalette.qml index fec8a89..d19cff8 100644 --- a/src/ui/qml/components/pipeline/StagePalette.qml +++ b/src/ui/qml/components/pipeline/StagePalette.qml @@ -19,54 +19,77 @@ Item { width: parent.width spacing: Theme.spacingXs - Label { text: "Фильтры"; font.bold: true; color: Theme.paletteHeader; padding: Theme.spacingXs } + Label { text: "Стадии по этапам"; font.bold: true; color: Theme.paletteHeader; padding: Theme.spacingXs } Repeater { - model: UiCatalog.filterPaletteModel() - delegate: Rectangle { - property string paletteStageId: modelData.idValue + model: UiCatalog.filterPaletteGroupedModel() + delegate: Column { + id: phaseSection + property color accentColor: modelData.phaseAccent ? modelData.phaseAccent : Theme.paletteHeader + property color filterBgColor: modelData.phaseFilterBg ? modelData.phaseFilterBg : Theme.paletteFilterBg + property color filterBorderColor: modelData.phaseFilterBorder ? modelData.phaseFilterBorder : Theme.paletteFilterBorder width: paletteColumn.width - height: Theme.paletteItemHeight - radius: Theme.radiusSm - color: Theme.paletteFilterBg - border.width: 1 - border.color: Theme.paletteFilterBorder - Label { - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: Theme.spacingMd - text: modelData.title - color: Theme.paletteFilterText - font.pixelSize: Theme.fontSm - } - MouseArea { - anchors.fill: parent - onDoubleClicked: root.safeBackend.addStage(parent.paletteStageId) - } - } - } + spacing: Theme.spacingXs / 2 - Label { text: "Реконструкторы поверхности"; font.bold: true; color: Theme.paletteHeader; padding: Theme.spacingXs } - Repeater { - model: UiCatalog.reconstructionPaletteModel() - delegate: Rectangle { - property string paletteStageId: modelData.idValue - width: paletteColumn.width - height: Theme.paletteItemHeight - radius: Theme.radiusSm - color: Theme.paletteReconBg - border.width: 1 - border.color: Theme.paletteReconBorder Label { - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: Theme.spacingMd - text: modelData.title - color: Theme.paletteReconText - font.pixelSize: Theme.fontSm + text: modelData.phaseTitle + font.bold: true + color: phaseSection.accentColor + padding: Theme.spacingXs } - MouseArea { - anchors.fill: parent - onDoubleClicked: root.safeBackend.setReconstructionMethod(parent.paletteStageId) + + Label { + visible: modelData.phaseHint.length > 0 + text: modelData.phaseHint + color: phaseSection.accentColor + font.pixelSize: Theme.fontXs + leftPadding: Theme.spacingXs + } + + Label { + visible: modelData.items.length === 0 + text: "Пока нет доступных фильтров для этого этапа." + color: phaseSection.accentColor + font.pixelSize: Theme.fontXs + leftPadding: Theme.spacingXs + } + + Repeater { + model: modelData.items + delegate: Rectangle { + property string paletteStageId: modelData.idValue + width: paletteColumn.width + height: Theme.paletteItemHeight + radius: Theme.radiusSm + color: phaseSection.filterBgColor + border.width: 1 + border.color: phaseSection.filterBorderColor + + Label { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Theme.spacingMd + text: modelData.title + color: Theme.paletteFilterText + font.pixelSize: Theme.fontSm + } + + ToolTip.visible: hoverArea.containsMouse + ToolTip.text: modelData.hint + ToolTip.delay: 200 + + MouseArea { + id: hoverArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onDoubleClicked: { + if (modelData.family === "reconstruction") + root.safeBackend.setReconstructionMethod(parent.paletteStageId) + else + root.safeBackend.addStage(parent.paletteStageId) + } + } + } } } } diff --git a/src/ui/qml/dialogs/StageSettingsDialog.qml b/src/ui/qml/dialogs/StageSettingsDialog.qml index bd172b8..850bebd 100644 --- a/src/ui/qml/dialogs/StageSettingsDialog.qml +++ b/src/ui/qml/dialogs/StageSettingsDialog.qml @@ -24,6 +24,12 @@ Dialog { width: Theme.dialogWidth title: "" standardButtons: Dialog.NoButton + background: Rectangle { + radius: Theme.radiusMd + color: Theme.dialogBg + border.color: Theme.dialogBorder + border.width: 1 + } onAccepted: { if (safeBackend.selectedStageIndex >= 0) { @@ -36,15 +42,17 @@ Dialog { onOpened: root.paramItems = UiCatalog.parseDefaults(popupItem.defaults) contentItem: ColumnLayout { - spacing: Theme.spacingSm + spacing: Theme.dialogContentSpacing Label { text: popupItem.title ? popupItem.title : "Элемент не выбран" font.bold: true + color: Theme.controlText wrapMode: Text.WordWrap Layout.fillWidth: true } Label { text: popupItem.hint ? popupItem.hint : "Для этого элемента нет подробного описания." + color: Theme.dialogDescriptionText wrapMode: Text.WordWrap Layout.fillWidth: true } @@ -58,10 +66,10 @@ Dialog { model: root.paramItems delegate: ColumnLayout { Layout.fillWidth: true - spacing: 2 + spacing: Theme.spacingXs / 2 RowLayout { Layout.fillWidth: true - spacing: Theme.spacingMd + spacing: Theme.dialogRowSpacing Label { text: modelData.key + ":" color: Theme.dialogHintText @@ -71,12 +79,30 @@ Dialog { ComboBox { Layout.fillWidth: true visible: modelData.kind === "bool" + hoverEnabled: true model: ["false", "true"] currentIndex: modelData.value === "true" ? 1 : 0 onCurrentTextChanged: { if (modelData.kind === "bool") root.updateParamValue(index, currentText) } + contentItem: Text { + text: parent.displayText + color: Theme.controlText + verticalAlignment: Text.AlignVCenter + leftPadding: Theme.spacingSm + } + background: Rectangle { + radius: Theme.radiusSm + color: Theme.controlBg + border.color: Theme.controlBorder + border.width: 1 + } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + } } TextField { Layout.fillWidth: true @@ -84,6 +110,19 @@ Dialog { text: modelData.value validator: IntValidator {} onEditingFinished: root.updateParamValue(index, text) + color: Theme.controlText + placeholderTextColor: Theme.controlPlaceholder + background: Rectangle { + radius: Theme.radiusSm + color: Theme.controlBg + border.color: Theme.controlBorder + border.width: 1 + } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: Qt.IBeamCursor + } } TextField { Layout.fillWidth: true @@ -91,12 +130,38 @@ Dialog { text: modelData.value validator: DoubleValidator {} onEditingFinished: root.updateParamValue(index, text) + color: Theme.controlText + placeholderTextColor: Theme.controlPlaceholder + background: Rectangle { + radius: Theme.radiusSm + color: Theme.controlBg + border.color: Theme.controlBorder + border.width: 1 + } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: Qt.IBeamCursor + } } TextField { Layout.fillWidth: true visible: modelData.kind === "string" text: modelData.value onEditingFinished: root.updateParamValue(index, text) + color: Theme.controlText + placeholderTextColor: Theme.controlPlaceholder + background: Rectangle { + radius: Theme.radiusSm + color: Theme.controlBg + border.color: Theme.controlBorder + border.width: 1 + } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: Qt.IBeamCursor + } } } Label { @@ -110,18 +175,34 @@ Dialog { } RowLayout { Layout.fillWidth: true - Layout.topMargin: Theme.spacingMd - spacing: Theme.spacingMd + Layout.topMargin: Theme.spacingSm + spacing: Theme.dialogRowSpacing Button { text: "Установить по умолчанию" Layout.preferredWidth: Theme.dialogResetButtonWidth + hoverEnabled: true onClicked: root.paramItems = UiCatalog.parseDefaults(safeBackend.defaultsForStage(popupItem.id)) + contentItem: Text { text: parent.text; color: Theme.buttonText; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { radius: Theme.radiusSm; color: parent.down ? Theme.buttonPressedBg : (parent.hovered ? Theme.buttonHoverBg : Theme.buttonBg); border.color: Theme.buttonBorder; border.width: 1 } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + } } Item { Layout.fillWidth: true } Button { text: "OK" Layout.preferredWidth: Theme.dialogOkButtonWidth + hoverEnabled: true onClicked: root.accept() + contentItem: Text { text: parent.text; color: Theme.primaryButtonText; font.bold: true; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { radius: Theme.radiusSm; color: parent.down ? Theme.primaryButtonPressedBg : (parent.hovered ? Theme.primaryButtonHoverBg : Theme.primaryButtonBg); border.color: Theme.primaryButtonBorder; border.width: 1 } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + } } } } diff --git a/src/ui/qml/pages/PipelineDashboard.qml b/src/ui/qml/pages/PipelineDashboard.qml index 5129e91..532941b 100644 --- a/src/ui/qml/pages/PipelineDashboard.qml +++ b/src/ui/qml/pages/PipelineDashboard.qml @@ -7,15 +7,27 @@ import "../shared/Theme.js" as Theme Rectangle { color: Theme.panelBg property int compactH: Theme.compactControlHeight + property int selectedPresetIndex: 0 readonly property var safeBackend: backend ? backend : ({ surfaceVisible: true, - demoSurfaceTypes: ["Сфера", "Тор", "Волна"], + busy: false, + demoSurfaceTypes: ["Сфера", "Тор", "Волна", "Дно реки + труба"], demoSurfaceType: "Сфера", + presetItems: [ + { title: "LiDAR-скан", idValue: "LiDAR_scan" }, + { title: "RGB-D камера", idValue: "RGBD_camera" }, + { title: "Синтетика", idValue: "Synthetic_clean" } + ], openPointsFile: function() {}, generateDemo: function() {}, setDemoSurfaceType: function(_) {}, setSurfaceVisible: function(_) {}, - applyPipelineFromUi: function() {} + applyPipelineFromUi: function() {}, + applyPreset: function(_) {}, + saveCurrentPreset: function() {}, + loadPresetFromFile: function() {}, + exportCapabilitiesToFile: function() {}, + chooseUserDataDirectory: function() {} }) ColumnLayout { @@ -29,15 +41,68 @@ Rectangle { ComboBox { Layout.preferredWidth: Theme.dashboardSurfaceComboWidth Layout.preferredHeight: compactH + hoverEnabled: true model: safeBackend.demoSurfaceTypes currentIndex: model.indexOf(safeBackend.demoSurfaceType) onActivated: safeBackend.setDemoSurfaceType(currentText) + contentItem: Text { + text: parent.displayText + color: Theme.controlText + verticalAlignment: Text.AlignVCenter + leftPadding: Theme.spacingSm + elide: Text.ElideRight + } + background: Rectangle { + radius: Theme.radiusSm + color: parent.pressed ? Theme.controlHoverBg : Theme.controlBg + border.color: Theme.controlBorder + border.width: 1 + } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + } + } + Button { + text: "Сгенерировать" + height: compactH + hoverEnabled: true + onClicked: safeBackend.generateDemo() + contentItem: Text { text: parent.text; color: Theme.buttonText; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { radius: Theme.radiusSm; color: parent.down ? Theme.buttonPressedBg : (parent.hovered ? Theme.buttonHoverBg : Theme.buttonBg); border.color: Theme.buttonBorder; border.width: 1 } + MouseArea { anchors.fill: parent; acceptedButtons: Qt.NoButton; cursorShape: Qt.PointingHandCursor } + } + Button { + text: "Загрузить" + height: compactH + hoverEnabled: true + onClicked: safeBackend.openPointsFile() + contentItem: Text { text: parent.text; color: Theme.buttonText; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { radius: Theme.radiusSm; color: parent.down ? Theme.buttonPressedBg : (parent.hovered ? Theme.buttonHoverBg : Theme.buttonBg); border.color: Theme.buttonBorder; border.width: 1 } + MouseArea { anchors.fill: parent; acceptedButtons: Qt.NoButton; cursorShape: Qt.PointingHandCursor } } - Button { text: "Сгенерировать"; height: compactH; onClicked: safeBackend.generateDemo() } - Button { text: "Загрузить"; height: compactH; onClicked: safeBackend.openPointsFile() } Item { Layout.fillWidth: true } } + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + Label { + text: "Пресет:" + color: Theme.summaryDetailText + font.pixelSize: Theme.fontSm + } + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: "Стартовая цепочка: примените и при необходимости отредактируйте вручную." + color: Theme.chainDragHandle + font.pixelSize: Theme.fontXs + } + } + PipelineCanvas { Layout.fillWidth: true Layout.fillHeight: true @@ -46,9 +111,121 @@ Rectangle { RowLayout { Layout.fillWidth: true spacing: Theme.spacingSm - Switch { checked: safeBackend.surfaceVisible; onToggled: safeBackend.setSurfaceVisible(checked) } + Switch { + checked: safeBackend.surfaceVisible + onToggled: safeBackend.setSurfaceVisible(checked) + MouseArea { anchors.fill: parent; acceptedButtons: Qt.NoButton; cursorShape: Qt.PointingHandCursor } + } Item { Layout.fillWidth: true } - Button { text: "Применить"; Layout.preferredHeight: compactH; onClicked: safeBackend.applyPipelineFromUi() } + Button { + text: "Применить" + Layout.preferredHeight: compactH + hoverEnabled: true + onClicked: safeBackend.applyPipelineFromUi() + contentItem: Text { text: parent.text; color: Theme.primaryButtonText; font.bold: true; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { + radius: Theme.radiusSm + color: parent.down ? Theme.primaryButtonPressedBg : (parent.hovered ? Theme.primaryButtonHoverBg : Theme.primaryButtonBg) + border.color: Theme.primaryButtonBorder + border.width: 2 + } + MouseArea { anchors.fill: parent; acceptedButtons: Qt.NoButton; cursorShape: Qt.PointingHandCursor } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingXs + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + ComboBox { + Layout.preferredWidth: 190 + Layout.minimumWidth: 170 + Layout.maximumWidth: 220 + Layout.preferredHeight: compactH + hoverEnabled: true + model: safeBackend.presetItems + textRole: "title" + currentIndex: selectedPresetIndex + onActivated: selectedPresetIndex = currentIndex + contentItem: Text { + text: parent.displayText + color: Theme.controlText + verticalAlignment: Text.AlignVCenter + leftPadding: Theme.spacingSm + elide: Text.ElideRight + } + background: Rectangle { + radius: Theme.radiusSm + color: parent.pressed ? Theme.controlHoverBg : Theme.controlBg + border.color: Theme.controlBorder + border.width: 1 + } + MouseArea { anchors.fill: parent; acceptedButtons: Qt.NoButton; cursorShape: Qt.PointingHandCursor } + } + Button { + text: "Применить пресет" + Layout.preferredHeight: compactH + hoverEnabled: true + onClicked: { + if (selectedPresetIndex >= 0 && selectedPresetIndex < safeBackend.presetItems.length) + safeBackend.applyPreset(safeBackend.presetItems[selectedPresetIndex].idValue) + } + contentItem: Text { text: parent.text; color: Theme.buttonText; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { radius: Theme.radiusSm; color: parent.down ? Theme.buttonPressedBg : (parent.hovered ? Theme.buttonHoverBg : Theme.buttonBg); border.color: Theme.buttonBorder; border.width: 1 } + MouseArea { anchors.fill: parent; acceptedButtons: Qt.NoButton; cursorShape: Qt.PointingHandCursor } + } + Item { Layout.fillWidth: true } + } + + Item { + Layout.fillWidth: true + implicitHeight: presetActionsFlow.childrenRect.height + + Flow { + id: presetActionsFlow + width: parent.width + spacing: Theme.spacingSm + Button { + text: "Загрузить пресет" + height: compactH + hoverEnabled: true + onClicked: safeBackend.loadPresetFromFile() + contentItem: Text { text: parent.text; color: Theme.buttonText; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { radius: Theme.radiusSm; color: parent.down ? Theme.buttonPressedBg : (parent.hovered ? Theme.buttonHoverBg : Theme.buttonBg); border.color: Theme.buttonBorder; border.width: 1 } + MouseArea { anchors.fill: parent; acceptedButtons: Qt.NoButton; cursorShape: Qt.PointingHandCursor } + } + Button { + text: "Сохранить пресет" + height: compactH + hoverEnabled: true + onClicked: safeBackend.saveCurrentPreset() + contentItem: Text { text: parent.text; color: Theme.buttonText; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { radius: Theme.radiusSm; color: parent.down ? Theme.buttonPressedBg : (parent.hovered ? Theme.buttonHoverBg : Theme.buttonBg); border.color: Theme.buttonBorder; border.width: 1 } + MouseArea { anchors.fill: parent; acceptedButtons: Qt.NoButton; cursorShape: Qt.PointingHandCursor } + } + Button { + text: "Выгрузка возможностей" + height: compactH + hoverEnabled: true + onClicked: safeBackend.exportCapabilitiesToFile() + contentItem: Text { text: parent.text; color: Theme.buttonText; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { radius: Theme.radiusSm; color: parent.down ? Theme.buttonPressedBg : (parent.hovered ? Theme.buttonHoverBg : Theme.buttonBg); border.color: Theme.buttonBorder; border.width: 1 } + MouseArea { anchors.fill: parent; acceptedButtons: Qt.NoButton; cursorShape: Qt.PointingHandCursor } + } + Button { + text: "Папка данных" + height: compactH + hoverEnabled: true + onClicked: safeBackend.chooseUserDataDirectory() + contentItem: Text { text: parent.text; color: Theme.buttonText; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { radius: Theme.radiusSm; color: parent.down ? Theme.buttonPressedBg : (parent.hovered ? Theme.buttonHoverBg : Theme.buttonBg); border.color: Theme.buttonBorder; border.width: 1 } + MouseArea { anchors.fill: parent; acceptedButtons: Qt.NoButton; cursorShape: Qt.PointingHandCursor } + } + } + } } MetricsStrip { @@ -58,4 +235,31 @@ Rectangle { } } + + Rectangle { + anchors.fill: parent + visible: safeBackend.busy + color: "#90000000" + z: 1000 + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.AllButtons + } + + Column { + anchors.centerIn: parent + spacing: Theme.spacingSm + + BusyIndicator { + running: safeBackend.busy + width: 40 + height: 40 + } + Label { + text: "Выполняются вычисления..." + color: "white" + } + } + } } diff --git a/src/ui/qml/shared/PipelineUiCatalog.js b/src/ui/qml/shared/PipelineUiCatalog.js index 24034dd..fe28005 100644 --- a/src/ui/qml/shared/PipelineUiCatalog.js +++ b/src/ui/qml/shared/PipelineUiCatalog.js @@ -52,6 +52,58 @@ function paramDescription(key) { return "Радиус поиска соседей." if (key === "minNeighbors") return "Минимум соседей, чтобы точка считалась валидной." + if (key === "threshold") + return "Порог отклонения точки от геометрической модели." + if (key === "shadowThreshold") + return "Порог для удаления теневых точек по нормалям." + if (key === "resolution") + return "Размер ячейки 2D-сетки для фильтра GridMinimum." + if (key === "sample") + return "Количество точек, которое нужно оставить после выборки." + if (key === "axis") + return "Ось фильтрации: x, y или z." + if (key === "min" || key === "max") + return "Граница диапазона для фильтра PassThrough." + if (key === "minX" || key === "minY" || key === "minZ" || key === "maxX" || key === "maxY" || key === "maxZ") + return "Границы CropBox/CropHull по соответствующим осям." + if (key === "near" || key === "far") + return "Ближняя/дальняя граница фрустума." + if (key === "hfov" || key === "vfov") + return "Горизонтальный/вертикальный угол обзора (в градусах)." + if (key === "a" || key === "b" || key === "c" || key === "d") + return "Коэффициенты плоскости ax+by+cz+d=0." + if (key === "keepPositive") + return "Оставлять ли точки на положительной стороне плоскости." + if (key === "zMin" || key === "zMax") + return "Допустимый диапазон координаты Z для conditional-фильтра." + if (key === "nth") + return "Оставлять каждую N-ю точку." + if (key === "radiusMax") + return "Максимальный радиус точки от начала координат." + if (key === "sigmaS" || key === "sigmaR") + return "Параметры bilateral-фильтра (пространство/интенсивность)." + if (key === "sigma" || key === "kernel") + return "Параметры гауссова ядра свертки." + if (key === "iterations") + return "Число итераций уточнения." + if (key === "minHits") + return "Минимальная заполненность вокселя для прохождения фильтра." + if (key === "neighborRadiusScale") + return "Масштаб радиуса поиска соседей для fallback-реконструкции." + if (key === "runEveryNthFrame") + return "Запуск реконструкции на каждом N-м кадре." + if (key === "searchRadius") + return "Радиус поиска соседей для greedy triangulation." + if (key === "mu") + return "Коэффициент плотности соседей для greedy triangulation." + if (key === "maxNearest") + return "Максимум соседей для greedy triangulation." + if (key === "maxSurfaceAngle") + return "Максимальный угол поверхности в радианах." + if (key === "poissonDepth") + return "Глубина октодерева для Poisson реконструкции." + if (key === "samplesPerNode") + return "Число выборок на узел для сглаживания Poisson." return "Параметр этапа обработки." } @@ -60,22 +112,346 @@ function reconstructionTitle(id) { return "Fallback Surface" if (id === "pcl_greedy_triangulation") return "PCL Greedy Triangulation" + if (id === "pcl_poisson_reconstruction") + return "PCL Poisson Reconstruction" return id } -function filterPaletteModel() { +function phaseModel() { return [ - { title: "Крупнейший кластер", idValue: "keep_largest_cluster" }, - { title: "Прореживание плотности", idValue: "downsample_dense" }, - { title: "PCL Voxel Grid", idValue: "pcl_voxel_grid" }, - { title: "Статистическая фильтрация", idValue: "pcl_statistical_outlier" }, - { title: "Радиусная фильтрация", idValue: "pcl_radius_outlier" } + { + id: "crop", + title: "Обрезка", + hint: "Ограничение области интереса и удаление лишних фрагментов.", + accent: "#88d1ff", + filterBg: "#263e55", + filterBorder: "#4b7598" + }, + { + id: "nan_preclean", + title: "NaN (предочистка)", + hint: "Удаление NaN сразу после загрузки/обрезки, иначе статистические фильтры работают нестабильно.", + accent: "#8fe8ff", + filterBg: "#1f4450", + filterBorder: "#3f8394" + }, + { + id: "conditions_indexes", + title: "Условия/Индексы", + hint: "Отбор точек по полям, диапазонам и индексным маскам.", + accent: "#9fd7ff", + filterBg: "#24405a", + filterBorder: "#4f7aa1" + }, + { + id: "noise", + title: "Шум", + hint: "Удаление выбросов и нестабильных точек перед геометрией.", + accent: "#a4f4b9", + filterBg: "#244534", + filterBorder: "#4b8f68" + }, + { + id: "morphology", + title: "Морфология", + hint: "Геометрические операции локальной структуры облака.", + accent: "#9cf5d1", + filterBg: "#1f4a3e", + filterBorder: "#4b8f7d" + }, + { + id: "downsample", + title: "Прореживание", + hint: "Снижение плотности облака для скорости и устойчивости.", + accent: "#ffcb8a", + filterBg: "#4a3724", + filterBorder: "#8e6f47" + }, + { + id: "smoothing", + title: "Сглаживание", + hint: "Снижение локального шума перед расчетом нормалей и реконструкцией.", + accent: "#ffd892", + filterBg: "#4e3a22", + filterBorder: "#917349" + }, + { + id: "normals", + title: "Нормали", + hint: "Подготовка нормалей для продвинутой реконструкции (этап зарезервирован).", + accent: "#d2b7ff", + filterBg: "#3e3155", + filterBorder: "#6f5a95" + }, + { + id: "reconstruction", + title: "Реконструкция", + hint: "Построение поверхности по подготовленному облаку точек.", + accent: "#ff9dc4", + filterBg: "#4b2f40", + filterBorder: "#8d5a75" + } ] } +function filterDefinitions() { + return [ + { + title: "Крупнейший кластер", + idValue: "keep_largest_cluster", + phaseId: "crop", + hint: "Оставляет основной объект и отбрасывает изолированные фрагменты." + }, + { + title: "Remove NaN Points", + idValue: "pcl_remove_nan", + phaseId: "nan_preclean", + family: "preprocess", + hint: "Удаляет NaN/Inf после загрузки и обрезки, до статистических фильтров." + }, + { + title: "Remove NaN Normals", + idValue: "pcl_remove_nan_normals", + phaseId: "nan_preclean", + family: "preprocess", + hint: "Удаляет невалидные нормали (в текущем формате — невалидную геометрию)." + }, + { + title: "PassThrough", + idValue: "pcl_pass_through", + phaseId: "crop", + hint: "Фильтрует точки по диапазону выбранной оси." + }, + { + title: "CropBox", + idValue: "pcl_crop_box", + phaseId: "crop", + hint: "Ограничивает облако заданным 3D-параллелепипедом." + }, + { + title: "CropHull", + idValue: "pcl_crop_hull", + phaseId: "crop", + hint: "Обрезка по области интереса (в текущей версии через box-границы)." + }, + { + title: "Frustum Culling", + idValue: "pcl_frustum_culling", + phaseId: "crop", + hint: "Оставляет точки в пирамиде видимости камеры." + }, + { + title: "PlaneClipper3D", + idValue: "pcl_plane_clipper_3d", + phaseId: "crop", + hint: "Отсекает точки по уравнению плоскости." + }, + { + title: "Conditional Removal", + idValue: "pcl_conditional_removal", + phaseId: "conditions_indexes", + hint: "Удаляет точки по логическому условию (диапазон Z)." + }, + { + title: "Extract Indices", + idValue: "pcl_extract_indices", + phaseId: "conditions_indexes", + hint: "Извлекает точки по индексной маске (каждая N-я)." + }, + { + title: "Functor Filter", + idValue: "pcl_functor_filter", + phaseId: "conditions_indexes", + hint: "Пользовательский предикат (радиус + проверка валидности)." + }, + { + title: "ProjectInliers", + idValue: "pcl_project_inliers", + phaseId: "normals", + hint: "Проецирует точки на геометрическую модель (плоскость)." + }, + { + title: "Normal Refinement", + idValue: "pcl_normal_refinement", + phaseId: "normals", + hint: "Уточняет локальную геометрию по соседям." + }, + { + title: "Статистическая фильтрация", + idValue: "pcl_statistical_outlier", + phaseId: "noise", + hint: "Удаляет выбросы на основе распределения расстояний до соседей." + }, + { + title: "Радиусная фильтрация", + idValue: "pcl_radius_outlier", + phaseId: "noise", + hint: "Удаляет точки с недостаточным числом соседей в заданном радиусе." + }, + { + title: "Model Outlier Removal", + idValue: "pcl_model_outlier", + phaseId: "noise", + hint: "Удаляет точки, отклоняющиеся от геометрической модели." + }, + { + title: "Shadow Points Removal", + idValue: "pcl_shadow_points", + phaseId: "noise", + hint: "Удаляет теневые точки на основе нормалей поверхности." + }, + { + title: "Approximate Voxel Grid", + idValue: "pcl_approximate_voxel_grid", + phaseId: "downsample", + hint: "Ускоренное воксельное прореживание для больших облаков." + }, + { + title: "Voxel Grid Label", + idValue: "pcl_voxel_grid_label", + phaseId: "downsample", + hint: "Воксельное прореживание с поддержкой меток." + }, + { + title: "Voxel Grid Covariance", + idValue: "pcl_voxel_grid_covariance", + phaseId: "downsample", + hint: "Воксельная сетка с ковариациями (для NDT-пайплайнов)." + }, + { + title: "Grid Minimum", + idValue: "pcl_grid_minimum", + phaseId: "downsample", + hint: "Оставляет точку с минимальным Z в каждой ячейке." + }, + { + title: "Farthest Point Sampling", + idValue: "pcl_farthest_point_sampling", + phaseId: "downsample", + hint: "Выбирает наиболее удаленные друг от друга точки." + }, + { + title: "Normal Space Sampling", + idValue: "pcl_normal_space_sampling", + phaseId: "downsample", + hint: "Равномерная выборка в пространстве нормалей." + }, + { + title: "Sampling Surface Normal", + idValue: "pcl_sampling_surface_normal", + phaseId: "downsample", + hint: "Выборка точек на основе нормалей поверхности." + }, + { + title: "Bilateral Filter", + idValue: "pcl_bilateral_filter", + phaseId: "smoothing", + hint: "Двустороннее сглаживание с сохранением границ." + }, + { + title: "Fast Bilateral Filter", + idValue: "pcl_fast_bilateral_filter", + phaseId: "smoothing", + hint: "Быстрое двустороннее сглаживание." + }, + { + title: "Fast Bilateral Filter OMP", + idValue: "pcl_fast_bilateral_filter_omp", + phaseId: "smoothing", + hint: "Параллельная версия bilateral-фильтра." + }, + { + title: "Convolution", + idValue: "pcl_convolution", + phaseId: "smoothing", + hint: "Свертка облака точек с ядром." + }, + { + title: "Gaussian Kernel", + idValue: "pcl_gaussian_kernel", + phaseId: "smoothing", + hint: "Гауссово ядро свертки." + }, + { + title: "Gaussian Kernel RGB", + idValue: "pcl_gaussian_kernel_rgb", + phaseId: "smoothing", + hint: "Гауссово ядро с RGB-ориентированной семантикой." + }, + { + title: "VoxelGrid Occlusion Estimation", + idValue: "pcl_voxel_grid_occlusion", + phaseId: "morphology", + hint: "Оценка окклюзии через заполненность вокселей." + }, + { + title: "Fallback Surface", + idValue: "surface_fallback", + phaseId: "reconstruction", + family: "reconstruction", + hint: "Базовая реконструкция поверхности по облаку точек." + }, + { + title: "PCL Greedy Triangulation", + idValue: "pcl_greedy_triangulation", + phaseId: "reconstruction", + family: "reconstruction", + hint: "Жадная триангуляция с параметрами радиуса и углов." + }, + { + title: "PCL Poisson Reconstruction", + idValue: "pcl_poisson_reconstruction", + phaseId: "reconstruction", + family: "reconstruction", + hint: "Реконструкция поверхности методом Poisson из libpcl_surface." + }, + { + title: "Прореживание плотности", + idValue: "downsample_dense", + phaseId: "downsample", + hint: "Снижает число точек для ускорения пайплайна на плотных облаках." + }, + { + title: "PCL Voxel Grid", + idValue: "pcl_voxel_grid", + phaseId: "downsample", + hint: "Равномерно прореживает облако с помощью воксельной сетки." + } + ] +} + +function filterPaletteModel() { + return filterDefinitions() +} + +function filterPaletteGroupedModel() { + var phases = phaseModel() + var filters = filterDefinitions() + var grouped = [] + for (var p = 0; p < phases.length; ++p) { + var phase = phases[p] + var items = [] + for (var i = 0; i < filters.length; ++i) { + if (filters[i].phaseId === phase.id) + items.push(filters[i]) + } + grouped.push({ + phaseId: phase.id, + phaseTitle: phase.title, + phaseHint: phase.hint, + phaseAccent: phase.accent, + phaseFilterBg: phase.filterBg, + phaseFilterBorder: phase.filterBorder, + items: items + }) + } + return grouped +} + function reconstructionPaletteModel() { return [ { title: "Fallback Surface", idValue: "surface_fallback" }, - { title: "PCL Greedy Triangulation", idValue: "pcl_greedy_triangulation" } + { title: "PCL Greedy Triangulation", idValue: "pcl_greedy_triangulation" }, + { title: "PCL Poisson Reconstruction", idValue: "pcl_poisson_reconstruction" } ] } diff --git a/src/ui/qml/shared/Theme.js b/src/ui/qml/shared/Theme.js index 6e2cd4e..caf41f3 100644 --- a/src/ui/qml/shared/Theme.js +++ b/src/ui/qml/shared/Theme.js @@ -30,9 +30,29 @@ var summarySecondaryText = "#cde0ff" var summaryRecommendation = "#9ec3ff" var summaryDetailText = "#b8cff8" -var dialogHintText = "#4a4f63" -var dialogDescriptionText = "#7e869d" -var dialogSectionText = "#9eb0d9" +var dialogHintText = "#c7d6f3" +var dialogDescriptionText = "#aebfde" +var dialogSectionText = "#dce7ff" +var dialogBg = "#1a2234" +var dialogBorder = "#3a4f78" + +var controlBg = "#23324d" +var controlBorder = "#4a6494" +var controlText = "#eaf0ff" +var controlPlaceholder = "#9cb0d6" +var controlHoverBg = "#2a3b5a" + +var buttonBg = "#2c3f61" +var buttonBorder = "#5574a9" +var buttonText = "#eef4ff" +var buttonHoverBg = "#35507a" +var buttonPressedBg = "#273b5c" + +var primaryButtonBg = "#ffb020" +var primaryButtonBorder = "#ffd27a" +var primaryButtonText = "#142033" +var primaryButtonHoverBg = "#ffc247" +var primaryButtonPressedBg = "#e39b0f" var spacingXs = 4 var spacingSm = 6 @@ -43,7 +63,7 @@ var radiusMd = 8 var compactControlHeight = 28 var dashboardSurfaceComboWidth = 170 -var dashboardMetricsHeight = 96 +var dashboardMetricsHeight = 148 var dashboardBottomMargin = 8 var palettePanelWidth = 260 @@ -52,10 +72,12 @@ var stageHandleWidth = 16 var reconstructionBadgeWidth = 28 var paletteItemHeight = 30 -var dialogWidth = 430 -var dialogLabelWidth = 150 -var dialogResetButtonWidth = 190 -var dialogOkButtonWidth = 90 +var dialogWidth = 390 +var dialogLabelWidth = 128 +var dialogResetButtonWidth = 168 +var dialogOkButtonWidth = 76 +var dialogContentSpacing = 4 +var dialogRowSpacing = 6 var fontXs = 10 var fontSm = 11