最近阅读了一篇文章——《基于内存映射文件的海量点云数据快速读取方法》,文中介绍的方法能够极大地提升点云读取速率,这为我们读取数以千万的点云数据提供了方法。
通常情况下,在C++中读文件,使用std::ifstream,写文件使用std::ofstream。这种传统的IO读取方式比较常见,但是读取文件速度较慢。
下面是基于IO的读文件C++代码
std::ifstream ifs(path, std::ios::in);
if (!ifs.is_open())
{
std::cout << "文件不存在" << std::endl;
ifs.close();
return NULL;
}
char ch;
ifs >> ch;
if (ifs.eof())
{
std::cout << "文件为空" << std::endl;
ifs.close();
return NULL;
}
ifs.putback(ch);
std::string data_;
while (ifs >> data_)
{
}
ifs.close();
回到重点,文中主要从两个方面对读取过程进行改进:(1)针对点云数据特有的格式,重写并简化了格式转换的函数,从而提高数据格式转换的效率;(2)通过在磁盘空间中开辟虚拟内存,省掉了由磁盘数据到内存空间的步骤,从而提高了数据读取的效率。
通常情况下,点云数据会被存储成ASCII形式文本,每一点占据一行
x1 y1 z1
x2 y2 z2
…
(空格部分通常是‘\t’或者’\n’)
文本格式点云数据的特点为:(1)每一行数据格式一致,具有重复性;(2)数据量大,一个文件中保存的三维点数量可达上千万个。
将ASCII形式的字符转换成数值类型并保存在内存中一共要包括三个步骤:(1)二进制数据读取;(2)数值类型转换;(3)内存赋值。
在读数据的时候,使用fgets()可以一次性读取一整行数据,但是一整行数据是包括x、y和z的坐标值的。并且此时读进来的是一个个字符,也就是char类型。因此,每读取一行便需要根据其空格对整行字符串进行分割。最后,将分割的结果中的每个字符转换为数值再进行拼接。例如,x = 123.5,分割之后其实是‘1’ ‘2’ ‘3’ ‘.’ ‘5’。使用atof()函数可以将字符转换为数值。(atof是C和C++标准库中的函数,不需要额外安装库)
经过作者测试发现,最消耗时间的步骤就是这个数据类型转换步骤。atof函数对输入的格式很广泛,牺牲了效率来满足数据的兼容性。所以作者针对点云数据的特点重新编写了转换函数,其实就是简化了atof,来提高效率。
这篇博客中实现了atof函数,代码如下
double myatof(const char *s)
{
int sign, sign_e;//数值的符号,指数部分的符号
int hasdot = 0;
int hase = 0;
double intpart = 0.0;//小数的整数部分
double decpart = 0.0; //小数的小数部分
int decdigit = 1; //小数的小数位数
int exp = 0; //指数部分
double ret;
int i;
//跳过开头的空格
for (i = 0; isspace(s[i]); i++)
;
//判断符号,如有,跳过
sign = (s[i] == '-') ? -1 : 1;
if (s[i] == '-' || s[i] == '+')
i++;
//判断浮点数
//第一部分:
for (; s[i] != '\0'; i++)
{
if (isdigit(s[i])) //数字
intpart = 10 * intpart + s[i] - '0';//计算小数的整数部分
else if (s[i] == '.') //小数点
{
hasdot = 1;
i++;
break;
}
else if (s[i] == 'e' || s[i] == 'E') //科学计数符
{
hase = 1;
i++;
break;
}
else //非法字符
return sign * intpart;
}
/*第一部分结束,有如下情况:
1. 扫描数字知道非法字符或字符串结尾,2d 234
2. 数字加小数点 2.
3. 小数点 .
4. 数字加科学计数符 3e
5. 科学计数符 e 这种情况是非法表示,但最终计算的结果为0,
因此可当作正常计算,不单独列出
6. 非法字符, 直接退出
*/
//第二部分,接着上述情况扫描
//能进入下面循环,排除遇到字符串结尾,非法字符
//因此只能遇到点号或科学计数符
for (; s[i] != '\0'; i++)
{
//第一种:.3 或 3.4,均为合法,计算小数的小数部分
if (hasdot && isdigit(s[i]))
decpart += (s[i] - '0') / pow(10, decdigit++);
//第二种:.e 或 2.e 或 .2e 或 3.3e 第一种非法,但计算结果为0
else if (hasdot && (s[i] == 'e' || s[i] == 'E'))
{
hase = 1;
i++;
break;
}
//第三种:第一部分以e结束,3e e
else if (hase)
break;
//第四种:第一部分以点号结束,现在扫描非数字,非科学计数符的其他非法字符
else
return sign * (intpart + decpart);
}
/*第三部分
从第二部分退出后继续后面的程序,有如下情况:
以科学计算符 e 结束第二部分,前面有小数点或者没有
小数部分计算完,下面讨论指数部分
*/
//判断指数部分符号
sign_e = (s[i] == '-') ? -1 : 1;
if (s[i] == '+' || s[i] == '-')
i++;
for(; s[i] != '\0'; i++)
{
if(isdigit(s[i]))
exp = exp * 10 + s[i] - '0';
else
break;
}
ret = sign * ((intpart + decpart) * pow(10, sign_e * exp));
return ret;
}
经过测试发现,这个重写的新方法比atof确实要省时间的,数据量越大,效果肯定越明显。观察这个代码发现,其实还可以继续简化
简化代码如下
int _isdigit(int c)
{
if ((c >= '0' && c <= '9') || (c == '.')) return 1024;
return 0;
}
double myatof(const char* s)
{
int hasdot = 0;
double intpart = 0.0;//小数的整数部分
double decpart = 0.0; //小数的小数部分
int i = 0;
for (; _isdigit(s[i]); ++i)
{
if (s[i] >= '0' && s[i] <= '9') //数字
intpart = 10 * intpart + s[i] - '0';//计算小数的整数部分
else if (s[i] == '.') //小数点
{
hasdot = 1;
i++;
break;
}
}
float val = 0.1;
for (; _isdigit(s[i]); ++i)
{
//第一种:.3 或 3.4,均为合法,计算小数的小数部分
if (hasdot && s[i] >= '0' && s[i] <= '9')
{
//decpart += (s[i] - '0') / pow(10, decdigit++);
decpart = decpart + (s[i] - '0') * val;
val = val * 0.1;
}
}
return intpart + decpart;
}
简化之后,速度进一步大幅度提升。
完整的点云数据流程还包括内存接收数据环节。容器或动态数组的创建和数据赋值效率比固定内存创建和赋值效率低一到两个数量级,因此在处理大量数据时,首选定长数组或直接开辟内存。
整个转换流程如下:(1)逐行读文件获取文件行数N,关闭文件;(2)开辟长度为N的点云数据结构类型内存空间;(3)gets()方法逐行读文件获取字符串;(4)对字符串进行分割;(5)自定义函数将字符串转换为数值赋值到内存地址中。
在知道要读取的点云个数之后,在堆区开辟固定大小内存来存放数据能够进一步节省时间,但是文中介绍到在IO读取点云数据方面仍热有待优化。
于是,不再采用IO读取数据的方式,而改用内存映射来进行快速读取。
通过文件内存映射操作可获取数据区指针,由于文本文件以ASCII码保存,因此这里的数据区指针定义为char*类型。获取数据指针后,通过该指针实现数据的任意访问,而非IO读取,可以明显提升效率。
基于内存映射方式,完整的文本格式点云数据读取流程如下:(1)对文件内存映射获取数据指针p,文件字节数n;(2)通过指针p遍历长度为n的映射内存数据,查找统计换行符数量N;(3)开辟长度为N的点云数据结构类型固定内存空间;(4)遍历p指向的映射内存数据,分割字符串,同时将其转换为double类型数值,赋值到新开辟内存空间中。
此处附上内存映射部分代码
CString str = path.c_str();
USES_CONVERSION;
LPCWSTR wszClassName = A2CW(W2A(str));
str.ReleaseBuffer();
DWORD error_code;
HANDLE hFile = CreateFile(wszClassName,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile == INVALID_HANDLE_VALUE)
{
error_code = GetLastError();
cout << " CreateFile fail" << error_code << endl;
return NULL;
}
//创建一个文件映射内核对象
HANDLE hFileMap = CreateFileMapping(hFile,
NULL,
PAGE_READWRITE,
NULL,
NULL,
L"Resource");
if (hFileMap == NULL)
{
error_code = GetLastError();
cout << "CreateFileMapping fail: " << error_code << endl;
return NULL;
}
//将文件数据映射到进程的地址空间
char* pMapData = (char*)MapViewOfFile(hFileMap,
FILE_MAP_ALL_ACCESS,
NULL,
NULL,
NULL);
if (pMapData == NULL)
{
error_code = GetLastError();
cout << " MapViewOfFile fail: " << error_code << endl;
return NULL;
}
//读取数据
char* pBuf = pMapData;
以上代码源自此处
其他参考如下:
1.CreateFileMapping , OpenFileMapping, MapViewOfFile, UnmapViewOfFile 和 FlushViewOfFile
2.C++ CreateFileMapping 内存映射实现快速读取文件
3.C\C++对大文件的快速读写(内存映射)
这些博主其实将内存映射用到的函数用法介绍的很清楚,此处就不多赘述。
内存映射+堆上开辟固定长度内存+简化atof函数能够大幅度提升文本读取效率,18s能够完成2000+万个点云数据读取和重建。
本文思想和方法源自于下述文章
[1]赵文.基于内存映射文件的海量点云数据快速读取方法[J].铁路计算机应用,2017,26(06):39-42.