Добавить PLY-загрузку и Docker Web UI с PCL-пайплайном.
PLY читается в FilePointCloudSource, CLI отдаёт геометрию в output JSON, а Docker/FastAPI/Three.js дают веб-запуск пайплайна без конфликта libpq на хосте. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -48,6 +48,7 @@ SOURCES += \
|
|||||||
src/adapters/ros2/pointcloud2_adapter.cpp \
|
src/adapters/ros2/pointcloud2_adapter.cpp \
|
||||||
src/adapters/ros2/tf2_adapter.cpp \
|
src/adapters/ros2/tf2_adapter.cpp \
|
||||||
src/adapters/sources/file_point_cloud_source.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/adapters/sources/ros2_point_cloud_source.cpp \
|
||||||
src/core/pipeline_config.cpp \
|
src/core/pipeline_config.cpp \
|
||||||
src/core/pipeline_config_validation.cpp \
|
src/core/pipeline_config_validation.cpp \
|
||||||
@@ -75,6 +76,7 @@ HEADERS += \
|
|||||||
src/adapters/ros2/pointcloud2_adapter.h \
|
src/adapters/ros2/pointcloud2_adapter.h \
|
||||||
src/adapters/ros2/tf2_adapter.h \
|
src/adapters/ros2/tf2_adapter.h \
|
||||||
src/adapters/sources/file_point_cloud_source.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/adapters/sources/ros2_point_cloud_source.h \
|
||||||
src/core/data_source.h \
|
src/core/data_source.h \
|
||||||
src/core/point_cloud_types.h \
|
src/core/point_cloud_types.h \
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ SOURCES += \
|
|||||||
src/adapters/ros2/pointcloud2_adapter.cpp \
|
src/adapters/ros2/pointcloud2_adapter.cpp \
|
||||||
src/adapters/ros2/tf2_adapter.cpp \
|
src/adapters/ros2/tf2_adapter.cpp \
|
||||||
src/adapters/sources/file_point_cloud_source.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/adapters/sources/ros2_point_cloud_source.cpp \
|
||||||
src/core/pipeline_config.cpp \
|
src/core/pipeline_config.cpp \
|
||||||
src/core/pipeline_config_validation.cpp \
|
src/core/pipeline_config_validation.cpp \
|
||||||
@@ -70,6 +71,7 @@ HEADERS += \
|
|||||||
src/adapters/ros2/pointcloud2_adapter.h \
|
src/adapters/ros2/pointcloud2_adapter.h \
|
||||||
src/adapters/ros2/tf2_adapter.h \
|
src/adapters/ros2/tf2_adapter.h \
|
||||||
src/adapters/sources/file_point_cloud_source.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/adapters/sources/ros2_point_cloud_source.h \
|
||||||
src/core/data_source.h \
|
src/core/data_source.h \
|
||||||
src/core/point_cloud_types.h \
|
src/core/point_cloud_types.h \
|
||||||
|
|||||||
@@ -204,6 +204,40 @@ CLI печатает краткую сводку по метрикам (`input`,
|
|||||||
поэтому его можно загрузить напрямую кнопкой `Загрузить пресет`.
|
поэтому его можно загрузить напрямую кнопкой `Загрузить пресет`.
|
||||||
Дополнительно рядом сохраняется `*_best_preset.json` (чистый preset-JSON).
|
Дополнительно рядом сохраняется `*_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`.
|
||||||
|
|
||||||
## Демо
|
## Демо
|
||||||
|
|
||||||
При запуске приложение:
|
При запуске приложение:
|
||||||
|
|||||||
+191
@@ -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")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.32.1
|
||||||
|
python-multipart==0.0.20
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
docker compose up --build "$@"
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "file_point_cloud_source.h"
|
#include "file_point_cloud_source.h"
|
||||||
|
|
||||||
|
#include "ply_point_cloud_loader.h"
|
||||||
|
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
@@ -21,48 +23,54 @@ bool FilePointCloudSource::nextFrame(core::PointCloudFrame &frame, QString &erro
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
QFile file(m_filePath);
|
|
||||||
if (!file.open(QIODevice::ReadOnly)) {
|
|
||||||
errorText = QString("Cannot open file: %1").arg(m_filePath);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
QVector<core::Point3f> points;
|
QVector<core::Point3f> points;
|
||||||
const QString ext = QFileInfo(m_filePath).suffix().toLower();
|
const QString ext = QFileInfo(m_filePath).suffix().toLower();
|
||||||
if (ext == "bin") {
|
if (ext == "ply") {
|
||||||
const QByteArray raw = file.readAll();
|
if (!loadPointsFromPly(m_filePath, points, errorText)) {
|
||||||
if (raw.size() % static_cast<int>(sizeof(float) * 3) != 0) {
|
|
||||||
errorText = "Invalid .bin size.";
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const int n = raw.size() / static_cast<int>(sizeof(float) * 3);
|
|
||||||
points.reserve(n);
|
|
||||||
const float *values = reinterpret_cast<const float *>(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 {
|
} else {
|
||||||
QTextStream stream(&file);
|
QFile file(m_filePath);
|
||||||
while (!stream.atEnd()) {
|
if (!file.open(QIODevice::ReadOnly)) {
|
||||||
QString line = stream.readLine().trimmed();
|
errorText = QString("Cannot open file: %1").arg(m_filePath);
|
||||||
if (line.isEmpty() || line.startsWith('#')) {
|
return false;
|
||||||
continue;
|
}
|
||||||
|
|
||||||
|
if (ext == "bin") {
|
||||||
|
const QByteArray raw = file.readAll();
|
||||||
|
if (raw.size() % static_cast<int>(sizeof(float) * 3) != 0) {
|
||||||
|
errorText = "Invalid .bin size.";
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
line.replace(';', ' ');
|
const int n = raw.size() / static_cast<int>(sizeof(float) * 3);
|
||||||
line.replace(',', ' ');
|
points.reserve(n);
|
||||||
const QStringList parts = line.split(QRegularExpression("\\s+"), QString::SkipEmptyParts);
|
const float *values = reinterpret_cast<const float *>(raw.constData());
|
||||||
if (parts.size() < 3) {
|
for (int i = 0; i < n; ++i) {
|
||||||
continue;
|
const int k = i * 3;
|
||||||
|
points.push_back({values[k], values[k + 1], values[k + 2]});
|
||||||
}
|
}
|
||||||
bool okX = false;
|
} else {
|
||||||
bool okY = false;
|
QTextStream stream(&file);
|
||||||
bool okZ = false;
|
while (!stream.atEnd()) {
|
||||||
const float x = parts[0].toFloat(&okX);
|
QString line = stream.readLine().trimmed();
|
||||||
const float y = parts[1].toFloat(&okY);
|
if (line.isEmpty() || line.startsWith('#')) {
|
||||||
const float z = parts[2].toFloat(&okZ);
|
continue;
|
||||||
if (okX && okY && okZ) {
|
}
|
||||||
points.push_back({x, y, z});
|
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});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
#include "ply_point_cloud_loader.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QIODevice>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QTextStream>
|
||||||
|
|
||||||
|
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<PlyProperty> &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<quint16>(static_cast<quint8>(data[off]))
|
||||||
|
| (static_cast<quint16>(static_cast<quint8>(data[off + 1])) << 8);
|
||||||
|
} else {
|
||||||
|
value = (static_cast<quint16>(static_cast<quint8>(data[off])) << 8)
|
||||||
|
| static_cast<quint16>(static_cast<quint8>(data[off + 1]));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
auto readU32 = [&](int off) -> quint32 {
|
||||||
|
quint32 value = 0;
|
||||||
|
if (littleEndian) {
|
||||||
|
value = static_cast<quint32>(static_cast<quint8>(data[off]))
|
||||||
|
| (static_cast<quint32>(static_cast<quint8>(data[off + 1])) << 8)
|
||||||
|
| (static_cast<quint32>(static_cast<quint8>(data[off + 2])) << 16)
|
||||||
|
| (static_cast<quint32>(static_cast<quint8>(data[off + 3])) << 24);
|
||||||
|
} else {
|
||||||
|
value = (static_cast<quint32>(static_cast<quint8>(data[off])) << 24)
|
||||||
|
| (static_cast<quint32>(static_cast<quint8>(data[off + 1])) << 16)
|
||||||
|
| (static_cast<quint32>(static_cast<quint8>(data[off + 2])) << 8)
|
||||||
|
| static_cast<quint32>(static_cast<quint8>(data[off + 3]));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (t == "char" || t == "int8") {
|
||||||
|
return static_cast<double>(static_cast<qint8>(data[offset]));
|
||||||
|
}
|
||||||
|
if (t == "uchar" || t == "uint8") {
|
||||||
|
return static_cast<double>(static_cast<quint8>(data[offset]));
|
||||||
|
}
|
||||||
|
if (t == "short" || t == "int16") {
|
||||||
|
return static_cast<double>(static_cast<qint16>(readU16(offset)));
|
||||||
|
}
|
||||||
|
if (t == "ushort" || t == "uint16") {
|
||||||
|
return static_cast<double>(readU16(offset));
|
||||||
|
}
|
||||||
|
if (t == "int" || t == "int32") {
|
||||||
|
return static_cast<double>(static_cast<qint32>(readU32(offset)));
|
||||||
|
}
|
||||||
|
if (t == "uint" || t == "uint32") {
|
||||||
|
return static_cast<double>(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<double>(value);
|
||||||
|
}
|
||||||
|
if (t == "double" || t == "float64") {
|
||||||
|
quint64 bits = 0;
|
||||||
|
if (littleEndian) {
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
bits |= static_cast<quint64>(static_cast<quint8>(data[offset + i])) << (8 * i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
bits |= static_cast<quint64>(static_cast<quint8>(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<PlyProperty> &vertexProperties,
|
||||||
|
QVector<double> &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<PlyProperty> &vertexProperties,
|
||||||
|
bool littleEndian,
|
||||||
|
QVector<double> &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<PlyProperty> &vertexProperties,
|
||||||
|
QVector<core::Point3f> &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<double> 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<float>(values[xIndex]),
|
||||||
|
static_cast<float>(values[yIndex]),
|
||||||
|
static_cast<float>(values[zIndex])});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool loadBinaryVertices(
|
||||||
|
QIODevice &device,
|
||||||
|
int vertexCount,
|
||||||
|
const QVector<PlyProperty> &vertexProperties,
|
||||||
|
bool littleEndian,
|
||||||
|
QVector<core::Point3f> &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<double> values;
|
||||||
|
for (int i = 0; i < vertexCount; ++i) {
|
||||||
|
if (!readBinaryVertexValues(device, vertexProperties, littleEndian, values, errorText)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
points.push_back({
|
||||||
|
static_cast<float>(values[xIndex]),
|
||||||
|
static_cast<float>(values[yIndex]),
|
||||||
|
static_cast<float>(values[zIndex])});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool loadPointsFromPly(const QString &filePath, QVector<core::Point3f> &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<PlyProperty> 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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#ifndef PLY_POINT_CLOUD_LOADER_H
|
||||||
|
#define PLY_POINT_CLOUD_LOADER_H
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
#include "../../core/point_cloud_types.h"
|
||||||
|
|
||||||
|
namespace adapters
|
||||||
|
{
|
||||||
|
namespace sources
|
||||||
|
{
|
||||||
|
bool loadPointsFromPly(const QString &filePath, QVector<core::Point3f> &points, QString &errorText);
|
||||||
|
} // namespace sources
|
||||||
|
} // namespace adapters
|
||||||
|
|
||||||
|
#endif // PLY_POINT_CLOUD_LOADER_H
|
||||||
@@ -97,6 +97,29 @@ bool loadConfigFromJsonFile(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void appendGeometryToJson(const core::PipelineResult &result, QJsonObject &root)
|
||||||
|
{
|
||||||
|
QJsonArray pointsArray;
|
||||||
|
for (const core::Point3f &point : result.frame.points) {
|
||||||
|
QJsonArray xyz;
|
||||||
|
xyz.push_back(static_cast<double>(point.x));
|
||||||
|
xyz.push_back(static_cast<double>(point.y));
|
||||||
|
xyz.push_back(static_cast<double>(point.z));
|
||||||
|
pointsArray.push_back(xyz);
|
||||||
|
}
|
||||||
|
root["points"] = pointsArray;
|
||||||
|
|
||||||
|
QJsonArray trianglesArray;
|
||||||
|
for (const core::Triangle &triangle : result.triangles) {
|
||||||
|
QJsonArray indices;
|
||||||
|
indices.push_back(triangle.i0);
|
||||||
|
indices.push_back(triangle.i1);
|
||||||
|
indices.push_back(triangle.i2);
|
||||||
|
trianglesArray.push_back(indices);
|
||||||
|
}
|
||||||
|
root["triangleIndices"] = trianglesArray;
|
||||||
|
}
|
||||||
|
|
||||||
struct AutotuneCliConfig
|
struct AutotuneCliConfig
|
||||||
{
|
{
|
||||||
QString baseConfigJsonPath;
|
QString baseConfigJsonPath;
|
||||||
@@ -496,6 +519,7 @@ int runCliPipeline(int argc, char *argv[])
|
|||||||
root["reconstructionMs"] = static_cast<qint64>(result.stats.reconstructionMs);
|
root["reconstructionMs"] = static_cast<qint64>(result.stats.reconstructionMs);
|
||||||
root["reconstruction"] = config.reconstructionPlugin;
|
root["reconstruction"] = config.reconstructionPlugin;
|
||||||
root["preprocessChain"] = config.preprocessPlugins.join(",");
|
root["preprocessChain"] = config.preprocessPlugins.join(",");
|
||||||
|
appendGeometryToJson(result, root);
|
||||||
|
|
||||||
QFile file(outputJsonPath);
|
QFile file(outputJsonPath);
|
||||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||||
|
|||||||
@@ -582,7 +582,7 @@ void MainWindow::openPointsFile()
|
|||||||
this,
|
this,
|
||||||
"Open points file",
|
"Open points file",
|
||||||
QString(),
|
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()) {
|
if (filePath.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+236
@@ -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));
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DotsToSirface Web</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<div>
|
||||||
|
<h1>DotsToSirface Web</h1>
|
||||||
|
<p>PLY upload, PCL pipeline in Docker, 3D preview in browser</p>
|
||||||
|
</div>
|
||||||
|
<div id="health" class="health">Checking API...</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<section class="panel controls">
|
||||||
|
<h2>Pipeline</h2>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Point cloud (.ply)</span>
|
||||||
|
<input id="fileInput" type="file" accept=".ply,.txt,.csv,.xyz,.bin">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Preset</span>
|
||||||
|
<select id="presetSelect"></select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button id="runButton" type="button">Run pipeline</button>
|
||||||
|
<p id="status" class="status">Upload a PLY file and click Run.</p>
|
||||||
|
|
||||||
|
<div id="metrics" class="metrics hidden">
|
||||||
|
<div><strong>Input:</strong> <span id="metricInput">-</span></div>
|
||||||
|
<div><strong>After preprocess:</strong> <span id="metricAfter">-</span></div>
|
||||||
|
<div><strong>Triangles:</strong> <span id="metricTriangles">-</span></div>
|
||||||
|
<div><strong>Reconstruction:</strong> <span id="metricReconstruction">-</span></div>
|
||||||
|
<div><strong>Time:</strong> <span id="metricTime">-</span> ms</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel viewer">
|
||||||
|
<h2>3D Viewer</h2>
|
||||||
|
<div id="viewport"></div>
|
||||||
|
<p class="hint">Drag to rotate, wheel to zoom. Points and mesh are shown after pipeline run.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
|
||||||
|
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="module" src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+154
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user