Three.js中PCDLoader.js加载点云原理分析
1.源码
既然想知道原理,第一步必须是翻看PCDLoader.js的源码!
2.源码分析
1.代码结构
代码经过精简,大概是下面这么个结构。
// 导入需要使用的 THREE.js 类和方法
import {
BufferGeometry,
Color,
FileLoader,
Float32BufferAttribute,
Int32BufferAttribute,
Loader,
Points,
PointsMaterial
} from 'three';
// PCDLoader 类继承了 THREE.js 的 Loader 类,
// 用于加载 PCD (Point Cloud Data) 格式的文件。
class PCDLoader extends Loader {
// 构造函数,用于创建 PCDLoader 类的实例对象
constructor(manager) { }
// 加载方法
load(url, onLoad, onProgress, onError) { }
// 解析方法,解析 PCD 格式数据
parse(data) {
// 解压数据
function decompressLZF(inData, outLength) { }
// 解析头信息
function parseHeader(data) { }
// 解析点信息
// ...
}
}
// 导出 PCDLoader 类
export { PCDLoader };
2.代码具体分析
调用路径:
new PCDLoader()
=>loader.load
=>源码里的load方法
=>onLoad(scope.parse(data))
=>parse()
=>parseHeader()
=>判断解析类型为 ASCII 的数据代码块
=>判断解析类型为 二进制压缩格式 的数据
=>上一步解析的时候调用了decompressLZF方法用于解压缩
=>判断解析类型为 二进制压缩格式 的数据
=>构建点云对象并返回代码块
当你使用PCDLoader.js加载pcd点云文件时:
import { PCDLoader } from 'three/examples/jsm/loaders/PCDLoader.js'
// 初始化PCDLoader并加载.pcd文件
const loader = new PCDLoader()
loader.load(url, function (points) {
scene.add(points)
})
上面的代码首先是执行 const loader = new PCDLoader()
,创建了 PCDLoader 类的实例对象,然后loader.load
这个就到源码里的load方法了:
// 加载方法
load(url, onLoad, onProgress, onError) {
const scope = this;
// 创建文件加载器对象
const loader = new FileLoader(scope.manager);
// 设置各种参数
loader.setPath(scope.path);
loader.setResponseType('arraybuffer');
loader.setRequestHeader(scope.requestHeader);
loader.setWithCredentials(scope.withCredentials);
// 开始加载文件
loader.load(url, function (data) {
try {
// 解析数据
onLoad(scope.parse(data));
} catch (e) {
// 错误处理
if (onError) {
onError(e);
} else {
console.error(e);
}
scope.manager.itemError(url);
}
}, onProgress, onError);
}
然后上面的 onLoad(scope.parse(data))
这行就调用了 parse()
,而 onLoad
是回调:
parse中先执行的是下面这段代码:
// 使用 TextDecoder 解码数据流,用于将二进制数据转换为文本格式。
const textData = new TextDecoder().decode(data);
// 解析文件头(Header),这通常是 ASCII 格式的文本,
// 用于描述点云数据的一些基础信息
const PCDheader = parseHeader(textData);
// 初始化点云的位置(coordinates)数组
const position = [];
// 初始化点云的法线(normals)数组
const normal = [];
// 初始化点云的颜色(colors)数组
const color = [];
// 初始化点云的强度(intensity)数组
const intensity = [];
// 初始化点云的标签(labels)数组
const label = [];
// 初始化颜色对象
const c = new Color();
上面的代码中我们注意到 parseHeader(textData)
这行代码是调用了 parseHeader
方法的:
// 定义 parseHeader 函数,接受点云数据(通常为文本)作为参数
function parseHeader(data) {
// 初始化一个空对象,用于存储解析后的头部信息
const PCDheader = {};
// 使用正则表达式找到“DATA”关键字出现的位置
const result1 = data.search(/[\r\n]DATA\s(\S*)\s/i);
// 从找到的位置开始,提取与“DATA”关键字关联的值
const result2 = /[\r\n]DATA\s(\S*)\s/i.exec(data.slice(result1 - 1));
// 存储与“DATA”关联的值
PCDheader.data = result2[1];
// 存储头部信息的总长度
PCDheader.headerLen = result2[0].length + result1;
// 截取并存储整个头部的字符串信息
PCDheader.str = data.slice(0, PCDheader.headerLen);
// 删除注释(以 '#' 开头的行)
PCDheader.str = PCDheader.str.replace(/#.*/gi, '');
// 使用正则表达式解析各种头部字段,并将它们存储在对象中
// 解析版本、字段、大小、类型、数量、宽度、高度、视点和点数
PCDheader.version = /VERSION (.*)/i.exec(PCDheader.str);
PCDheader.fields = /FIELDS (.*)/i.exec(PCDheader.str);
PCDheader.size = /SIZE (.*)/i.exec(PCDheader.str);
PCDheader.type = /TYPE (.*)/i.exec(PCDheader.str);
PCDheader.count = /COUNT (.*)/i.exec(PCDheader.str);
PCDheader.width = /WIDTH (.*)/i.exec(PCDheader.str);
PCDheader.height = /HEIGHT (.*)/i.exec(PCDheader.str);
PCDheader.viewpoint = /VIEWPOINT (.*)/i.exec(PCDheader.str);
PCDheader.points = /POINTS (.*)/i.exec(PCDheader.str);
// 根据解析结果,进一步处理和存储字段值
if (PCDheader.version !== null)
PCDheader.version = parseFloat(PCDheader.version[1]);
// 字段信息变成数组
PCDheader.fields = (PCDheader.fields !== null) ? PCDheader.fields[1].split(' ') : [];
// 类型信息变成数组
if (PCDheader.type !== null)
PCDheader.type = PCDheader.type[1].split(' ');
// 宽度和高度转换为整数
if (PCDheader.width !== null)
PCDheader.width = parseInt(PCDheader.width[1]);
if (PCDheader.height !== null)
PCDheader.height = parseInt(PCDheader.height[1]);
// 存储视点信息
if (PCDheader.viewpoint !== null)
PCDheader.viewpoint = PCDheader.viewpoint[1];
// 点数转换为整数
if (PCDheader.points !== null)
PCDheader.points = parseInt(PCDheader.points[1], 10);
// 如果点数是 null,则计算点数(宽度 * 高度)
if (PCDheader.points === null)
PCDheader.points = PCDheader.width * PCDheader.height;
// 处理“SIZE”和“COUNT”字段,将字符串信息转换为整数数组
if (PCDheader.size !== null) {
PCDheader.size = PCDheader.size[1].split(' ').map(function (x) {
return parseInt(x, 10);
});
}
if (PCDheader.count !== null) {
PCDheader.count = PCDheader.count[1].split(' ').map(function (x) {
return parseInt(x, 10);
});
} else {
PCDheader.count = [];
for (let i = 0, l = PCDheader.fields.length; i < l; i++) {
PCDheader.count.push(1);
}
}
// 初始化一个偏移对象,用于存储每个字段在数据中的偏移位置
PCDheader.offset = {};
// 计算偏移和行大小(仅用于二进制数据)
let sizeSum = 0;
for (let i = 0, l = PCDheader.fields.length; i < l; i++) {
if (PCDheader.data === 'ascii') {
PCDheader.offset[PCDheader.fields[i]] = i;
} else {
PCDheader.offset[PCDheader.fields[i]] = sizeSum;
sizeSum += PCDheader.size[i] * PCDheader.count[i];
}
}
// 仅用于二进制数据:存储一行数据的总字节数
PCDheader.rowSize = sizeSum;
// 返回解析后的头部信息对象
return PCDheader;
}
继续往下走解析类型为 ASCII 的数据:
// 解析类型为 ASCII 的数据
if (PCDheader.data === 'ascii') {
// 获取字段偏移信息
const offset = PCDheader.offset;
// 去掉头部信息,只保留点云数据
const pcdData = textData.slice(PCDheader.headerLen);
// 按行切割数据
const lines = pcdData.split('\n');
// 遍历每一行数据
for (let i = 0, l = lines.length; i < l; i++) {
// 跳过空行
if (lines[i] === '') continue;
// 分割每一行的元素
const line = lines[i].split(' ');
// 如果存在 x, y, z 偏移,解析并存储位置信息
if (offset.x !== undefined) {
position.push(parseFloat(line[offset.x]));
position.push(parseFloat(line[offset.y]));
position.push(parseFloat(line[offset.z]));
}
// 如果存在 RGB 偏移,解析并存储颜色信息
if (offset.rgb !== undefined) {
// 查找 RGB 字段的类型
const rgb_field_index = PCDheader.fields.findIndex((field) => field === 'rgb');
const rgb_type = PCDheader.type[rgb_field_index];
// 解析 RGB 值
const float = parseFloat(line[offset.rgb]);
let rgb = float;
// 如果 RGB 类型为 'F'(浮点数),则将其转换为整数
if (rgb_type === 'F') {
const farr = new Float32Array(1);
farr[0] = float;
rgb = new Int32Array(farr.buffer)[0];
}
// 从 RGB 整数中提取 R, G, B 值,并转换为 [0,1] 范围
const r = ((rgb >> 16) & 0x0000ff) / 255;
const g = ((rgb >> 8) & 0x0000ff) / 255;
const b = ((rgb >> 0) & 0x0000ff) / 255;
// 转换颜色并存储
c.set(r, g, b).convertSRGBToLinear();
color.push(c.r, c.g, c.b);
}
// 如果存在法线信息,解析并存储
if (offset.normal_x !== undefined) {
normal.push(parseFloat(line[offset.normal_x]));
normal.push(parseFloat(line[offset.normal_y]));
normal.push(parseFloat(line[offset.normal_z]));
}
// 如果存在光照强度信息,解析并存储
if (offset.intensity !== undefined) {
intensity.push(parseFloat(line[offset.intensity]));
}
// 如果存在标签信息,解析并存储
if (offset.label !== undefined) {
label.push(parseInt(line[offset.label]));
}
}
}
继续往下走解析类型为 二进制压缩格式 的数据:
// 通常 PCD 文件中的数据被组织为结构数组:XYZRGBXYZRGB
// 二进制压缩的 PCD 文件将其数据组织为数组结构: XXYYZZRGBRGB
// 与非压缩数据相比,这需要完全不同的解析方法
// 解析类型为 二进制压缩格式 的数据
if (PCDheader.data === 'binary_compressed') {
// 读取压缩和解压缩大小
const sizes = new Uint32Array(data.slice(PCDheader.headerLen, PCDheader.headerLen + 8));
const compressedSize = sizes[0];
const decompressedSize = sizes[1];
// 进行LZF解压缩
const decompressed = decompressLZF(new Uint8Array(data, PCDheader.headerLen + 8, compressedSize), decompressedSize);
// 创建一个DataView对象以读取解压后的二进制数据
const dataview = new DataView(decompressed.buffer);
// 获取每个字段(比如x, y, z, rgb等)在数据中的偏移量
const offset = PCDheader.offset;
// 遍历所有点
for (let i = 0; i < PCDheader.points; i++) {
// 如果存在x字段
if (offset.x !== undefined) {
// 寻找x, y, z字段在字段列表中的索引
const xIndex = PCDheader.fields.indexOf('x');
const yIndex = PCDheader.fields.indexOf('y');
const zIndex = PCDheader.fields.indexOf('z');
// 读取并存储x, y, z坐标
position.push(dataview.getFloat32((PCDheader.points * offset.x) + PCDheader.size[xIndex] * i, this.littleEndian));
position.push(dataview.getFloat32((PCDheader.points * offset.y) + PCDheader.size[yIndex] * i, this.littleEndian));
position.push(dataview.getFloat32((PCDheader.points * offset.z) + PCDheader.size[zIndex] * i, this.littleEndian));
}
// 如果存在rgb字段
if (offset.rgb !== undefined) {
// 寻找rgb字段在字段列表中的索引
const rgbIndex = PCDheader.fields.indexOf('rgb');
// 读取并存储r, g, b颜色值
const r = dataview.getUint8((PCDheader.points * offset.rgb) + PCDheader.size[rgbIndex] * i + 2) / 255.0;
const g = dataview.getUint8((PCDheader.points * offset.rgb) + PCDheader.size[rgbIndex] * i + 1) / 255.0;
const b = dataview.getUint8((PCDheader.points * offset.rgb) + PCDheader.size[rgbIndex] * i + 0) / 255.0;
// 将sRGB颜色转换为线性颜色
c.set(r, g, b).convertSRGBToLinear();
// 存储颜色
color.push(c.r, c.g, c.b);
}
// 如果存在normal_x字段
if (offset.normal_x !== undefined) {
// 寻找normal_x, normal_y, normal_z字段在字段列表中的索引
const xIndex = PCDheader.fields.indexOf('normal_x');
const yIndex = PCDheader.fields.indexOf('normal_y');
const zIndex = PCDheader.fields.indexOf('normal_z');
// 读取并存储法线信息
normal.push(dataview.getFloat32((PCDheader.points * offset.normal_x) + PCDheader.size[xIndex] * i, this.littleEndian));
normal.push(dataview.getFloat32((PCDheader.points * offset.normal_y) + PCDheader.size[yIndex] * i, this.littleEndian));
normal.push(dataview.getFloat32((PCDheader.points * offset.normal_z) + PCDheader.size[zIndex] * i, this.littleEndian));
}
// 如果存在intensity字段
if (offset.intensity !== undefined) {
// 寻找intensity字段在字段列表中的索引
const intensityIndex = PCDheader.fields.indexOf('intensity');
// 读取并存储光强值
intensity.push(dataview.getFloat32((PCDheader.points * offset.intensity) + PCDheader.size[intensityIndex] * i, this.littleEndian));
}
// 如果存在label字段
if (offset.label !== undefined) {
// 寻找label字段在字段列表中的索引
const labelIndex = PCDheader.fields.indexOf('label');
// 读取并存储标签值
label.push(dataview.getInt32((PCDheader.points * offset.label) + PCDheader.size[labelIndex] * i, this.littleEndian));
}
}
}
注意在上面的代码中调用了 decompressLZF
方法用于解压缩二进制压缩格式数据:
// 定义解压缩LZF的函数,接受压缩数据和解压缩后数据的长度
function decompressLZF(inData, outLength) {
// 获取输入数据的长度
const inLength = inData.length;
// 创建一个Uint8Array作为输出数据的缓冲区
const outData = new Uint8Array(outLength);
// 初始化输入和输出的指针
let inPtr = 0;
let outPtr = 0;
// 初始化其他变量
let ctrl;
let len;
let ref;
// 主解压缩循环
do {
// 读取控制字节
ctrl = inData[inPtr++];
// 判断是否为字面量(非重复数据)
if (ctrl < (1 << 5)) {
// 更新字面量长度
ctrl++;
// 检查输出缓冲区是否足够大
if (outPtr + ctrl > outLength) throw new Error('Output buffer is not large enough');
// 检查输入数据是否有效
if (inPtr + ctrl > inLength) throw new Error('Invalid compressed data');
// 复制字面量到输出缓冲区
do {
outData[outPtr++] = inData[inPtr++];
} while (--ctrl);
} else { // 否则,处理重复数据
// 获取长度和引用偏移量
len = ctrl >> 5;
ref = outPtr - ((ctrl & 0x1f) << 8) - 1;
// 检查输入数据是否有效
if (inPtr >= inLength) throw new Error('Invalid compressed data');
// 检查长度是否需要一个额外的字节
if (len === 7) {
len += inData[inPtr++];
if (inPtr >= inLength) throw new Error('Invalid compressed data');
}
// 更新引用偏移量
ref -= inData[inPtr++];
// 检查输出缓冲区是否足够大
if (outPtr + len + 2 > outLength) throw new Error('Output buffer is not large enough');
// 检查引用偏移量是否有效
if (ref < 0) throw new Error('Invalid compressed data');
if (ref >= outPtr) throw new Error('Invalid compressed data');
// 从引用偏移量开始复制数据到输出缓冲区
do {
outData[outPtr++] = outData[ref++];
} while (--len + 2);
}
} while (inPtr < inLength); // 继续解压缩,直到输入数据被完全读取
// 返回解压缩后的数据
return outData;
}
继续往下走解析类型为 二进制压缩格式 的数据:
// 解析类型为 二进制格式 的数据
if (PCDheader.data === 'binary') {
// 创建一个DataView对象以便更容易地访问二进制数据
const dataview = new DataView(data, PCDheader.headerLen);
// 获取点云数据字段的偏移量
const offset = PCDheader.offset;
// 遍历所有点
for (let i = 0, row = 0; i < PCDheader.points; i++, row += PCDheader.rowSize) {
// 如果数据中包含x、y、z坐标
if (offset.x !== undefined) {
// 读取并存储x、y、z坐标
position.push(dataview.getFloat32(row + offset.x, this.littleEndian));
position.push(dataview.getFloat32(row + offset.y, this.littleEndian));
position.push(dataview.getFloat32(row + offset.z, this.littleEndian));
}
// 如果数据中包含RGB颜色信息
if (offset.rgb !== undefined) {
// 读取并存储RGB值,然后将其转换为线性空间
const r = dataview.getUint8(row + offset.rgb + 2) / 255.0;
const g = dataview.getUint8(row + offset.rgb + 1) / 255.0;
const b = dataview.getUint8(row + offset.rgb + 0) / 255.0;
c.set(r, g, b).convertSRGBToLinear();
color.push(c.r, c.g, c.b);
}
// 如果数据中包含法线信息
if (offset.normal_x !== undefined) {
// 读取并存储法线向量
normal.push(dataview.getFloat32(row + offset.normal_x, this.littleEndian));
normal.push(dataview.getFloat32(row + offset.normal_y, this.littleEndian));
normal.push(dataview.getFloat32(row + offset.normal_z, this.littleEndian));
}
// 如果数据中包含光照强度信息
if (offset.intensity !== undefined) {
// 读取并存储光照强度
intensity.push(dataview.getFloat32(row + offset.intensity, this.littleEndian));
}
// 如果数据中包含标签信息
if (offset.label !== undefined) {
// 读取并存储标签
label.push(dataview.getInt32(row + offset.label, this.littleEndian));
}
}
}
继续往下走最后一步构建点云对象并返回:
// 构建几何体
// 创建一个新的缓冲几何体(BufferGeometry对象)
const geometry = new BufferGeometry();
// 如果位置数组非空,将其作为点的位置属性添加到几何体中
if (position.length > 0) geometry.setAttribute('position', new Float32BufferAttribute(position, 3));
// 如果法线数组非空,将其作为点的法线属性添加到几何体中
if (normal.length > 0) geometry.setAttribute('normal', new Float32BufferAttribute(normal, 3));
// 如果颜色数组非空,将其作为点的颜色属性添加到几何体中
if (color.length > 0) geometry.setAttribute('color', new Float32BufferAttribute(color, 3));
// 如果光照强度数组非空,将其作为点的光照强度属性添加到几何体中
if (intensity.length > 0) geometry.setAttribute('intensity', new Float32BufferAttribute(intensity, 1));
// 如果标签数组非空,将其作为点的标签属性添加到几何体中
if (label.length > 0) geometry.setAttribute('label', new Int32BufferAttribute(label, 1));
// 计算几何体的边界球,用于进行一些优化和碰撞检测等操作
geometry.computeBoundingSphere();
// 构建材质,设置点的大小为0.005
const material = new PointsMaterial({ size: 0.005 });
// 如果颜色数组非空,则设置顶点颜色为true,这样点将使用顶点颜色数组中的颜色
if (color.length > 0) {
material.vertexColors = true;
}
// 构建点云对象并返回
return new Points(geometry, material);
然后到这里整个代码也就完结了。
3.总结
那我们来总结下Three.js中PCDLoader.js加载点云具体做了哪些工作。
1.load方法加载点云文件 url 地址,加载完得到的data
是一个ArrayBuffer
对象。
2.解析方法parse
开始解析上面传过来的ArrayBuffer
对象数据。
3.使用 TextDecoder
解码数据流,将二进制数据转换为文本格式。
4.使用 parseHeader
解析头文件信息,解析完返回解析后的头文件信息对象,这个返回的对象是一个标准的JS对象。
5.如果点云头文件类型为 ascii
,那么就开始解析点云头文件类型为 ASCII
的数据。
6.如果点云头文件类型为 binary_compressed
,那么就开始解析点云头文件类型为 二进制压缩格式
的数据,这里解析的时候调用了解压缩LZF的函数 decompressLZF
。
7.如果点云头文件类型为 binary
,那么就开始解析点云头文件类型为 二进制格式
的数据。
8.构建点云对象并返回。
转载自:https://juejin.cn/post/7273680035097591827