Добавить 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/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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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 "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
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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