diff --git a/DotsToSirface.pro b/DotsToSirface.pro index 1788aa1..029a614 100644 --- a/DotsToSirface.pro +++ b/DotsToSirface.pro @@ -1,17 +1,91 @@ -QT += core gui widgets opengl - -CONFIG += c++11 -TEMPLATE = app -TARGET = DotsToSirface -LIBS += -lopengl32 - -SOURCES += \ - src/main.cpp \ - src/geometry/surface_reconstruction.cpp \ - src/ui/mainwindow.cpp \ - src/ui/glview.cpp - -HEADERS += \ - src/geometry/surface_reconstruction.h \ - src/ui/mainwindow.h \ - src/ui/glview.h +QT += core gui widgets opengl quick quickwidgets qml + +CONFIG += c++14 +TEMPLATE = app +TARGET = DotsToSirface + +win32:LIBS += -lopengl32 +unix:LIBS += -lGL + +contains(DEFINES, PCL_ENABLED) { + message(PCL support enabled) + win32 { + isEmpty(PCL_ROOT) { + PCL_ROOT = C:/PCL + } + INCLUDEPATH += $$PCL_ROOT/include $$PCL_ROOT/include/pcl-1.12 + LIBS += -L$$PCL_ROOT/lib \ + -lpcl_common \ + -lpcl_io \ + -lpcl_filters \ + -lpcl_features \ + -lpcl_kdtree \ + -lpcl_search \ + -lpcl_surface + } + + unix { + CONFIG += link_pkgconfig + PKGCONFIG += \ + pcl_common \ + pcl_io \ + pcl_filters \ + pcl_features \ + pcl_kdtree \ + pcl_search \ + pcl_surface + } +} + +SOURCES += \ + src/main.cpp \ + src/algorithms/preprocess/preprocess_algorithms.cpp \ + src/algorithms/reconstruction/surface_reconstruction.cpp \ + src/adapters/reconstruction/reconstruction_adapter.cpp \ + src/adapters/pcl/pcl_point_cloud_adapter.cpp \ + src/adapters/registration/registration_adapter.cpp \ + src/adapters/ros2/pointcloud2_adapter.cpp \ + src/adapters/ros2/tf2_adapter.cpp \ + src/adapters/sources/file_point_cloud_source.cpp \ + src/adapters/sources/ros2_point_cloud_source.cpp \ + src/core/pipeline_config.cpp \ + src/core/pipeline_executor.cpp \ + src/core/pipeline_plugin_registry.cpp \ + src/factories/pipeline/desktop_pipeline_factory.cpp \ + src/strategies/registration_icp_stage.cpp \ + src/strategies/preprocess_basic_stages.cpp \ + src/strategies/preprocess_pcl_stages.cpp \ + src/strategies/transform_tf_stage.cpp \ + src/tests/pipeline_smoke_tests.cpp \ + src/ui/mainwindow.cpp \ + src/ui/glview.cpp + +HEADERS += \ + src/algorithms/preprocess/preprocess_algorithms.h \ + src/algorithms/reconstruction/surface_reconstruction.h \ + src/adapters/reconstruction/reconstruction_adapter.h \ + src/adapters/pcl/pcl_point_cloud_adapter.h \ + src/adapters/registration/registration_adapter.h \ + src/adapters/ros2/pointcloud2_adapter.h \ + src/adapters/ros2/tf2_adapter.h \ + src/adapters/sources/file_point_cloud_source.h \ + src/adapters/sources/ros2_point_cloud_source.h \ + src/core/data_source.h \ + src/core/point_cloud_types.h \ + src/core/pipeline_config.h \ + src/core/pipeline_stage.h \ + src/core/pipeline_executor.h \ + src/core/pipeline_plugin_registry.h \ + src/factories/pipeline/desktop_pipeline_factory.h \ + src/strategies/preprocess_basic_stages.h \ + src/strategies/preprocess_pcl_stages.h \ + src/strategies/registration_icp_stage.h \ + src/strategies/reconstruction_pcl_greedy_stage.h \ + src/strategies/reconstruction_surface_stage.h \ + src/strategies/transform_tf_stage.h \ + src/tests/pipeline_smoke_tests.h \ + src/ui/mainwindow.h \ + src/ui/glview.h + +RESOURCES += \ + src/ui/qml/ui_qml.qrc diff --git a/DotsToSirfaceTests.pro b/DotsToSirfaceTests.pro new file mode 100644 index 0000000..59aa915 --- /dev/null +++ b/DotsToSirfaceTests.pro @@ -0,0 +1,82 @@ +QT += core + +CONFIG += console c++14 +CONFIG -= app_bundle +TEMPLATE = app +TARGET = DotsToSirfaceTests + +contains(DEFINES, PCL_ENABLED) { + message(PCL support enabled) + win32 { + isEmpty(PCL_ROOT) { + PCL_ROOT = C:/PCL + } + INCLUDEPATH += $$PCL_ROOT/include $$PCL_ROOT/include/pcl-1.12 + LIBS += -L$$PCL_ROOT/lib \ + -lpcl_common \ + -lpcl_io \ + -lpcl_filters \ + -lpcl_features \ + -lpcl_kdtree \ + -lpcl_search \ + -lpcl_surface + } + + unix { + CONFIG += link_pkgconfig + PKGCONFIG += \ + pcl_common \ + pcl_io \ + pcl_filters \ + pcl_features \ + pcl_kdtree \ + pcl_search \ + pcl_surface + } +} + +SOURCES += \ + src/tests/test_runner_main.cpp \ + src/tests/pipeline_smoke_tests.cpp \ + src/algorithms/preprocess/preprocess_algorithms.cpp \ + src/algorithms/reconstruction/surface_reconstruction.cpp \ + src/adapters/reconstruction/reconstruction_adapter.cpp \ + src/adapters/pcl/pcl_point_cloud_adapter.cpp \ + src/adapters/registration/registration_adapter.cpp \ + src/adapters/ros2/pointcloud2_adapter.cpp \ + src/adapters/ros2/tf2_adapter.cpp \ + src/adapters/sources/file_point_cloud_source.cpp \ + src/adapters/sources/ros2_point_cloud_source.cpp \ + src/core/pipeline_config.cpp \ + src/core/pipeline_executor.cpp \ + src/core/pipeline_plugin_registry.cpp \ + src/factories/pipeline/desktop_pipeline_factory.cpp \ + src/strategies/preprocess_basic_stages.cpp \ + src/strategies/preprocess_pcl_stages.cpp \ + src/strategies/registration_icp_stage.cpp \ + src/strategies/transform_tf_stage.cpp + +HEADERS += \ + src/tests/pipeline_smoke_tests.h \ + src/algorithms/preprocess/preprocess_algorithms.h \ + src/algorithms/reconstruction/surface_reconstruction.h \ + src/adapters/reconstruction/reconstruction_adapter.h \ + src/adapters/pcl/pcl_point_cloud_adapter.h \ + src/adapters/registration/registration_adapter.h \ + src/adapters/ros2/pointcloud2_adapter.h \ + src/adapters/ros2/tf2_adapter.h \ + src/adapters/sources/file_point_cloud_source.h \ + src/adapters/sources/ros2_point_cloud_source.h \ + src/core/data_source.h \ + src/core/point_cloud_types.h \ + src/core/pipeline_config.h \ + src/core/pipeline_stage.h \ + src/core/pipeline_executor.h \ + src/core/pipeline_plugin_registry.h \ + src/factories/pipeline/desktop_pipeline_factory.h \ + src/strategies/preprocess_basic_stages.h \ + src/strategies/preprocess_pcl_stages.h \ + src/strategies/reconstruction_surface_stage.h \ + src/strategies/reconstruction_pcl_greedy_stage.h \ + src/strategies/registration_icp_stage.h \ + src/strategies/transform_tf_stage.h diff --git a/README.md b/README.md index 45cf5a8..97e176a 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ Демонстрационная программа на Qt 5.11 C++, которая: - принимает массив 3D-точек `QVector`; -- строит массив треугольников `QVector`; +- выполняет stage-based pipeline (preprocess -> transform -> registration -> reconstruction); - визуализирует точки и полученную треугольную поверхность. ## Вход/выход API -`src/geometry/surface_reconstruction.h`: +`src/algorithms/reconstruction/surface_reconstruction.h`: - `struct Point3f { float x, y, z; };` - `struct Triangle { int i0, i1, i2; };` @@ -28,6 +28,22 @@ Текущая реализация строит **выпуклую оболочку** облака точек. Для невыпуклых объектов и детальной реконструкции произвольной поверхности нужны более сложные алгоритмы (например, alpha-shapes, Poisson reconstruction и т.п.). +## Архитектурный каркас под robotics pipeline + +- `core`: контракты (`PointCloudFrame`, `PipelineContext`, `PipelineStats`, stage-интерфейсы, plugin registry). +- `adapters`: мосты к источникам и внешним транспортам (`FilePointCloudSource`, `Ros2PointCloudSource`, `PointCloud2`/`tf2` adapters). +- `strategies`: конкретные стадии пайплайна (preprocess, transform, registration, reconstruction). +- `factories`: сборка `PipelineExecutor` из plugin-id и профиля. + +### Профили выполнения + +- `desktop_debug`: дефолтный профиль для GUI/отладки. +- `rpi4_runtime`: профиль для Raspberry Pi 4 (жестче downsampling, лимит точек, reconstruction реже, async-friendly настройки). + +Выбор профиля: +- `factories::pipeline::createPipelineExecutorForProfile("desktop_debug")` +- `factories::pipeline::createPipelineExecutorForProfile("rpi4_runtime")` + ## Сборка Пример для Qt 5.11: @@ -39,6 +55,19 @@ make Для Windows/MSVC используйте соответствующий `nmake`/`jom`. +## Smoke tests (отдельный runner) + +Для быстрой проверки пайплайна без GUI добавлен отдельный консольный таргет: +`DotsToSirfaceTests.pro`. + +```bash +qmake DotsToSirfaceTests.pro +make +./release/DotsToSirfaceTests.exe +``` + +При успешном запуске runner печатает `Smoke tests passed.` и завершаетcя с кодом `0`. + ## Демо При запуске приложение: diff --git a/src/adapters/pcl/pcl_point_cloud_adapter.cpp b/src/adapters/pcl/pcl_point_cloud_adapter.cpp new file mode 100644 index 0000000..6611ef6 --- /dev/null +++ b/src/adapters/pcl/pcl_point_cloud_adapter.cpp @@ -0,0 +1,186 @@ +#include "pcl_point_cloud_adapter.h" + +#include + +#ifdef PCL_ENABLED +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace adapters +{ +namespace pcl +{ +namespace +{ +#ifdef PCL_ENABLED +::pcl::PointCloud<::pcl::PointXYZ>::Ptr toPclCloud(const QVector &points) +{ + ::pcl::PointCloud<::pcl::PointXYZ>::Ptr cloud(new ::pcl::PointCloud<::pcl::PointXYZ>()); + cloud->reserve(static_cast(points.size())); + for (const core::Point3f &p : points) { + cloud->push_back(::pcl::PointXYZ(p.x, p.y, p.z)); + } + return cloud; +} + +QVector fromPclCloud(const ::pcl::PointCloud<::pcl::PointXYZ>::Ptr &cloud) +{ + QVector points; + points.reserve(static_cast(cloud->size())); + for (std::size_t i = 0; i < cloud->size(); ++i) { + const ::pcl::PointXYZ &p = cloud->at(i); + points.push_back({p.x, p.y, p.z}); + } + return points; +} +#endif +} // namespace + +QVector passThroughPoints(const QVector &points) +{ + return points; +} + +QVector applyVoxelGrid( + 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::VoxelGrid<::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 applyStatisticalOutlierRemoval( + const QVector &points, + const int meanK, + const double stdDevMulThresh, + 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::StatisticalOutlierRemoval<::pcl::PointXYZ> filter; + filter.setInputCloud(in); + filter.setMeanK(meanK); + filter.setStddevMulThresh(stdDevMulThresh); + filter.filter(*out); + removedCount = points.size() - static_cast(out->size()); + return fromPclCloud(out); +#else + Q_UNUSED(meanK); + Q_UNUSED(stdDevMulThresh); + removedCount = 0; + return points; +#endif +} + +QVector applyRadiusOutlierRemoval( + const QVector &points, + const float radius, + const int minNeighbors, + 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::RadiusOutlierRemoval<::pcl::PointXYZ> filter; + filter.setInputCloud(in); + filter.setRadiusSearch(radius); + filter.setMinNeighborsInRadius(minNeighbors); + filter.filter(*out); + removedCount = points.size() - static_cast(out->size()); + return fromPclCloud(out); +#else + Q_UNUSED(radius); + Q_UNUSED(minNeighbors); + removedCount = 0; + return points; +#endif +} + +QVector buildGreedyTriangles( + const QVector &points, + const float searchRadius, + const float mu, + const int maxNearestNeighbors, + const float maxSurfaceAngleRadians) +{ +#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::search::KdTree<::pcl::PointNormal>::Ptr tree2(new ::pcl::search::KdTree<::pcl::PointNormal>()); + ::pcl::GreedyProjectionTriangulation<::pcl::PointNormal> gp3; + gp3.setSearchRadius(searchRadius); + gp3.setMu(mu); + gp3.setMaximumNearestNeighbors(maxNearestNeighbors); + gp3.setMaximumSurfaceAngle(maxSurfaceAngleRadians); + gp3.setMinimumAngle(0.1); + gp3.setMaximumAngle(2.5); + gp3.setNormalConsistency(false); + gp3.setInputCloud(cloudWithNormals); + gp3.setSearchMethod(tree2); + + ::pcl::PolygonMesh mesh; + gp3.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; + } + core::Triangle triangle = { + static_cast(v.vertices[0]), + static_cast(v.vertices[1]), + static_cast(v.vertices[2])}; + triangles.push_back(triangle); + } + return triangles; +#else + Q_UNUSED(searchRadius); + Q_UNUSED(mu); + Q_UNUSED(maxNearestNeighbors); + Q_UNUSED(maxSurfaceAngleRadians); + Q_UNUSED(points); + 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 new file mode 100644 index 0000000..5ef2aa7 --- /dev/null +++ b/src/adapters/pcl/pcl_point_cloud_adapter.h @@ -0,0 +1,34 @@ +#ifndef PCL_POINT_CLOUD_ADAPTER_H +#define PCL_POINT_CLOUD_ADAPTER_H + +#include "../../core/point_cloud_types.h" + +namespace adapters +{ +namespace pcl +{ +QVector passThroughPoints(const QVector &points); +QVector applyVoxelGrid( + const QVector &points, + float leafSize, + int &removedCount); +QVector applyStatisticalOutlierRemoval( + const QVector &points, + int meanK, + double stdDevMulThresh, + int &removedCount); +QVector applyRadiusOutlierRemoval( + const QVector &points, + float radius, + int minNeighbors, + int &removedCount); +QVector buildGreedyTriangles( + const QVector &points, + float searchRadius, + float mu, + int maxNearestNeighbors, + float maxSurfaceAngleRadians); +} // namespace pcl +} // namespace adapters + +#endif // PCL_POINT_CLOUD_ADAPTER_H diff --git a/src/adapters/reconstruction/reconstruction_adapter.cpp b/src/adapters/reconstruction/reconstruction_adapter.cpp new file mode 100644 index 0000000..f9a59c1 --- /dev/null +++ b/src/adapters/reconstruction/reconstruction_adapter.cpp @@ -0,0 +1,32 @@ +#include "reconstruction_adapter.h" + +#include "../../algorithms/reconstruction/surface_reconstruction.h" + +namespace adapters +{ +namespace reconstruction +{ +QVector buildSurfaceTriangles( + const QVector &points, + const core::ReconstructionConfig &config) +{ + algorithms::reconstruction::setNeighborRadiusScale(config.neighborRadiusScale); + + QVector algorithmPoints; + algorithmPoints.reserve(points.size()); + for (const core::Point3f &p : points) { + algorithmPoints.push_back({p.x, p.y, p.z}); + } + + const QVector algorithmTriangles = + algorithms::reconstruction::buildSurfaceTriangles(algorithmPoints); + + QVector result; + result.reserve(algorithmTriangles.size()); + for (const algorithms::reconstruction::Triangle &t : algorithmTriangles) { + result.push_back({t.i0, t.i1, t.i2}); + } + return result; +} +} // namespace reconstruction +} // namespace adapters diff --git a/src/adapters/reconstruction/reconstruction_adapter.h b/src/adapters/reconstruction/reconstruction_adapter.h new file mode 100644 index 0000000..cb1fb5c --- /dev/null +++ b/src/adapters/reconstruction/reconstruction_adapter.h @@ -0,0 +1,17 @@ +#ifndef RECONSTRUCTION_ADAPTER_H +#define RECONSTRUCTION_ADAPTER_H + +#include "../../core/pipeline_config.h" +#include "../../core/point_cloud_types.h" + +namespace adapters +{ +namespace reconstruction +{ +QVector buildSurfaceTriangles( + const QVector &points, + const core::ReconstructionConfig &config); +} // namespace reconstruction +} // namespace adapters + +#endif // RECONSTRUCTION_ADAPTER_H diff --git a/src/adapters/registration/registration_adapter.cpp b/src/adapters/registration/registration_adapter.cpp new file mode 100644 index 0000000..2f40d61 --- /dev/null +++ b/src/adapters/registration/registration_adapter.cpp @@ -0,0 +1,51 @@ +#include "registration_adapter.h" + +#include + +namespace +{ +core::Point3f centroid(const QVector &points) +{ + core::Point3f c{0.0f, 0.0f, 0.0f}; + if (points.isEmpty()) { + return c; + } + for (const core::Point3f &p : points) { + c.x += p.x; + c.y += p.y; + c.z += p.z; + } + const float inv = 1.0f / static_cast(points.size()); + c.x *= inv; + c.y *= inv; + c.z *= inv; + return c; +} +} // namespace + +namespace adapters +{ +namespace registration +{ +RegistrationResult alignToReference( + const core::PointCloudFrame &input, + const core::RegistrationConfig &config) +{ + Q_UNUSED(config); + + RegistrationResult result; + result.alignedFrame = input; + + // Fallback implementation for environments without PCL. + const core::Point3f c = centroid(result.alignedFrame.points); + for (int i = 0; i < result.alignedFrame.points.size(); ++i) { + result.alignedFrame.points[i].x -= c.x; + result.alignedFrame.points[i].y -= c.y; + result.alignedFrame.points[i].z -= c.z; + } + result.fitness = 1.0; + result.rmse = 0.0; + return result; +} +} // namespace registration +} // namespace adapters diff --git a/src/adapters/registration/registration_adapter.h b/src/adapters/registration/registration_adapter.h new file mode 100644 index 0000000..4c266d8 --- /dev/null +++ b/src/adapters/registration/registration_adapter.h @@ -0,0 +1,24 @@ +#ifndef REGISTRATION_ADAPTER_H +#define REGISTRATION_ADAPTER_H + +#include "../../core/pipeline_config.h" +#include "../../core/point_cloud_types.h" + +namespace adapters +{ +namespace registration +{ +struct RegistrationResult +{ + core::PointCloudFrame alignedFrame; + double fitness = 0.0; + double rmse = 0.0; +}; + +RegistrationResult alignToReference( + const core::PointCloudFrame &input, + const core::RegistrationConfig &config); +} // namespace registration +} // namespace adapters + +#endif // REGISTRATION_ADAPTER_H diff --git a/src/adapters/ros2/pointcloud2_adapter.cpp b/src/adapters/ros2/pointcloud2_adapter.cpp new file mode 100644 index 0000000..96a2d4e --- /dev/null +++ b/src/adapters/ros2/pointcloud2_adapter.cpp @@ -0,0 +1,17 @@ +#include "pointcloud2_adapter.h" + +namespace adapters +{ +namespace ros2 +{ +core::PointCloudFrame fromPointCloud2(const PointCloud2Message &message) +{ + core::PointCloudFrame frame; + frame.points = message.xyz; + frame.frameId = message.frameId; + frame.timestampUsec = message.timestampUsec; + frame.sourceLabel = "ros2:PointCloud2"; + return frame; +} +} // namespace ros2 +} // namespace adapters diff --git a/src/adapters/ros2/pointcloud2_adapter.h b/src/adapters/ros2/pointcloud2_adapter.h new file mode 100644 index 0000000..47fc50c --- /dev/null +++ b/src/adapters/ros2/pointcloud2_adapter.h @@ -0,0 +1,21 @@ +#ifndef POINTCLOUD2_ADAPTER_H +#define POINTCLOUD2_ADAPTER_H + +#include "../../core/point_cloud_types.h" + +namespace adapters +{ +namespace ros2 +{ +struct PointCloud2Message +{ + QVector xyz; + QString frameId; + qint64 timestampUsec = 0; +}; + +core::PointCloudFrame fromPointCloud2(const PointCloud2Message &message); +} // namespace ros2 +} // namespace adapters + +#endif // POINTCLOUD2_ADAPTER_H diff --git a/src/adapters/ros2/tf2_adapter.cpp b/src/adapters/ros2/tf2_adapter.cpp new file mode 100644 index 0000000..1460deb --- /dev/null +++ b/src/adapters/ros2/tf2_adapter.cpp @@ -0,0 +1,19 @@ +#include "tf2_adapter.h" + +namespace adapters +{ +namespace ros2 +{ +core::PointCloudFrame transformFrame(const core::PointCloudFrame &input, const TfTransform &transform) +{ + core::PointCloudFrame output = input; + output.frameId = transform.toFrame; + for (int i = 0; i < output.points.size(); ++i) { + output.points[i].x += transform.tx; + output.points[i].y += transform.ty; + output.points[i].z += transform.tz; + } + return output; +} +} // namespace ros2 +} // namespace adapters diff --git a/src/adapters/ros2/tf2_adapter.h b/src/adapters/ros2/tf2_adapter.h new file mode 100644 index 0000000..e6995d1 --- /dev/null +++ b/src/adapters/ros2/tf2_adapter.h @@ -0,0 +1,23 @@ +#ifndef TF2_ADAPTER_H +#define TF2_ADAPTER_H + +#include "../../core/point_cloud_types.h" + +namespace adapters +{ +namespace ros2 +{ +struct TfTransform +{ + QString fromFrame; + QString toFrame; + float tx = 0.0f; + float ty = 0.0f; + float tz = 0.0f; +}; + +core::PointCloudFrame transformFrame(const core::PointCloudFrame &input, const TfTransform &transform); +} // namespace ros2 +} // namespace adapters + +#endif // TF2_ADAPTER_H diff --git a/src/adapters/sources/file_point_cloud_source.cpp b/src/adapters/sources/file_point_cloud_source.cpp new file mode 100644 index 0000000..b2eeacd --- /dev/null +++ b/src/adapters/sources/file_point_cloud_source.cpp @@ -0,0 +1,82 @@ +#include "file_point_cloud_source.h" + +#include +#include +#include +#include + +namespace adapters +{ +namespace sources +{ +FilePointCloudSource::FilePointCloudSource(const QString &filePath) + : m_filePath(filePath) +{ +} + +bool FilePointCloudSource::nextFrame(core::PointCloudFrame &frame, QString &errorText) +{ + if (m_consumed) { + errorText = "End of file source."; + return false; + } + + QFile file(m_filePath); + if (!file.open(QIODevice::ReadOnly)) { + errorText = QString("Cannot open file: %1").arg(m_filePath); + return false; + } + + QVector points; + const QString ext = QFileInfo(m_filePath).suffix().toLower(); + if (ext == "bin") { + const QByteArray raw = file.readAll(); + if (raw.size() % static_cast(sizeof(float) * 3) != 0) { + errorText = "Invalid .bin size."; + return false; + } + const int n = raw.size() / static_cast(sizeof(float) * 3); + points.reserve(n); + const float *values = reinterpret_cast(raw.constData()); + for (int i = 0; i < n; ++i) { + const int k = i * 3; + points.push_back({values[k], values[k + 1], values[k + 2]}); + } + } else { + QTextStream stream(&file); + while (!stream.atEnd()) { + QString line = stream.readLine().trimmed(); + if (line.isEmpty() || line.startsWith('#')) { + continue; + } + line.replace(';', ' '); + line.replace(',', ' '); + const QStringList parts = line.split(QRegularExpression("\\s+"), QString::SkipEmptyParts); + if (parts.size() < 3) { + continue; + } + bool okX = false; + bool okY = false; + bool okZ = false; + const float x = parts[0].toFloat(&okX); + const float y = parts[1].toFloat(&okY); + const float z = parts[2].toFloat(&okZ); + if (okX && okY && okZ) { + points.push_back({x, y, z}); + } + } + } + + if (points.size() < 4) { + errorText = "Need at least 4 points."; + return false; + } + + frame.points = points; + frame.sourceLabel = QFileInfo(m_filePath).fileName(); + frame.frameId = "sensor"; + m_consumed = true; + return true; +} +} // namespace sources +} // namespace adapters diff --git a/src/adapters/sources/file_point_cloud_source.h b/src/adapters/sources/file_point_cloud_source.h new file mode 100644 index 0000000..5162e93 --- /dev/null +++ b/src/adapters/sources/file_point_cloud_source.h @@ -0,0 +1,23 @@ +#ifndef FILE_POINT_CLOUD_SOURCE_H +#define FILE_POINT_CLOUD_SOURCE_H + +#include "../../core/data_source.h" + +namespace adapters +{ +namespace sources +{ +class FilePointCloudSource : public core::IDataSource +{ +public: + explicit FilePointCloudSource(const QString &filePath); + bool nextFrame(core::PointCloudFrame &frame, QString &errorText) override; + +private: + QString m_filePath; + bool m_consumed = false; +}; +} // namespace sources +} // namespace adapters + +#endif // FILE_POINT_CLOUD_SOURCE_H diff --git a/src/adapters/sources/ros2_point_cloud_source.cpp b/src/adapters/sources/ros2_point_cloud_source.cpp new file mode 100644 index 0000000..10278ae --- /dev/null +++ b/src/adapters/sources/ros2_point_cloud_source.cpp @@ -0,0 +1,27 @@ +#include "ros2_point_cloud_source.h" + +namespace adapters +{ +namespace sources +{ +Ros2PointCloudSource::Ros2PointCloudSource() +{ +} + +void Ros2PointCloudSource::pushMockMessage(const ros2::PointCloud2Message &message) +{ + m_queue.push_back(message); +} + +bool Ros2PointCloudSource::nextFrame(core::PointCloudFrame &frame, QString &errorText) +{ + if (m_queue.isEmpty()) { + errorText = "ROS2 source has no frames."; + return false; + } + frame = ros2::fromPointCloud2(m_queue.front()); + m_queue.pop_front(); + return true; +} +} // namespace sources +} // namespace adapters diff --git a/src/adapters/sources/ros2_point_cloud_source.h b/src/adapters/sources/ros2_point_cloud_source.h new file mode 100644 index 0000000..a472376 --- /dev/null +++ b/src/adapters/sources/ros2_point_cloud_source.h @@ -0,0 +1,24 @@ +#ifndef ROS2_POINT_CLOUD_SOURCE_H +#define ROS2_POINT_CLOUD_SOURCE_H + +#include "../../core/data_source.h" +#include "../ros2/pointcloud2_adapter.h" + +namespace adapters +{ +namespace sources +{ +class Ros2PointCloudSource : public core::IDataSource +{ +public: + Ros2PointCloudSource(); + void pushMockMessage(const ros2::PointCloud2Message &message); + bool nextFrame(core::PointCloudFrame &frame, QString &errorText) override; + +private: + QVector m_queue; +}; +} // namespace sources +} // namespace adapters + +#endif // ROS2_POINT_CLOUD_SOURCE_H diff --git a/src/algorithms/preprocess/preprocess_algorithms.cpp b/src/algorithms/preprocess/preprocess_algorithms.cpp new file mode 100644 index 0000000..ad9a769 --- /dev/null +++ b/src/algorithms/preprocess/preprocess_algorithms.cpp @@ -0,0 +1,214 @@ +#include "preprocess_algorithms.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +struct Dsu +{ + QVector parent; + QVector size; + + explicit Dsu(const int n) + : parent(n) + , size(n, 1) + { + for (int i = 0; i < n; ++i) { + parent[i] = i; + } + } + + int find(int x) + { + while (parent[x] != x) { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + return x; + } + + void unite(const int a, const int b) + { + int ra = find(a); + int rb = find(b); + if (ra == rb) { + return; + } + if (size[ra] < size[rb]) { + qSwap(ra, rb); + } + parent[rb] = ra; + size[ra] += size[rb]; + } +}; +} // namespace + +namespace algorithms +{ +namespace preprocess +{ +QVector keepLargestCluster( + const QVector &points, + int &removedCount, + int &clusterCount, + const double clusterJoinDistanceScale) +{ + removedCount = 0; + clusterCount = 1; + if (points.size() < 8) { + return points; + } + + QVector nearestDist; + nearestDist.reserve(points.size()); + for (int i = 0; i < points.size(); ++i) { + double best = std::numeric_limits::max(); + for (int j = 0; j < points.size(); ++j) { + if (i == j) { + continue; + } + const double dx = static_cast(points[i].x) - static_cast(points[j].x); + const double dy = static_cast(points[i].y) - static_cast(points[j].y); + const double dz = static_cast(points[i].z) - static_cast(points[j].z); + const double d = qSqrt(dx * dx + dy * dy + dz * dz); + if (d < best) { + best = d; + } + } + if (best < std::numeric_limits::max()) { + nearestDist.push_back(best); + } + } + if (nearestDist.isEmpty()) { + return points; + } + + std::sort(nearestDist.begin(), nearestDist.end()); + const double medianNn = nearestDist[nearestDist.size() / 2]; + const double joinDistance = qMax(1e-9, medianNn * clusterJoinDistanceScale); + const double joinDistance2 = joinDistance * joinDistance; + + Dsu dsu(points.size()); + for (int i = 0; i < points.size(); ++i) { + for (int j = i + 1; j < points.size(); ++j) { + const double dx = static_cast(points[i].x) - static_cast(points[j].x); + const double dy = static_cast(points[i].y) - static_cast(points[j].y); + const double dz = static_cast(points[i].z) - static_cast(points[j].z); + const double d2 = dx * dx + dy * dy + dz * dz; + if (d2 <= joinDistance2) { + dsu.unite(i, j); + } + } + } + + QHash compSizes; + for (int i = 0; i < points.size(); ++i) { + const int root = dsu.find(i); + compSizes[root] = compSizes.value(root, 0) + 1; + } + clusterCount = compSizes.size(); + + int largestRoot = -1; + int largestSize = 0; + for (auto it = compSizes.constBegin(); it != compSizes.constEnd(); ++it) { + if (it.value() > largestSize) { + largestSize = it.value(); + largestRoot = it.key(); + } + } + + QVector filtered; + filtered.reserve(largestSize); + for (int i = 0; i < points.size(); ++i) { + if (dsu.find(i) == largestRoot) { + filtered.push_back(points[i]); + } + } + + removedCount = points.size() - filtered.size(); + return filtered; +} + +QVector downsampleDenseAreas( + const QVector &points, + int &removedCount, + const double downsampleCellScale) +{ + removedCount = 0; + if (points.size() < 16) { + return points; + } + + QVector nearestXY; + nearestXY.reserve(points.size()); + for (int i = 0; i < points.size(); ++i) { + double best = std::numeric_limits::max(); + for (int j = 0; j < points.size(); ++j) { + if (i == j) { + continue; + } + const double dx = static_cast(points[i].x) - static_cast(points[j].x); + const double dy = static_cast(points[i].y) - static_cast(points[j].y); + const double d = qSqrt(dx * dx + dy * dy); + if (d < best) { + best = d; + } + } + if (best < std::numeric_limits::max()) { + nearestXY.push_back(best); + } + } + if (nearestXY.isEmpty()) { + return points; + } + + std::sort(nearestXY.begin(), nearestXY.end()); + const double medianXY = nearestXY[nearestXY.size() / 2]; + const double cellSize = qMax(1e-9, medianXY * downsampleCellScale); + + struct Bucket + { + double sx; + double sy; + double sz; + int n; + }; + QHash buckets; + buckets.reserve(points.size()); + + for (const core::Point3f &p : points) { + const qint64 ix = qFloor(static_cast(p.x) / cellSize); + const qint64 iy = qFloor(static_cast(p.y) / cellSize); + const QString key = QString::number(ix) + "_" + QString::number(iy); + if (!buckets.contains(key)) { + buckets.insert(key, {p.x, p.y, p.z, 1}); + } else { + Bucket &b = buckets[key]; + b.sx += p.x; + b.sy += p.y; + b.sz += p.z; + b.n += 1; + } + } + + QVector reduced; + reduced.reserve(buckets.size()); + for (auto it = buckets.constBegin(); it != buckets.constEnd(); ++it) { + const Bucket &bucket = it.value(); + reduced.push_back({ + static_cast(bucket.sx / bucket.n), + static_cast(bucket.sy / bucket.n), + static_cast(bucket.sz / bucket.n) + }); + } + + removedCount = points.size() - reduced.size(); + return reduced; +} +} // namespace preprocess +} // namespace algorithms diff --git a/src/algorithms/preprocess/preprocess_algorithms.h b/src/algorithms/preprocess/preprocess_algorithms.h new file mode 100644 index 0000000..5636c7a --- /dev/null +++ b/src/algorithms/preprocess/preprocess_algorithms.h @@ -0,0 +1,23 @@ +#ifndef PREPROCESS_ALGORITHMS_H +#define PREPROCESS_ALGORITHMS_H + +#include "../../core/point_cloud_types.h" + +namespace algorithms +{ +namespace preprocess +{ +QVector keepLargestCluster( + const QVector &points, + int &removedCount, + int &clusterCount, + double clusterJoinDistanceScale); + +QVector downsampleDenseAreas( + const QVector &points, + int &removedCount, + double downsampleCellScale); +} // namespace preprocess +} // namespace algorithms + +#endif // PREPROCESS_ALGORITHMS_H diff --git a/src/geometry/surface_reconstruction.cpp b/src/algorithms/reconstruction/surface_reconstruction.cpp similarity index 94% rename from src/geometry/surface_reconstruction.cpp rename to src/algorithms/reconstruction/surface_reconstruction.cpp index 92c9af5..117eb64 100644 --- a/src/geometry/surface_reconstruction.cpp +++ b/src/algorithms/reconstruction/surface_reconstruction.cpp @@ -7,6 +7,10 @@ #include #include +namespace algorithms +{ +namespace reconstruction +{ namespace { const double kEps = 1e-6; @@ -184,3 +188,5 @@ QVector buildSurfaceTriangles(const QVector &points) return triangles; } +} // namespace reconstruction +} // namespace algorithms diff --git a/src/geometry/surface_reconstruction.h b/src/algorithms/reconstruction/surface_reconstruction.h similarity index 73% rename from src/geometry/surface_reconstruction.h rename to src/algorithms/reconstruction/surface_reconstruction.h index e952099..784f187 100644 --- a/src/geometry/surface_reconstruction.h +++ b/src/algorithms/reconstruction/surface_reconstruction.h @@ -3,6 +3,10 @@ #include +namespace algorithms +{ +namespace reconstruction +{ struct Point3f { float x; @@ -20,5 +24,7 @@ struct Triangle void setNeighborRadiusScale(float scale); float neighborRadiusScale(); QVector buildSurfaceTriangles(const QVector &points); +} // namespace reconstruction +} // namespace algorithms #endif // SURFACE_RECONSTRUCTION_H diff --git a/src/core/data_source.h b/src/core/data_source.h new file mode 100644 index 0000000..e8dd83a --- /dev/null +++ b/src/core/data_source.h @@ -0,0 +1,16 @@ +#ifndef DATA_SOURCE_H +#define DATA_SOURCE_H + +#include "point_cloud_types.h" + +namespace core +{ +class IDataSource +{ +public: + virtual ~IDataSource() {} + virtual bool nextFrame(PointCloudFrame &frame, QString &errorText) = 0; +}; +} // namespace core + +#endif // DATA_SOURCE_H diff --git a/src/core/pipeline_config.cpp b/src/core/pipeline_config.cpp new file mode 100644 index 0000000..e178765 --- /dev/null +++ b/src/core/pipeline_config.cpp @@ -0,0 +1,38 @@ +#include "pipeline_config.h" + +namespace core +{ +PipelineConfig makeDesktopDebugConfig() +{ + PipelineConfig config; + config.preprocessPlugins = QStringList() << "keep_largest_cluster" << "downsample_dense"; + config.reconstructionPlugin = "surface_fallback"; + config.runtime.profile = "desktop_debug"; + config.runtime.asyncReconstruction = false; + config.runtime.targetFps = 20; + return config; +} + +static PipelineConfig makePclFastConfig() +{ + PipelineConfig config = makeDesktopDebugConfig(); + config.preprocessPlugins = QStringList() << "pcl_voxel_grid" << "pcl_statistical_outlier"; + config.reconstructionPlugin = "pcl_greedy_triangulation"; + config.preprocess.pclVoxelLeafSize = 0.05f; + config.preprocess.pclSorMeanK = 20; + config.reconstruction.pclGreedySearchRadius = 0.10f; + return config; +} + +PipelineConfig makeRpi4RuntimeConfig() +{ + PipelineConfig config = makePclFastConfig(); + config.runtime.profile = "rpi4_runtime"; + config.runtime.asyncReconstruction = true; + config.runtime.targetFps = 10; + config.preprocess.maxInputPoints = 60000; + config.preprocess.downsampleCellScale = 1.2; + config.reconstruction.runEveryNthFrame = 3; + return config; +} +} // namespace core diff --git a/src/core/pipeline_config.h b/src/core/pipeline_config.h new file mode 100644 index 0000000..2d1627d --- /dev/null +++ b/src/core/pipeline_config.h @@ -0,0 +1,69 @@ +#ifndef PIPELINE_CONFIG_H +#define PIPELINE_CONFIG_H + +#include +#include + +namespace core +{ +struct PreprocessConfig +{ + double clusterJoinDistanceScale = 5.0; + double downsampleCellScale = 0.8; + int maxInputPoints = 150000; + float pclVoxelLeafSize = 0.04f; + int pclSorMeanK = 24; + double pclSorStdDevMul = 1.0; + float pclRorRadius = 0.08f; + int pclRorMinNeighbors = 4; +}; + +struct TransformConfig +{ + bool enabled = false; + QString targetFrameId = "map"; +}; + +struct RegistrationConfig +{ + bool enabled = false; + QString method = "icp_centroid"; + int maxIterations = 20; +}; + +struct ReconstructionConfig +{ + float neighborRadiusScale = 3.5f; + int runEveryNthFrame = 1; + float pclGreedySearchRadius = 0.08f; + float pclGreedyMu = 2.5f; + int pclGreedyMaxNearest = 100; + float pclGreedyMaxSurfaceAngle = 0.8f; +}; + +struct RuntimeConfig +{ + QString profile = "desktop_debug"; + bool asyncReconstruction = false; + int maxQueueDepth = 4; + int targetFps = 15; +}; + +struct PipelineConfig +{ + QStringList preprocessPlugins; + QStringList transformPlugins; + QStringList registrationPlugins; + QString reconstructionPlugin = "surface_fallback"; + PreprocessConfig preprocess; + TransformConfig transform; + RegistrationConfig registration; + ReconstructionConfig reconstruction; + RuntimeConfig runtime; +}; + +PipelineConfig makeDesktopDebugConfig(); +PipelineConfig makeRpi4RuntimeConfig(); +} // namespace core + +#endif // PIPELINE_CONFIG_H diff --git a/src/core/pipeline_executor.cpp b/src/core/pipeline_executor.cpp new file mode 100644 index 0000000..b486cc0 --- /dev/null +++ b/src/core/pipeline_executor.cpp @@ -0,0 +1,113 @@ +#include "pipeline_executor.h" + +#include + +namespace core +{ +void PipelineExecutor::addPreprocessStage(const std::shared_ptr &stage) +{ + if (!stage) { + return; + } + m_preprocessStages.push_back(stage); +} + +void PipelineExecutor::addTransformStage(const std::shared_ptr &stage) +{ + if (!stage) { + return; + } + m_transformStages.push_back(stage); +} + +void PipelineExecutor::addRegistrationStage(const std::shared_ptr &stage) +{ + if (!stage) { + return; + } + m_registrationStages.push_back(stage); +} + +void PipelineExecutor::addLocalizationStage(const std::shared_ptr &stage) +{ + if (!stage) { + return; + } + m_localizationStages.push_back(stage); +} + +void PipelineExecutor::addSegmentationStage(const std::shared_ptr &stage) +{ + if (!stage) { + return; + } + m_segmentationStages.push_back(stage); +} + +void PipelineExecutor::addMappingStage(const std::shared_ptr &stage) +{ + if (!stage) { + return; + } + m_mappingStages.push_back(stage); +} + +void PipelineExecutor::addPlanningStage(const std::shared_ptr &stage) +{ + if (!stage) { + return; + } + m_planningStages.push_back(stage); +} + +void PipelineExecutor::setReconstructionStage(const std::shared_ptr &stage) +{ + m_reconstructionStage = stage; +} + +PipelineResult PipelineExecutor::run(const PointCloudFrame &input) const +{ + PipelineResult result; + result.stats.inputPoints = input.points.size(); + result.context.inputFrameId = input.frameId; + + PointCloudFrame current = input; + for (const std::shared_ptr &stage : m_preprocessStages) { + current = stage->process(current, result.stats); + } + for (const std::shared_ptr &stage : m_transformStages) { + current = stage->process(current, result.context, result.stats); + } + for (const std::shared_ptr &stage : m_registrationStages) { + current = stage->process(current, result.context, result.stats); + } + for (const std::shared_ptr &stage : m_localizationStages) { + stage->process(current, result.context, result.stats); + } + for (const std::shared_ptr &stage : m_segmentationStages) { + stage->process(current, result.context, result.stats); + } + for (const std::shared_ptr &stage : m_mappingStages) { + stage->process(current, result.context, result.stats); + } + for (const std::shared_ptr &stage : m_planningStages) { + stage->process(result.context, result.stats); + } + + result.stats.afterPreprocessingPoints = current.points.size(); + result.frame = current; + + if (!m_reconstructionStage) { + result.stats.outputTriangles = 0; + result.stats.reconstructionMs = 0; + return result; + } + + QElapsedTimer timer; + timer.start(); + result.triangles = m_reconstructionStage->reconstruct(current, result.context, result.stats); + result.stats.reconstructionMs = timer.elapsed(); + result.stats.outputTriangles = result.triangles.size(); + return result; +} +} // namespace core diff --git a/src/core/pipeline_executor.h b/src/core/pipeline_executor.h new file mode 100644 index 0000000..5baa32f --- /dev/null +++ b/src/core/pipeline_executor.h @@ -0,0 +1,35 @@ +#ifndef PIPELINE_EXECUTOR_H +#define PIPELINE_EXECUTOR_H + +#include + +#include "pipeline_stage.h" + +namespace core +{ +class PipelineExecutor +{ +public: + void addPreprocessStage(const std::shared_ptr &stage); + void addTransformStage(const std::shared_ptr &stage); + void addRegistrationStage(const std::shared_ptr &stage); + void addLocalizationStage(const std::shared_ptr &stage); + void addSegmentationStage(const std::shared_ptr &stage); + void addMappingStage(const std::shared_ptr &stage); + void addPlanningStage(const std::shared_ptr &stage); + void setReconstructionStage(const std::shared_ptr &stage); + PipelineResult run(const PointCloudFrame &input) const; + +private: + QVector > m_preprocessStages; + QVector > m_transformStages; + QVector > m_registrationStages; + QVector > m_localizationStages; + QVector > m_segmentationStages; + QVector > m_mappingStages; + QVector > m_planningStages; + std::shared_ptr m_reconstructionStage; +}; +} // namespace core + +#endif // PIPELINE_EXECUTOR_H diff --git a/src/core/pipeline_plugin_registry.cpp b/src/core/pipeline_plugin_registry.cpp new file mode 100644 index 0000000..eba6eb5 --- /dev/null +++ b/src/core/pipeline_plugin_registry.cpp @@ -0,0 +1,52 @@ +#include "pipeline_plugin_registry.h" + +namespace core +{ +void PipelinePluginRegistry::registerPreprocess(const QString &id, const PreprocessFactory &factory) +{ + m_preprocessFactories.insert(id, factory); +} + +void PipelinePluginRegistry::registerTransform(const QString &id, const TransformFactory &factory) +{ + m_transformFactories.insert(id, factory); +} + +void PipelinePluginRegistry::registerRegistration(const QString &id, const RegistrationFactory &factory) +{ + m_registrationFactories.insert(id, factory); +} + +void PipelinePluginRegistry::registerReconstruction(const QString &id, const ReconstructionFactory &factory) +{ + m_reconstructionFactories.insert(id, factory); +} + +std::shared_ptr PipelinePluginRegistry::createPreprocess( + const QString &id, + const PipelineConfig &config) const +{ + return m_preprocessFactories.contains(id) ? m_preprocessFactories.value(id)(config) : std::shared_ptr(); +} + +std::shared_ptr PipelinePluginRegistry::createTransform( + const QString &id, + const PipelineConfig &config) const +{ + return m_transformFactories.contains(id) ? m_transformFactories.value(id)(config) : std::shared_ptr(); +} + +std::shared_ptr PipelinePluginRegistry::createRegistration( + const QString &id, + const PipelineConfig &config) const +{ + return m_registrationFactories.contains(id) ? m_registrationFactories.value(id)(config) : std::shared_ptr(); +} + +std::shared_ptr PipelinePluginRegistry::createReconstruction( + const QString &id, + const PipelineConfig &config) const +{ + return m_reconstructionFactories.contains(id) ? m_reconstructionFactories.value(id)(config) : std::shared_ptr(); +} +} // namespace core diff --git a/src/core/pipeline_plugin_registry.h b/src/core/pipeline_plugin_registry.h new file mode 100644 index 0000000..de6523e --- /dev/null +++ b/src/core/pipeline_plugin_registry.h @@ -0,0 +1,40 @@ +#ifndef PIPELINE_PLUGIN_REGISTRY_H +#define PIPELINE_PLUGIN_REGISTRY_H + +#include +#include + +#include + +#include "pipeline_config.h" +#include "pipeline_stage.h" + +namespace core +{ +class PipelinePluginRegistry +{ +public: + typedef std::function(const PipelineConfig &)> PreprocessFactory; + typedef std::function(const PipelineConfig &)> TransformFactory; + typedef std::function(const PipelineConfig &)> RegistrationFactory; + typedef std::function(const PipelineConfig &)> ReconstructionFactory; + + void registerPreprocess(const QString &id, const PreprocessFactory &factory); + void registerTransform(const QString &id, const TransformFactory &factory); + void registerRegistration(const QString &id, const RegistrationFactory &factory); + void registerReconstruction(const QString &id, const ReconstructionFactory &factory); + + std::shared_ptr createPreprocess(const QString &id, const PipelineConfig &config) const; + std::shared_ptr createTransform(const QString &id, const PipelineConfig &config) const; + std::shared_ptr createRegistration(const QString &id, const PipelineConfig &config) const; + std::shared_ptr createReconstruction(const QString &id, const PipelineConfig &config) const; + +private: + QHash m_preprocessFactories; + QHash m_transformFactories; + QHash m_registrationFactories; + QHash m_reconstructionFactories; +}; +} // namespace core + +#endif // PIPELINE_PLUGIN_REGISTRY_H diff --git a/src/core/pipeline_stage.h b/src/core/pipeline_stage.h new file mode 100644 index 0000000..2e26e48 --- /dev/null +++ b/src/core/pipeline_stage.h @@ -0,0 +1,83 @@ +#ifndef PIPELINE_STAGE_H +#define PIPELINE_STAGE_H + +#include "point_cloud_types.h" + +namespace core +{ +class IPreprocessStage +{ +public: + virtual ~IPreprocessStage() {} + virtual PointCloudFrame process(const PointCloudFrame &input, PipelineStats &stats) const = 0; +}; + +class ITransformStage +{ +public: + virtual ~ITransformStage() {} + virtual PointCloudFrame process( + const PointCloudFrame &input, + PipelineContext &context, + PipelineStats &stats) const = 0; +}; + +class IRegistrationStage +{ +public: + virtual ~IRegistrationStage() {} + virtual PointCloudFrame process( + const PointCloudFrame &input, + PipelineContext &context, + PipelineStats &stats) const = 0; +}; + +class ILocalizationStage +{ +public: + virtual ~ILocalizationStage() {} + virtual void process( + const PointCloudFrame &input, + PipelineContext &context, + PipelineStats &stats) const = 0; +}; + +class ISegmentationStage +{ +public: + virtual ~ISegmentationStage() {} + virtual void process( + const PointCloudFrame &input, + PipelineContext &context, + PipelineStats &stats) const = 0; +}; + +class IMappingStage +{ +public: + virtual ~IMappingStage() {} + virtual void process( + const PointCloudFrame &input, + PipelineContext &context, + PipelineStats &stats) const = 0; +}; + +class IPlanningStage +{ +public: + virtual ~IPlanningStage() {} + virtual void process(const PipelineContext &context, PipelineStats &stats) const = 0; +}; + +class IReconstructionStage +{ +public: + virtual ~IReconstructionStage() {} + virtual QVector reconstruct( + const PointCloudFrame &frame, + const PipelineContext &context, + PipelineStats &stats) const = 0; +}; +} // namespace core + +#endif // PIPELINE_STAGE_H diff --git a/src/core/point_cloud_types.h b/src/core/point_cloud_types.h new file mode 100644 index 0000000..3b3b1ee --- /dev/null +++ b/src/core/point_cloud_types.h @@ -0,0 +1,78 @@ +#ifndef POINT_CLOUD_TYPES_H +#define POINT_CLOUD_TYPES_H + +#include +#include + +namespace core +{ +struct Point3f +{ + float x; + float y; + float z; +}; + +struct Triangle +{ + int i0; + int i1; + int i2; +}; + +struct PointCloudFrame +{ + QVector points; + QString sourceLabel; + QString frameId; + qint64 timestampUsec = 0; +}; + +struct PoseEstimate +{ + float tx = 0.0f; + float ty = 0.0f; + float tz = 0.0f; + float qx = 0.0f; + float qy = 0.0f; + float qz = 0.0f; + float qw = 1.0f; +}; + +struct PipelineContext +{ + QString inputFrameId; + QString targetFrameId; + bool hasPoseEstimate = false; + PoseEstimate poseEstimate; + double registrationFitness = 0.0; + double registrationRmse = 0.0; + qint64 registrationMs = 0; + qint64 transformMs = 0; + qint64 localizationMs = 0; + qint64 segmentationMs = 0; + qint64 mappingMs = 0; + qint64 planningMs = 0; +}; + +struct PipelineStats +{ + int inputPoints = 0; + int afterPreprocessingPoints = 0; + int outputTriangles = 0; + int removedClusterPoints = 0; + int removedDownsamplePoints = 0; + int detectedClusters = 1; + qint64 reconstructionMs = 0; +}; + +struct PipelineResult +{ + PointCloudFrame frame; + QVector triangles; + PipelineStats stats; + PipelineContext context; +}; +} // namespace core + +#endif // POINT_CLOUD_TYPES_H diff --git a/src/factories/pipeline/desktop_pipeline_factory.cpp b/src/factories/pipeline/desktop_pipeline_factory.cpp new file mode 100644 index 0000000..9a5ae57 --- /dev/null +++ b/src/factories/pipeline/desktop_pipeline_factory.cpp @@ -0,0 +1,94 @@ +#include "desktop_pipeline_factory.h" + +#include + +#include "../../strategies/preprocess_basic_stages.h" +#include "../../strategies/preprocess_pcl_stages.h" +#include "../../strategies/registration_icp_stage.h" +#include "../../strategies/reconstruction_pcl_greedy_stage.h" +#include "../../strategies/reconstruction_surface_stage.h" +#include "../../strategies/transform_tf_stage.h" + +namespace factories +{ +namespace pipeline +{ +core::PipelineExecutor createDesktopPipelineExecutor(const core::PipelineConfig &config) +{ + const core::PipelinePluginRegistry registry = createDefaultPluginRegistry(); + core::PipelineExecutor executor; + + const QStringList preprocessIds = config.preprocessPlugins.isEmpty() + ? (QStringList() << "keep_largest_cluster" << "downsample_dense") + : config.preprocessPlugins; + + for (const QString &id : preprocessIds) { + const std::shared_ptr stage = registry.createPreprocess(id, config); + if (stage) { + executor.addPreprocessStage(stage); + } + } + + for (const QString &id : config.transformPlugins) { + const std::shared_ptr stage = registry.createTransform(id, config); + if (stage) { + executor.addTransformStage(stage); + } + } + + for (const QString &id : config.registrationPlugins) { + const std::shared_ptr stage = registry.createRegistration(id, config); + if (stage) { + executor.addRegistrationStage(stage); + } + } + + const std::shared_ptr reconstruction = registry.createReconstruction( + config.reconstructionPlugin, + config); + executor.setReconstructionStage(reconstruction); + return executor; +} + +core::PipelineExecutor createPipelineExecutorForProfile(const QString &profileName) +{ + if (profileName == "rpi4_runtime") { + return createDesktopPipelineExecutor(core::makeRpi4RuntimeConfig()); + } + return createDesktopPipelineExecutor(core::makeDesktopDebugConfig()); +} + +core::PipelinePluginRegistry createDefaultPluginRegistry() +{ + core::PipelinePluginRegistry registry; + registry.registerPreprocess("keep_largest_cluster", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::KeepLargestClusterStage(config.preprocess)); + }); + registry.registerPreprocess("downsample_dense", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::DownsampleDenseAreasStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_voxel_grid", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclVoxelGridStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_statistical_outlier", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclStatisticalOutlierStage(config.preprocess)); + }); + registry.registerPreprocess("pcl_radius_outlier", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclRadiusOutlierStage(config.preprocess)); + }); + registry.registerTransform("tf_transform", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::TransformTfStage(config)); + }); + registry.registerRegistration("icp_centroid", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::RegistrationIcpStage(config)); + }); + registry.registerReconstruction("surface_fallback", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::SurfaceReconstructionStage(config.reconstruction)); + }); + registry.registerReconstruction("pcl_greedy_triangulation", [](const core::PipelineConfig &config) { + return std::shared_ptr(new strategies::PclGreedyReconstructionStage(config.reconstruction)); + }); + return registry; +} +} // namespace pipeline +} // namespace factories diff --git a/src/factories/pipeline/desktop_pipeline_factory.h b/src/factories/pipeline/desktop_pipeline_factory.h new file mode 100644 index 0000000..9460f97 --- /dev/null +++ b/src/factories/pipeline/desktop_pipeline_factory.h @@ -0,0 +1,18 @@ +#ifndef DESKTOP_PIPELINE_FACTORY_H +#define DESKTOP_PIPELINE_FACTORY_H + +#include "../../core/pipeline_config.h" +#include "../../core/pipeline_executor.h" +#include "../../core/pipeline_plugin_registry.h" + +namespace factories +{ +namespace pipeline +{ +core::PipelineExecutor createDesktopPipelineExecutor(const core::PipelineConfig &config = core::PipelineConfig()); +core::PipelineExecutor createPipelineExecutorForProfile(const QString &profileName); +core::PipelinePluginRegistry createDefaultPluginRegistry(); +} // namespace pipeline +} // namespace factories + +#endif // DESKTOP_PIPELINE_FACTORY_H diff --git a/src/strategies/preprocess_basic_stages.cpp b/src/strategies/preprocess_basic_stages.cpp new file mode 100644 index 0000000..58474cb --- /dev/null +++ b/src/strategies/preprocess_basic_stages.cpp @@ -0,0 +1,36 @@ +#include "preprocess_basic_stages.h" +#include "../algorithms/preprocess/preprocess_algorithms.h" + +namespace strategies +{ +KeepLargestClusterStage::KeepLargestClusterStage(const core::PreprocessConfig &config) + : m_config(config) +{ +} + +core::PointCloudFrame KeepLargestClusterStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + output.points = algorithms::preprocess::keepLargestCluster( + input.points, + stats.removedClusterPoints, + stats.detectedClusters, + m_config.clusterJoinDistanceScale); + return output; +} + +DownsampleDenseAreasStage::DownsampleDenseAreasStage(const core::PreprocessConfig &config) + : m_config(config) +{ +} + +core::PointCloudFrame DownsampleDenseAreasStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + output.points = algorithms::preprocess::downsampleDenseAreas( + input.points, + stats.removedDownsamplePoints, + m_config.downsampleCellScale); + return output; +} +} // namespace strategies diff --git a/src/strategies/preprocess_basic_stages.h b/src/strategies/preprocess_basic_stages.h new file mode 100644 index 0000000..a2d1134 --- /dev/null +++ b/src/strategies/preprocess_basic_stages.h @@ -0,0 +1,30 @@ +#ifndef PREPROCESS_BASIC_STAGES_H +#define PREPROCESS_BASIC_STAGES_H + +#include "../core/pipeline_config.h" +#include "../core/pipeline_stage.h" + +namespace strategies +{ +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; + +private: + core::PreprocessConfig m_config; +}; + +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; + +private: + core::PreprocessConfig m_config; +}; +} // namespace strategies + +#endif // PREPROCESS_BASIC_STAGES_H diff --git a/src/strategies/preprocess_pcl_stages.cpp b/src/strategies/preprocess_pcl_stages.cpp new file mode 100644 index 0000000..cdf17e5 --- /dev/null +++ b/src/strategies/preprocess_pcl_stages.cpp @@ -0,0 +1,53 @@ +#include "preprocess_pcl_stages.h" + +#include "../adapters/pcl/pcl_point_cloud_adapter.h" +#include "../algorithms/preprocess/preprocess_algorithms.h" + +namespace strategies +{ +core::PointCloudFrame PclVoxelGridStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyVoxelGrid(input.points, m_config.pclVoxelLeafSize, removed); +#ifndef PCL_ENABLED + output.points = algorithms::preprocess::downsampleDenseAreas(input.points, removed, m_config.downsampleCellScale); +#endif + stats.removedDownsamplePoints += removed; + return output; +} + +core::PointCloudFrame PclStatisticalOutlierStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + int clusters = 1; + output.points = adapters::pcl::applyStatisticalOutlierRemoval( + input.points, + m_config.pclSorMeanK, + m_config.pclSorStdDevMul, + removed); +#ifndef PCL_ENABLED + output.points = algorithms::preprocess::keepLargestCluster(input.points, removed, clusters, m_config.clusterJoinDistanceScale); +#endif + stats.removedClusterPoints += removed; + stats.detectedClusters = clusters; + return output; +} + +core::PointCloudFrame PclRadiusOutlierStage::process(const core::PointCloudFrame &input, core::PipelineStats &stats) const +{ + core::PointCloudFrame output = input; + int removed = 0; + output.points = adapters::pcl::applyRadiusOutlierRemoval( + input.points, + m_config.pclRorRadius, + m_config.pclRorMinNeighbors, + removed); +#ifndef PCL_ENABLED + output.points = algorithms::preprocess::downsampleDenseAreas(input.points, removed, m_config.downsampleCellScale * 0.8); +#endif + stats.removedDownsamplePoints += removed; + return output; +} +} // namespace strategies diff --git a/src/strategies/preprocess_pcl_stages.h b/src/strategies/preprocess_pcl_stages.h new file mode 100644 index 0000000..9da12a7 --- /dev/null +++ b/src/strategies/preprocess_pcl_stages.h @@ -0,0 +1,52 @@ +#ifndef PREPROCESS_PCL_STAGES_H +#define PREPROCESS_PCL_STAGES_H + +#include "../core/pipeline_config.h" +#include "../core/pipeline_stage.h" + +namespace strategies +{ +class PclVoxelGridStage : public core::IPreprocessStage +{ +public: + explicit PclVoxelGridStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + +private: + core::PreprocessConfig m_config; +}; + +class PclStatisticalOutlierStage : public core::IPreprocessStage +{ +public: + explicit PclStatisticalOutlierStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + +private: + core::PreprocessConfig m_config; +}; + +class PclRadiusOutlierStage : public core::IPreprocessStage +{ +public: + explicit PclRadiusOutlierStage(const core::PreprocessConfig &config) + : m_config(config) + { + } + + core::PointCloudFrame process(const core::PointCloudFrame &input, core::PipelineStats &stats) const override; + +private: + core::PreprocessConfig m_config; +}; +} // namespace strategies + +#endif // PREPROCESS_PCL_STAGES_H diff --git a/src/strategies/reconstruction_pcl_greedy_stage.h b/src/strategies/reconstruction_pcl_greedy_stage.h new file mode 100644 index 0000000..a9324e7 --- /dev/null +++ b/src/strategies/reconstruction_pcl_greedy_stage.h @@ -0,0 +1,41 @@ +#ifndef RECONSTRUCTION_PCL_GREEDY_STAGE_H +#define RECONSTRUCTION_PCL_GREEDY_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 PclGreedyReconstructionStage : public core::IReconstructionStage +{ +public: + explicit PclGreedyReconstructionStage(const core::ReconstructionConfig &config) + : m_config(config) + { + } + + QVector reconstruct( + const core::PointCloudFrame &frame, + const core::PipelineContext &, + core::PipelineStats &) const override + { + QVector triangles = adapters::pcl::buildGreedyTriangles( + frame.points, + m_config.pclGreedySearchRadius, + m_config.pclGreedyMu, + m_config.pclGreedyMaxNearest, + m_config.pclGreedyMaxSurfaceAngle); + if (!triangles.isEmpty()) { + return triangles; + } + return adapters::reconstruction::buildSurfaceTriangles(frame.points, m_config); + } + +private: + core::ReconstructionConfig m_config; +}; +} // namespace strategies + +#endif // RECONSTRUCTION_PCL_GREEDY_STAGE_H diff --git a/src/strategies/reconstruction_surface_stage.h b/src/strategies/reconstruction_surface_stage.h new file mode 100644 index 0000000..0a047e4 --- /dev/null +++ b/src/strategies/reconstruction_surface_stage.h @@ -0,0 +1,31 @@ +#ifndef RECONSTRUCTION_SURFACE_STAGE_H +#define RECONSTRUCTION_SURFACE_STAGE_H + +#include "../adapters/reconstruction/reconstruction_adapter.h" +#include "../core/pipeline_config.h" +#include "../core/pipeline_stage.h" + +namespace strategies +{ +class SurfaceReconstructionStage : public core::IReconstructionStage +{ +public: + explicit SurfaceReconstructionStage(const core::ReconstructionConfig &config = core::ReconstructionConfig()) + : m_config(config) + { + } + + QVector reconstruct( + const core::PointCloudFrame &frame, + const core::PipelineContext &, + core::PipelineStats &) const override + { + return adapters::reconstruction::buildSurfaceTriangles(frame.points, m_config); + } + +private: + core::ReconstructionConfig m_config; +}; +} // namespace strategies + +#endif // RECONSTRUCTION_SURFACE_STAGE_H diff --git a/src/strategies/registration_icp_stage.cpp b/src/strategies/registration_icp_stage.cpp new file mode 100644 index 0000000..0f095b4 --- /dev/null +++ b/src/strategies/registration_icp_stage.cpp @@ -0,0 +1,30 @@ +#include "registration_icp_stage.h" + +#include "../adapters/registration/registration_adapter.h" + +#include + +namespace strategies +{ +core::PointCloudFrame RegistrationIcpStage::process( + const core::PointCloudFrame &input, + core::PipelineContext &context, + core::PipelineStats &) const +{ + if (!m_config.registration.enabled) { + return input; + } + + QElapsedTimer timer; + timer.start(); + + const adapters::registration::RegistrationResult regResult = + adapters::registration::alignToReference(input, m_config.registration); + + context.hasPoseEstimate = true; + context.registrationFitness = regResult.fitness; + context.registrationRmse = regResult.rmse; + context.registrationMs += timer.elapsed(); + return regResult.alignedFrame; +} +} // namespace strategies diff --git a/src/strategies/registration_icp_stage.h b/src/strategies/registration_icp_stage.h new file mode 100644 index 0000000..4ce77ea --- /dev/null +++ b/src/strategies/registration_icp_stage.h @@ -0,0 +1,27 @@ +#ifndef REGISTRATION_ICP_STAGE_H +#define REGISTRATION_ICP_STAGE_H + +#include "../core/pipeline_config.h" +#include "../core/pipeline_stage.h" + +namespace strategies +{ +class RegistrationIcpStage : public core::IRegistrationStage +{ +public: + explicit RegistrationIcpStage(const core::PipelineConfig &config) + : m_config(config) + { + } + + core::PointCloudFrame process( + const core::PointCloudFrame &input, + core::PipelineContext &context, + core::PipelineStats &stats) const override; + +private: + core::PipelineConfig m_config; +}; +} // namespace strategies + +#endif // REGISTRATION_ICP_STAGE_H diff --git a/src/strategies/transform_tf_stage.cpp b/src/strategies/transform_tf_stage.cpp new file mode 100644 index 0000000..451cf80 --- /dev/null +++ b/src/strategies/transform_tf_stage.cpp @@ -0,0 +1,28 @@ +#include "transform_tf_stage.h" + +#include + +namespace strategies +{ +core::PointCloudFrame TransformTfStage::process( + const core::PointCloudFrame &input, + core::PipelineContext &context, + core::PipelineStats &) const +{ + if (!m_config.transform.enabled) { + return input; + } + + QElapsedTimer timer; + timer.start(); + + adapters::ros2::TfTransform transform; + transform.fromFrame = input.frameId; + transform.toFrame = m_config.transform.targetFrameId; + const core::PointCloudFrame output = adapters::ros2::transformFrame(input, transform); + + context.targetFrameId = output.frameId; + context.transformMs += timer.elapsed(); + return output; +} +} // namespace strategies diff --git a/src/strategies/transform_tf_stage.h b/src/strategies/transform_tf_stage.h new file mode 100644 index 0000000..73a69c3 --- /dev/null +++ b/src/strategies/transform_tf_stage.h @@ -0,0 +1,28 @@ +#ifndef TRANSFORM_TF_STAGE_H +#define TRANSFORM_TF_STAGE_H + +#include "../adapters/ros2/tf2_adapter.h" +#include "../core/pipeline_config.h" +#include "../core/pipeline_stage.h" + +namespace strategies +{ +class TransformTfStage : public core::ITransformStage +{ +public: + explicit TransformTfStage(const core::PipelineConfig &config) + : m_config(config) + { + } + + core::PointCloudFrame process( + const core::PointCloudFrame &input, + core::PipelineContext &context, + core::PipelineStats &) const override; + +private: + core::PipelineConfig m_config; +}; +} // namespace strategies + +#endif // TRANSFORM_TF_STAGE_H diff --git a/src/tests/pipeline_smoke_tests.cpp b/src/tests/pipeline_smoke_tests.cpp new file mode 100644 index 0000000..51bebc9 --- /dev/null +++ b/src/tests/pipeline_smoke_tests.cpp @@ -0,0 +1,196 @@ +#include "pipeline_smoke_tests.h" + +#include "../algorithms/reconstruction/surface_reconstruction.h" +#include "../adapters/sources/ros2_point_cloud_source.h" +#include "../core/pipeline_config.h" +#include "../core/pipeline_executor.h" +#include "../factories/pipeline/desktop_pipeline_factory.h" + +namespace +{ +QVector buildDemoCloud() +{ + return QVector{ + {-1.0f, -1.0f, 0.0f}, + {1.0f, -1.0f, 0.0f}, + {1.0f, 1.0f, 0.0f}, + {-1.0f, 1.0f, 0.0f}, + {0.0f, 0.0f, 1.0f}, + {0.0f, 0.0f, -1.0f} + }; +} + +bool runDemoCloudCase(QString &failureReason) +{ + core::PipelineExecutor executor = factories::pipeline::createDesktopPipelineExecutor(); + core::PointCloudFrame frame; + frame.points = buildDemoCloud(); + frame.sourceLabel = "smoke-demo"; + const core::PipelineResult result = executor.run(frame); + if (result.triangles.isEmpty()) { + failureReason = "Demo cloud produced zero triangles."; + return false; + } + return true; +} + +bool runValidCloudCase(QString &failureReason) +{ + core::PipelineExecutor executor = factories::pipeline::createDesktopPipelineExecutor(); + core::PointCloudFrame frame; + frame.points = buildDemoCloud(); + frame.points.push_back({2.0f, 0.0f, 0.3f}); + frame.sourceLabel = "smoke-valid"; + const core::PipelineResult result = executor.run(frame); + if (result.stats.inputPoints < 4 || result.stats.afterPreprocessingPoints < 4) { + failureReason = "Pipeline dropped points unexpectedly."; + return false; + } + return true; +} + +bool runNeighborRadiusCase(QString &failureReason) +{ + const float oldScale = algorithms::reconstruction::neighborRadiusScale(); + algorithms::reconstruction::setNeighborRadiusScale(2.0f); + + core::PipelineConfig config; + config.reconstruction.neighborRadiusScale = 2.0f; + core::PipelineExecutor executor = factories::pipeline::createDesktopPipelineExecutor(config); + + core::PointCloudFrame frame; + frame.points = buildDemoCloud(); + frame.sourceLabel = "smoke-radius"; + const core::PipelineResult result = executor.run(frame); + + algorithms::reconstruction::setNeighborRadiusScale(oldScale); + + if (result.triangles.isEmpty()) { + failureReason = "Neighbor radius run produced zero triangles."; + return false; + } + return true; +} + +bool runRos2MockSourceCase(QString &failureReason) +{ + adapters::sources::Ros2PointCloudSource source; + adapters::ros2::PointCloud2Message msg; + msg.frameId = "lidar"; + msg.timestampUsec = 42; + msg.xyz = buildDemoCloud(); + source.pushMockMessage(msg); + + core::PointCloudFrame frame; + if (!source.nextFrame(frame, failureReason)) { + return false; + } + if (frame.frameId != "lidar" || frame.points.size() != msg.xyz.size()) { + failureReason = "ROS2 mock conversion mismatch."; + return false; + } + return true; +} + +bool runRpiProfileCase(QString &failureReason) +{ + core::PipelineConfig config = core::makeRpi4RuntimeConfig(); + config.transform.enabled = true; + config.transformPlugins = QStringList() << "tf_transform"; + config.registration.enabled = true; + config.registrationPlugins = QStringList() << "icp_centroid"; + core::PipelineExecutor executor = factories::pipeline::createDesktopPipelineExecutor(config); + + core::PointCloudFrame frame; + frame.points = buildDemoCloud(); + frame.frameId = "sensor"; + frame.sourceLabel = "smoke-rpi4"; + const core::PipelineResult result = executor.run(frame); + if (!result.context.hasPoseEstimate) { + failureReason = "Registration stage did not populate pose estimate."; + return false; + } + if (result.frame.frameId != "map") { + failureReason = "TF stage did not set target frame."; + return false; + } + return true; +} + +bool runPluginChainCase(QString &failureReason) +{ + core::PipelineConfig config = core::makeDesktopDebugConfig(); + config.preprocessPlugins = QStringList() << "pcl_voxel_grid" << "pcl_statistical_outlier" << "pcl_radius_outlier"; + config.reconstructionPlugin = "surface_fallback"; + config.preprocess.pclSorMeanK = 4; + config.preprocess.pclRorRadius = 0.5f; + config.preprocess.pclRorMinNeighbors = 2; + core::PipelineExecutor executor = factories::pipeline::createDesktopPipelineExecutor(config); + + core::PointCloudFrame frame; + frame.points = QVector{ + {0.00f, 0.00f, 0.00f}, + {0.10f, 0.00f, 0.00f}, + {0.20f, 0.00f, 0.00f}, + {0.00f, 0.10f, 0.00f}, + {0.10f, 0.10f, 0.02f}, + {0.20f, 0.10f, 0.00f}, + {0.00f, 0.20f, 0.00f}, + {0.10f, 0.20f, 0.00f}, + {0.20f, 0.20f, 0.01f} + }; + frame.sourceLabel = "smoke-plugin-chain"; + const core::PipelineResult result = executor.run(frame); + if (result.frame.points.isEmpty()) { + failureReason = "Plugin chain produced empty frame."; + return false; + } + return true; +} + +bool runReconstructionPluginSwitchCase(QString &failureReason) +{ + core::PipelineConfig config = core::makeDesktopDebugConfig(); + config.reconstructionPlugin = "pcl_greedy_triangulation"; + core::PipelineExecutor executor = factories::pipeline::createDesktopPipelineExecutor(config); + + core::PointCloudFrame frame; + frame.points = buildDemoCloud(); + frame.sourceLabel = "smoke-plugin-reconstruction"; + const core::PipelineResult result = executor.run(frame); + if (result.triangles.isEmpty()) { + failureReason = "Greedy plugin fallback produced zero triangles."; + return false; + } + return true; +} +} // namespace + +namespace tests +{ +bool runPipelineSmokeTests(QString &failureReason) +{ + if (!runDemoCloudCase(failureReason)) { + return false; + } + if (!runValidCloudCase(failureReason)) { + return false; + } + if (!runNeighborRadiusCase(failureReason)) { + return false; + } + if (!runRos2MockSourceCase(failureReason)) { + return false; + } + if (!runRpiProfileCase(failureReason)) { + return false; + } + if (!runPluginChainCase(failureReason)) { + return false; + } + if (!runReconstructionPluginSwitchCase(failureReason)) { + return false; + } + return true; +} +} // namespace tests diff --git a/src/tests/pipeline_smoke_tests.h b/src/tests/pipeline_smoke_tests.h new file mode 100644 index 0000000..fb5fa18 --- /dev/null +++ b/src/tests/pipeline_smoke_tests.h @@ -0,0 +1,11 @@ +#ifndef PIPELINE_SMOKE_TESTS_H +#define PIPELINE_SMOKE_TESTS_H + +#include + +namespace tests +{ +bool runPipelineSmokeTests(QString &failureReason); +} // namespace tests + +#endif // PIPELINE_SMOKE_TESTS_H diff --git a/src/tests/test_runner_main.cpp b/src/tests/test_runner_main.cpp new file mode 100644 index 0000000..765033a --- /dev/null +++ b/src/tests/test_runner_main.cpp @@ -0,0 +1,21 @@ +#include +#include + +#include "pipeline_smoke_tests.h" + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + QTextStream out(stdout); + QTextStream err(stderr); + + QString failureReason; + const bool ok = tests::runPipelineSmokeTests(failureReason); + if (ok) { + out << "Smoke tests passed.\n"; + return 0; + } + + err << "Smoke tests failed: " << failureReason << "\n"; + return 1; +} diff --git a/src/ui/glview.cpp b/src/ui/glview.cpp index c284ec6..6618b76 100644 --- a/src/ui/glview.cpp +++ b/src/ui/glview.cpp @@ -21,7 +21,7 @@ GlView::GlView(QWidget *parent) { } -void GlView::setData(const QVector &points, const QVector &triangles) +void GlView::setData(const QVector &points, const QVector &triangles) { m_points = points; m_triangles = triangles; @@ -29,7 +29,7 @@ void GlView::setData(const QVector &points, const QVector &tr if (!m_points.isEmpty()) { QVector3D bmin(m_points[0].x, m_points[0].y, m_points[0].z); QVector3D bmax = bmin; - for (const Point3f &p : m_points) { + for (const core::Point3f &p : m_points) { bmin.setX(qMin(bmin.x(), p.x)); bmin.setY(qMin(bmin.y(), p.y)); bmin.setZ(qMin(bmin.z(), p.z)); @@ -94,7 +94,7 @@ void GlView::paintGL() QVector vertices; vertices.reserve(m_points.size() * 3); - for (const Point3f &p : m_points) { + for (const core::Point3f &p : m_points) { vertices.push_back(p.x - m_center.x()); vertices.push_back(p.y - m_center.y()); vertices.push_back(p.z - m_center.z()); @@ -104,7 +104,7 @@ void GlView::paintGL() indices.reserve(m_triangles.size() * 3); QVector edgeIndices; edgeIndices.reserve(m_triangles.size() * 6); - for (const Triangle &t : m_triangles) { + for (const core::Triangle &t : m_triangles) { indices.push_back(static_cast(t.i0)); indices.push_back(static_cast(t.i1)); indices.push_back(static_cast(t.i2)); diff --git a/src/ui/glview.h b/src/ui/glview.h index 27dfd88..8f97035 100644 --- a/src/ui/glview.h +++ b/src/ui/glview.h @@ -8,7 +8,7 @@ #include #include -#include "../geometry/surface_reconstruction.h" +#include "../core/point_cloud_types.h" class GlView : public QOpenGLWidget, protected QOpenGLFunctions { @@ -16,7 +16,7 @@ class GlView : public QOpenGLWidget, protected QOpenGLFunctions public: explicit GlView(QWidget *parent = nullptr); - void setData(const QVector &points, const QVector &triangles); + void setData(const QVector &points, const QVector &triangles); void setSurfaceVisible(bool visible); protected: @@ -28,8 +28,8 @@ protected: void wheelEvent(QWheelEvent *event) override; private: - QVector m_points; - QVector m_triangles; + QVector m_points; + QVector m_triangles; QOpenGLShaderProgram m_program; float m_yawDeg; float m_pitchDeg; diff --git a/src/ui/mainwindow.cpp b/src/ui/mainwindow.cpp index 1135be7..7b0336b 100644 --- a/src/ui/mainwindow.cpp +++ b/src/ui/mainwindow.cpp @@ -1,416 +1,919 @@ #include "mainwindow.h" -#include -#include -#include #include #include -#include -#include +#include #include -#include -#include +#include +#include +#include #include -#include +#include #include -#include +#include +#include #include -#include -#include -#include "../geometry/surface_reconstruction.h" +#include "../adapters/sources/file_point_cloud_source.h" +#include "../factories/pipeline/desktop_pipeline_factory.h" #include "glview.h" namespace { -struct Dsu +struct StageMeta { - QVector parent; - QVector size; - - explicit Dsu(const int n) - : parent(n) - , size(n, 1) - { - for (int i = 0; i < n; ++i) { - parent[i] = i; - } - } - - int find(int x) - { - while (parent[x] != x) { - parent[x] = parent[parent[x]]; - x = parent[x]; - } - return x; - } - - void unite(const int a, const int b) - { - int ra = find(a); - int rb = find(b); - if (ra == rb) { - return; - } - if (size[ra] < size[rb]) { - qSwap(ra, rb); - } - parent[rb] = ra; - size[ra] += size[rb]; - } + const char *id; + const char *title; + const char *category; + const char *hint; + const char *defaults; }; -QVector generateDemoPoints(const int count) +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"}, +}; + +const char *kReconGreedy = "pcl_greedy_triangulation"; + +QVector generateDemoPoints(const QString &surfaceType, const int count) { - QVector points; + QVector points; points.reserve(count); - QRandomGenerator rng(42u); + QRandomGenerator *rng = QRandomGenerator::global(); for (int i = 0; i < count; ++i) { - const float u = rng.generateDouble() * 2.0f - 1.0f; - const float theta = rng.generateDouble() * 2.0f * static_cast(M_PI); - const float r = 1.0f + (rng.generateDouble() * 0.08f - 0.04f); - const float s = qSqrt(qMax(0.0f, 1.0f - u * u)); + core::Point3f p; - Point3f p; - p.x = r * s * qCos(theta); - p.y = r * s * qSin(theta); - p.z = r * u; + 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; + const float minorR = 0.35f; + const float jitter = static_cast(rng->generateDouble() * 0.02 - 0.01); + const float radial = minorR + jitter; + p.x = (majorR + radial * qCos(v)) * qCos(u); + p.y = (majorR + radial * qCos(v)) * qSin(u); + p.z = radial * qSin(v); + } else if (surfaceType == "Волна") { + const float x = static_cast(rng->generateDouble() * 2.4 - 1.2); + const float y = static_cast(rng->generateDouble() * 2.4 - 1.2); + const float noise = static_cast(rng->generateDouble() * 0.03 - 0.015); + p.x = x; + p.y = y; + p.z = 0.35f * qSin(2.5f * x) * qCos(2.5f * y) + noise; + } else { + const float u = static_cast(rng->generateDouble() * 2.0 - 1.0); + const float theta = static_cast(rng->generateDouble() * 2.0 * M_PI); + const float r = 1.0f + static_cast(rng->generateDouble() * 0.08 - 0.04); + const float s = qSqrt(qMax(0.0f, 1.0f - u * u)); + p.x = r * s * qCos(theta); + p.y = r * s * qSin(theta); + p.z = r * u; + } points.push_back(p); } - return points; } -QVector keepLargestCluster(const QVector &points, int &removedCount, int &clusterCount) +const StageMeta *findMeta(const QString &id) { - removedCount = 0; - clusterCount = 1; - if (points.size() < 8) { - return points; - } - - QVector nearestDist; - nearestDist.reserve(points.size()); - for (int i = 0; i < points.size(); ++i) { - double best = std::numeric_limits::max(); - for (int j = 0; j < points.size(); ++j) { - if (i == j) { - continue; - } - const double dx = static_cast(points[i].x) - static_cast(points[j].x); - const double dy = static_cast(points[i].y) - static_cast(points[j].y); - const double dz = static_cast(points[i].z) - static_cast(points[j].z); - const double d = qSqrt(dx * dx + dy * dy + dz * dz); - if (d < best) { - best = d; - } - } - if (best < std::numeric_limits::max()) { - nearestDist.push_back(best); + for (size_t i = 0; i < sizeof(kStageMeta) / sizeof(kStageMeta[0]); ++i) { + if (id == kStageMeta[i].id) { + return &kStageMeta[i]; } } - - if (nearestDist.isEmpty()) { - return points; - } - - std::sort(nearestDist.begin(), nearestDist.end()); - const double medianNn = nearestDist[nearestDist.size() / 2]; - // Conservative threshold: keeps local neighborhood connected, - // while detached sparse groups are separated. - const double joinDistance = qMax(1e-9, medianNn * 5.0); - const double joinDistance2 = joinDistance * joinDistance; - - Dsu dsu(points.size()); - for (int i = 0; i < points.size(); ++i) { - for (int j = i + 1; j < points.size(); ++j) { - const double dx = static_cast(points[i].x) - static_cast(points[j].x); - const double dy = static_cast(points[i].y) - static_cast(points[j].y); - const double dz = static_cast(points[i].z) - static_cast(points[j].z); - const double d2 = dx * dx + dy * dy + dz * dz; - if (d2 <= joinDistance2) { - dsu.unite(i, j); - } - } - } - - QHash compSizes; - for (int i = 0; i < points.size(); ++i) { - const int root = dsu.find(i); - compSizes[root] = compSizes.value(root, 0) + 1; - } - clusterCount = compSizes.size(); - - int largestRoot = -1; - int largestSize = 0; - for (auto it = compSizes.constBegin(); it != compSizes.constEnd(); ++it) { - if (it.value() > largestSize) { - largestSize = it.value(); - largestRoot = it.key(); - } - } - - QVector filtered; - filtered.reserve(largestSize); - for (int i = 0; i < points.size(); ++i) { - if (dsu.find(i) == largestRoot) { - filtered.push_back(points[i]); - } - } - removedCount = points.size() - filtered.size(); - return filtered; + return nullptr; } -QVector downsampleDenseAreas(const QVector &points, int &removedCount) +QMap parseDefaultsMap(const QString &defaultsText) { - removedCount = 0; - if (points.size() < 16) { - return points; - } - - QVector nearestXY; - nearestXY.reserve(points.size()); - for (int i = 0; i < points.size(); ++i) { - double best = std::numeric_limits::max(); - for (int j = 0; j < points.size(); ++j) { - if (i == j) { - continue; - } - const double dx = static_cast(points[i].x) - static_cast(points[j].x); - const double dy = static_cast(points[i].y) - static_cast(points[j].y); - const double d = qSqrt(dx * dx + dy * dy); - if (d < best) { - best = d; - } + QMap parsed; + const QStringList chunks = defaultsText.split(',', Qt::SkipEmptyParts); + for (const QString &chunkRaw : chunks) { + const QString chunk = chunkRaw.trimmed(); + if (chunk.isEmpty()) { + continue; } - if (best < std::numeric_limits::max()) { - nearestXY.push_back(best); + const int pos = chunk.indexOf('='); + if (pos <= 0) { + continue; + } + const QString key = chunk.left(pos).trimmed(); + const QString value = chunk.mid(pos + 1).trimmed(); + if (!key.isEmpty()) { + parsed.insert(key, value); } } - if (nearestXY.isEmpty()) { - return points; + return parsed; +} + +bool parseIntParam(const QMap &parsed, const QString &key, int &target, QString &errorText) +{ + if (!parsed.contains(key)) { + return true; + } + bool ok = false; + const int value = parsed.value(key).toInt(&ok); + if (!ok) { + errorText = QString("Параметр '%1' должен быть целым числом.").arg(key); + return false; + } + target = value; + return true; +} + +bool parseFloatParam(const QMap &parsed, const QString &key, float &target, QString &errorText) +{ + if (!parsed.contains(key)) { + return true; + } + bool ok = false; + const float value = parsed.value(key).toFloat(&ok); + if (!ok) { + errorText = QString("Параметр '%1' должен быть числом.").arg(key); + return false; + } + target = value; + return true; +} + +bool parseDoubleParam(const QMap &parsed, const QString &key, double &target, QString &errorText) +{ + if (!parsed.contains(key)) { + return true; + } + bool ok = false; + const double value = parsed.value(key).toDouble(&ok); + if (!ok) { + errorText = QString("Параметр '%1' должен быть числом.").arg(key); + return false; + } + target = value; + return true; +} + +bool applyStageDefaultsToConfig( + const QString &stageId, + const QString &defaultsText, + core::PipelineConfig &cfg, + QString &errorText) +{ + const QMap parsed = parseDefaultsMap(defaultsText); + if (parsed.isEmpty()) { + return true; } - std::sort(nearestXY.begin(), nearestXY.end()); - const double medianXY = nearestXY[nearestXY.size() / 2]; - const double cellSize = qMax(1e-9, medianXY * 0.8); - - struct Bucket - { - double sx; - double sy; - double sz; - int n; - }; - QHash buckets; - buckets.reserve(points.size()); - - for (const Point3f &p : points) { - const qint64 ix = qFloor(static_cast(p.x) / cellSize); - const qint64 iy = qFloor(static_cast(p.y) / cellSize); - const QString key = QString::number(ix) + "_" + QString::number(iy); - if (!buckets.contains(key)) { - buckets.insert(key, {p.x, p.y, p.z, 1}); - } else { - Bucket &b = buckets[key]; - b.sx += p.x; - b.sy += p.y; - b.sz += p.z; - b.n += 1; + if (stageId == "keep_largest_cluster") { + return parseDoubleParam(parsed, "clusterJoinDistanceScale", cfg.preprocess.clusterJoinDistanceScale, errorText); + } + if (stageId == "downsample_dense") { + return parseDoubleParam(parsed, "downsampleCellScale", cfg.preprocess.downsampleCellScale, errorText); + } + if (stageId == "pcl_voxel_grid") { + float leaf = cfg.preprocess.pclVoxelLeafSize; + if (!parseFloatParam(parsed, "leaf", leaf, errorText)) { + return false; } + if (!parseFloatParam(parsed, "pclVoxelLeafSize", leaf, errorText)) { + return false; + } + cfg.preprocess.pclVoxelLeafSize = leaf; + return true; + } + if (stageId == "pcl_statistical_outlier") { + if (!parseIntParam(parsed, "meanK", cfg.preprocess.pclSorMeanK, errorText)) { + return false; + } + if (!parseDoubleParam(parsed, "stddev", cfg.preprocess.pclSorStdDevMul, errorText)) { + return false; + } + return true; + } + if (stageId == "pcl_radius_outlier") { + if (!parseFloatParam(parsed, "radius", cfg.preprocess.pclRorRadius, errorText)) { + return false; + } + if (!parseIntParam(parsed, "minNeighbors", cfg.preprocess.pclRorMinNeighbors, errorText)) { + return false; + } + return true; } - QVector out; - out.reserve(buckets.size()); - for (auto it = buckets.constBegin(); it != buckets.constEnd(); ++it) { - const Bucket &b = it.value(); - out.push_back({ - static_cast(b.sx / b.n), - static_cast(b.sy / b.n), - static_cast(b.sz / b.n) - }); - } - - removedCount = points.size() - out.size(); - return out; + return true; } } // namespace MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , m_glView(new GlView(this)) + , m_dashboardView(new QQuickWidget(this)) + , m_dashboardDock(new QDockWidget("Pipeline Dashboard", this)) + , m_pipelineExecutor(factories::pipeline::createPipelineExecutorForProfile("desktop_debug")) + , m_pipelineConfig(core::makeDesktopDebugConfig()) + , m_surfaceVisible(true) + , m_selectedStageIndex(-1) + , m_demoSurfaceType("Сфера") + , m_uiMode("canvas") + , m_wizardGoal("balanced") + , m_wizardProfile("general") { - setWindowTitle("DotsToSirface - Surface Triangulation Demo"); + setWindowTitle("DotsToSirface - QML Dashboard"); setCentralWidget(m_glView); - QAction *loadAction = new QAction("Load points (.txt/.csv)...", this); - QAction *generateDemoAction = new QAction("Generate demo points", this); - QAction *showSurfaceAction = new QAction("Show surface", this); - QAction *neighborRadiusAction = new QAction("Neighbor radius...", this); - showSurfaceAction->setCheckable(true); - showSurfaceAction->setChecked(true); - menuBar()->addAction(loadAction); - menuBar()->addAction(generateDemoAction); - menuBar()->addAction(showSurfaceAction); - menuBar()->addAction(neighborRadiusAction); - - connect(generateDemoAction, &QAction::triggered, this, [this]() { - const QVector points = generateDemoPoints(350); - rebuildSurface(points, "built-in demo"); - }); - - connect(loadAction, &QAction::triggered, this, [this]() { - const QString filePath = QFileDialog::getOpenFileName( + m_dashboardView->setMinimumWidth(420); + m_dashboardView->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_dashboardView->rootContext()->setContextProperty("backend", this); + m_dashboardView->setSource(QUrl("qrc:/pages/PipelineDashboard.qml")); + if (m_dashboardView->status() == QQuickWidget::Error) { + QStringList errors; + const QList qmlErrors = m_dashboardView->errors(); + for (const QQmlError &err : qmlErrors) { + errors.push_back(err.toString()); + } + QMessageBox::critical( this, - "Open points file", - QString(), - "Point files (*.txt *.csv *.bin);;Binary files (*.bin);;Text files (*.txt);;CSV files (*.csv);;All files (*.*)"); - if (filePath.isEmpty()) { - return; - } + "QML load error", + QString("Failed to load dashboard QML.\n%1").arg(errors.join("\n"))); + } - QVector points; - QString errorText; - if (!loadPointsFromFile(filePath, points, errorText)) { - QMessageBox::warning(this, "Load error", errorText); - return; - } + m_dashboardDock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); + // Floating dock with QQuickWidget is unstable in current WSLg setup. + // Keep dock movable between sides but disable undocking to preserve input. + m_dashboardDock->setFeatures(QDockWidget::DockWidgetMovable); + m_dashboardDock->setMinimumWidth(640); + m_dashboardDock->setMinimumHeight(760); + m_dashboardDock->setWidget(m_dashboardView); + addDockWidget(Qt::RightDockWidgetArea, m_dashboardDock); + m_dashboardDock->setFloating(false); + m_dashboardDock->show(); + resize(1680, 960); - int removedCount = 0; - int clusterCount = 1; - const QVector clustered = keepLargestCluster(points, removedCount, clusterCount); - - int denseRemoved = 0; - const QVector filteredPoints = downsampleDenseAreas(clustered, denseRemoved); - - const int totalRemoved = removedCount + denseRemoved; - const QString source = totalRemoved > 0 - ? QString("%1 (clusters: %2, removed: %3, dense: %4)") - .arg(QFileInfo(filePath).fileName()) - .arg(clusterCount) - .arg(totalRemoved) - .arg(denseRemoved) - : QFileInfo(filePath).fileName(); - rebuildSurface(filteredPoints, source); - }); - - connect(showSurfaceAction, &QAction::toggled, this, [this](const bool enabled) { - m_glView->setSurfaceVisible(enabled); - }); - - connect(neighborRadiusAction, &QAction::triggered, this, [this]() { - bool ok = false; - const double value = QInputDialog::getDouble( - this, - "Neighbor radius", - "Radius scale (0.5 .. 20.0):", - static_cast(neighborRadiusScale()), - 0.5, - 20.0, - 2, - &ok); - if (!ok) { - return; - } - setNeighborRadiusScale(static_cast(value)); - if (!m_currentPoints.isEmpty()) { - rebuildSurface(m_currentPoints, m_currentSourceLabel); - } else { - statusBar()->showMessage(QString("Neighbor radius scale set to %1").arg(value), 3500); - } - }); - - const QVector points = generateDemoPoints(350); - rebuildSurface(points, "built-in demo"); + statusBar()->showMessage("QML dashboard loaded"); + rebuildCardsFromConfig(); + recomputeInsights(); + notifyPipelineStateChanged(); + generateDemo(); } -void MainWindow::rebuildSurface(const QVector &points, const QString &sourceLabel) +void MainWindow::rebuildSurface(const QVector &points, const QString &sourceLabel) { m_currentPoints = points; m_currentSourceLabel = sourceLabel; - QElapsedTimer timer; - timer.start(); - const QVector triangles = buildSurfaceTriangles(points); - const qint64 elapsedMs = timer.elapsed(); + core::PointCloudFrame frame; + frame.points = points; + frame.sourceLabel = sourceLabel; + const core::PipelineResult pipelineResult = m_pipelineExecutor.run(frame); - m_glView->setData(points, triangles); + m_glView->setData(pipelineResult.frame.points, pipelineResult.triangles); - statusBar()->showMessage( - QString("Source: %1 | Points: %2 | Triangles: %3 | Time: %4 ms") + m_metrics["inputPoints"] = pipelineResult.stats.inputPoints; + m_metrics["afterPreprocess"] = pipelineResult.stats.afterPreprocessingPoints; + m_metrics["triangles"] = pipelineResult.stats.outputTriangles; + m_metrics["clusters"] = pipelineResult.stats.detectedClusters; + m_metrics["removedPoints"] = pipelineResult.stats.removedClusterPoints + pipelineResult.stats.removedDownsamplePoints; + m_metrics["reconstructMs"] = pipelineResult.stats.reconstructionMs; + emit metricsChanged(); + recomputeInsights(); + + updateStatusText( + QString("Source: %1 | In: %2 | After preprocess: %3 | Triangles: %4 | Clusters: %5 | Removed: %6 | Reconstruct: %7 ms | %8") .arg(sourceLabel) - .arg(points.size()) - .arg(triangles.size()) - .arg(elapsedMs)); + .arg(pipelineResult.stats.inputPoints) + .arg(pipelineResult.stats.afterPreprocessingPoints) + .arg(pipelineResult.stats.outputTriangles) + .arg(pipelineResult.stats.detectedClusters) + .arg(pipelineResult.stats.removedClusterPoints + pipelineResult.stats.removedDownsamplePoints) + .arg(pipelineResult.stats.reconstructionMs) + .arg(pipelineSummary())); } -bool MainWindow::loadPointsFromFile(const QString &filePath, QVector &points, QString &errorText) const +bool MainWindow::loadPointsFromFile(const QString &filePath, QVector &points, QString &errorText) const { - const QString ext = QFileInfo(filePath).suffix().toLower(); - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly)) { - errorText = QString("Cannot open file: %1").arg(filePath); + adapters::sources::FilePointCloudSource source(filePath); + core::PointCloudFrame frame; + if (!source.nextFrame(frame, errorText)) { return false; } - - QTextStream stream(&file); - points.clear(); - if (ext == "bin") { - const QByteArray raw = file.readAll(); - if (raw.size() % static_cast(sizeof(float) * 3) != 0) { - errorText = "Invalid .bin size: expected multiples of 3 floats (x,y,z)."; - return false; - } - - const int pointCount = raw.size() / static_cast(sizeof(float) * 3); - points.reserve(pointCount); - const float *values = reinterpret_cast(raw.constData()); - for (int i = 0; i < pointCount; ++i) { - const int k = i * 3; - points.push_back({values[k], values[k + 1], values[k + 2]}); - } - } else { - stream.seek(0); - int lineNo = 0; - while (!stream.atEnd()) { - QString line = stream.readLine(); - ++lineNo; - line = line.trimmed(); - - if (line.isEmpty() || line.startsWith('#')) { - continue; - } - - line.replace(';', ' '); - line.replace(',', ' '); - const QStringList parts = line.split(QRegularExpression("\\s+"), QString::SkipEmptyParts); - if (parts.size() < 3) { - continue; - } - - bool okX = false; - bool okY = false; - bool okZ = false; - const float x = parts[0].toFloat(&okX); - const float y = parts[1].toFloat(&okY); - const float z = parts[2].toFloat(&okZ); - - if (!(okX && okY && okZ)) { - errorText = QString("Invalid numeric values at line %1").arg(lineNo); - points.clear(); - return false; - } - - points.push_back({x, y, z}); - } - } - - if (points.size() < 4) { - errorText = "Need at least 4 valid 3D points in file."; - return false; - } - + points = frame.points; return true; } + +void MainWindow::rebuildExecutorFromConfig() +{ + QString errorText; + core::PipelineConfig cfg = m_pipelineConfig; + cfg.preprocessPlugins = activeStageIds(); + for (const StageCardData &card : m_stageCards) { + if (!card.enabled) { + continue; + } + if (!applyStageDefaultsToConfig(card.id, card.defaults, cfg, errorText)) { + QMessageBox::warning(this, "Параметры фильтра", errorText); + return; + } + } + if (!normalizeAndValidateConfig(cfg, errorText)) { + QMessageBox::warning(this, "Pipeline config", errorText); + return; + } + m_pipelineConfig = cfg; + rebuildCardsFromConfig(); + m_pipelineExecutor = factories::pipeline::createDesktopPipelineExecutor(m_pipelineConfig); + notifyPipelineStateChanged(); +} + +void MainWindow::applyPreset(const QString &presetId) +{ + if (presetId == "Fast") { + m_pipelineConfig = core::makeDesktopDebugConfig(); + m_pipelineConfig.preprocessPlugins = QStringList() << "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.reconstructionPlugin = "surface_fallback"; + } else { + m_pipelineConfig = core::makeDesktopDebugConfig(); + m_pipelineConfig.preprocessPlugins = QStringList() << "pcl_voxel_grid" << "pcl_statistical_outlier"; + m_pipelineConfig.reconstructionPlugin = "pcl_greedy_triangulation"; + } + + rebuildExecutorFromConfig(); + if (!m_currentPoints.isEmpty()) { + rebuildSurface(m_currentPoints, m_currentSourceLabel); + } +} + +QStringList MainWindow::stageChain() const +{ + return activeStageIds(); +} + +QStringList MainWindow::availableStages() const +{ + return QStringList() + << "keep_largest_cluster" + << "downsample_dense" + << "pcl_voxel_grid" + << "pcl_statistical_outlier" + << "pcl_radius_outlier"; +} + +QStringList MainWindow::availableReconstructions() const +{ + return QStringList() << "surface_fallback" << "pcl_greedy_triangulation"; +} + +QString MainWindow::reconstructionMethod() const +{ + return m_pipelineConfig.reconstructionPlugin; +} + +QString MainWindow::pipelinePreview() const +{ + return pipelineSummary(); +} + +QString MainWindow::statusText() const +{ + return m_statusText; +} + +bool MainWindow::surfaceVisible() const +{ + return m_surfaceVisible; +} + +QVariantList MainWindow::stageCards() const +{ + QVariantList list; + for (const StageCardData &card : m_stageCards) { + QVariantMap m; + m["id"] = card.id; + m["title"] = card.title; + m["category"] = card.category; + m["hint"] = card.hint; + m["defaults"] = card.defaults; + m["enabled"] = card.enabled; + list.push_back(m); + } + return list; +} + +QVariantList MainWindow::stageLibrary() const +{ + QVariantList list; + for (size_t i = 0; i < sizeof(kStageMeta) / sizeof(kStageMeta[0]); ++i) { + StageCardData card = createCardForStage(kStageMeta[i].id, true); + list.push_back(makeStageLibraryEntry(card)); + } + return list; +} + +int MainWindow::selectedStageIndex() const +{ + return m_selectedStageIndex; +} + +QVariantMap MainWindow::selectedStage() const +{ + if (m_selectedStageIndex < 0 || m_selectedStageIndex >= m_stageCards.size()) { + return QVariantMap(); + } + return makeStageLibraryEntry(m_stageCards[m_selectedStageIndex]); +} + +QVariantMap MainWindow::metrics() const +{ + return m_metrics; +} + +QString MainWindow::recommendation() const +{ + return m_recommendation; +} + +QString MainWindow::chainHealth() const +{ + return m_chainHealth; +} + +QString MainWindow::uiMode() const +{ + return m_uiMode; +} + +QStringList MainWindow::demoSurfaceTypes() const +{ + return QStringList() << "Сфера" << "Тор" << "Волна"; +} + +QString MainWindow::demoSurfaceType() const +{ + return m_demoSurfaceType; +} + +QString MainWindow::wizardGoal() const +{ + return m_wizardGoal; +} + +QString MainWindow::wizardProfile() const +{ + return m_wizardProfile; +} + +QVariantList MainWindow::snapshots() const +{ + QVariantList list; + const QStringList keys = m_savedSnapshots.keys(); + for (const QString &key : keys) { + QVariantMap item; + item["name"] = key; + item["summary"] = m_savedSnapshots.value(key).toMap().value("summary").toString(); + list.push_back(item); + } + return list; +} + +void MainWindow::generateDemo() +{ + rebuildSurface(generateDemoPoints(m_demoSurfaceType, 350), QString("demo: %1").arg(m_demoSurfaceType)); +} + +void MainWindow::openPointsFile() +{ + const QString filePath = QFileDialog::getOpenFileName( + this, + "Open points file", + QString(), + "Point files (*.txt *.csv *.bin);;Binary files (*.bin);;Text files (*.txt);;CSV files (*.csv);;All files (*.*)"); + if (filePath.isEmpty()) { + return; + } + QVector points; + QString errorText; + if (!loadPointsFromFile(filePath, points, errorText)) { + QMessageBox::warning(this, "Load error", errorText); + return; + } + rebuildSurface(points, QFileInfo(filePath).fileName()); +} + +void MainWindow::setStageChain(const QStringList &chain) +{ + m_stageCards.clear(); + for (const QString &id : chain) { + m_stageCards.push_back(createCardForStage(id, true)); + } + if (m_selectedStageIndex >= m_stageCards.size()) { + m_selectedStageIndex = m_stageCards.isEmpty() ? -1 : m_stageCards.size() - 1; + } + emit stageCardsChanged(); + emit selectedStageIndexChanged(); + emit selectedStageChanged(); + recomputeInsights(); + notifyPipelineStateChanged(); +} + +void MainWindow::addStage(const QString &stageId) +{ + m_stageCards.push_back(createCardForStage(stageId, true)); + m_selectedStageIndex = m_stageCards.size() - 1; + emit stageCardsChanged(); + emit selectedStageIndexChanged(); + emit selectedStageChanged(); + recomputeInsights(); + notifyPipelineStateChanged(); +} + +void MainWindow::insertStage(const int index, const QString &stageId) +{ + const StageCardData card = createCardForStage(stageId, true); + int insertAt = index; + if (insertAt < 0) { + insertAt = 0; + } + if (insertAt > m_stageCards.size()) { + insertAt = m_stageCards.size(); + } + m_stageCards.insert(insertAt, card); + m_selectedStageIndex = insertAt; + emit stageCardsChanged(); + emit selectedStageIndexChanged(); + emit selectedStageChanged(); + recomputeInsights(); + notifyPipelineStateChanged(); +} + +void MainWindow::removeStage(const int index) +{ + if (index < 0 || index >= m_stageCards.size()) { + return; + } + m_stageCards.remove(index); + if (m_stageCards.isEmpty()) { + m_selectedStageIndex = -1; + } else if (m_selectedStageIndex >= m_stageCards.size()) { + m_selectedStageIndex = m_stageCards.size() - 1; + } + emit stageCardsChanged(); + emit selectedStageIndexChanged(); + emit selectedStageChanged(); + recomputeInsights(); + notifyPipelineStateChanged(); +} + +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; + } + m_stageCards.move(from, to); + m_selectedStageIndex = to; + emit stageCardsChanged(); + emit selectedStageIndexChanged(); + emit selectedStageChanged(); + recomputeInsights(); + notifyPipelineStateChanged(); +} + +void MainWindow::applyPipelineFromUi() +{ + rebuildExecutorFromConfig(); + if (!m_currentPoints.isEmpty()) { + rebuildSurface(m_currentPoints, m_currentSourceLabel); + } +} + +void MainWindow::setReconstructionMethod(const QString &pluginId) +{ + if (m_pipelineConfig.reconstructionPlugin == pluginId) { + return; + } + m_pipelineConfig.reconstructionPlugin = pluginId; + recomputeInsights(); + emit reconstructionMethodChanged(); + emit pipelinePreviewChanged(); +} + +void MainWindow::setSurfaceVisible(const bool visible) +{ + if (m_surfaceVisible == visible) { + return; + } + m_surfaceVisible = visible; + m_glView->setSurfaceVisible(visible); + emit surfaceVisibleChanged(); +} + +void MainWindow::setNeighborRadius(const double value) +{ + algorithms::reconstruction::setNeighborRadiusScale(static_cast(value)); + if (!m_currentPoints.isEmpty()) { + rebuildSurface(m_currentPoints, m_currentSourceLabel); + } else { + updateStatusText(QString("Neighbor radius scale set to %1").arg(value)); + } +} + +void MainWindow::setStageEnabled(const int index, const bool enabled) +{ + if (index < 0 || index >= m_stageCards.size()) { + return; + } + if (m_stageCards[index].enabled == enabled) { + return; + } + m_stageCards[index].enabled = enabled; + emit stageCardsChanged(); + recomputeInsights(); + notifyPipelineStateChanged(); +} + +void MainWindow::setStageDefaults(const int index, const QString &defaultsText) +{ + if (index < 0 || index >= m_stageCards.size()) { + return; + } + if (m_stageCards[index].defaults == defaultsText) { + return; + } + m_stageCards[index].defaults = defaultsText; + emit stageCardsChanged(); + emit selectedStageChanged(); +} + +QString MainWindow::defaultsForStage(const QString &stageId) const +{ + const StageMeta *meta = findMeta(stageId); + if (meta) { + return QString(meta->defaults); + } + return QString(); +} + +void MainWindow::setSelectedStageIndex(const int index) +{ + if (index == m_selectedStageIndex) { + return; + } + if (index < -1 || index >= m_stageCards.size()) { + return; + } + m_selectedStageIndex = index; + emit selectedStageIndexChanged(); + emit selectedStageChanged(); +} + +void MainWindow::setUiMode(const QString &mode) +{ + if (mode == m_uiMode) { + return; + } + if (mode != "wizard" && mode != "canvas") { + return; + } + m_uiMode = mode; + emit uiModeChanged(); +} + +void MainWindow::setDemoSurfaceType(const QString &surfaceType) +{ + if (surfaceType == m_demoSurfaceType) { + return; + } + if (!demoSurfaceTypes().contains(surfaceType)) { + return; + } + m_demoSurfaceType = surfaceType; + emit demoSurfaceTypeChanged(); +} + +void MainWindow::setWizardGoal(const QString &goal) +{ + if (goal == m_wizardGoal) { + return; + } + m_wizardGoal = goal; + emit wizardGoalChanged(); +} + +void MainWindow::setWizardProfile(const QString &profile) +{ + if (profile == m_wizardProfile) { + 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"); + } +} + +void MainWindow::saveSnapshot(const QString &slotName) +{ + if (slotName.isEmpty()) { + return; + } + QVariantMap snap; + snap["stages"] = stageCards(); + snap["reconstruction"] = m_pipelineConfig.reconstructionPlugin; + snap["summary"] = pipelineSummary(); + m_savedSnapshots.insert(slotName, snap); + emit snapshotsChanged(); + updateStatusText(QString("Snapshot '%1' saved").arg(slotName)); +} + +void MainWindow::loadSnapshot(const QString &slotName) +{ + if (!m_savedSnapshots.contains(slotName)) { + return; + } + const QVariantMap snap = m_savedSnapshots.value(slotName).toMap(); + const QVariantList stages = snap.value("stages").toList(); + m_stageCards.clear(); + 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); + } + m_pipelineConfig.reconstructionPlugin = snap.value("reconstruction").toString(); + m_selectedStageIndex = m_stageCards.isEmpty() ? -1 : 0; + emit stageCardsChanged(); + emit selectedStageIndexChanged(); + emit selectedStageChanged(); + rebuildExecutorFromConfig(); + if (!m_currentPoints.isEmpty()) { + rebuildSurface(m_currentPoints, m_currentSourceLabel); + } + emit snapshotsChanged(); +} + +bool MainWindow::normalizeAndValidateConfig(core::PipelineConfig &config, QString &errorText) const +{ + QSet seen; + QStringList normalized; + const QStringList allowed = QStringList() + << "keep_largest_cluster" + << "downsample_dense" + << "pcl_voxel_grid" + << "pcl_statistical_outlier" + << "pcl_radius_outlier"; + for (const QString &id : config.preprocessPlugins) { + if (id.isEmpty() || seen.contains(id)) { + continue; + } + if (!allowed.contains(id)) { + errorText = QString("Unknown preprocess plugin: %1").arg(id); + return false; + } + seen.insert(id); + normalized.push_back(id); + } + if (normalized.isEmpty()) { + normalized = QStringList() << "keep_largest_cluster" << "downsample_dense"; + } + config.preprocessPlugins = normalized; + + const QStringList reconAllowed = QStringList() << "surface_fallback" << "pcl_greedy_triangulation"; + if (!reconAllowed.contains(config.reconstructionPlugin)) { + errorText = QString("Unknown reconstruction plugin: %1").arg(config.reconstructionPlugin); + return false; + } +#ifndef PCL_ENABLED + for (int i = 0; i < config.preprocessPlugins.size(); ++i) { + if (config.preprocessPlugins[i].startsWith("pcl_")) { + config.preprocessPlugins[i] = "downsample_dense"; + } + } + if (config.reconstructionPlugin.startsWith("pcl_")) { + config.reconstructionPlugin = "surface_fallback"; + } +#endif + return true; +} + +QString MainWindow::pipelineSummary() const +{ + return QString("PP:[%1] REC:%2") + .arg(activeStageIds().join("->")) + .arg(m_pipelineConfig.reconstructionPlugin); +} + +void MainWindow::updateStatusText(const QString &text) +{ + m_statusText = text; + statusBar()->showMessage(text); + emit statusTextChanged(); +} + +void MainWindow::notifyPipelineStateChanged() +{ + emit stageChainChanged(); + emit reconstructionMethodChanged(); + emit pipelinePreviewChanged(); +} + +QVariantMap MainWindow::makeStageLibraryEntry(const StageCardData &card) const +{ + QVariantMap m; + m["id"] = card.id; + m["title"] = card.title; + m["category"] = card.category; + m["hint"] = card.hint; + m["defaults"] = card.defaults; + m["enabled"] = card.enabled; + return m; +} + +void MainWindow::rebuildCardsFromConfig() +{ + const QVector previousCards = m_stageCards; + m_stageCards.clear(); + 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) { + // Keep user-edited parameters across executor rebuilds. + card.defaults = previousCards[i].defaults; + } + m_stageCards.push_back(card); + } + if (m_selectedStageIndex < 0 && !m_stageCards.isEmpty()) { + m_selectedStageIndex = 0; + } + emit stageCardsChanged(); + emit selectedStageIndexChanged(); + emit selectedStageChanged(); +} + +void MainWindow::recomputeInsights() +{ + const QStringList stages = activeStageIds(); + if (stages.isEmpty()) { + 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")) { + 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."; + } + emit chainHealthChanged(); + emit recommendationChanged(); +} + +QString MainWindow::displayNameForStage(const QString &stageId) const +{ + const StageMeta *meta = findMeta(stageId); + return meta ? QString(meta->title) : stageId; +} + +MainWindow::StageCardData MainWindow::createCardForStage(const QString &stageId, const bool enabled) const +{ + StageCardData card; + card.id = stageId; + const StageMeta *meta = findMeta(stageId); + if (meta) { + card.title = meta->title; + card.category = meta->category; + card.hint = meta->hint; + card.defaults = meta->defaults; + } else { + card.title = stageId; + card.category = "Custom"; + card.hint = "No hint registered for this stage."; + card.defaults = "-"; + } + card.enabled = enabled; + return card; +} + +QStringList MainWindow::activeStageIds() const +{ + QStringList ids; + for (const StageCardData &card : m_stageCards) { + if (card.enabled) { + ids.push_back(card.id); + } + } + return ids; +} diff --git a/src/ui/mainwindow.h b/src/ui/mainwindow.h index 6b761ef..4ffe089 100644 --- a/src/ui/mainwindow.h +++ b/src/ui/mainwindow.h @@ -2,27 +2,153 @@ #define MAINWINDOW_H #include +#include +#include #include +#include #include -#include "../geometry/surface_reconstruction.h" +#include "../algorithms/reconstruction/surface_reconstruction.h" +#include "../core/pipeline_config.h" +#include "../core/pipeline_executor.h" class GlView; +class QQuickWidget; +class QDockWidget; class MainWindow : public QMainWindow { Q_OBJECT + Q_PROPERTY(QStringList stageChain READ stageChain NOTIFY stageChainChanged) + Q_PROPERTY(QStringList availableStages READ availableStages CONSTANT) + Q_PROPERTY(QStringList availableReconstructions READ availableReconstructions CONSTANT) + Q_PROPERTY(QString reconstructionMethod READ reconstructionMethod WRITE setReconstructionMethod NOTIFY reconstructionMethodChanged) + Q_PROPERTY(QString pipelinePreview READ pipelinePreview NOTIFY pipelinePreviewChanged) + Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged) + Q_PROPERTY(bool surfaceVisible READ surfaceVisible WRITE setSurfaceVisible NOTIFY surfaceVisibleChanged) + Q_PROPERTY(QVariantList stageCards READ stageCards NOTIFY stageCardsChanged) + Q_PROPERTY(QVariantList stageLibrary READ stageLibrary CONSTANT) + 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(QString recommendation READ recommendation NOTIFY recommendationChanged) + 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) + Q_PROPERTY(QString uiMode READ uiMode WRITE setUiMode NOTIFY uiModeChanged) + 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) public: explicit MainWindow(QWidget *parent = nullptr); + QStringList stageChain() const; + QStringList availableStages() const; + QStringList availableReconstructions() const; + QString reconstructionMethod() const; + QString pipelinePreview() const; + QString statusText() const; + bool surfaceVisible() const; + QVariantList stageCards() const; + QVariantList stageLibrary() const; + int selectedStageIndex() const; + QVariantMap selectedStage() const; + QVariantMap metrics() const; + QString recommendation() const; + QString chainHealth() const; + QStringList demoSurfaceTypes() const; + QString demoSurfaceType() const; + QString uiMode() const; + QString wizardGoal() const; + QString wizardProfile() const; + QVariantList snapshots() const; + +public slots: + void generateDemo(); + void openPointsFile(); + void applyPreset(const QString &presetId); + void setStageChain(const QStringList &chain); + void addStage(const QString &stageId); + void insertStage(int index, const QString &stageId); + void removeStage(int index); + void moveStage(int from, int to); + void applyPipelineFromUi(); + void setReconstructionMethod(const QString &pluginId); + void setSurfaceVisible(bool visible); + void setNeighborRadius(double value); + void setStageEnabled(int index, bool enabled); + void setStageDefaults(int index, const QString &defaultsText); + QString defaultsForStage(const QString &stageId) const; + void setSelectedStageIndex(int index); + void setDemoSurfaceType(const QString &surfaceType); + void setUiMode(const QString &mode); + void setWizardGoal(const QString &goal); + void setWizardProfile(const QString &profile); + void applyWizardSuggestion(); + void saveSnapshot(const QString &slotName); + void loadSnapshot(const QString &slotName); private: - void rebuildSurface(const QVector &points, const QString &sourceLabel); - bool loadPointsFromFile(const QString &filePath, QVector &points, QString &errorText) const; + struct StageCardData { + QString id; + QString title; + QString category; + QString hint; + QString defaults; + bool enabled; + }; + + void rebuildSurface(const QVector &points, const QString &sourceLabel); + bool loadPointsFromFile(const QString &filePath, QVector &points, QString &errorText) const; + void rebuildExecutorFromConfig(); + bool normalizeAndValidateConfig(core::PipelineConfig &config, QString &errorText) const; + QString pipelineSummary() const; + void updateStatusText(const QString &text); + void notifyPipelineStateChanged(); + QVariantMap makeStageLibraryEntry(const StageCardData &card) const; + void rebuildCardsFromConfig(); + void recomputeInsights(); + QString displayNameForStage(const QString &stageId) const; + StageCardData createCardForStage(const QString &stageId, bool enabled = true) const; + QStringList activeStageIds() const; GlView *m_glView; - QVector m_currentPoints; + QQuickWidget *m_dashboardView; + QDockWidget *m_dashboardDock; + core::PipelineExecutor m_pipelineExecutor; + core::PipelineConfig m_pipelineConfig; + QVector m_currentPoints; QString m_currentSourceLabel; + QString m_statusText; + bool m_surfaceVisible; + QVector m_stageCards; + int m_selectedStageIndex; + QVariantMap m_metrics; + QString m_recommendation; + QString m_chainHealth; + QString m_demoSurfaceType; + QString m_uiMode; + QString m_wizardGoal; + QString m_wizardProfile; + QVariantMap m_savedSnapshots; + +signals: + void stageChainChanged(); + void reconstructionMethodChanged(); + void pipelinePreviewChanged(); + void statusTextChanged(); + void surfaceVisibleChanged(); + void stageCardsChanged(); + void selectedStageIndexChanged(); + void selectedStageChanged(); + void metricsChanged(); + void recommendationChanged(); + void chainHealthChanged(); + void demoSurfaceTypeChanged(); + void uiModeChanged(); + void wizardGoalChanged(); + void wizardProfileChanged(); + void snapshotsChanged(); }; #endif // MAINWINDOW_H diff --git a/src/ui/qml/components/StageInspector.qml b/src/ui/qml/components/StageInspector.qml new file mode 100644 index 0000000..7ddffab --- /dev/null +++ b/src/ui/qml/components/StageInspector.qml @@ -0,0 +1,39 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 + +GroupBox { + title: "Stage Inspector" + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + Label { + text: backend.selectedStage.title ? backend.selectedStage.title : "No stage selected" + color: "#eaf0ff" + font.bold: true + font.pixelSize: 13 + wrapMode: Text.WordWrap + } + Label { + text: backend.selectedStage.id ? ("id: " + backend.selectedStage.id) : "" + color: "#9eb0d9" + font.pixelSize: 10 + } + Label { + text: backend.selectedStage.hint ? backend.selectedStage.hint : "Select a stage to see usage hints." + color: "#d0ddff" + wrapMode: Text.WordWrap + Layout.fillWidth: true + font.pixelSize: 11 + } + Label { + text: backend.selectedStage.defaults ? ("Defaults: " + backend.selectedStage.defaults) : "" + color: "#8fb4ff" + wrapMode: Text.WordWrap + Layout.fillWidth: true + font.pixelSize: 11 + } + } +} diff --git a/src/ui/qml/components/WizardPanel.qml b/src/ui/qml/components/WizardPanel.qml new file mode 100644 index 0000000..d3b2c13 --- /dev/null +++ b/src/ui/qml/components/WizardPanel.qml @@ -0,0 +1,41 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 + +GroupBox { + title: "Quick Wizard" + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + Label { text: "Data profile"; color: "#dbe7ff" } + ComboBox { + Layout.fillWidth: true + model: ["general", "urban_scan", "indoor_object"] + currentIndex: Math.max(0, find(backend.wizardProfile)) + onActivated: backend.setWizardProfile(currentText) + } + + Label { text: "Optimization goal"; color: "#dbe7ff" } + ComboBox { + Layout.fillWidth: true + model: ["speed", "balanced", "quality"] + currentIndex: Math.max(0, find(backend.wizardGoal)) + onActivated: backend.setWizardGoal(currentText) + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + color: "#9eb0d9" + text: "Wizard builds a start chain and explains trade-offs." + } + + Button { + Layout.fillWidth: true + text: "Generate suggested chain" + onClicked: backend.applyWizardSuggestion() + } + } +} diff --git a/src/ui/qml/components/pipeline/MetricsStrip.qml b/src/ui/qml/components/pipeline/MetricsStrip.qml new file mode 100644 index 0000000..a675a88 --- /dev/null +++ b/src/ui/qml/components/pipeline/MetricsStrip.qml @@ -0,0 +1,62 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 +import "../../shared/Theme.js" as Theme + +GroupBox { + title: "Сводка" + readonly property var safeBackend: backend ? backend : ({ + chainHealth: "", + recommendation: "", + metrics: ({ + triangles: 0, + reconstructMs: 0, + inputPoints: 0, + afterPreprocess: 0, + removedPoints: 0, + clusters: 0 + }) + }) + function localizedHealth(value) { + if (value === "OK") + return "ОК" + if (value === "Warning") + return "Предупреждение" + if (value === "Risk") + return "Риск" + return value + } + + ColumnLayout { + anchors.fill: parent + 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 + } + + 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 } + } + } +} diff --git a/src/ui/qml/components/pipeline/PipelineCanvas.qml b/src/ui/qml/components/pipeline/PipelineCanvas.qml new file mode 100644 index 0000000..32865d9 --- /dev/null +++ b/src/ui/qml/components/pipeline/PipelineCanvas.qml @@ -0,0 +1,82 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 +import "../../dialogs" +import "../../shared/Theme.js" as Theme + +GroupBox { + id: root + title: "" + readonly property var safeBackend: backend ? backend : ({ + selectedStageIndex: -1, + stageCards: [], + reconstructionMethod: "surface_fallback", + addStage: function(_) {}, + setReconstructionMethod: function(_) {}, + moveStage: function(_, __) {}, + setSelectedStageIndex: function(_) {}, + removeStage: function(_) {}, + setStageDefaults: function(_, __) {}, + defaultsForStage: function(_) { return ""; } + }) + property var popupItem: ({}) + + property int selectedIndex: safeBackend.selectedStageIndex + + ColumnLayout { + anchors.fill: parent + spacing: Theme.spacingSm + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Theme.radiusMd + color: Theme.cardBg + border.width: 1 + border.color: Theme.cardBorder + + RowLayout { + anchors.fill: parent + anchors.margins: Theme.spacingSm + spacing: Theme.spacingMd + + Item { + id: chainArea + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + StageChainView { + anchors.fill: parent + safeBackend: root.safeBackend + selectedIndex: root.selectedIndex + onSettingsRequested: { + root.popupItem = stageItem + stageSettingsDialog.open() + } + } + + } + + Item { + id: paletteArea + Layout.preferredWidth: Theme.palettePanelWidth + Layout.fillHeight: true + clip: false + + StagePalette { + anchors.fill: parent + safeBackend: root.safeBackend + } + } + } + } + } + + StageSettingsDialog { + id: stageSettingsDialog + parent: root + safeBackend: root.safeBackend + popupItem: root.popupItem + } +} diff --git a/src/ui/qml/components/pipeline/StageChainView.qml b/src/ui/qml/components/pipeline/StageChainView.qml new file mode 100644 index 0000000..ac8ce34 --- /dev/null +++ b/src/ui/qml/components/pipeline/StageChainView.qml @@ -0,0 +1,153 @@ +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 { + id: root + property var safeBackend + property int selectedIndex: -1 + signal settingsRequested(var stageItem) + + ListView { + id: stageList + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: reconstructionCard.top + anchors.bottomMargin: Theme.spacingSm + clip: true + model: root.safeBackend.stageCards + + delegate: Rectangle { + id: rowRoot + property int itemIndex: index + width: stageList.width + height: Theme.stageRowHeight + radius: Theme.radiusMd + border.width: 1 + border.color: index === root.selectedIndex ? Theme.chainSelectedBorder : Theme.chainIdleBorder + color: modelData.enabled ? Theme.chainEnabledBg : Theme.chainDisabledBg + + RowLayout { + anchors.fill: parent + anchors.margins: Theme.spacingSm + spacing: Theme.spacingSm + + Label { + text: "↕" + color: Theme.chainDragHandle + font.pixelSize: Theme.fontLg + width: Theme.stageHandleWidth + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + MouseArea { + id: dragHandle + anchors.fill: parent + drag.target: dragProxy + onPressed: root.safeBackend.setSelectedStageIndex(index) + } + } + + 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 } + } + } + + Rectangle { + id: dragProxy + visible: dragHandle.drag.active + width: rowRoot.width + height: rowRoot.height + color: Theme.chainDragOverlay + opacity: 0.35 + radius: Theme.radiusMd + z: 3 + Drag.active: dragHandle.drag.active + Drag.source: rowRoot + Drag.hotSpot.x: width / 2 + Drag.hotSpot.y: height / 2 + } + + DropArea { + anchors.fill: parent + onEntered: { + if (drag.source && drag.source !== rowRoot && drag.source.itemIndex !== undefined) { + root.safeBackend.moveStage(drag.source.itemIndex, index) + } + } + } + + MouseArea { + anchors.fill: parent + z: -1 + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: root.safeBackend.setSelectedStageIndex(index) + onDoubleClicked: { + root.safeBackend.setSelectedStageIndex(index) + root.settingsRequested(modelData) + } + onPressed: { + if (mouse.button === Qt.RightButton) { + root.safeBackend.setSelectedStageIndex(index) + stageMenu.popup() + } + } + } + + Menu { + id: stageMenu + MenuItem { + text: "Удалить элемент" + 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 new file mode 100644 index 0000000..fec8a89 --- /dev/null +++ b/src/ui/qml/components/pipeline/StagePalette.qml @@ -0,0 +1,75 @@ +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 { + id: root + property var safeBackend + + Flickable { + anchors.fill: parent + contentWidth: width + contentHeight: paletteColumn.implicitHeight + Theme.spacingMd + clip: true + + Column { + id: paletteColumn + width: parent.width + spacing: Theme.spacingXs + + Label { text: "Фильтры"; font.bold: true; color: Theme.paletteHeader; padding: Theme.spacingXs } + Repeater { + model: UiCatalog.filterPaletteModel() + delegate: Rectangle { + property string paletteStageId: modelData.idValue + 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) + } + } + } + + 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 + } + MouseArea { + anchors.fill: parent + onDoubleClicked: root.safeBackend.setReconstructionMethod(parent.paletteStageId) + } + } + } + } + } +} diff --git a/src/ui/qml/dialogs/StageSettingsDialog.qml b/src/ui/qml/dialogs/StageSettingsDialog.qml new file mode 100644 index 0000000..bd172b8 --- /dev/null +++ b/src/ui/qml/dialogs/StageSettingsDialog.qml @@ -0,0 +1,128 @@ +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 + +Dialog { + id: root + property var safeBackend + property var popupItem: ({}) + property var paramItems: [] + + function updateParamValue(idx, newValue) { + var next = paramItems.slice(0) + var item = next[idx] + item.value = String(newValue) + next[idx] = item + paramItems = next + } + + modal: true + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + width: Theme.dialogWidth + title: "" + standardButtons: Dialog.NoButton + + onAccepted: { + if (safeBackend.selectedStageIndex >= 0) { + var serialized = UiCatalog.serializeParams(root.paramItems) + safeBackend.setStageDefaults(safeBackend.selectedStageIndex, serialized) + popupItem.defaults = serialized + } + } + + onOpened: root.paramItems = UiCatalog.parseDefaults(popupItem.defaults) + + contentItem: ColumnLayout { + spacing: Theme.spacingSm + Label { + text: popupItem.title ? popupItem.title : "Элемент не выбран" + font.bold: true + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + Label { + text: popupItem.hint ? popupItem.hint : "Для этого элемента нет подробного описания." + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + Label { + text: "Параметры" + font.bold: true + color: Theme.dialogSectionText + Layout.fillWidth: true + } + Repeater { + model: root.paramItems + delegate: ColumnLayout { + Layout.fillWidth: true + spacing: 2 + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingMd + Label { + text: modelData.key + ":" + color: Theme.dialogHintText + Layout.preferredWidth: Theme.dialogLabelWidth + elide: Text.ElideRight + } + ComboBox { + Layout.fillWidth: true + visible: modelData.kind === "bool" + model: ["false", "true"] + currentIndex: modelData.value === "true" ? 1 : 0 + onCurrentTextChanged: { + if (modelData.kind === "bool") + root.updateParamValue(index, currentText) + } + } + TextField { + Layout.fillWidth: true + visible: modelData.kind === "int" + text: modelData.value + validator: IntValidator {} + onEditingFinished: root.updateParamValue(index, text) + } + TextField { + Layout.fillWidth: true + visible: modelData.kind === "float" + text: modelData.value + validator: DoubleValidator {} + onEditingFinished: root.updateParamValue(index, text) + } + TextField { + Layout.fillWidth: true + visible: modelData.kind === "string" + text: modelData.value + onEditingFinished: root.updateParamValue(index, text) + } + } + Label { + Layout.fillWidth: true + text: UiCatalog.paramDescription(modelData.key) + color: Theme.dialogDescriptionText + font.pixelSize: Theme.fontXs + wrapMode: Text.WordWrap + } + } + } + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Theme.spacingMd + spacing: Theme.spacingMd + Button { + text: "Установить по умолчанию" + Layout.preferredWidth: Theme.dialogResetButtonWidth + onClicked: root.paramItems = UiCatalog.parseDefaults(safeBackend.defaultsForStage(popupItem.id)) + } + Item { Layout.fillWidth: true } + Button { + text: "OK" + Layout.preferredWidth: Theme.dialogOkButtonWidth + onClicked: root.accept() + } + } + } +} diff --git a/src/ui/qml/pages/PipelineDashboard.qml b/src/ui/qml/pages/PipelineDashboard.qml new file mode 100644 index 0000000..5129e91 --- /dev/null +++ b/src/ui/qml/pages/PipelineDashboard.qml @@ -0,0 +1,61 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 +import "../components/pipeline" +import "../shared/Theme.js" as Theme + +Rectangle { + color: Theme.panelBg + property int compactH: Theme.compactControlHeight + readonly property var safeBackend: backend ? backend : ({ + surfaceVisible: true, + demoSurfaceTypes: ["Сфера", "Тор", "Волна"], + demoSurfaceType: "Сфера", + openPointsFile: function() {}, + generateDemo: function() {}, + setDemoSurfaceType: function(_) {}, + setSurfaceVisible: function(_) {}, + applyPipelineFromUi: function() {} + }) + + ColumnLayout { + anchors.fill: parent + anchors.margins: Theme.spacingMd + spacing: Theme.spacingSm + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + ComboBox { + Layout.preferredWidth: Theme.dashboardSurfaceComboWidth + Layout.preferredHeight: compactH + model: safeBackend.demoSurfaceTypes + currentIndex: model.indexOf(safeBackend.demoSurfaceType) + onActivated: safeBackend.setDemoSurfaceType(currentText) + } + Button { text: "Сгенерировать"; height: compactH; onClicked: safeBackend.generateDemo() } + Button { text: "Загрузить"; height: compactH; onClicked: safeBackend.openPointsFile() } + Item { Layout.fillWidth: true } + } + + PipelineCanvas { + Layout.fillWidth: true + Layout.fillHeight: true + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + Switch { checked: safeBackend.surfaceVisible; onToggled: safeBackend.setSurfaceVisible(checked) } + Item { Layout.fillWidth: true } + Button { text: "Применить"; Layout.preferredHeight: compactH; onClicked: safeBackend.applyPipelineFromUi() } + } + + MetricsStrip { + Layout.fillWidth: true + Layout.preferredHeight: Theme.dashboardMetricsHeight + Layout.bottomMargin: Theme.dashboardBottomMargin + } + + } +} diff --git a/src/ui/qml/shared/PipelineUiCatalog.js b/src/ui/qml/shared/PipelineUiCatalog.js new file mode 100644 index 0000000..24034dd --- /dev/null +++ b/src/ui/qml/shared/PipelineUiCatalog.js @@ -0,0 +1,81 @@ +.pragma library + +function inferParamType(valueText) { + var v = (valueText === undefined || valueText === null) ? "" : String(valueText).trim() + if (v === "true" || v === "false") + return "bool" + if (/^-?\d+$/.test(v)) + return "int" + if (/^-?(?:\d+\.\d*|\d*\.\d+)$/.test(v)) + return "float" + return "string" +} + +function parseDefaults(defaultsText) { + var out = [] + if (!defaultsText) + return out + var chunks = String(defaultsText).split(",") + for (var i = 0; i < chunks.length; ++i) { + var chunk = chunks[i].trim() + if (!chunk) + continue + var eq = chunk.indexOf("=") + var key = eq >= 0 ? chunk.slice(0, eq).trim() : chunk + var value = eq >= 0 ? chunk.slice(eq + 1).trim() : "" + out.push({ key: key, value: value, kind: inferParamType(value) }) + } + return out +} + +function serializeParams(items) { + var parts = [] + for (var i = 0; i < items.length; ++i) { + var p = items[i] + parts.push(p.key + "=" + p.value) + } + return parts.join(",") +} + +function paramDescription(key) { + if (key === "minCluster") + return "Минимальный размер кластера в точках." + if (key === "targetPoints") + return "Целевое количество точек после прореживания." + if (key === "leaf") + return "Размер вокселя для Voxel Grid." + if (key === "meanK") + return "Число соседей для статистической оценки." + if (key === "stddev") + return "Порог отклонения для удаления выбросов." + if (key === "radius") + return "Радиус поиска соседей." + if (key === "minNeighbors") + return "Минимум соседей, чтобы точка считалась валидной." + return "Параметр этапа обработки." +} + +function reconstructionTitle(id) { + if (id === "surface_fallback") + return "Fallback Surface" + if (id === "pcl_greedy_triangulation") + return "PCL Greedy Triangulation" + return id +} + +function filterPaletteModel() { + 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" } + ] +} + +function reconstructionPaletteModel() { + return [ + { title: "Fallback Surface", idValue: "surface_fallback" }, + { title: "PCL Greedy Triangulation", idValue: "pcl_greedy_triangulation" } + ] +} diff --git a/src/ui/qml/shared/Theme.js b/src/ui/qml/shared/Theme.js new file mode 100644 index 0000000..6e2cd4e --- /dev/null +++ b/src/ui/qml/shared/Theme.js @@ -0,0 +1,63 @@ +.pragma library + +var panelBg = "#141821" +var cardBg = "#111a2c" +var cardBorder = "#33486f" + +var chainSelectedBorder = "#6ea8ff" +var chainIdleBorder = "#384866" +var chainEnabledBg = "#1f2a3d" +var chainDisabledBg = "#23272f" +var chainDragOverlay = "#39507d" +var chainDragHandle = "#9fb4da" + +var reconstructionBg = "#2d2438" +var reconstructionBorder = "#625481" +var reconstructionAccent = "#d9c8ff" +var reconstructionText = "#efe7ff" +var reconstructionSubtext = "#c3b2e8" + +var paletteFilterBg = "#26344f" +var paletteFilterBorder = "#3d547d" +var paletteReconBg = "#3a2f44" +var paletteReconBorder = "#625481" +var paletteHeader = "#d6e3ff" +var paletteFilterText = "#eaf0ff" +var paletteReconText = "#efe7ff" + +var summaryPrimaryText = "#eaf0ff" +var summarySecondaryText = "#cde0ff" +var summaryRecommendation = "#9ec3ff" +var summaryDetailText = "#b8cff8" + +var dialogHintText = "#4a4f63" +var dialogDescriptionText = "#7e869d" +var dialogSectionText = "#9eb0d9" + +var spacingXs = 4 +var spacingSm = 6 +var spacingMd = 8 +var spacingLg = 10 +var radiusSm = 6 +var radiusMd = 8 +var compactControlHeight = 28 + +var dashboardSurfaceComboWidth = 170 +var dashboardMetricsHeight = 96 +var dashboardBottomMargin = 8 +var palettePanelWidth = 260 + +var stageRowHeight = 48 +var stageHandleWidth = 16 +var reconstructionBadgeWidth = 28 +var paletteItemHeight = 30 + +var dialogWidth = 430 +var dialogLabelWidth = 150 +var dialogResetButtonWidth = 190 +var dialogOkButtonWidth = 90 + +var fontXs = 10 +var fontSm = 11 +var fontMd = 12 +var fontLg = 14 diff --git a/src/ui/qml/ui_qml.qrc b/src/ui/qml/ui_qml.qrc new file mode 100644 index 0000000..79201f8 --- /dev/null +++ b/src/ui/qml/ui_qml.qrc @@ -0,0 +1,14 @@ + + + pages/PipelineDashboard.qml + components/WizardPanel.qml + components/StageInspector.qml + components/pipeline/PipelineCanvas.qml + components/pipeline/StageChainView.qml + components/pipeline/StagePalette.qml + components/pipeline/MetricsStrip.qml + dialogs/StageSettingsDialog.qml + shared/PipelineUiCatalog.js + shared/Theme.js + +