diff --git a/DotsToSirface.pro b/DotsToSirface.pro index 15cfe13..7973427 100644 --- a/DotsToSirface.pro +++ b/DotsToSirface.pro @@ -48,6 +48,7 @@ SOURCES += \ src/adapters/ros2/pointcloud2_adapter.cpp \ src/adapters/ros2/tf2_adapter.cpp \ src/adapters/sources/file_point_cloud_source.cpp \ + src/adapters/sources/ply_point_cloud_loader.cpp \ src/adapters/sources/ros2_point_cloud_source.cpp \ src/core/pipeline_config.cpp \ src/core/pipeline_config_validation.cpp \ @@ -75,6 +76,7 @@ HEADERS += \ src/adapters/ros2/pointcloud2_adapter.h \ src/adapters/ros2/tf2_adapter.h \ src/adapters/sources/file_point_cloud_source.h \ + src/adapters/sources/ply_point_cloud_loader.h \ src/adapters/sources/ros2_point_cloud_source.h \ src/core/data_source.h \ src/core/point_cloud_types.h \ diff --git a/DotsToSirfaceTests.pro b/DotsToSirfaceTests.pro index 17b35b4..b9de0f5 100644 --- a/DotsToSirfaceTests.pro +++ b/DotsToSirfaceTests.pro @@ -46,6 +46,7 @@ SOURCES += \ src/adapters/ros2/pointcloud2_adapter.cpp \ src/adapters/ros2/tf2_adapter.cpp \ src/adapters/sources/file_point_cloud_source.cpp \ + src/adapters/sources/ply_point_cloud_loader.cpp \ src/adapters/sources/ros2_point_cloud_source.cpp \ src/core/pipeline_config.cpp \ src/core/pipeline_config_validation.cpp \ @@ -70,6 +71,7 @@ HEADERS += \ src/adapters/ros2/pointcloud2_adapter.h \ src/adapters/ros2/tf2_adapter.h \ src/adapters/sources/file_point_cloud_source.h \ + src/adapters/sources/ply_point_cloud_loader.h \ src/adapters/sources/ros2_point_cloud_source.h \ src/core/data_source.h \ src/core/point_cloud_types.h \ diff --git a/README.md b/README.md index 85df501..5dba240 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,40 @@ CLI печатает краткую сводку по метрикам (`input`, поэтому его можно загрузить напрямую кнопкой `Загрузить пресет`. Дополнительно рядом сохраняется `*_best_preset.json` (чистый preset-JSON). +## Docker + Web UI (CLI + PCL) + +Для запуска пайплайна с PCL в изолированной среде (без конфликтов с `libpq` на хосте) добавлены: + +- `docker/Dockerfile` — сборка `DotsToSirface` с `PCL_ENABLED` и запуск FastAPI; +- `docker/docker-compose.yml`; +- `api/main.py` — HTTP API (`/api/run`, `/api/presets`); +- `web/` — браузерный 3D viewer (Three.js). + +### Быстрый старт + +```bash +cd docker +docker compose up --build +``` + +Откройте в браузере: [http://localhost:8080](http://localhost:8080) + +1. Загрузите `.ply` +2. Выберите preset (или оставьте default) +3. Нажмите **Run pipeline** +4. В viewer появятся точки и mesh, справа — метрики пайплайна + +### API + +- `GET /api/health` — статус сервиса +- `GET /api/presets` — список JSON-пресетов +- `POST /api/run` — multipart: `file` + опционально `preset_id` + +CLI внутри контейнера пишет в `--output-json` не только метрики, но и геометрию: +`points` (массив `[x,y,z]`) и `triangleIndices` (массив `[i0,i1,i2]`). + +Конфиг пайплайна по умолчанию: `docker/default_pipeline.json`. + ## Демо При запуске приложение: diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..8826817 --- /dev/null +++ b/api/main.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import uuid +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +APP_ROOT = Path(__file__).resolve().parent.parent +WEB_ROOT = APP_ROOT / "web" +PRESETS_DIRS = [APP_ROOT / "presets", APP_ROOT / "docker"] +DEFAULT_PIPELINE_CONFIG = Path( + os.environ.get("PIPELINE_CONFIG", APP_ROOT / "docker" / "default_pipeline.json") +) +DOTSTOSIRFACE_BIN = Path(os.environ.get("DOTSTOSIRFACE_BIN", "/usr/local/bin/DotsToSirface")) + +app = FastAPI(title="DotsToSirface Web API", version="1.0.0") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def preset_to_pipeline_config(preset: dict[str, Any]) -> dict[str, Any]: + if "preprocessPlugins" in preset and "reconstructionPlugin" in preset: + return preset + + preprocess_plugins: list[str] = [] + reconstruction_plugin = "surface_fallback" + stage_defaults: dict[str, str] = {} + + for stage in preset.get("stages", []): + if not stage.get("enabled", True): + continue + stage_id = str(stage.get("id", "")).strip() + if not stage_id: + continue + family = str(stage.get("family", "")).strip() + if family == "preprocess": + preprocess_plugins.append(stage_id) + elif family == "reconstruction": + reconstruction_plugin = stage_id + defaults = str(stage.get("defaults", "")).strip() + if defaults: + stage_defaults[stage_id] = defaults + + return { + "profile": preset.get("profile", "desktop_debug"), + "preprocessPlugins": preprocess_plugins, + "reconstructionPlugin": reconstruction_plugin, + "stageDefaults": stage_defaults, + } + + +def list_preset_files() -> list[Path]: + files: list[Path] = [] + seen: set[str] = set() + for directory in PRESETS_DIRS: + if not directory.is_dir(): + continue + for path in sorted(directory.glob("*.json")): + if path.name in seen: + continue + seen.add(path.name) + files.append(path) + return files + + +@app.get("/api/health") +def health() -> dict[str, str]: + return { + "status": "ok", + "binary": str(DOTSTOSIRFACE_BIN), + "binaryExists": str(DOTSTOSIRFACE_BIN.is_file()), + } + + +@app.get("/api/presets") +def presets() -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for path in list_preset_files(): + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + items.append( + { + "id": path.stem, + "filename": path.name, + "title": data.get("title", path.stem), + "config": preset_to_pipeline_config(data), + } + ) + return items + + +@app.get("/api/default-config") +def default_config() -> dict[str, Any]: + if not DEFAULT_PIPELINE_CONFIG.is_file(): + raise HTTPException(status_code=500, detail="Default pipeline config is missing.") + with DEFAULT_PIPELINE_CONFIG.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +@app.post("/api/run") +async def run_pipeline( + file: UploadFile = File(...), + preset_id: str | None = Form(default=None), + config_json: str | None = Form(default=None), +) -> dict[str, Any]: + if not DOTSTOSIRFACE_BIN.is_file(): + raise HTTPException(status_code=500, detail=f"Binary not found: {DOTSTOSIRFACE_BIN}") + + suffix = Path(file.filename or "cloud.ply").suffix or ".ply" + work_id = uuid.uuid4().hex + work_dir = Path(tempfile.gettempdir()) / "dotstosirface" / work_id + work_dir.mkdir(parents=True, exist_ok=True) + + input_path = work_dir / f"input{suffix}" + config_path = work_dir / "pipeline_config.json" + output_path = work_dir / "result.json" + + try: + content = await file.read() + input_path.write_bytes(content) + + if config_json: + pipeline_config = json.loads(config_json) + elif preset_id: + preset_path = next((p for p in list_preset_files() if p.stem == preset_id), None) + if preset_path is None: + raise HTTPException(status_code=400, detail=f"Unknown preset: {preset_id}") + with preset_path.open("r", encoding="utf-8") as handle: + pipeline_config = preset_to_pipeline_config(json.load(handle)) + else: + with DEFAULT_PIPELINE_CONFIG.open("r", encoding="utf-8") as handle: + pipeline_config = json.load(handle) + + config_path.write_text(json.dumps(pipeline_config, indent=2), encoding="utf-8") + + command = [ + str(DOTSTOSIRFACE_BIN), + "--cli", + "--input", + str(input_path), + "--config-json", + str(config_path), + "--output-json", + str(output_path), + ] + completed = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() or "Pipeline failed." + raise HTTPException(status_code=500, detail=detail) + + if not output_path.is_file(): + raise HTTPException(status_code=500, detail="Pipeline finished without output JSON.") + + with output_path.open("r", encoding="utf-8") as handle: + result = json.load(handle) + + result["stdout"] = completed.stdout.strip() + result["workId"] = work_id + return result + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail=f"Invalid config JSON: {exc}") from exc + except HTTPException: + raise + except Exception as exc: # pragma: no cover - defensive path for API boundary + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(WEB_ROOT / "index.html") + + +app.mount("/static", StaticFiles(directory=WEB_ROOT), name="static") diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..0dd6d4c --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +python-multipart==0.0.20 diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..7cfe0fe --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,65 @@ +FROM ubuntu:24.04 AS builder + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + qtbase5-dev \ + qt5-qmake \ + qtdeclarative5-dev \ + libqt5opengl5-dev \ + libpcl-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY . . + +RUN mkdir -p build && cd build \ + && qmake ../DotsToSirface.pro 'DEFINES+=PCL_ENABLED' \ + && make -j"$(nproc)" + +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV DOTSTOSIRFACE_BIN=/usr/local/bin/DotsToSirface +ENV PIPELINE_CONFIG=/app/docker/default_pipeline.json +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + python3-venv \ + libqt5core5t64 \ + libqt5gui5t64 \ + libqt5widgets5t64 \ + libqt5opengl5t64 \ + libqt5qml5 \ + libqt5quick5 \ + libqt5quickwidgets5 \ + libqt5network5t64 \ + libpcl-common1.14 \ + libpcl-io1.14 \ + libpcl-filters1.14 \ + libpcl-features1.14 \ + libpcl-kdtree1.14 \ + libpcl-search1.14 \ + libpcl-surface1.14 \ + libgl1 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /src/build/DotsToSirface /usr/local/bin/DotsToSirface +COPY api /app/api +COPY web /app/web +COPY docker /app/docker +COPY presets /app/presets + +WORKDIR /app/api +RUN python3 -m venv /opt/venv \ + && /opt/venv/bin/pip install --no-cache-dir -r requirements.txt + +ENV PATH="/opt/venv/bin:${PATH}" + +EXPOSE 8080 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/docker/default_pipeline.json b/docker/default_pipeline.json new file mode 100644 index 0000000..ccbdac3 --- /dev/null +++ b/docker/default_pipeline.json @@ -0,0 +1,14 @@ +{ + "profile": "desktop_debug", + "preprocessPlugins": [ + "pcl_remove_nan", + "pcl_voxel_grid", + "pcl_statistical_outlier" + ], + "reconstructionPlugin": "pcl_greedy_triangulation", + "stageDefaults": { + "pcl_voxel_grid": "leaf=0.03", + "pcl_statistical_outlier": "meanK=24,stddev=1.2", + "pcl_greedy_triangulation": "searchRadius=0.08,mu=2.5,maxNearest=100,maxSurfaceAngle=0.8" + } +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..ee61644 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,12 @@ +services: + dotstosirface-web: + build: + context: .. + dockerfile: docker/Dockerfile + ports: + - "8080:8080" + environment: + DOTSTOSIRFACE_BIN: /usr/local/bin/DotsToSirface + PIPELINE_CONFIG: /app/docker/default_pipeline.json + volumes: + - ../presets:/app/presets:ro diff --git a/docker/run.sh b/docker/run.sh new file mode 100755 index 0000000..d03a556 --- /dev/null +++ b/docker/run.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" +docker compose up --build "$@" diff --git a/src/adapters/sources/file_point_cloud_source.cpp b/src/adapters/sources/file_point_cloud_source.cpp index b2eeacd..40b4f51 100644 --- a/src/adapters/sources/file_point_cloud_source.cpp +++ b/src/adapters/sources/file_point_cloud_source.cpp @@ -1,5 +1,7 @@ #include "file_point_cloud_source.h" +#include "ply_point_cloud_loader.h" + #include #include #include @@ -21,48 +23,54 @@ bool FilePointCloudSource::nextFrame(core::PointCloudFrame &frame, QString &erro 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."; + if (ext == "ply") { + if (!loadPointsFromPly(m_filePath, points, errorText)) { 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; + QFile file(m_filePath); + if (!file.open(QIODevice::ReadOnly)) { + errorText = QString("Cannot open file: %1").arg(m_filePath); + return false; + } + + if (ext == "bin") { + const QByteArray raw = file.readAll(); + if (raw.size() % static_cast(sizeof(float) * 3) != 0) { + errorText = "Invalid .bin size."; + return false; } - line.replace(';', ' '); - line.replace(',', ' '); - const QStringList parts = line.split(QRegularExpression("\\s+"), QString::SkipEmptyParts); - if (parts.size() < 3) { - continue; + 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]}); } - 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}); + } 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}); + } } } } diff --git a/src/adapters/sources/ply_point_cloud_loader.cpp b/src/adapters/sources/ply_point_cloud_loader.cpp new file mode 100644 index 0000000..9ffe7ef --- /dev/null +++ b/src/adapters/sources/ply_point_cloud_loader.cpp @@ -0,0 +1,468 @@ +#include "ply_point_cloud_loader.h" + +#include + +#include +#include +#include +#include +#include + +namespace adapters +{ +namespace sources +{ +namespace +{ +struct PlyProperty +{ + QString type; + QString name; + bool isList = false; + QString countType; + QString itemType; +}; + +int plyScalarTypeSize(const QString &type) +{ + const QString t = type.toLower(); + if (t == "char" || t == "int8" || t == "uchar" || t == "uint8") { + return 1; + } + if (t == "short" || t == "int16" || t == "ushort" || t == "uint16") { + return 2; + } + if (t == "int" || t == "int32" || t == "uint" || t == "uint32" || t == "float" || t == "float32") { + return 4; + } + if (t == "double" || t == "float64") { + return 8; + } + return 0; +} + +bool readHeaderLine(QIODevice &device, QString &line, QString &errorText) +{ + while (true) { + const QByteArray raw = device.readLine(); + if (raw.isEmpty()) { + if (device.atEnd()) { + errorText = "Unexpected end of PLY header."; + return false; + } + errorText = "Failed to read PLY header line."; + return false; + } + line = QString::fromUtf8(raw).trimmed(); + if (!line.isEmpty()) { + return true; + } + } +} + +bool parsePlyHeader( + QIODevice &device, + bool &asciiFormat, + bool &littleEndian, + int &vertexCount, + QVector &vertexProperties, + QString &errorText) +{ + QString line; + if (!readHeaderLine(device, line, errorText)) { + return false; + } + if (line.toLower() != "ply") { + errorText = "Not a PLY file (missing 'ply' magic)."; + return false; + } + + asciiFormat = true; + littleEndian = true; + vertexCount = 0; + vertexProperties.clear(); + QString currentElement; + + while (true) { + if (!readHeaderLine(device, line, errorText)) { + return false; + } + + const QString lower = line.toLower(); + if (lower == "end_header") { + break; + } + + if (lower.startsWith("format ")) { + if (lower.contains("ascii")) { + asciiFormat = true; + } else if (lower.contains("binary_little_endian")) { + asciiFormat = false; + littleEndian = true; + } else if (lower.contains("binary_big_endian")) { + asciiFormat = false; + littleEndian = false; + } else { + errorText = QString("Unsupported PLY format: %1").arg(line); + return false; + } + continue; + } + + if (lower.startsWith("element ")) { + const QStringList parts = line.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + if (parts.size() < 3) { + errorText = QString("Invalid PLY element line: %1").arg(line); + return false; + } + currentElement = parts[1].toLower(); + if (currentElement == "vertex") { + bool ok = false; + vertexCount = parts[2].toInt(&ok); + if (!ok || vertexCount < 0) { + errorText = QString("Invalid vertex count in PLY: %1").arg(line); + return false; + } + vertexProperties.clear(); + } + continue; + } + + if (lower.startsWith("property ")) { + if (currentElement != "vertex") { + continue; + } + + const QStringList parts = line.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + if (parts.size() < 3) { + errorText = QString("Invalid PLY property line: %1").arg(line); + return false; + } + + PlyProperty prop; + if (parts[1].toLower() == "list") { + if (parts.size() < 5) { + errorText = QString("Invalid PLY list property line: %1").arg(line); + return false; + } + prop.isList = true; + prop.countType = parts[2].toLower(); + prop.itemType = parts[3].toLower(); + prop.name = parts[4]; + } else { + prop.type = parts[1].toLower(); + prop.name = parts[2]; + if (plyScalarTypeSize(prop.type) == 0) { + errorText = QString("Unsupported PLY property type: %1").arg(parts[1]); + return false; + } + } + vertexProperties.push_back(prop); + } + } + + if (vertexCount <= 0) { + errorText = "PLY file has no vertex element."; + return false; + } + + int xIndex = -1; + int yIndex = -1; + int zIndex = -1; + for (int i = 0; i < vertexProperties.size(); ++i) { + const QString name = vertexProperties[i].name.toLower(); + if (name == "x") { + xIndex = i; + } else if (name == "y") { + yIndex = i; + } else if (name == "z") { + zIndex = i; + } + } + if (xIndex < 0 || yIndex < 0 || zIndex < 0) { + errorText = "PLY vertex element must define x, y and z properties."; + return false; + } + + return true; +} + +double readBinaryScalar(const QByteArray &data, int offset, const QString &type, bool littleEndian) +{ + const QString t = type.toLower(); + auto readU16 = [&](int off) -> quint16 { + quint16 value = 0; + if (littleEndian) { + value = static_cast(static_cast(data[off])) + | (static_cast(static_cast(data[off + 1])) << 8); + } else { + value = (static_cast(static_cast(data[off])) << 8) + | static_cast(static_cast(data[off + 1])); + } + return value; + }; + auto readU32 = [&](int off) -> quint32 { + quint32 value = 0; + if (littleEndian) { + value = static_cast(static_cast(data[off])) + | (static_cast(static_cast(data[off + 1])) << 8) + | (static_cast(static_cast(data[off + 2])) << 16) + | (static_cast(static_cast(data[off + 3])) << 24); + } else { + value = (static_cast(static_cast(data[off])) << 24) + | (static_cast(static_cast(data[off + 1])) << 16) + | (static_cast(static_cast(data[off + 2])) << 8) + | static_cast(static_cast(data[off + 3])); + } + return value; + }; + + if (t == "char" || t == "int8") { + return static_cast(static_cast(data[offset])); + } + if (t == "uchar" || t == "uint8") { + return static_cast(static_cast(data[offset])); + } + if (t == "short" || t == "int16") { + return static_cast(static_cast(readU16(offset))); + } + if (t == "ushort" || t == "uint16") { + return static_cast(readU16(offset)); + } + if (t == "int" || t == "int32") { + return static_cast(static_cast(readU32(offset))); + } + if (t == "uint" || t == "uint32") { + return static_cast(readU32(offset)); + } + if (t == "float" || t == "float32") { + const quint32 bits = readU32(offset); + float value = 0.0f; + static_assert(sizeof(float) == sizeof(quint32), "float size mismatch"); + std::memcpy(&value, &bits, sizeof(float)); + return static_cast(value); + } + if (t == "double" || t == "float64") { + quint64 bits = 0; + if (littleEndian) { + for (int i = 0; i < 8; ++i) { + bits |= static_cast(static_cast(data[offset + i])) << (8 * i); + } + } else { + for (int i = 0; i < 8; ++i) { + bits |= static_cast(static_cast(data[offset + i])) << (8 * (7 - i)); + } + } + double value = 0.0; + static_assert(sizeof(double) == sizeof(quint64), "double size mismatch"); + std::memcpy(&value, &bits, sizeof(double)); + return value; + } + return 0.0; +} + +int binaryPropertySize(const PlyProperty &prop) +{ + if (prop.isList) { + return 0; + } + return plyScalarTypeSize(prop.type); +} + +bool readAsciiVertexValues( + const QString &line, + const QVector &vertexProperties, + QVector &values, + QString &errorText) +{ + const QStringList tokens = line.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + int tokenIndex = 0; + values.clear(); + values.reserve(vertexProperties.size()); + + for (const PlyProperty &prop : vertexProperties) { + if (prop.isList) { + if (tokenIndex >= tokens.size()) { + errorText = "PLY ASCII vertex line is too short."; + return false; + } + bool ok = false; + const int count = tokens[tokenIndex].toInt(&ok); + if (!ok || count < 0) { + errorText = "Invalid PLY ASCII list property count."; + return false; + } + ++tokenIndex; + if (tokenIndex + count > tokens.size()) { + errorText = "PLY ASCII vertex line is too short for list property."; + return false; + } + tokenIndex += count; + values.push_back(0.0); + continue; + } + + if (tokenIndex >= tokens.size()) { + errorText = "PLY ASCII vertex line is too short."; + return false; + } + bool ok = false; + const double value = tokens[tokenIndex].toDouble(&ok); + if (!ok) { + errorText = "Failed to parse PLY ASCII vertex value."; + return false; + } + values.push_back(value); + ++tokenIndex; + } + + return true; +} + +bool readBinaryVertexValues( + QIODevice &device, + const QVector &vertexProperties, + bool littleEndian, + QVector &values, + QString &errorText) +{ + int vertexByteSize = 0; + for (const PlyProperty &prop : vertexProperties) { + if (prop.isList) { + errorText = "PLY binary vertex list properties are not supported."; + return false; + } + vertexByteSize += binaryPropertySize(prop); + } + + const QByteArray raw = device.read(vertexByteSize); + if (raw.size() != vertexByteSize) { + errorText = "Unexpected end of PLY binary vertex data."; + return false; + } + + values.clear(); + values.reserve(vertexProperties.size()); + int offset = 0; + for (const PlyProperty &prop : vertexProperties) { + values.push_back(readBinaryScalar(raw, offset, prop.type, littleEndian)); + offset += binaryPropertySize(prop); + } + return true; +} + +bool loadAsciiVertices( + QIODevice &device, + int vertexCount, + const QVector &vertexProperties, + QVector &points, + QString &errorText) +{ + QTextStream stream(&device); + int xIndex = -1; + int yIndex = -1; + int zIndex = -1; + for (int i = 0; i < vertexProperties.size(); ++i) { + const QString name = vertexProperties[i].name.toLower(); + if (name == "x") { + xIndex = i; + } else if (name == "y") { + yIndex = i; + } else if (name == "z") { + zIndex = i; + } + } + + points.clear(); + points.reserve(vertexCount); + QVector values; + for (int i = 0; i < vertexCount; ++i) { + QString line; + while (line.isEmpty() && !stream.atEnd()) { + line = stream.readLine().trimmed(); + } + if (line.isEmpty()) { + errorText = "Unexpected end of PLY ASCII vertex data."; + return false; + } + if (!readAsciiVertexValues(line, vertexProperties, values, errorText)) { + return false; + } + points.push_back({ + static_cast(values[xIndex]), + static_cast(values[yIndex]), + static_cast(values[zIndex])}); + } + return true; +} + +bool loadBinaryVertices( + QIODevice &device, + int vertexCount, + const QVector &vertexProperties, + bool littleEndian, + QVector &points, + QString &errorText) +{ + int xIndex = -1; + int yIndex = -1; + int zIndex = -1; + for (int i = 0; i < vertexProperties.size(); ++i) { + const QString name = vertexProperties[i].name.toLower(); + if (name == "x") { + xIndex = i; + } else if (name == "y") { + yIndex = i; + } else if (name == "z") { + zIndex = i; + } + } + + points.clear(); + points.reserve(vertexCount); + QVector values; + for (int i = 0; i < vertexCount; ++i) { + if (!readBinaryVertexValues(device, vertexProperties, littleEndian, values, errorText)) { + return false; + } + points.push_back({ + static_cast(values[xIndex]), + static_cast(values[yIndex]), + static_cast(values[zIndex])}); + } + return true; +} +} // namespace + +bool loadPointsFromPly(const QString &filePath, QVector &points, QString &errorText) +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + errorText = QString("Cannot open PLY file: %1").arg(filePath); + return false; + } + + bool asciiFormat = true; + bool littleEndian = true; + int vertexCount = 0; + QVector vertexProperties; + if (!parsePlyHeader(file, asciiFormat, littleEndian, vertexCount, vertexProperties, errorText)) { + return false; + } + + if (asciiFormat) { + if (!loadAsciiVertices(file, vertexCount, vertexProperties, points, errorText)) { + return false; + } + } else if (!loadBinaryVertices(file, vertexCount, vertexProperties, littleEndian, points, errorText)) { + return false; + } + + if (points.size() < 4) { + errorText = "Need at least 4 points."; + return false; + } + return true; +} +} // namespace sources +} // namespace adapters diff --git a/src/adapters/sources/ply_point_cloud_loader.h b/src/adapters/sources/ply_point_cloud_loader.h new file mode 100644 index 0000000..6694f77 --- /dev/null +++ b/src/adapters/sources/ply_point_cloud_loader.h @@ -0,0 +1,17 @@ +#ifndef PLY_POINT_CLOUD_LOADER_H +#define PLY_POINT_CLOUD_LOADER_H + +#include +#include + +#include "../../core/point_cloud_types.h" + +namespace adapters +{ +namespace sources +{ +bool loadPointsFromPly(const QString &filePath, QVector &points, QString &errorText); +} // namespace sources +} // namespace adapters + +#endif // PLY_POINT_CLOUD_LOADER_H diff --git a/src/cli/pipeline_cli_runner.cpp b/src/cli/pipeline_cli_runner.cpp index 56f3171..c5179ce 100644 --- a/src/cli/pipeline_cli_runner.cpp +++ b/src/cli/pipeline_cli_runner.cpp @@ -97,6 +97,29 @@ bool loadConfigFromJsonFile( return true; } +void appendGeometryToJson(const core::PipelineResult &result, QJsonObject &root) +{ + QJsonArray pointsArray; + for (const core::Point3f &point : result.frame.points) { + QJsonArray xyz; + xyz.push_back(static_cast(point.x)); + xyz.push_back(static_cast(point.y)); + xyz.push_back(static_cast(point.z)); + pointsArray.push_back(xyz); + } + root["points"] = pointsArray; + + QJsonArray trianglesArray; + for (const core::Triangle &triangle : result.triangles) { + QJsonArray indices; + indices.push_back(triangle.i0); + indices.push_back(triangle.i1); + indices.push_back(triangle.i2); + trianglesArray.push_back(indices); + } + root["triangleIndices"] = trianglesArray; +} + struct AutotuneCliConfig { QString baseConfigJsonPath; @@ -496,6 +519,7 @@ int runCliPipeline(int argc, char *argv[]) root["reconstructionMs"] = static_cast(result.stats.reconstructionMs); root["reconstruction"] = config.reconstructionPlugin; root["preprocessChain"] = config.preprocessPlugins.join(","); + appendGeometryToJson(result, root); QFile file(outputJsonPath); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { diff --git a/src/ui/mainwindow.cpp b/src/ui/mainwindow.cpp index 427cacc..521024a 100644 --- a/src/ui/mainwindow.cpp +++ b/src/ui/mainwindow.cpp @@ -582,7 +582,7 @@ void MainWindow::openPointsFile() this, "Open points file", QString(), - "Point files (*.txt *.csv *.bin);;Binary files (*.bin);;Text files (*.txt);;CSV files (*.csv);;All files (*.*)"); + "Point files (*.txt *.csv *.bin *.ply);;PLY files (*.ply);;Binary files (*.bin);;Text files (*.txt);;CSV files (*.csv);;All files (*.*)"); if (filePath.isEmpty()) { return; } diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..77814c3 --- /dev/null +++ b/web/app.js @@ -0,0 +1,236 @@ +import * as THREE from "three"; +import { OrbitControls } from "three/addons/controls/OrbitControls.js"; + +const fileInput = document.getElementById("fileInput"); +const presetSelect = document.getElementById("presetSelect"); +const runButton = document.getElementById("runButton"); +const statusEl = document.getElementById("status"); +const healthEl = document.getElementById("health"); +const metricsEl = document.getElementById("metrics"); +const metricInput = document.getElementById("metricInput"); +const metricAfter = document.getElementById("metricAfter"); +const metricTriangles = document.getElementById("metricTriangles"); +const metricReconstruction = document.getElementById("metricReconstruction"); +const metricTime = document.getElementById("metricTime"); +const viewport = document.getElementById("viewport"); + +let presets = []; +let pointCloud = null; +let meshObject = null; + +const scene = new THREE.Scene(); +scene.background = new THREE.Color(0x0b1118); + +const camera = new THREE.PerspectiveCamera(55, 1, 0.001, 100000); +camera.position.set(2.5, 2.0, 2.5); + +const renderer = new THREE.WebGLRenderer({ antialias: true }); +renderer.setPixelRatio(window.devicePixelRatio || 1); +viewport.appendChild(renderer.domElement); + +const controls = new OrbitControls(camera, renderer.domElement); +controls.enableDamping = true; + +scene.add(new THREE.AmbientLight(0xffffff, 0.65)); +const keyLight = new THREE.DirectionalLight(0xffffff, 0.9); +keyLight.position.set(4, 6, 3); +scene.add(keyLight); + +const grid = new THREE.GridHelper(10, 20, 0x31465d, 0x1d2a38); +grid.position.y = -0.001; +scene.add(grid); + +function resize() { + const width = viewport.clientWidth; + const height = viewport.clientHeight; + camera.aspect = width / Math.max(height, 1); + camera.updateProjectionMatrix(); + renderer.setSize(width, height, false); +} + +function animate() { + controls.update(); + renderer.render(scene, camera); + requestAnimationFrame(animate); +} + +function clearSceneObjects() { + if (pointCloud) { + scene.remove(pointCloud); + pointCloud.geometry.dispose(); + pointCloud.material.dispose(); + pointCloud = null; + } + if (meshObject) { + scene.remove(meshObject); + meshObject.geometry.dispose(); + meshObject.material.dispose(); + meshObject = null; + } +} + +function fitCameraToPoints(points) { + if (!points || points.length === 0) { + return; + } + + const box = new THREE.Box3(); + for (const point of points) { + box.expandByPoint(new THREE.Vector3(point[0], point[1], point[2])); + } + + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const radius = Math.max(size.x, size.y, size.z) * 0.6 || 1.0; + + controls.target.copy(center); + camera.position.copy(center.clone().add(new THREE.Vector3(radius * 1.8, radius * 1.2, radius * 1.8))); + camera.near = Math.max(radius / 1000, 0.0001); + camera.far = radius * 100; + camera.updateProjectionMatrix(); + controls.update(); +} + +function renderGeometry(points, triangleIndices) { + clearSceneObjects(); + + if (!points || points.length === 0) { + return; + } + + const positions = new Float32Array(points.length * 3); + for (let i = 0; i < points.length; i += 1) { + positions[i * 3] = points[i][0]; + positions[i * 3 + 1] = points[i][1]; + positions[i * 3 + 2] = points[i][2]; + } + + const pointsGeometry = new THREE.BufferGeometry(); + pointsGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + pointCloud = new THREE.Points( + pointsGeometry, + new THREE.PointsMaterial({ color: 0x7dd3fc, size: 0.01, sizeAttenuation: true }) + ); + scene.add(pointCloud); + + if (triangleIndices && triangleIndices.length > 0) { + const indices = new Uint32Array(triangleIndices.length * 3); + for (let i = 0; i < triangleIndices.length; i += 1) { + indices[i * 3] = triangleIndices[i][0]; + indices[i * 3 + 1] = triangleIndices[i][1]; + indices[i * 3 + 2] = triangleIndices[i][2]; + } + + const meshGeometry = new THREE.BufferGeometry(); + meshGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + meshGeometry.setIndex(new THREE.BufferAttribute(indices, 1)); + meshGeometry.computeVertexNormals(); + + meshObject = new THREE.Mesh( + meshGeometry, + new THREE.MeshStandardMaterial({ + color: 0x60a5fa, + transparent: true, + opacity: 0.55, + side: THREE.DoubleSide, + flatShading: false, + }) + ); + scene.add(meshObject); + } + + fitCameraToPoints(points); +} + +function setStatus(message, isError = false) { + statusEl.textContent = message; + statusEl.style.color = isError ? "#ffb4c0" : "#9db0c3"; +} + +function updateMetrics(result) { + metricsEl.classList.remove("hidden"); + metricInput.textContent = String(result.inputPoints ?? "-"); + metricAfter.textContent = String(result.afterPreprocess ?? "-"); + metricTriangles.textContent = String(result.triangles ?? "-"); + metricReconstruction.textContent = String(result.reconstruction ?? "-"); + metricTime.textContent = String(result.reconstructionMs ?? "-"); +} + +async function loadHealth() { + try { + const response = await fetch("/api/health"); + const data = await response.json(); + if (!response.ok) { + throw new Error("Health check failed"); + } + healthEl.textContent = data.binaryExists === "True" || data.binaryExists === true + ? "API online, pipeline binary ready" + : "API online, binary missing"; + healthEl.className = "health ok"; + } catch (error) { + healthEl.textContent = "API unavailable"; + healthEl.className = "health error"; + } +} + +async function loadPresets() { + const response = await fetch("/api/presets"); + presets = await response.json(); + + presetSelect.innerHTML = ""; + const defaultOption = document.createElement("option"); + defaultOption.value = ""; + defaultOption.textContent = "Default pipeline"; + presetSelect.appendChild(defaultOption); + + for (const preset of presets) { + const option = document.createElement("option"); + option.value = preset.id; + option.textContent = preset.title || preset.id; + presetSelect.appendChild(option); + } +} + +async function runPipeline() { + const file = fileInput.files?.[0]; + if (!file) { + setStatus("Select a point cloud file first.", true); + return; + } + + runButton.disabled = true; + setStatus("Running pipeline..."); + + const formData = new FormData(); + formData.append("file", file); + const presetId = presetSelect.value; + if (presetId) { + formData.append("preset_id", presetId); + } + + try { + const response = await fetch("/api/run", { + method: "POST", + body: formData, + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.detail || "Pipeline failed"); + } + + updateMetrics(payload); + renderGeometry(payload.points || [], payload.triangleIndices || []); + setStatus(payload.stdout || "Pipeline completed."); + } catch (error) { + setStatus(String(error.message || error), true); + } finally { + runButton.disabled = false; + } +} + +runButton.addEventListener("click", runPipeline); +window.addEventListener("resize", resize); +resize(); +animate(); +loadHealth(); +loadPresets().catch((error) => setStatus(`Failed to load presets: ${error}`, true)); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0b1b341 --- /dev/null +++ b/web/index.html @@ -0,0 +1,61 @@ + + + + + + DotsToSirface Web + + + +
+
+

DotsToSirface Web

+

PLY upload, PCL pipeline in Docker, 3D preview in browser

+
+
Checking API...
+
+ +
+
+

Pipeline

+ + + + + + +

Upload a PLY file and click Run.

+ + +
+ +
+

3D Viewer

+
+

Drag to rotate, wheel to zoom. Points and mesh are shown after pipeline run.

+
+
+ + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..539dc62 --- /dev/null +++ b/web/style.css @@ -0,0 +1,154 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: Inter, Segoe UI, Roboto, sans-serif; + background: #0f1720; + color: #e8eef5; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 20px 24px; + border-bottom: 1px solid #243447; + background: #152231; +} + +.header h1 { + margin: 0 0 4px; + font-size: 24px; +} + +.header p { + margin: 0; + color: #9db0c3; +} + +.health { + padding: 8px 12px; + border-radius: 8px; + background: #1f2f40; + font-size: 13px; +} + +.health.ok { + background: #173528; + color: #9be7b5; +} + +.health.error { + background: #3a1d24; + color: #ffb4c0; +} + +.layout { + display: grid; + grid-template-columns: 360px 1fr; + gap: 16px; + padding: 16px; + min-height: calc(100vh - 96px); +} + +.panel { + background: #152231; + border: 1px solid #243447; + border-radius: 12px; + padding: 16px; +} + +.panel h2 { + margin: 0 0 16px; + font-size: 18px; +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 14px; + font-size: 14px; +} + +.field input, +.field select, +button { + font: inherit; +} + +.field input, +.field select { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #31465d; + background: #0f1720; + color: inherit; +} + +button { + width: 100%; + padding: 12px 14px; + border: 0; + border-radius: 8px; + background: #3b82f6; + color: white; + cursor: pointer; + font-weight: 600; +} + +button:disabled { + opacity: 0.6; + cursor: wait; +} + +.status { + margin: 14px 0 0; + color: #9db0c3; + font-size: 14px; + line-height: 1.4; + white-space: pre-wrap; +} + +.metrics { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #243447; + display: grid; + gap: 8px; + font-size: 14px; +} + +.hidden { + display: none; +} + +.viewer { + display: flex; + flex-direction: column; + min-height: 70vh; +} + +#viewport { + flex: 1; + min-height: 520px; + border-radius: 10px; + overflow: hidden; + border: 1px solid #243447; + background: #0b1118; +} + +.hint { + margin: 12px 0 0; + color: #7f93a8; + font-size: 13px; +} + +@media (max-width: 960px) { + .layout { + grid-template-columns: 1fr; + } +}