diff --git a/lace/mesh.py b/lace/mesh.py index 371ab2b..f956c20 100644 --- a/lace/mesh.py +++ b/lace/mesh.py @@ -30,32 +30,49 @@ class Mesh( Attributes: v: Vx3 array of vertices - f: Fx3 array of faces - f4: Fx4 array of faces (optional quads) - e: E*2 array of edges + f4: Fx4 array of vert indices for each quad face (with_quads=True) + f: Fx3 array of vert indices for each tri face (with_quads=False) Optional attributes: - fc: Fx3 array of face colors - vc: Vx3 array of vertex colors - vn: Vx3 array of vertex normals - segm: dictionary of part names to triangle indices + vn: VNx3 array of vertex normals + vt: VTx3 array of texture vertices + vc: Vx3 array of each vertex's color + segm: dictionary of part names to face indices (f or f4) + e: Ex2 array of edges + + Optional attributes (with_quads=True): + fc4: Fx4 array of colors for each face + fn4: Fx4 array of vertex normal indices for each face + ft4: Fx4 array of texture vertex indices for each face + + Optional attributes (with_quads=False): + fc: Fx3 array of colors for each face + fn: Fx3 array of vertex normal indices for each face + ft: Fx3 array of texture vertex indices for each face """ def __init__(self, filename=None, v=None, f=None, f4=None, - vc=None, fc=None, - vn=None, fn=None, - vt=None, ft=None, + vc=None, fc=None, fc4=None, + vn=None, fn=None, fn4=None, + vt=None, ft=None, ft4=None, e=None, segm=None, basename=None, - landmarks=None, ppfilename=None, lmrkfilename=None): - _properties = ['v', 'f', 'f4', 'fc', 'vc', 'fn', 'vn', 'ft', 'vt', 'e', 'segm'] + landmarks=None, ppfilename=None, lmrkfilename=None, + with_quads=False): + self._with_quads = with_quads + self._blocked_properties = [ + prop3 if with_quads else prop3 + '4' + for prop3 in ['f', 'fc', 'fn', 'ft'] + ] + + _properties = ['v', 'f', 'f4', 'vc', 'fc', 'fc4', 'vn', 'fn', 'fn4', 'vt', 'ft', 'ft4', 'e', 'segm'] # First, set initial values, so that unset v doesn't override the v # from a file. We do this explicitly rather than using _properties # so that pylint will recognize the attributes and make # attribute-defined-outside-init warnings useful. - self.v = self.f = self.f4 = self.fc = self.vc = self.vn = self.fn = self.vt = self.ft = self.e = self.segm = None + self.v = self.f = self.f4 = self.vc = self.fc = self.fc4 = self.vn = self.fn = self.fn4 = self.vt = self.ft = self.ft4 = self.e = self.segm = None self.basename = basename # Load from a file, if one is specified @@ -72,8 +89,11 @@ def __init__(self, # Support `Mesh(mesh)` import copy other_mesh = filename + if with_quads != other_mesh.with_quads: + raise ValueError('Must use with_quads={} when copying a mesh with_quads={}'.format( + other_mesh.with_quads, other_mesh.with_quads)) # A deep copy with all of the numpy arrays copied: - for a in ['v', 'f'] + other_mesh.__dict__.keys(): # NB: v and f first as vc and fc need them + for a in ['v', 'f', 'f4'] + other_mesh.__dict__.keys(): # NB: v and f[4] first as vc[4] and fc[4] need them if a == 'landm_raw_xyz': # We've deprecated landm_raw_xyz and it raises an error to access it now, but some # older pickled meshes (don't pickle meshes!) still have it as a property and they @@ -131,10 +151,10 @@ def copy(self, only=None): If only is a list of strings, i.e. ['f', 'v'], then only those properties will be copied ''' if only is None: - return Mesh(self) + return Mesh(self, with_quads=self.with_quads) else: import copy - m = Mesh() + m = Mesh(with_quads=self.with_quads) for a in only: setattr(m, a, copy.deepcopy(getattr(self, a))) return m @@ -149,33 +169,50 @@ def copy_fvs(self): ''' return self.copy(only=['f', 'v', 'segm']) + def _ensure_property_set_is_allowed(self, prop): + if prop in self._blocked_properties: + alternate_prop = prop + '4' if self.with_quads else prop.rstrip('4') + raise ValueError("When with_quads={}, setting {} is not allowed. Use {} instead.".format( + self.with_quads, prop, alternate_prop)) + + def _set_property(self, prop, val): + if val is not None: + self._ensure_property_set_is_allowed(prop) + self.__dict__[prop] = val + + @property + def with_quads(self): + return self._with_quads + # Rather than use a private _x varaible and boilerplate getters for # these, we'll use the actual var name and just override the setter. @setter_property def v(self, val): # cached properties that are dependent on v self._clear_cached_properties('vertices_to_edges_matrix', 'vertices_to_edges_matrix_single_axis') - self.__dict__['v'] = as_numeric_array(val, dtype=np.float64, shape=(-1, 3), allow_none=True, empty_as_none=True) + self._set_property('v', as_numeric_array(val, dtype=np.float64, shape=(-1, 3), allow_none=True, empty_as_none=True)) @setter_property def f(self, val): # cached properties that are dependent on f self._clear_cached_properties('faces_per_edge', 'vertices_per_edge', 'vertices_to_edges_matrix', 'vertices_to_edges_matrix_single_axis') - self.__dict__['f'] = as_numeric_array(val, dtype=np.uint64, shape=(-1, 3), allow_none=True, empty_as_none=True) + self._set_property('f', as_numeric_array(val, dtype=np.uint64, shape=(-1, 3), allow_none=True, empty_as_none=True)) @setter_property def f4(self, val): - self.__dict__['f4'] = as_numeric_array(val, dtype=np.uint64, shape=(-1, 4), allow_none=True, empty_as_none=True) + self._set_property('f4', as_numeric_array(val, dtype=np.uint64, shape=(-1, 4), allow_none=True, empty_as_none=True)) @setter_property def fc(self, val): - val = color.colors_like(val, self.f) - self.__dict__['fc'] = val + self._set_property('fc', color.colors_like(val, self.f)) + + @setter_property + def fc4(self, val): + self._set_property('fc4', color.colors_like(val, self.f4)) @setter_property def vc(self, val): - val = color.colors_like(val, self.v) - self.__dict__['vc'] = val + self._set_property('vc', color.colors_like(val, self.v)) @setter_property def fn(self, val): @@ -184,27 +221,35 @@ def fn(self, val): Someday we should refactor things so that we have seperate fn (face normal vectors) & fvn (face vertex normal indicies). Today is not that day. ''' - self.__dict__['fn'] = as_numeric_array(val, dtype=np.float64, shape=(-1, 3), allow_none=True, empty_as_none=True) + self._set_property('fn', as_numeric_array(val, dtype=np.float64, shape=(-1, 3), allow_none=True, empty_as_none=True)) @setter_property def vn(self, val): - self.__dict__['vn'] = as_numeric_array(val, dtype=np.float64, shape=(-1, 3), allow_none=True, empty_as_none=True) + self._set_property('vn', as_numeric_array(val, dtype=np.float64, shape=(-1, 3), allow_none=True, empty_as_none=True)) @setter_property def ft(self, val): - self.__dict__['ft'] = as_numeric_array(val, dtype=np.uint32, shape=(-1, 3), allow_none=True, empty_as_none=True) + self._set_property('ft', as_numeric_array(val, dtype=np.uint32, shape=(-1, 3), allow_none=True, empty_as_none=True)) + + @setter_property + def ft4(self, val): + self._set_property('ft4', as_numeric_array(val, dtype=np.uint32, shape=(-1, 4), allow_none=True, empty_as_none=True)) + + @setter_property + def fn4(self, val): + self._set_property('fn4', as_numeric_array(val, dtype=np.uint32, shape=(-1, 4), allow_none=True, empty_as_none=True)) @setter_property def vt(self, val): - self.__dict__['vt'] = as_numeric_array(val, dtype=np.float64, shape=(-1, 2), allow_none=True, empty_as_none=True) + self._set_property('vt', as_numeric_array(val, dtype=np.float64, shape=(-1, 2), allow_none=True, empty_as_none=True)) @setter_property def e(self, val): - self.__dict__['e'] = as_numeric_array(val, dtype=np.uint64, shape=(-1, 2), allow_none=True, empty_as_none=True) + self._set_property('e', as_numeric_array(val, dtype=np.uint64, shape=(-1, 2), allow_none=True, empty_as_none=True)) @setter_property def segm(self, val): - self.__dict__['segm'] = val + self._set_property('segm', val) def _clear_cached_properties(self, *keys): for cached_property_key in keys: @@ -221,9 +266,14 @@ def concatenate(cls, *args): if nargs == 1: return args[0] + with_quads = args[0].with_quads + if any([a.with_quads != with_quads for a in args]): + raise ValueError('Expected `with_quads` to match for all args.') + vs = [a.v for a in args if a.v is not None] vcs = [a.vc for a in args if a.vc is not None] fs = [a.f for a in args if a.f is not None] + f4s = [a.f4 for a in args if a.f4 is not None] if vs and len(vs) != nargs: raise ValueError('Expected `v` for all args or none.') @@ -231,6 +281,8 @@ def concatenate(cls, *args): raise ValueError('Expected `vc` for all args or none.') if fs and len(fs) != nargs: raise ValueError('Expected `f` for all args or none.') + if f4s and len(f4s) != nargs: + raise ValueError('Expected `f4` for all args or none.') # Offset face indices by the cumulative vertex count. face_offsets = np.cumsum([v.shape[0] for v in vs[:-1]]) @@ -241,4 +293,5 @@ def concatenate(cls, *args): v=np.vstack(vs) if vs else None, vc=np.vstack(vcs) if vcs else None, f=np.vstack(fs) if fs else None, + with_quads=with_quads ) diff --git a/lace/serialization/mesh.py b/lace/serialization/mesh.py index 33e0c6b..b601509 100644 --- a/lace/serialization/mesh.py +++ b/lace/serialization/mesh.py @@ -1,15 +1,17 @@ -def load(f, existing_mesh=None): +def load(f, existing_mesh=None, with_quads=False): from baiji.serialization.util.openlib import ensure_file_open_and_call return ensure_file_open_and_call(f, _load, mode='rb', existing_mesh=existing_mesh) -def _load(f, existing_mesh=None): +def _load(f, existing_mesh=None, with_quads=False): import os from lace.serialization import ply, obj, wrl, stl, xyz, bsf ext = os.path.splitext(f.name)[1].lower() + if ext == ".obj": + return obj.load(f, existing_mesh=existing_mesh, with_quads=with_quads) + if with_quads: + raise ValueError('Only the OBJ loader supports with_quads=True') if ext == ".ply": return ply.load(f, existing_mesh=existing_mesh) - elif ext == ".obj": - return obj.load(f, existing_mesh=existing_mesh) elif ext == ".wrl": return wrl.load(f, existing_mesh=existing_mesh) elif ext == ".ylp": diff --git a/lace/serialization/obj/__init__.py b/lace/serialization/obj/__init__.py index 73a77a7..11c0c5b 100644 --- a/lace/serialization/obj/__init__.py +++ b/lace/serialization/obj/__init__.py @@ -2,9 +2,9 @@ EXTENSION = '.obj' -def load(f, existing_mesh=None): +def load(f, existing_mesh=None, with_quads=False): from baiji.serialization.util.openlib import ensure_file_open_and_call - return ensure_file_open_and_call(f, _load, mode='rb', mesh=existing_mesh) + return ensure_file_open_and_call(f, _load, mode='rb', mesh=existing_mesh, with_quads=with_quads) def dump(obj, f, flip_faces=False, ungroup=False, comments=None, copyright=False, split_normals=False, write_mtl=True): # pylint: disable=redefined-outer-name, redefined-builtin, unused-argument @@ -15,16 +15,32 @@ def dump(obj, f, flip_faces=False, ungroup=False, comments=None, ungroup=ungroup, comments=comments, split_normals=split_normals, write_mtl=write_mtl) -def _load(fd, mesh=None): +def _load(fd, mesh=None, with_quads=False): from collections import OrderedDict from baiji import s3 from lace.mesh import Mesh from lace.cache import sc import lace.serialization.obj.objutils as objutils # pylint: disable=no-name-in-module - v, vt, vn, vc, f, ft, fn, mtl_path, landm, segm = objutils.read(fd.name) + v, vt, vn, vc, f, ft, fn, f4, ft4, fn4, mtl_path, landm, segm = objutils.read(fd.name) + if not mesh: - mesh = Mesh() + mesh = Mesh(with_quads=with_quads) + + # When loading a quad mesh, the legacy behavior of the loader is to + # triangulate on load, filling f/ft/fn. Now it populates _both_ f/ft/fn + # and f4/ft4/fn4, and when with_quads=False, we want callers to get the + # original behavior, so we discard f4/ft4/fn4. When with_quads=True, + # we keep f4/ft4/fn4, discard f/ft/fn, and reject a triangular mesh. + if with_quads: + has_tris = [attr.size != 0 for attr in [f, ft, fn]] + has_quads = [attr.size != 0 for attr in [f4, ft4, fn4]] + if has_tris and not has_quads: + raise ValueError("Can't use with_quads=True to load a triangulated mesh") + f = ft = fn = np.empty((0, 3)) + else: + f4 = ft4 = fn4 = np.empty((0, 4)) + if v.size != 0: mesh.v = v if f.size != 0: @@ -39,6 +55,12 @@ def _load(fd, mesh=None): mesh.fn = fn if ft.size != 0: mesh.ft = ft + if f4.size != 0: + mesh.f4 = f4 + if ft4.size != 0: + mesh.ft4 = ft4 + if fn4.size != 0: + mesh.fn4 = fn4 if segm: mesh.segm = OrderedDict([(k, v if isinstance(v, list) else v.tolist()) for k, v in segm.items()]) def path_relative_to_mesh(filename): @@ -117,7 +139,9 @@ def write_face_to_obj_file(obj, faces, face_index, obj_file): vertex_indices = faces[face_index][::ff] + 1 write_normals = obj.fn is not None or (obj.vn is not None and obj.vn.shape == obj.v.shape) - write_texture = obj.ft is not None and obj.vt is not None + write_texture = (obj.ft4 is not None or obj.ft is not None) and obj.vt is not None + + texture_faces = obj.ft4 if obj.ft4 is not None else obj.ft if write_normals and obj.fn is not None: normal_indices = obj.fn[face_index][::ff] + 1 @@ -126,7 +150,7 @@ def write_face_to_obj_file(obj, faces, face_index, obj_file): normal_indices = faces[face_index][::ff] + 1 if write_texture: - texture_indices = obj.ft[face_index][::ff] + 1 + texture_indices = texture_faces[face_index][::ff] + 1 assert len(texture_indices) == len(vertex_indices) # Valid obj face lines are: v, v/vt, v//vn, v/vt/vn @@ -182,7 +206,7 @@ def write_face_to_obj_file(obj, faces, face_index, obj_file): for r in obj.vn: f.write('vn %f %f %f\n' % (r[0], r[1], r[2])) - if obj.ft is not None and obj.vt is not None: + if (obj.ft4 is not None or obj.ft is not None) and obj.vt is not None: for r in obj.vt: if len(r) == 3: f.write('vt %f %f %f\n' % (r[0], r[1], r[2])) diff --git a/lace/serialization/obj/objutils.cpp b/lace/serialization/obj/objutils.cpp index e565ed5..9d44d71 100644 --- a/lace/serialization/obj/objutils.cpp +++ b/lace/serialization/obj/objutils.cpp @@ -73,6 +73,9 @@ objutils_read(PyObject *self, PyObject *args, PyObject *keywds) std::vector f; std::vector ft; std::vector fn; + std::vector f4; + std::vector ft4; + std::vector fn4; v.reserve(30000); vt.reserve(30000); vn.reserve(30000); @@ -80,6 +83,9 @@ objutils_read(PyObject *self, PyObject *args, PyObject *keywds) f.reserve(100000); ft.reserve(100000); fn.reserve(100000); + f4.reserve(100000); + ft4.reserve(100000); + fn4.reserve(100000); std::map > segm; bool next_v_is_land = false; @@ -177,6 +183,24 @@ objutils_read(PyObject *self, PyObject *args, PyObject *keywds) segm.find(*it)->second.push_back(face_index); } } + if (localf.size() == 4) { + f4.push_back(localf[0] - 1); + f4.push_back(localf[1] - 1); + f4.push_back(localf[2] - 1); + f4.push_back(localf[3] - 1); + } + if (localfn.size() == 4) { + fn4.push_back(localfn[0]); + fn4.push_back(localfn[1]); + fn4.push_back(localfn[2]); + fn4.push_back(localfn[3]); + } + if (localft.size() == 4) { + ft4.push_back(localft[0] - 1); + ft4.push_back(localft[1] - 1); + ft4.push_back(localft[2] - 1); + ft4.push_back(localft[3] - 1); + } if (localft.size() > 0) { if (localft.size() != localf.size()) { throw LoadObjException("Malformed OBJ file: could not parse face (len(ft) != len(f))"); @@ -257,6 +281,9 @@ objutils_read(PyObject *self, PyObject *args, PyObject *keywds) uint32_t n_f = (uint32_t)f.size()/3; uint32_t n_ft = (uint32_t)ft.size()/3; uint32_t n_fn = (uint32_t)fn.size()/3; + uint32_t n_f4 = (uint32_t)f4.size()/4; + uint32_t n_fn4 = (uint32_t)fn4.size()/4; + uint32_t n_ft4 = (uint32_t)ft4.size()/4; npy_intp v_dims[] = {n_v,3}; npy_intp vn_dims[] = {n_vn,3}; npy_intp vc_dims[] = {n_vc,len_vc}; @@ -264,6 +291,9 @@ objutils_read(PyObject *self, PyObject *args, PyObject *keywds) npy_intp f_dims[] = {n_f,3}; npy_intp ft_dims[] = {n_ft,3}; npy_intp fn_dims[] = {n_fn,3}; + npy_intp f4_dims[] = {n_f4,4}; + npy_intp fn4_dims[] = {n_fn4,3}; + npy_intp ft4_dims[] = {n_ft4,4}; /* // XXX Memory from vectors get deallocated! PyObject *py_v = PyArray_SimpleNewFromData(2, v_dims, NPY_DOUBLE, v.data()); @@ -288,6 +318,12 @@ objutils_read(PyObject *self, PyObject *args, PyObject *keywds) std::copy(ft.begin(), ft.end(), reinterpret_cast(PyArray_DATA(py_ft))); PyArrayObject *py_fn = (PyArrayObject *)PyArray_SimpleNew(2, fn_dims, NPY_UINT32); std::copy(fn.begin(), fn.end(), reinterpret_cast(PyArray_DATA(py_fn))); + PyArrayObject *py_f4 = (PyArrayObject *)PyArray_SimpleNew(2, f4_dims, NPY_UINT32); + std::copy(f4.begin(), f4.end(), reinterpret_cast(PyArray_DATA(py_f4))); + PyArrayObject *py_ft4 = (PyArrayObject *)PyArray_SimpleNew(2, ft4_dims, NPY_UINT32); + std::copy(ft4.begin(), ft4.end(), reinterpret_cast(PyArray_DATA(py_ft4))); + PyArrayObject *py_fn4 = (PyArrayObject *)PyArray_SimpleNew(2, fn4_dims, NPY_UINT32); + std::copy(fn4.begin(), fn4.end(), reinterpret_cast(PyArray_DATA(py_fn4))); PyObject *py_landm = PyDict_New(); for (std::map::iterator it=landm.begin(); it!=landm.end(); ++it) @@ -302,7 +338,7 @@ objutils_read(PyObject *self, PyObject *args, PyObject *keywds) PyDict_SetItemString(py_segm, it->first.c_str(), Py_BuildValue("N", temp)); } - return Py_BuildValue("NNNNNNNsNN",py_v,py_vt,py_vn,py_vc,py_f,py_ft,py_fn,mtl_path.c_str(),py_landm,py_segm); + return Py_BuildValue("NNNNNNNNNNsNN",py_v,py_vt,py_vn,py_vc,py_f,py_ft,py_fn,py_f4,py_ft4,py_fn4,mtl_path.c_str(),py_landm,py_segm); } catch (LoadObjException& e) { PyErr_SetString(LoadObjError, e.what()); return NULL; diff --git a/lace/topology.py b/lace/topology.py index 0792c88..7a0b007 100644 --- a/lace/topology.py +++ b/lace/topology.py @@ -26,6 +26,12 @@ def vertices_in_common(face_1, face_2): # import timeit; print timeit.timeit('vertices_in_common([0, 1, 2], [0, 1, 3])', setup='from lace.topology import vertices_in_common', number=10000) return [x for x in face_1 if x in face_2] +def face_indices_with_verts(faces, wanted_v_indices): + import numpy as np + max_vert_idx = np.amax(faces) + included_vertices = np.zeros(int(max_vert_idx) + 1, dtype=bool) + included_vertices[np.array(wanted_v_indices, dtype=np.uint32)] = True + return included_vertices[faces].all(axis=1) class MeshMixin(object): def faces_by_vertex(self, as_sparse_matrix=False): @@ -49,12 +55,13 @@ def all_faces_with_verts(self, v_indices, as_boolean=False): returns all of the faces that contain at least one of the vertices in v_indices ''' import numpy as np - included_vertices = np.zeros(self.v.shape[0], dtype=bool) - included_vertices[np.array(v_indices, dtype=np.uint32)] = True - faces_with_verts = included_vertices[self.f].all(axis=1) - if as_boolean: - return faces_with_verts - return np.nonzero(faces_with_verts)[0] + faces_with_verts = face_indices_with_verts(self.f, v_indices) + return faces_with_verts if as_boolean else np.nonzero(faces_with_verts[0]) + + def all_f4_with_verts(self, v_indices, as_boolean=False): + import numpy as np + faces_with_verts = face_indices_with_verts(self.f4, v_indices) + return faces_with_verts if as_boolean else np.nonzero(faces_with_verts[0]) def transfer_segm(self, mesh, exclude_empty_parts=True): import numpy as np @@ -238,6 +245,9 @@ def keep_vertices(self, indices_to_keep, ret_kept_faces=False): if self.f is not None: initial_num_faces = self.f.shape[0] f_indices_to_keep = self.all_faces_with_verts(indices_to_keep, as_boolean=True) + if self.f4 is not None: + initial_num_f4 = self.f4.shape[0] + f4_indices_to_keep = self.all_f4_with_verts(indices_to_keep, as_boolean=True) # Why do we test this? Don't know. But we do need to test it before we # mutate self.v. @@ -251,13 +261,18 @@ def keep_vertices(self, indices_to_keep, ret_kept_faces=False): if vc_should_update: self.vc = self.vc[indices_to_keep] - if self.f is not None: + if self.f is not None or self.f4 is not None: v_old_to_new = np.zeros(initial_num_verts, dtype=int) - f_old_to_new = np.zeros(initial_num_faces, dtype=int) - v_old_to_new[indices_to_keep] = np.arange(len(indices_to_keep), dtype=int) - self.f = v_old_to_new[self.f[f_indices_to_keep]] - f_old_to_new[f_indices_to_keep] = np.arange(self.f.shape[0], dtype=int) + + if self.f is not None: + f_old_to_new = np.zeros(initial_num_faces, dtype=int) + self.f = v_old_to_new[self.f[f_indices_to_keep]] + f_old_to_new[f_indices_to_keep] = np.arange(self.f.shape[0], dtype=int) + if self.f4 is not None: + f4_old_to_new = np.zeros(initial_num_f4, dtype=int) + self.f4 = v_old_to_new[self.f4[f4_indices_to_keep]] + f4_old_to_new[f4_indices_to_keep] = np.arange(self.f4.shape[0], dtype=int) else: # Make the code below work, in case there is somehow degenerate