feat(点云加载): 添加PLY文件格式支持并更新版本号至1.1.4

实现PLY文件加载功能,支持ASCII和二进制格式(大端/小端)
扩展文件对话框过滤器包含PLY格式
统一PLY和PCD文件的颜色处理逻辑
This commit is contained in:
杰仔 2026-04-10 20:13:04 +08:00
parent c98aa7b5d6
commit 7e887aaeee
4 changed files with 558 additions and 5 deletions

View File

@ -74,6 +74,12 @@ public:
int loadFromPcd(const std::string& fileName, PointCloudXYZ& cloud); int loadFromPcd(const std::string& fileName, PointCloudXYZ& cloud);
int loadFromPcd(const std::string& fileName, PointCloudXYZRGB& cloud); int loadFromPcd(const std::string& fileName, PointCloudXYZRGB& cloud);
/**
* @brief ply ASCII / Binary Little-Endian / Binary Big-Endian
*/
int loadFromPly(const std::string& fileName, PointCloudXYZ& cloud);
int loadFromPly(const std::string& fileName, PointCloudXYZRGB& cloud);
/** /**
* @brief * @brief
*/ */
@ -141,6 +147,48 @@ private:
bool parsePcdHeader(std::ifstream& file, PcdHeader& header); bool parsePcdHeader(std::ifstream& file, PcdHeader& header);
/**
* @brief PLY
*/
struct PlyProperty
{
std::string name; // 属性名 (x, y, z, red, green, blue, alpha, nx, ny, nz ...)
std::string type; // 数据类型 (float, double, uchar, int, short ...)
int byteSize = 0; // 字节数
bool isList = false; // 是否是 list 类型(如 face 的 vertex_indices
std::string listCountType; // list 的计数类型
std::string listValueType; // list 的值类型
};
/**
* @brief PLY
*/
struct PlyElement
{
std::string name; // 元素名 (vertex, face ...)
int count = 0; // 元素数量
std::vector<PlyProperty> properties;
};
/**
* @brief PLY
*/
struct PlyHeader
{
enum Format { ASCII, BINARY_LE, BINARY_BE };
Format format = ASCII;
std::vector<PlyElement> elements;
int vertexElementIndex = -1; // vertex 元素在 elements 中的索引
};
bool parsePlyHeader(std::ifstream& file, PlyHeader& header);
int getPlyTypeByteSize(const std::string& type);
/**
* @brief PLY list
*/
int calcPlyElementStride(const PlyElement& element);
std::string m_lastError; std::string m_lastError;
size_t m_loadedPointCount; size_t m_loadedPointCount;
int m_loadedLineCount; int m_loadedLineCount;

View File

@ -759,7 +759,7 @@ void CloudViewMainWindow::onOpenFile()
this, this,
"打开文件", "打开文件",
QString(), QString(),
"所有支持格式 (*.pcd *.txt);;PCD 文件 (*.pcd);;TXT 文件 (*.txt);;所有文件 (*.*)" "所有支持格式 (*.pcd *.ply *.txt);;PCD 文件 (*.pcd);;PLY 文件 (*.ply);;TXT 文件 (*.txt);;所有文件 (*.*)"
); );
if (fileName.isEmpty()) { if (fileName.isEmpty()) {
@ -806,8 +806,8 @@ bool CloudViewMainWindow::loadPointCloudFile(const QString& fileName)
// 根据是否有颜色选择显示方式 // 根据是否有颜色选择显示方式
bool hadColor = m_converter->lastLoadHadColor(); bool hadColor = m_converter->lastLoadHadColor();
if (ext == "pcd") { if (ext == "pcd" || ext == "ply") {
// PCD 文件始终走 XYZRGB 路径,避免被颜色轮换表染成蓝色等 // PCD/PLY 文件始终走 XYZRGB 路径,避免被颜色轮换表染成蓝色等
if (!hadColor) { if (!hadColor) {
// 无 rgb 字段:默认灰色显示 // 无 rgb 字段:默认灰色显示
for (size_t i = 0; i < rgbCloud.points.size(); ++i) { for (size_t i = 0; i < rgbCloud.points.size(); ++i) {
@ -817,7 +817,7 @@ bool CloudViewMainWindow::loadPointCloudFile(const QString& fileName)
} }
} }
m_glWidget->addPointCloud(rgbCloud, cloudName); m_glWidget->addPointCloud(rgbCloud, cloudName);
LOG_INFO("[CloudView] PCD loaded with XYZRGB path (hasRgbField=%d), points: %zu\n", LOG_INFO("[CloudView] PCD/PLY loaded with XYZRGB path (hasRgbField=%d), points: %zu\n",
m_converter->lastLoadHadColor(), rgbCloud.size()); m_converter->lastLoadHadColor(), rgbCloud.size());
} else if (hadColor) { } else if (hadColor) {
m_glWidget->addPointCloud(rgbCloud, cloudName); m_glWidget->addPointCloud(rgbCloud, cloudName);

View File

@ -445,6 +445,8 @@ int PointCloudConverter::loadFromFile(const std::string& fileName, PointCloudXYZ
return loadFromPcd(fileName, cloud); return loadFromPcd(fileName, cloud);
} else if (ext == "txt") { } else if (ext == "txt") {
return loadFromTxt(fileName, cloud); return loadFromTxt(fileName, cloud);
} else if (ext == "ply") {
return loadFromPly(fileName, cloud);
} else { } else {
m_lastError = "不支持的文件格式: " + ext; m_lastError = "不支持的文件格式: " + ext;
return -1; return -1;
@ -459,6 +461,8 @@ int PointCloudConverter::loadFromFile(const std::string& fileName, PointCloudXYZ
return loadFromPcd(fileName, cloud); return loadFromPcd(fileName, cloud);
} else if (ext == "txt") { } else if (ext == "txt") {
return loadFromTxt(fileName, cloud); return loadFromTxt(fileName, cloud);
} else if (ext == "ply") {
return loadFromPly(fileName, cloud);
} else { } else {
m_lastError = "不支持的文件格式: " + ext; m_lastError = "不支持的文件格式: " + ext;
return -1; return -1;
@ -513,6 +517,507 @@ int PointCloudConverter::saveToTxt(const std::string& fileName, const PointCloud
return 0; return 0;
} }
int PointCloudConverter::getPlyTypeByteSize(const std::string& type)
{
if (type == "char" || type == "int8") return 1;
if (type == "uchar" || type == "uint8") return 1;
if (type == "short" || type == "int16") return 2;
if (type == "ushort" || type == "uint16") return 2;
if (type == "int" || type == "int32") return 4;
if (type == "uint" || type == "uint32") return 4;
if (type == "float" || type == "float32") return 4;
if (type == "double" || type == "float64") return 8;
return 0;
}
int PointCloudConverter::calcPlyElementStride(const PlyElement& element)
{
int stride = 0;
for (const auto& prop : element.properties) {
if (prop.isList) return -1; // list 类型无法计算固定 stride
stride += prop.byteSize;
}
return stride;
}
bool PointCloudConverter::parsePlyHeader(std::ifstream& file, PlyHeader& header)
{
std::string line;
// 第一行必须是 "ply"
if (!std::getline(file, line)) return false;
// 去除行尾可能的 \r
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line != "ply") return false;
PlyElement* currentElement = nullptr;
while (std::getline(file, line)) {
// 去除行尾 \r
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty()) continue;
std::istringstream iss(line);
std::string keyword;
iss >> keyword;
if (keyword == "format") {
std::string fmt;
iss >> fmt;
if (fmt == "ascii") header.format = PlyHeader::ASCII;
else if (fmt == "binary_little_endian") header.format = PlyHeader::BINARY_LE;
else if (fmt == "binary_big_endian") header.format = PlyHeader::BINARY_BE;
else return false;
} else if (keyword == "comment" || keyword == "obj_info") {
// 跳过注释
} else if (keyword == "element") {
PlyElement elem;
iss >> elem.name >> elem.count;
header.elements.push_back(elem);
currentElement = &header.elements.back();
if (elem.name == "vertex") {
header.vertexElementIndex = static_cast<int>(header.elements.size()) - 1;
}
} else if (keyword == "property") {
if (!currentElement) return false;
std::string secondToken;
iss >> secondToken;
PlyProperty prop;
if (secondToken == "list") {
// property list <count_type> <value_type> <name>
prop.isList = true;
iss >> prop.listCountType >> prop.listValueType >> prop.name;
prop.byteSize = 0; // list 类型没有固定大小
} else {
// property <type> <name>
prop.type = secondToken;
iss >> prop.name;
prop.byteSize = getPlyTypeByteSize(prop.type);
prop.isList = false;
}
currentElement->properties.push_back(prop);
} else if (keyword == "end_header") {
return true;
}
}
return false;
}
/**
* @brief PLY float
*/
static float readPlyValueAsFloat(const char* data, const std::string& type, bool swapEndian)
{
if (type == "float" || type == "float32") {
float v;
memcpy(&v, data, sizeof(float));
if (swapEndian) {
char* p = reinterpret_cast<char*>(&v);
std::swap(p[0], p[3]);
std::swap(p[1], p[2]);
}
return v;
} else if (type == "double" || type == "float64") {
double v;
memcpy(&v, data, sizeof(double));
if (swapEndian) {
char* p = reinterpret_cast<char*>(&v);
std::swap(p[0], p[7]);
std::swap(p[1], p[6]);
std::swap(p[2], p[5]);
std::swap(p[3], p[4]);
}
return static_cast<float>(v);
} else if (type == "int" || type == "int32") {
int32_t v;
memcpy(&v, data, sizeof(int32_t));
if (swapEndian) {
char* p = reinterpret_cast<char*>(&v);
std::swap(p[0], p[3]);
std::swap(p[1], p[2]);
}
return static_cast<float>(v);
} else if (type == "uint" || type == "uint32") {
uint32_t v;
memcpy(&v, data, sizeof(uint32_t));
if (swapEndian) {
char* p = reinterpret_cast<char*>(&v);
std::swap(p[0], p[3]);
std::swap(p[1], p[2]);
}
return static_cast<float>(v);
} else if (type == "short" || type == "int16") {
int16_t v;
memcpy(&v, data, sizeof(int16_t));
if (swapEndian) {
char* p = reinterpret_cast<char*>(&v);
std::swap(p[0], p[1]);
}
return static_cast<float>(v);
} else if (type == "ushort" || type == "uint16") {
uint16_t v;
memcpy(&v, data, sizeof(uint16_t));
if (swapEndian) {
char* p = reinterpret_cast<char*>(&v);
std::swap(p[0], p[1]);
}
return static_cast<float>(v);
} else if (type == "uchar" || type == "uint8") {
uint8_t v;
memcpy(&v, data, sizeof(uint8_t));
return static_cast<float>(v);
} else if (type == "char" || type == "int8") {
int8_t v;
memcpy(&v, data, sizeof(int8_t));
return static_cast<float>(v);
}
return 0.0f;
}
/**
* @brief PLY uint8_t
*/
static uint8_t readPlyValueAsUint8(const char* data, const std::string& type, bool swapEndian)
{
float v = readPlyValueAsFloat(data, type, swapEndian);
// 如果是 float/double 类型的颜色0~1范围需要映射到 0~255
if (type == "float" || type == "float32" || type == "double" || type == "float64") {
if (v >= 0.0f && v <= 1.0f) {
return static_cast<uint8_t>(v * 255.0f + 0.5f);
}
}
// 截断到 0~255
if (v < 0.0f) return 0;
if (v > 255.0f) return 255;
return static_cast<uint8_t>(v);
}
int PointCloudConverter::loadFromPly(const std::string& fileName, PointCloudXYZ& cloud)
{
std::ifstream file(fileName, std::ios::binary);
if (!file.is_open()) {
m_lastError = "无法打开文件: " + fileName;
return -1;
}
PlyHeader header;
if (!parsePlyHeader(file, header)) {
m_lastError = "无法解析 PLY 文件头";
return -1;
}
if (header.vertexElementIndex < 0) {
m_lastError = "PLY 文件中未找到 vertex 元素";
return -1;
}
const PlyElement& vertexElem = header.elements[header.vertexElementIndex];
int numPoints = vertexElem.count;
// 查找 x, y, z 属性索引
int xIdx = -1, yIdx = -1, zIdx = -1;
for (size_t i = 0; i < vertexElem.properties.size(); ++i) {
const std::string& name = vertexElem.properties[i].name;
if (name == "x") xIdx = static_cast<int>(i);
else if (name == "y") yIdx = static_cast<int>(i);
else if (name == "z") zIdx = static_cast<int>(i);
}
if (xIdx < 0 || yIdx < 0 || zIdx < 0) {
m_lastError = "PLY 文件缺少 x, y, z 属性";
return -1;
}
LOG_INFO("[CloudView] PLY header: vertex count=%d, format=%d\n", numPoints, header.format);
cloud.clear();
cloud.reserve(numPoints);
bool swapEndian = (header.format == PlyHeader::BINARY_BE);
if (header.format == PlyHeader::ASCII) {
// 跳过 vertex 之前的元素
for (int ei = 0; ei < header.vertexElementIndex; ++ei) {
const PlyElement& elem = header.elements[ei];
for (int j = 0; j < elem.count; ++j) {
std::string skipLine;
if (!std::getline(file, skipLine)) {
m_lastError = "PLY 文件数据不完整(跳过元素时)";
return -1;
}
}
}
// 读取 vertex 数据
std::string line;
for (int i = 0; i < numPoints && std::getline(file, line); ++i) {
// 去除 \r
if (!line.empty() && line.back() == '\r') line.pop_back();
std::istringstream iss(line);
std::vector<float> values;
float val;
while (iss >> val) {
values.push_back(val);
}
if (static_cast<size_t>(xIdx) < values.size() &&
static_cast<size_t>(yIdx) < values.size() &&
static_cast<size_t>(zIdx) < values.size()) {
Point3D pt(values[xIdx], values[yIdx], values[zIdx]);
if (std::isfinite(pt.x) && std::isfinite(pt.y) && std::isfinite(pt.z)) {
cloud.push_back(pt);
}
}
}
} else {
// Binary 格式Little-Endian 或 Big-Endian
// 跳过 vertex 之前的元素
for (int ei = 0; ei < header.vertexElementIndex; ++ei) {
const PlyElement& elem = header.elements[ei];
int stride = calcPlyElementStride(elem);
if (stride > 0) {
// 固定大小元素,直接跳过
file.seekg(static_cast<std::streamoff>(stride) * elem.count, std::ios::cur);
} else {
// 含 list 类型,需要逐条跳过
for (int j = 0; j < elem.count; ++j) {
for (const auto& prop : elem.properties) {
if (prop.isList) {
int countSize = getPlyTypeByteSize(prop.listCountType);
char countBuf[8] = {};
file.read(countBuf, countSize);
// 读取 list 计数值
uint32_t listCount = 0;
if (countSize == 1) listCount = static_cast<uint8_t>(countBuf[0]);
else if (countSize == 2) { uint16_t v; memcpy(&v, countBuf, 2); listCount = v; }
else if (countSize == 4) { uint32_t v; memcpy(&v, countBuf, 4); listCount = v; }
int valueSize = getPlyTypeByteSize(prop.listValueType);
file.seekg(static_cast<std::streamoff>(valueSize) * listCount, std::ios::cur);
} else {
file.seekg(prop.byteSize, std::ios::cur);
}
}
}
}
}
// 计算 vertex 的 stride
int vertexStride = calcPlyElementStride(vertexElem);
if (vertexStride <= 0) {
m_lastError = "PLY vertex 元素包含 list 属性,不支持";
return -1;
}
// 计算各属性在 vertex 记录中的偏移
std::vector<int> offsets(vertexElem.properties.size(), 0);
int offset = 0;
for (size_t i = 0; i < vertexElem.properties.size(); ++i) {
offsets[i] = offset;
offset += vertexElem.properties[i].byteSize;
}
// 读取所有 vertex 数据
std::vector<char> buffer(static_cast<size_t>(vertexStride) * numPoints);
file.read(buffer.data(), buffer.size());
if (!file) {
m_lastError = "PLY 文件数据不完整";
return -1;
}
for (int i = 0; i < numPoints; ++i) {
const char* record = buffer.data() + static_cast<size_t>(i) * vertexStride;
float x = readPlyValueAsFloat(record + offsets[xIdx], vertexElem.properties[xIdx].type, swapEndian);
float y = readPlyValueAsFloat(record + offsets[yIdx], vertexElem.properties[yIdx].type, swapEndian);
float z = readPlyValueAsFloat(record + offsets[zIdx], vertexElem.properties[zIdx].type, swapEndian);
if (std::isfinite(x) && std::isfinite(y) && std::isfinite(z)) {
cloud.push_back(Point3D(x, y, z));
}
}
}
LOG_INFO("[CloudView] Loaded %zu points from PLY\n", cloud.size());
m_loadedPointCount = cloud.size();
m_loadedLineCount = 0; // PLY 文件没有线信息
return 0;
}
int PointCloudConverter::loadFromPly(const std::string& fileName, PointCloudXYZRGB& cloud)
{
std::ifstream file(fileName, std::ios::binary);
if (!file.is_open()) {
m_lastError = "无法打开文件: " + fileName;
return -1;
}
PlyHeader header;
if (!parsePlyHeader(file, header)) {
m_lastError = "无法解析 PLY 文件头";
return -1;
}
if (header.vertexElementIndex < 0) {
m_lastError = "PLY 文件中未找到 vertex 元素";
return -1;
}
const PlyElement& vertexElem = header.elements[header.vertexElementIndex];
int numPoints = vertexElem.count;
// 查找属性索引
int xIdx = -1, yIdx = -1, zIdx = -1;
int rIdx = -1, gIdx = -1, bIdx = -1;
for (size_t i = 0; i < vertexElem.properties.size(); ++i) {
const std::string& name = vertexElem.properties[i].name;
if (name == "x") xIdx = static_cast<int>(i);
else if (name == "y") yIdx = static_cast<int>(i);
else if (name == "z") zIdx = static_cast<int>(i);
else if (name == "red" || name == "r") rIdx = static_cast<int>(i);
else if (name == "green" || name == "g") gIdx = static_cast<int>(i);
else if (name == "blue" || name == "b") bIdx = static_cast<int>(i);
}
if (xIdx < 0 || yIdx < 0 || zIdx < 0) {
m_lastError = "PLY 文件缺少 x, y, z 属性";
return -1;
}
bool hasColor = (rIdx >= 0 && gIdx >= 0 && bIdx >= 0);
LOG_INFO("[CloudView] PLY header(XYZRGB): vertex count=%d, format=%d, hasColor=%d\n",
numPoints, header.format, hasColor);
cloud.clear();
cloud.reserve(numPoints);
bool swapEndian = (header.format == PlyHeader::BINARY_BE);
if (header.format == PlyHeader::ASCII) {
// 跳过 vertex 之前的元素
for (int ei = 0; ei < header.vertexElementIndex; ++ei) {
const PlyElement& elem = header.elements[ei];
for (int j = 0; j < elem.count; ++j) {
std::string skipLine;
if (!std::getline(file, skipLine)) {
m_lastError = "PLY 文件数据不完整(跳过元素时)";
return -1;
}
}
}
// 读取 vertex 数据
std::string line;
for (int i = 0; i < numPoints && std::getline(file, line); ++i) {
if (!line.empty() && line.back() == '\r') line.pop_back();
std::istringstream iss(line);
std::vector<float> values;
float val;
while (iss >> val) {
values.push_back(val);
}
if (static_cast<size_t>(xIdx) < values.size() &&
static_cast<size_t>(yIdx) < values.size() &&
static_cast<size_t>(zIdx) < values.size()) {
Point3DRGB pt;
pt.x = values[xIdx];
pt.y = values[yIdx];
pt.z = values[zIdx];
if (hasColor &&
static_cast<size_t>(rIdx) < values.size() &&
static_cast<size_t>(gIdx) < values.size() &&
static_cast<size_t>(bIdx) < values.size()) {
pt.r = static_cast<uint8_t>(std::min(255.0f, std::max(0.0f, values[rIdx])));
pt.g = static_cast<uint8_t>(std::min(255.0f, std::max(0.0f, values[gIdx])));
pt.b = static_cast<uint8_t>(std::min(255.0f, std::max(0.0f, values[bIdx])));
}
if (std::isfinite(pt.x) && std::isfinite(pt.y) && std::isfinite(pt.z)) {
cloud.push_back(pt);
}
}
}
} else {
// Binary 格式
// 跳过 vertex 之前的元素
for (int ei = 0; ei < header.vertexElementIndex; ++ei) {
const PlyElement& elem = header.elements[ei];
int stride = calcPlyElementStride(elem);
if (stride > 0) {
file.seekg(static_cast<std::streamoff>(stride) * elem.count, std::ios::cur);
} else {
for (int j = 0; j < elem.count; ++j) {
for (const auto& prop : elem.properties) {
if (prop.isList) {
int countSize = getPlyTypeByteSize(prop.listCountType);
char countBuf[8] = {};
file.read(countBuf, countSize);
uint32_t listCount = 0;
if (countSize == 1) listCount = static_cast<uint8_t>(countBuf[0]);
else if (countSize == 2) { uint16_t v; memcpy(&v, countBuf, 2); listCount = v; }
else if (countSize == 4) { uint32_t v; memcpy(&v, countBuf, 4); listCount = v; }
int valueSize = getPlyTypeByteSize(prop.listValueType);
file.seekg(static_cast<std::streamoff>(valueSize) * listCount, std::ios::cur);
} else {
file.seekg(prop.byteSize, std::ios::cur);
}
}
}
}
}
// 计算 vertex 的 stride
int vertexStride = calcPlyElementStride(vertexElem);
if (vertexStride <= 0) {
m_lastError = "PLY vertex 元素包含 list 属性,不支持";
return -1;
}
// 计算各属性偏移
std::vector<int> offsets(vertexElem.properties.size(), 0);
int offset = 0;
for (size_t i = 0; i < vertexElem.properties.size(); ++i) {
offsets[i] = offset;
offset += vertexElem.properties[i].byteSize;
}
// 读取所有 vertex 数据
std::vector<char> buffer(static_cast<size_t>(vertexStride) * numPoints);
file.read(buffer.data(), buffer.size());
if (!file) {
m_lastError = "PLY 文件数据不完整";
return -1;
}
for (int i = 0; i < numPoints; ++i) {
const char* record = buffer.data() + static_cast<size_t>(i) * vertexStride;
Point3DRGB pt;
pt.x = readPlyValueAsFloat(record + offsets[xIdx], vertexElem.properties[xIdx].type, swapEndian);
pt.y = readPlyValueAsFloat(record + offsets[yIdx], vertexElem.properties[yIdx].type, swapEndian);
pt.z = readPlyValueAsFloat(record + offsets[zIdx], vertexElem.properties[zIdx].type, swapEndian);
if (hasColor) {
pt.r = readPlyValueAsUint8(record + offsets[rIdx], vertexElem.properties[rIdx].type, swapEndian);
pt.g = readPlyValueAsUint8(record + offsets[gIdx], vertexElem.properties[gIdx].type, swapEndian);
pt.b = readPlyValueAsUint8(record + offsets[bIdx], vertexElem.properties[bIdx].type, swapEndian);
}
if (std::isfinite(pt.x) && std::isfinite(pt.y) && std::isfinite(pt.z)) {
cloud.push_back(pt);
}
}
}
LOG_INFO("[CloudView] Loaded %zu points from PLY (hasColor=%d)\n", cloud.size(), hasColor);
m_loadedPointCount = cloud.size();
m_loadedLineCount = 0; // PLY 文件没有线信息
m_lastLoadHadColor = hasColor;
return 0;
}
int PointCloudConverter::rotateCloud(const PointCloudXYZ& cloud, PointCloudXYZ& rotatedCloud, int PointCloudConverter::rotateCloud(const PointCloudXYZ& cloud, PointCloudXYZ& rotatedCloud,
int lineNum, int linePtNum, int& newLineNum, int& newLinePtNum) int lineNum, int linePtNum, int& newLineNum, int& newLinePtNum)
{ {

View File

@ -3,7 +3,7 @@
#include <QIcon> #include <QIcon>
#include "CloudViewMainWindow.h" #include "CloudViewMainWindow.h"
#define APP_VERSION "1.1.3" #define APP_VERSION "1.1.4"
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {