最近在学习open3d的相关应用,然后遇到了一个很有趣的问题。给定多个mesh,我们可能会需要把他们全部合并到一个文件并使用。但是这并不好实现,因为open3d自己不支持这样的操作。相比之下,其他一些集成度非常高的软件,是可以实现这样的操作的,例如meshlab通过交互栏中的“flatten visible layer”指令来实现。
唯一的缺点是,你每次都需要手动操作才行,这对于需要高度自动化的使用场景,就不是很合适了。因此,如何可以实现一个自动化的脚本,支持直接合并多个可染色的mesh,并输出带有纹理的最终结果,是一个非常重要的功能。遗憾的是度娘和谷歌目前没有相关的教程。因此本文带大家了解一下,如何重头写一个ply文件并且合并输出所有需要合并的m
esh。
▍如何存储一个带纹理的obj格式的mesh
这里我们首先介绍一下,怎么去存储一个mesh。为了方便,我们使用这篇文章中的代码 (https://zhuanlan.zhihu.com/p/569974846),先自己生成若干个mesh(有些带有纹理,有些没有),然后进行存储。单模型存储在open3d中是很简单的,open3d提供了一个接口来直接存储对应的mesh,接口是o3d.io.write_triangle_mesh。
但是要注意的是,如果要存纹理信息,这个命令需要使用obj格式,因为另外一种常见的ply格式,则无法存储纹理信息。因此,作为合并的第一步,我们手动输出全部mesh为obj格式以支持纹理信息,并且分开存储。
以下代码把场景内的全部mesh文件输出为obj格式。
代码语言:javascript复制if not os.path.exists("save_mesh"):
os.makedirs("save_mesh", exist_ok=False)
o3d.io.write_triangle_mesh("save_mesh/obj_back.obj", back_obj)
o3d.io.write_triangle_mesh("save_mesh/obj_left.obj", left_obj)
o3d.io.write_triangle_mesh("save_mesh/obj_front.obj", front_obj)
o3d.io.write_triangle_mesh("save_mesh/obj_right.obj", right_obj)
o3d.io.write_triangle_mesh("save_mesh/box_back.obj", back_box)
o3d.io.write_triangle_mesh("save_mesh/box_left.obj", left_box)
o3d.io.write_triangle_mesh("save_mesh/box_front.obj", front_box)
o3d.io.write_triangle_mesh("save_mesh/box_right.obj", right_box)
▍如何存储一个带纹理的ply格式的mesh
存储为obj格式之后,我们通过meshlab自带的命令行格式,把所有带有纹理的mesh全部转化为ply文件。这里要注意的是,如果你的mesh模型本身是不带有色彩的,那么这一步可以直接加载mesh模型然后转为ply文件,上一步输出为obj格式则是可以跳过的。
下面我们依次加载obj文件并转存为ply文件。代码如下:
代码语言:javascript复制if not os.path.exists("save_mesh_ply"):
os.makedirs("save_mesh_ply", exist_ok=False)
for obj in ["save_mesh/obj_back.obj", "save_mesh/obj_left.obj", "save_mesh/obj_front.obj",
"save_mesh/obj_right.obj", "save_mesh/box_back.obj", "save_mesh/box_left.obj",
"save_mesh/box_front.obj", “save_mesh/box_right.obj"]: #简单粗暴的列出来所有mesh
ms = pymeshlab.MeshSet()
ms.load_new_mesh(obj)
ms.save_current_mesh(os.path.join(obj.split("/")[0] "_ply", obj.split("/")[1].replace("obj", “ply")))
最终存储的mesh,重新使用meshlab可视化结果如下:
注意右侧红框,此时存在8个不同的层(layers)。我们的最终目的是把他们全部合并为一层并且统一存储。
▍ply文件格式介绍
下面我们来介绍一下ply文件格式的组成。ply文件有两个重要组成部分。第一组是头部(header),之后是对应于头部定义的数值组。
首先是头部(header)的定义,对于无纹理mesh文件来说,直接套用ply头部固定模板即可。
代码语言:javascript复制ply
format ascii 1.0
comment VCGLIB generated
element vertex vertex_count
property float x
property float y
property float z
property uchar red
property uchar green
property uchar blue
property uchar alpha
element face face_count
property list uchar int vertex_indices
end_header
关于无纹理文件的头部的定义,大部分情况下可以直接照抄,无需修改,除了点、面对应的数量(红色变量对应位置替换即可)。这里进一步解释一下关键字:header中的comment是注释的意思,property详细定义了所需要的数据结构。最后使用end_header标注定义结束。另外ply文件格式的编码,我强烈推荐使用ascii格式,否则使用文本编辑工具打开是乱码,不利于分析问题。
头部的定义具体包含了顶点与面的定义。对于不带纹理的ply文件,其对应顶点的定义需要如下关键参数,分别为:当前mesh的三维坐标(X,Y,Z)以及对应面的顶点索引(vertex indices)
对于带纹理的ply文件,除了上述所需参数外,额外需要向header添加纹理定义。
纹理定义格式如下
代码语言:javascript复制comment TextureFile texture_name
其中,前两个单词为关键字,最后一个为变量,指向纹理文件存储位置。需要注意的是,有多少纹理文件,就要对应性的添加多少行。否则对应的mesh无法染色。
对于面来说,需要额外定义五个变量,分别为 texcoord,red, green, blue, alpha,合并在一起记录面的颜色,分别对应于有纹理图片(texcoord)与自带纹理的情况(RGBA)。代码如下:
代码语言:javascript复制property list uchar float texcoord
property uchar red
property uchar green
property uchar blue
property uchar alpha
全部合在一起,即为有纹理的mesh对应的完整定义。
介绍完header之后,我们就可以依次按照定义的顶点和面的顺序,往里面填数据了。对于带有、不带有纹理的mesh,其对应的ply文件的顶点信息和面对应信息稍有不同,具体的不同可以通过header的定义看出来,这里不再赘述。为了方便起见,我们统一填补所有不带纹理的mesh里缺失的列信息。具体如何填补我们稍后介绍。
▍如何读取并操作ply文件
ply文件本身是单纯的文本流,为了处理方便,这里我们使用python自带的plyfile进行处理,从而快捷的读取ply文件并转化为相应的numpy矩阵。
在读取相关文件前,我们先准备一下输入输出(IO)。代码如下:
代码语言:javascript复制need_texture = False
model_in_folder = "save_mesh_ply"
model_out_folder = "save_mesh_ply_out"
assert os.path.exists(model_in_folder), "input mesh models are not available! file path : {}".format(model_in_folder)
if not os.path.exists(model_out_folder):
os.makedirs(model_out_folder, exist_ok=True)
fuse_model_path = glob.glob(os.path.join(model_in_folder, "*.ply"))
fuse_model_path.sort()
fused_vertex, fused_faces = None, None
vertex_count = 0
coloring_list = []
merge_file_name = os.path.join(model_out_folder, "merged_mesh.ply")
之后我们来讲解一下plyfile怎么处理ply文件。plyfile是python下处理ply一个经典的库,其自带plyData模块,可以读入输出ply文件。读取时,直接调用plyData即可。返回结果是一个字典,可以用来获得对应mesh的顶点和面的结果。
具体如何处理,可以看一下这里的代码。
代码语言:javascript复制plydata = PlyData.read(f_path)
vertexs = np.array(plydata['vertex'].data)
faces = plydata[‘face'].data
然后我们来处理一下顶点和面,处理的原因上面已经提到过了,主要就是同步有纹理与无纹理mesh对应的顶点和面属性不匹配的问题。具体来说,无纹理的mesh对应的属性定义,要少于有纹理的mesh。如果不处理的话,是无法直接进行合并的,因此我们严格按照header中属性的定义,对于无纹理的mesh对应缺失的属性依次填充,即可得到最终的结果。关于如何填充缺失值,我们下一节会详细介绍。
继续讨论顶点和面的定义。首先我们介绍一下相关的数据结构。
对于顶点来说,我们需要读入三维坐标点信息与对应每个顶点的色彩纹理信息,而对于面来说,我们需要存入顶点顺序来构造每个面,以及对应的纹理坐标(Texcoord),和对应面的颜色值(RGBA)。这里要注意的是,如果提供了图像 纹理坐标,则对应面的RGBA值会被覆盖。详细数据结构的定义请看下表。
顶点对应的数据结构 | |
---|---|
变量 | 数据结构 |
X | float32 |
Y | float32 |
Z | float32 |
Red | Uint8 |
Green | Uint8 |
Blue | Uint8 |
Alpha | Uint8 |
面对应的数据结构 | |
---|---|
变量 | 数据结构 |
vertex_indices | Object |
Texcoord | Object |
Red | Uint8 |
Green | Uint8 |
Blue | Uint8 |
Alpha | Uint8 |
相关代码定义如下(这一节用不到,后面填充矩阵的时候会用到):
代码语言:javascript复制vertex_dtype = [('x', '<f8'), ('y',="" '
face_dtype = [('vertex_indices', 'O'), ('texcoord', 'O'), ('red', 'u1'), ('green', 'u1'), ('blue', 'u1'),
('alpha', ‘u1')]
▍如何对顶点与面进行填充
我们再来看一下如何对顶点和面进行填充。填充的核心是针对无纹理的mesh操作的,主要是将其没有的属性,使用默认值直接进行填充,从而与有纹理的mesh相兼容。那么需要填充什么内容呢?
对于无纹理的mesh,具体来说:
- 其对应顶点的顶点颜色信息(red, green, blue, alpha)统一设定为(255,255,255,255),也就是设定为白色。不一定非要这个颜色,对应值可以根据你默认的需要颜色来改变。
- 其对应面的vertex_indices会按照实际遍历过的面对应顶点的编号重新排布,
- 其对应的texcoord一律设置为[0,0,0,0,0,0],也就是两个三角片面的染色坐标提取点为0。换句话说,不提取该位置的纹理信息。
- 该面的颜色一律设置为白色(对应RGBA值为255,255,255,255,如果你需要其他颜色可以直接改)
这部分直接看一下相关代码。
代码语言:javascript复制def process_vertex(vertexs, vertex_dtype):
if len(vertexs[0])<=3:
new_vertexs = []
for i in range(len(vertexs)):
new_vertexs.append(vertexs[i].tolist() (255,)*4)
new_vertexs = np.array(new_vertexs, dtype=vertex_dtype)
return new_vertexs
else:
return vertexs
def process_face(faces, faces_dtype):
if len(faces[0]) == 1:
new_faces = []
texcoord = np.array([0]*6, dtype=np.float32)
rgba = (255, )*4
for i in range(len(faces)):
new_faces.append((faces[i][0], texcoord) rgba)
new_faces = np.array(new_faces, dtype = faces_dtype)
return new_faces
else:
return faces
def update_vertex_idx(vertex_counts, faces, faces_dtype):
new_faces = []
for i in range(len(faces)):
vertex_idx, texcoord, r, g, b, a = faces[i].tolist()
vertex_idx = vertex_idx vertex_counts
new_faces.append((vertex_idx, texcoord, r, g, b, a))
new_faces = np.array(new_faces, dtype=faces_dtype)
return new_faces
上述代码中,process_vertex函数处理了问题1,process_face处理了问题3、4,而update_vertex_idx函数处理了问题2。通过使用这些函数,可以顺利的修正所有的顶点与相对应的面的匹配关系,并且合并所有的ply文件。
▍如何合并所有给定的ply文件
最后一步,我们尝试使用已有的代码来合并全部给定的ply文件。这里我们定义一个函数 write_merge_mesh来实现合并这一核心功能。
这个函数会执行如下操作:
- 自动生成header。同时检查是否有纹理mesh(通过传入参数need_texture判断)。如果有,则向header注入纹理文件信息。
- 从预处理好的顶点和面(也就是上面process_vertex和process_face的输出结果)上收集数据,然后统一写入新的ply文件。
相关代码如下:
代码语言:javascript复制def write_merge_mesh(merge_file_name, point_count, face_count, coloring_list, fused_vertex, fused_faces, need_texture=True):
texture_file = ""
if need_texture:
for file in coloring_list:
texture_file = "comment TextureFile {}n".format(file)
head = """ply
format ascii 1.0
comment VCGLIB generated
"""
head = texture_file
head = """element vertex {}
property float x
property float y
property float z
property uchar red
property uchar green
property uchar blue
property uchar alpha
element face {}
property list uchar int vertex_indices
property list uchar float texcoord
property uchar red
property uchar green
property uchar blue
property uchar alpha
end_header""".format(point_count, face_count)
with open(merge_file_name, 'w') as f2:
f2.write(head)
for ve in fused_vertex:
f2.write(" ".join(map(str, ve.tolist())) "n")
for fe in fused_faces:
vertex_idx, texcoord, r, g, b, a = fe
out_str = str(len(vertex_idx.tolist())) " " " ".join(map(str, vertex_idx.tolist())) " "
str(len(texcoord.tolist())) " " " ".join(map(str, texcoord.tolist())) " " str(r) " " str(g) " " str(b) " " str(a) "n"
f2.write(out_str)
return head
注意,最终合并的ply文件的输出位置,需要和存储纹理信息的位置一致,否则需要手动复制粘贴纹理信息到生成ply文件的位置。
最终由多个mesh合并为一个mesh并且输出的可视化结果如下:
到底为止,我们顺利完成了多个组合面合并起来进行ply文件输出的python代码。
▍附录:本文涉及全部代码
代码语言:javascript复制# 设置need_texture = False 如果不存染色结果
import os
from plyfile import PlyData
import numpy as np
import glob
def process_vertex(vertexs, vertex_dtype):
if len(vertexs[0])<=3:
new_vertexs = []
for i in range(len(vertexs)):
new_vertexs.append(vertexs[i].tolist() (255,)*4)
new_vertexs = np.array(new_vertexs, dtype=vertex_dtype)
return new_vertexs
else:
return vertexs
def process_face(faces, faces_dtype):
if len(faces[0]) == 1:
new_faces = []
texcoord = np.array([0]*6, dtype=np.float32)
rgba = (255, )*4
for i in range(len(faces)):
new_faces.append((faces[i][0], texcoord) rgba)
new_faces = np.array(new_faces, dtype = faces_dtype)
return new_faces
else:
return faces
def update_vertex_idx(vertex_counts, faces, faces_dtype):
new_faces = []
for i in range(len(faces)):
vertex_idx, texcoord, r, g, b, a = faces[i].tolist()
vertex_idx = vertex_idx vertex_counts
new_faces.append((vertex_idx, texcoord, r, g, b, a))
new_faces = np.array(new_faces, dtype=faces_dtype)
return new_faces
def write_merge_mesh(merge_file_name, point_count, face_count, coloring_list, fused_vertex, fused_faces, need_texture=True):
texture_file = ""
if need_texture:
for file in coloring_list:
texture_file = "comment TextureFile {}n".format(file)
head = "plynformat ascii 1.0ncomment VCGLIB generatedn"
head = texture_file
head = """element vertex %d
property float x
property float y
property float z
property uchar red
property uchar green
property uchar blue
property uchar alpha
element face %d
property list uchar int vertex_indices
property list uchar float texcoord
property uchar red
property uchar green
property uchar blue
property uchar alpha
end_header
""" % (point_count, face_count)
with open(merge_file_name, 'w') as f2:
f2.write(head)
for ve in fused_vertex:
f2.write(" ".join(map(str, ve.tolist())) "n")
for fe in fused_faces:
vertex_idx, texcoord, r, g, b, a = fe
out_str = str(len(vertex_idx.tolist())) " " " ".join(map(str, vertex_idx.tolist())) " "
str(len(texcoord.tolist())) " " " ".join(map(str, texcoord.tolist())) " " str(r) " "
str(g) " " str(b) " " str(a) "n"
f2.write(out_str)
return head
if __name__ == "__main__":
need_texture = False
model_in_folder = "save_mesh_ply"
model_out_folder = "save_mesh_ply"
assert os.path.exists(model_in_folder), "input mesh models are not available! file path : {}".format(model_in_folder)
if not os.path.exists(model_out_folder):
os.makedirs(model_out_folder, exist_ok=True)
fuse_model_path = glob.glob(os.path.join(model_in_folder, "*.ply"))
fuse_model_path.sort()
fused_vertex, fused_faces = None, None
vertex_counts = 0
coloring_list = []
merge_file_name = os.path.join(model_out_folder, "merged_mesh.ply")
# definition
vertex_dtype = [('x', '<f8'), ('y',="" '
face_dtype = [('vertex_indices', 'O'), ('texcoord', 'O'), ('red', 'u1'), ('green', 'u1'), ('blue', 'u1'),
('alpha', 'u1')]
for f_path in fuse_model_path:
if need_texture:
# need_texture = True
coloring_list.append(f_path.split("/")[-1].split(".")[0] "_0.png")
plydata = PlyData.read(f_path)
vertexs = np.array(plydata['vertex'].data)
faces = plydata['face'].data
vertexs = process_vertex(vertexs, vertex_dtype)
faces = process_face(faces, face_dtype)
if fused_vertex is None:
fused_vertex = vertexs
fused_faces = faces
else:
fused_vertex = np.concatenate((fused_vertex, vertexs))
faces = update_vertex_idx(vertex_counts, faces, face_dtype)
fused_faces = np.concatenate((fused_faces, faces))
vertex_counts = vertexs.shape[0]
write_merge_mesh(merge_file_name, vertex_counts, fused_faces.shape[0], coloring_list, fused_vertex, fused_faces)