Добавить 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:
2026-06-18 10:39:34 +03:00
co-authored by Cursor
parent 98afe5af5a
commit 45b2ed6e22
17 changed files with 1332 additions and 36 deletions
+2
View File
@@ -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 \
+2
View File
@@ -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 \
+34
View File
@@ -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`.
## Демо
При запуске приложение:
+191
View File
@@ -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")
+3
View File
@@ -0,0 +1,3 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
python-multipart==0.0.20
+65
View File
@@ -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"]
+14
View File
@@ -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"
}
}
+12
View File
@@ -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
View File
@@ -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 "ply_point_cloud_loader.h"
#include <QFile>
#include <QFileInfo>
#include <QRegularExpression>
@@ -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<core::Point3f> points;
const QString ext = QFileInfo(m_filePath).suffix().toLower();
if (ext == "bin") {
const QByteArray raw = file.readAll();
if (raw.size() % static_cast<int>(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<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 {
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<int>(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<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]});
}
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});
}
}
}
}
@@ -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
+24
View File
@@ -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<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
{
QString baseConfigJsonPath;
@@ -496,6 +519,7 @@ int runCliPipeline(int argc, char *argv[])
root["reconstructionMs"] = static_cast<qint64>(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)) {
+1 -1
View File
@@ -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;
}
+236
View File
@@ -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));
+61
View File
@@ -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
View File
@@ -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;
}
}