hub/venv/lib/python3.7/site-packages/trimesh/exchange/ply.py

802 lines
26 KiB
Python

import numpy as np
from distutils.spawn import find_executable
from string import Template
import json
import tempfile
import subprocess
import collections
from .. import util
from .. import visual
from .. import grouping
from .. import resources
from ..constants import log
try:
import PIL.Image
except ImportError:
pass
# from ply specification, and additional dtypes found in the wild
dtypes = {
'char': 'i1',
'uchar': 'u1',
'short': 'i2',
'ushort': 'u2',
'int': 'i4',
'int8': 'i1',
'int16': 'i2',
'int32': 'i4',
'int64': 'i8',
'uint': 'u4',
'uint8': 'u1',
'uint16': 'u2',
'uint32': 'u4',
'uint64': 'u8',
'float': 'f4',
'float16': 'f2',
'float32': 'f4',
'float64': 'f8',
'double': 'f8'}
def load_ply(file_obj,
resolver=None,
fix_texture=True,
prefer_color=None,
*args,
**kwargs):
"""
Load a PLY file from an open file object.
Parameters
---------
file_obj : an open file- like object
Source data, ASCII or binary PLY
resolver : trimesh.visual.resolvers.Resolver
Object which can resolve assets
fix_texture : bool
If True, will re- index vertices and faces
so vertices with different UV coordinates
are disconnected.
prefer_color : None, 'vertex', or 'face'
Which kind of color to prefer if both defined
Returns
---------
mesh_kwargs : dict
Data which can be passed to
Trimesh constructor, eg: a = Trimesh(**mesh_kwargs)
"""
# OrderedDict which is populated from the header
elements, is_ascii, image_name = parse_header(file_obj)
# functions will fill in elements from file_obj
if is_ascii:
ply_ascii(elements, file_obj)
else:
ply_binary(elements, file_obj)
# try to load the referenced image
image = None
if image_name is not None:
try:
data = resolver.get(image_name)
image = PIL.Image.open(util.wrap_as_stream(data))
except BaseException:
log.warning('unable to load image!',
exc_info=True)
kwargs = elements_to_kwargs(elements,
fix_texture=fix_texture,
image=image,
prefer_color=prefer_color)
return kwargs
def export_ply(mesh,
encoding='binary',
vertex_normal=None):
"""
Export a mesh in the PLY format.
Parameters
----------
mesh : Trimesh object
encoding : ['ascii'|'binary_little_endian']
vertex_normal : include vertex normals
Returns
----------
export : bytes of result
"""
# evaluate input args
# allow a shortcut for binary
if encoding == 'binary':
encoding = 'binary_little_endian'
elif encoding not in ['binary_little_endian', 'ascii']:
raise ValueError('encoding must be binary or ascii')
# if vertex normals aren't specifically asked for
# only export them if they are stored in cache
if vertex_normal is None:
vertex_normal = 'vertex_normal' in mesh._cache
# custom numpy dtypes for exporting
dtype_face = [('count', '<u1'),
('index', '<i4', (3))]
dtype_vertex = [('vertex', '<f4', (3))]
# will be appended to main dtype if needed
dtype_vertex_normal = ('normals', '<f4', (3))
dtype_color = ('rgba', '<u1', (4))
# get template strings in dict
templates = json.loads(resources.get('ply.template'))
# start collecting elements into a string for the header
header = templates['intro']
header += templates['vertex']
# if we're exporting vertex normals add them
# to the header and dtype
if vertex_normal:
header += templates['vertex_normal']
dtype_vertex.append(dtype_vertex_normal)
# if mesh has a vertex coloradd it to the header
if mesh.visual.kind == 'vertex' and encoding != 'ascii':
header += templates['color']
dtype_vertex.append(dtype_color)
# create and populate the custom dtype for vertices
vertex = np.zeros(len(mesh.vertices),
dtype=dtype_vertex)
vertex['vertex'] = mesh.vertices
if vertex_normal:
vertex['normals'] = mesh.vertex_normals
if mesh.visual.kind == 'vertex':
vertex['rgba'] = mesh.visual.vertex_colors
header_params = {'vertex_count': len(mesh.vertices),
'encoding': encoding}
if hasattr(mesh, 'faces'):
header += templates['face']
if mesh.visual.kind == 'face' and encoding != 'ascii':
header += templates['color']
dtype_face.append(dtype_color)
# put mesh face data into custom dtype to export
faces = np.zeros(len(mesh.faces), dtype=dtype_face)
faces['count'] = 3
faces['index'] = mesh.faces
if mesh.visual.kind == 'face' and encoding != 'ascii':
faces['rgba'] = mesh.visual.face_colors
header_params['face_count'] = len(mesh.faces)
header += templates['outro']
export = Template(header).substitute(header_params).encode('utf-8')
if encoding == 'binary_little_endian':
export += vertex.tostring()
if hasattr(mesh, 'faces'):
export += faces.tostring()
elif encoding == 'ascii':
if hasattr(mesh, 'faces'):
# ply format is: (face count, v0, v1, v2)
fstack = np.column_stack((np.ones(len(mesh.faces),
dtype=np.int64) * 3,
mesh.faces))
else:
fstack = []
# if we're exporting vertex normals they get stacked
if vertex_normal:
vstack = np.column_stack((mesh.vertices,
mesh.vertex_normals))
else:
vstack = mesh.vertices
# add the string formatted vertices and faces
export += (util.array_to_string(vstack,
col_delim=' ',
row_delim='\n') +
'\n' +
util.array_to_string(fstack,
col_delim=' ',
row_delim='\n')).encode('utf-8')
else:
raise ValueError('encoding must be ascii or binary!')
return export
def parse_header(file_obj):
"""
Read the ASCII header of a PLY file, and leave the file object
at the position of the start of data but past the header.
Parameters
-----------
file_obj : open file object
Positioned at the start of the file
Returns
-----------
elements : collections.OrderedDict
Fields and data types populated
is_ascii : bool
Whether the data is ASCII or binary
image_name : None or str
File name of TextureFile
"""
if 'ply' not in str(file_obj.readline()):
raise ValueError('not a ply file!')
# collect the encoding: binary or ASCII
encoding = file_obj.readline().decode('utf-8').strip().lower()
is_ascii = 'ascii' in encoding
# big or little endian
endian = ['<', '>'][int('big' in encoding)]
elements = collections.OrderedDict()
# store file name of TextureFiles in the header
image_name = None
while True:
line = file_obj.readline()
if line is None:
raise ValueError("Header not terminated properly!")
line = line.decode('utf-8').strip().split()
# we're done
if 'end_header' in line:
break
# elements are groups of properties
if 'element' in line[0]:
# we got a new element so add it
name, length = line[1:]
elements[name] = {
'length': int(length),
'properties': collections.OrderedDict()}
# a property is a member of an element
elif 'property' in line[0]:
# is the property a simple single value, like:
# `propert float x`
if len(line) == 3:
dtype, field = line[1:]
elements[name]['properties'][
str(field)] = endian + dtypes[dtype]
# is the property a painful list, like:
# `property list uchar int vertex_indices`
elif 'list' in line[1]:
dtype_count, dtype, field = line[2:]
elements[name]['properties'][
str(field)] = (
endian +
dtypes[dtype_count] +
', ($LIST,)' +
endian +
dtypes[dtype])
# referenced as a file name
elif 'TextureFile' in line:
# textures come listed like:
# `comment TextureFile fuze_uv.jpg`
index = line.index('TextureFile') + 1
if index < len(line):
image_name = line[index]
return elements, is_ascii, image_name
def elements_to_kwargs(elements,
fix_texture,
image,
prefer_color=None):
"""
Given an elements data structure, extract the keyword
arguments that a Trimesh object constructor will expect.
Parameters
------------
elements : OrderedDict object
With fields and data loaded
fix_texture : bool
If True, will re- index vertices and faces
so vertices with different UV coordinates
are disconnected.
image : PIL.Image
Image to be viewed
prefer_color : None, 'vertex', or 'face'
Which kind of color to prefer if both defined
Returns
-----------
kwargs : dict
Keyword arguments for Trimesh constructor
"""
kwargs = {'metadata': {'ply_raw': elements}}
vertices = np.column_stack([elements['vertex']['data'][i]
for i in 'xyz'])
if not util.is_shape(vertices, (-1, 3)):
raise ValueError('Vertices were not (n,3)!')
try:
face_data = elements['face']['data']
except (KeyError, ValueError):
# some PLY files only include vertices
face_data = None
faces = None
# what keys do in-the-wild exporters use for vertices
index_names = ['vertex_index',
'vertex_indices']
texcoord = None
if util.is_shape(face_data, (-1, (3, 4))):
faces = face_data
elif isinstance(face_data, dict):
# get vertex indexes
for i in index_names:
if i in face_data:
faces = face_data[i]
break
# if faces have UV coordinates defined use them
if 'texcoord' in face_data:
texcoord = face_data['texcoord']
elif isinstance(face_data, np.ndarray):
face_blob = elements['face']['data']
# some exporters set this name to 'vertex_index'
# and some others use 'vertex_indices' but we really
# don't care about the name unless there are multiple
if len(face_blob.dtype.names) == 1:
name = face_blob.dtype.names[0]
elif len(face_blob.dtype.names) > 1:
# loop through options
for i in face_blob.dtype.names:
if i in index_names:
name = i
break
# get faces
faces = face_blob[name]['f1']
try:
texcoord = face_blob['texcoord']['f1']
except (ValueError, KeyError):
# accessing numpy arrays with named fields
# incorrectly is a ValueError
pass
if faces is not None:
# PLY stores texture coordinates per- face which is
# slightly annoying, as we have to then figure out
# which vertices have the same position but different UV
expected = (faces.shape[0], faces.shape[1] * 2)
if (image is not None and
texcoord is not None and
texcoord.shape == expected):
# vertices with the same position but different
# UV coordinates can't be merged without it
# looking like it went through a woodchipper
# in- the- wild PLY comes with things merged that
# probably shouldn't be so disconnect vertices
if fix_texture:
# do import here
from ..visual.texture import unmerge_faces
# reshape to correspond with flattened faces
uv_all = texcoord.reshape((-1, 2))
# UV coordinates defined for every triangle have
# duplicates which can be merged so figure out
# which UV coordinates are the same here
unique, inverse = grouping.unique_rows(uv_all)
# use the indices of faces and face textures
# to only merge vertices where the position
# AND uv coordinate are the same
faces, mask_v, mask_vt = unmerge_faces(
faces, inverse.reshape(faces.shape))
# apply the mask to get resulting vertices
vertices = vertices[mask_v]
# apply the mask to get UV coordinates
uv = uv_all[unique][mask_vt]
else:
# don't alter vertices, UV will look like crap
# if it was exported with vertices merged
uv = np.zeros((len(vertices), 2))
uv[faces.reshape(-1)] = texcoord.reshape((-1, 2))
# create the visuals object for the texture
kwargs['visual'] = visual.texture.TextureVisuals(
uv=uv, image=image)
# faces were not none so assign them
kwargs['faces'] = faces
# kwargs for Trimesh or PointCloud
kwargs['vertices'] = vertices
# if both vertex and face color are defined pick the one
# with the most "signal," i.e. which one is not all zeros
colors = []
signal = []
if faces is not None:
# extract face colors or None
f_color, f_signal = element_colors(elements['face'])
colors.append({'face_colors': f_color})
signal.append(f_signal)
# extract vertex colors or None
v_color, v_signal = element_colors(elements['vertex'])
colors.append({'vertex_colors': v_color})
signal.append(v_signal)
if prefer_color is None:
# if we are in "auto-pick" mode take the one with the
# largest standard deviation of colors
kwargs.update(colors[np.argmax(signal)])
elif 'vert' in prefer_color and v_color is not None:
# vertex colors are preferred and defined
kwargs['vertex_colors'] = v_color
elif 'face' in prefer_color and f_color is not None:
# face colors are preferred and defined
kwargs['face_colors'] = f_color
else:
kwargs['colors'] = element_colors(elements['vertex'])[0]
return kwargs
def element_colors(element):
"""
Given an element, try to extract RGBA color from
properties and return them as an (n,3|4) array.
Parameters
-------------
element: dict, containing color keys
Returns
------------
colors: (n,(3|4)
signal: float, estimate of range
"""
keys = ['red', 'green', 'blue', 'alpha']
candidate_colors = [element['data'][i]
for i in keys if i in element['properties']]
if len(candidate_colors) >= 3:
colors = np.column_stack(candidate_colors)
signal = colors.std(axis=0).sum()
return colors, signal
return None, 0.0
def load_element_different(properties, data):
"""
Load elements which include lists of different lengths
based on the element's property-definitions.
Parameters
------------
properties : dict
Property definitions encoded in a dict where the property name is the key
and the property data type the value.
data : array
Data rows for this element.
"""
element_data = {k: [] for k in properties.keys()}
for row in data:
start = 0
for name, dt in properties.items():
length = 1
if '$LIST' in dt:
dt = dt.split('($LIST,)')[-1]
# the first entry in a list-property is the number of elements in the list
length = int(row[start])
# skip the first entry (the length), when reading the data
start += 1
end = start + length
element_data[name].append(row[start:end].astype(dt))
# start next property at the end of this one
start = end
# convert all property lists to numpy arrays
for name in element_data.keys():
element_data[name] = np.array(element_data[name]).squeeze()
return element_data
def load_element_single(properties, data):
"""
Load element data with lists of a single length
based on the element's property-definitions.
Parameters
------------
properties : dict
Property definitions encoded in a dict where the property name is the key
and the property data type the value.
data : array
Data rows for this element. If the data contains list-properties,
all lists belonging to one property must have the same length.
"""
col_ranges = []
start = 0
row0 = data[0]
for name, dt in properties.items():
length = 1
if '$LIST' in dt:
# the first entry in a list-property is the number of elements in the list
length = int(row0[start])
# skip the first entry (the length), when reading the data
start += 1
end = start + length
col_ranges.append((start, end))
# start next property at the end of this one
start = end
return {n: data[:, c[0]:c[1]].astype(dt.split('($LIST,)')[-1])
for c, (n, dt) in zip(col_ranges, properties.items())}
def ply_ascii(elements, file_obj):
"""
Load data from an ASCII PLY file into an existing elements data structure.
Parameters
------------
elements: OrderedDict object, populated from the file header.
object will be modified to add data by this function.
file_obj: open file object, with current position at the start
of the data section (past the header)
"""
# get the file contents as a string
text = str(file_obj.read().decode('utf-8'))
# split by newlines
lines = str.splitlines(text)
# get each line as an array split by whitespace
array = np.array([np.fromstring(i, sep=' ')
for i in lines])
# store the line position in the file
row_pos = 0
# loop through data we need
for key, values in elements.items():
# if the element is empty ignore it
if 'length' not in values or values['length'] == 0:
continue
data = array[row_pos:row_pos + values['length']]
row_pos += values['length']
# try stacking the data, which simplifies column-wise access. this is only
# possible, if all rows have the same length.
try:
data = np.vstack(data)
col_count_equal = True
except ValueError:
col_count_equal = False
# number of list properties in this element
list_count = sum(1 for dt in values['properties'].values() if '$LIST' in dt)
if col_count_equal and list_count <= 1:
# all rows have the same length and we only have at most one list
# property where all entries have the same length. this means we can
# use the quick numpy-based loading.
element_data = load_element_single(
values['properties'], data)
else:
# there are lists of differing lengths. we need to fall back to loading
# the data by iterating all rows and checking for list-lengths. this is
# slower than the variant above.
element_data = load_element_different(
values['properties'], data)
elements[key]['data'] = element_data
def ply_binary(elements, file_obj):
"""
Load the data from a binary PLY file into the elements data structure.
Parameters
------------
elements : OrderedDict
Populated from the file header.
Object will be modified to add data by this function.
file_obj : open file object
With current position at the start
of the data section (past the header)
"""
def populate_listsize(file_obj, elements):
"""
Given a set of elements populated from the header if there are any
list properties seek in the file the length of the list.
Note that if you have a list where each instance is different length
(if for example you mixed triangles and quads) this won't work at all
"""
p_start = file_obj.tell()
p_current = file_obj.tell()
elem_pop = []
for element_key, element in elements.items():
props = element['properties']
prior_data = ''
for k, dtype in props.items():
prop_pop = []
if '$LIST' in dtype:
# every list field has two data types:
# the list length (single value), and the list data (multiple)
# here we are only reading the single value for list length
field_dtype = np.dtype(dtype.split(',')[0])
if len(prior_data) == 0:
offset = 0
else:
offset = np.dtype(prior_data).itemsize
file_obj.seek(p_current + offset)
blob = file_obj.read(field_dtype.itemsize)
if len(blob) == 0:
# no data was read for property
prop_pop.append(k)
break
size = np.frombuffer(blob, dtype=field_dtype)[0]
props[k] = props[k].replace('$LIST', str(size))
prior_data += props[k] + ','
if len(prop_pop) > 0:
# if a property was empty remove it
for pop in prop_pop:
props.pop(pop)
# if we've removed all properties from
# an element remove the element later
if len(props) == 0:
elem_pop.append(element_key)
continue
# get the size of the items in bytes
itemsize = np.dtype(', '.join(props.values())).itemsize
# offset the file based on read size
p_current += element['length'] * itemsize
# move the file back to where we found it
file_obj.seek(p_start)
# if there were elements without properties remove them
for pop in elem_pop:
elements.pop(pop)
def populate_data(file_obj, elements):
"""
Given the data type and field information from the header,
read the data and add it to a 'data' field in the element.
"""
for key in elements.keys():
items = list(elements[key]['properties'].items())
dtype = np.dtype(items)
data = file_obj.read(elements[key]['length'] * dtype.itemsize)
elements[key]['data'] = np.frombuffer(data,
dtype=dtype)
return elements
def elements_size(elements):
"""
Given an elements data structure populated from the header,
calculate how long the file should be if it is intact.
"""
size = 0
for element in elements.values():
dtype = np.dtype(','.join(element['properties'].values()))
size += element['length'] * dtype.itemsize
return size
# some elements are passed where the list dimensions
# are not included in the header, so this function goes
# into the meat of the file and grabs the list dimensions
# before we to the main data read as a single operation
populate_listsize(file_obj, elements)
# how many bytes are left in the file
size_file = util.distance_to_end(file_obj)
# how many bytes should the data structure described by
# the header take up
size_elements = elements_size(elements)
# if the number of bytes is not the same the file is probably corrupt
if size_file != size_elements:
raise ValueError('File is unexpected length!')
# with everything populated and a reasonable confidence the file
# is intact, read the data fields described by the header
populate_data(file_obj, elements)
def export_draco(mesh, bits=28):
"""
Export a mesh using Google's Draco compressed format.
Only works if draco_encoder is in your PATH:
https://github.com/google/draco
Parameters
----------
mesh : Trimesh object
Mesh to export
bits : int
Bits of quantization for position
tol.merge=1e-8 is roughly 25 bits
Returns
----------
data : str or bytes
DRC file bytes
"""
with tempfile.NamedTemporaryFile(suffix='.ply') as temp_ply:
temp_ply.write(export_ply(mesh))
temp_ply.flush()
with tempfile.NamedTemporaryFile(suffix='.drc') as encoded:
subprocess.check_output([draco_encoder,
'-qp',
str(int(bits)),
'-i',
temp_ply.name,
'-o',
encoded.name])
encoded.seek(0)
data = encoded.read()
return data
def load_draco(file_obj, **kwargs):
"""
Load a mesh from Google's Draco format.
Parameters
----------
file_obj : file- like object
Contains data
Returns
----------
kwargs : dict
Keyword arguments to construct a Trimesh object
"""
with tempfile.NamedTemporaryFile(suffix='.drc') as temp_drc:
temp_drc.write(file_obj.read())
temp_drc.flush()
with tempfile.NamedTemporaryFile(suffix='.ply') as temp_ply:
subprocess.check_output([draco_decoder,
'-i',
temp_drc.name,
'-o',
temp_ply.name])
temp_ply.seek(0)
kwargs = load_ply(temp_ply)
return kwargs
_ply_loaders = {'ply': load_ply}
_ply_exporters = {'ply': export_ply}
draco_encoder = find_executable('draco_encoder')
draco_decoder = find_executable('draco_decoder')
if draco_decoder is not None:
_ply_loaders['drc'] = load_draco
if draco_encoder is not None:
_ply_exporters['drc'] = export_draco