Mesh_Rigger / UniRig /src /data /exporter.py
jkorstad's picture
Correctly add UniRig source files
f499d3b
import numpy as np
from numpy import ndarray
from typing import List, Union, Tuple
from collections import defaultdict
import os
try:
import open3d as o3d
OPEN3D_EQUIPPED = True
except:
print("do not have open3d")
OPEN3D_EQUIPPED = False
class Exporter():
def _safe_make_dir(self, path):
if os.path.dirname(path) == '':
return
os.makedirs(os.path.dirname(path), exist_ok=True)
def _export_skeleton(self, joints: ndarray, parents: List[Union[int, None]], path: str):
format = path.split('.')[-1]
assert format in ['obj']
name = path.removesuffix('.obj')
path = name + ".obj"
self._safe_make_dir(path)
J = joints.shape[0]
with open(path, 'w') as file:
file.write("o spring_joint\n")
_joints = []
for id in range(J):
pid = parents[id]
if pid is None or pid == -1:
continue
bx, by, bz = joints[id]
ex, ey, ez = joints[pid]
_joints.extend([
f"v {bx} {bz} {-by}\n",
f"v {ex} {ez} {-ey}\n",
f"v {ex} {ez} {-ey + 0.00001}\n"
])
file.writelines(_joints)
_faces = [f"f {id*3+1} {id*3+2} {id*3+3}\n" for id in range(J)]
file.writelines(_faces)
def _export_bones(self, bones: ndarray, path: str):
format = path.split('.')[-1]
assert format in ['obj']
name = path.removesuffix('.obj')
path = name + ".obj"
self._safe_make_dir(path)
J = bones.shape[0]
with open(path, 'w') as file:
file.write("o bones\n")
_joints = []
for bone in bones:
bx, by, bz = bone[:3]
ex, ey, ez = bone[3:]
_joints.extend([
f"v {bx} {bz} {-by}\n",
f"v {ex} {ez} {-ey}\n",
f"v {ex} {ez} {-ey + 0.00001}\n"
])
file.writelines(_joints)
_faces = [f"f {id*3+1} {id*3+2} {id*3+3}\n" for id in range(J)]
file.writelines(_faces)
def _export_skeleton_sequence(self, joints: ndarray, parents: List[Union[int, None]], path: str):
format = path.split('.')[-1]
assert format in ['obj']
name = path.removesuffix('.obj')
path = name + ".obj"
self._safe_make_dir(path)
J = joints.shape[0]
for i in range(J):
file = open(name + f"_{i}.obj", 'w')
file.write("o spring_joint\n")
_joints = []
for id in range(i + 1):
pid = parents[id]
if pid is None:
continue
bx, by, bz = joints[id]
ex, ey, ez = joints[pid]
_joints.extend([
f"v {bx} {bz} {-by}\n",
f"v {ex} {ez} {-ey}\n",
f"v {ex} {ez} {-ey + 0.00001}\n"
])
file.writelines(_joints)
_faces = [f"f {id*3+1} {id*3+2} {id*3+3}\n" for id in range(J)]
file.writelines(_faces)
file.close()
def _export_mesh(self, vertices: ndarray, faces: ndarray, path: str):
format = path.split('.')[-1]
assert format in ['obj', 'ply']
if path.endswith('ply'):
if not OPEN3D_EQUIPPED:
raise RuntimeError("open3d is not available")
mesh = o3d.geometry.TriangleMesh()
mesh.vertices = o3d.utility.Vector3dVector(vertices)
mesh.triangles = o3d.utility.Vector3iVector(faces)
self._safe_make_dir(path)
o3d.io.write_triangle_mesh(path, mesh)
return
name = path.removesuffix('.obj')
path = name + ".obj"
self._safe_make_dir(path)
with open(path, 'w') as file:
file.write("o mesh\n")
_vertices = []
for co in vertices:
_vertices.append(f"v {co[0]} {co[2]} {-co[1]}\n")
file.writelines(_vertices)
_faces = []
for face in faces:
_faces.append(f"f {face[0]+1} {face[1]+1} {face[2]+1}\n")
file.writelines(_faces)
def _export_pc(self, vertices: ndarray, path: str, vertex_normals: Union[ndarray, None]=None, normal_size: float=0.01):
if path.endswith('.ply'):
if vertex_normals is not None:
print("normal result will not be displayed in .ply format")
name = path.removesuffix('.ply')
path = name + ".ply"
pc = o3d.geometry.PointCloud()
pc.points = o3d.utility.Vector3dVector(vertices)
# segment fault when numpy >= 2.0 !! use torch environment
self._safe_make_dir(path)
o3d.io.write_point_cloud(path, pc)
return
name = path.removesuffix('.obj')
path = name + ".obj"
self._safe_make_dir(path)
with open(path, 'w') as file:
file.write("o pc\n")
_vertex = []
for co in vertices:
_vertex.append(f"v {co[0]} {co[2]} {-co[1]}\n")
file.writelines(_vertex)
if vertex_normals is not None:
new_path = path.replace('.obj', '_normal.obj')
nfile = open(new_path, 'w')
nfile.write("o normal\n")
_normal = []
for i in range(vertices.shape[0]):
co = vertices[i]
x = vertex_normals[i, 0]
y = vertex_normals[i, 1]
z = vertex_normals[i, 2]
_normal.extend([
f"v {co[0]} {co[2]} {-co[1]}\n",
f"v {co[0]+0.0001} {co[2]} {-co[1]}\n",
f"v {co[0]+x*normal_size} {co[2]+z*normal_size} {-(co[1]+y*normal_size)}\n",
f"f {i*3+1} {i*3+2} {i*3+3}\n",
])
nfile.writelines(_normal)
def _make_armature(
self,
vertices: Union[ndarray, None],
joints: ndarray,
skin: Union[ndarray, None],
parents: List[Union[int, None]],
names: List[str],
faces: Union[ndarray, None]=None,
extrude_size: float=0.03,
group_per_vertex: int=-1,
add_root: bool=False,
do_not_normalize: bool=False,
use_extrude_bone: bool=True,
use_connect_unique_child: bool=True,
extrude_from_parent: bool=True,
tails: Union[ndarray, None]=None,
):
import bpy # type: ignore
from mathutils import Vector # type: ignore
# make collection
collection = bpy.data.collections.new('new_collection')
bpy.context.scene.collection.children.link(collection)
# make mesh
if vertices is not None:
mesh = bpy.data.meshes.new('mesh')
if faces is None:
faces = []
mesh.from_pydata(vertices, [], faces)
mesh.update()
# make object from mesh
object = bpy.data.objects.new('character', mesh)
# add object to scene collection
collection.objects.link(object)
# deselect mesh
bpy.ops.object.armature_add(enter_editmode=True)
armature = bpy.data.armatures.get('Armature')
edit_bones = armature.edit_bones
J = joints.shape[0]
if tails is None:
tails = joints.copy()
tails[:, 2] += extrude_size
connects = [False for _ in range(J)]
children = defaultdict(list)
for i in range(1, J):
children[parents[i]].append(i)
if tails is not None:
if use_extrude_bone:
for i in range(J):
if len(children[i]) != 1 and extrude_from_parent and i != 0:
pjoint = joints[parents[i]]
joint = joints[i]
d = joint - pjoint
if np.linalg.norm(d) < 0.000001:
d = np.array([0., 0., 1.]) # in case son.head == parent.head
else:
d = d / np.linalg.norm(d)
tails[i] = joint + d * extrude_size
if use_connect_unique_child:
for i in range(J):
if len(children[i]) == 1:
child = children[i][0]
tails[i] = joints[child]
if parents[i] is not None and len(children[parents[i]]) == 1:
connects[i] = True
if add_root:
bone_root = edit_bones.get('Bone')
bone_root.name = 'Root'
bone_root.tail = Vector((joints[0, 0], joints[0, 1], joints[0, 2]))
else:
bone_root = edit_bones.get('Bone')
bone_root.name = names[0]
bone_root.head = Vector((joints[0, 0], joints[0, 1], joints[0, 2]))
bone_root.tail = Vector((joints[0, 0], joints[0, 1], joints[0, 2] + extrude_size))
def extrude_bone(
edit_bones,
name: str,
parent_name: str,
head: Tuple[float, float, float],
tail: Tuple[float, float, float],
connect: bool
):
bone = edit_bones.new(name)
bone.head = Vector((head[0], head[1], head[2]))
bone.tail = Vector((tail[0], tail[1], tail[2]))
bone.name = name
parent_bone = edit_bones.get(parent_name)
bone.parent = parent_bone
bone.use_connect = connect
assert not np.isnan(head).any(), f"nan found in head of bone {name}"
assert not np.isnan(tail).any(), f"nan found in tail of bone {name}"
for i in range(J):
if add_root is False and i==0:
continue
edit_bones = armature.edit_bones
pname = 'Root' if parents[i] is None else names[parents[i]]
extrude_bone(edit_bones, names[i], pname, joints[i], tails[i], connects[i])
for i in range(J):
bone = edit_bones.get(names[i])
bone.head = Vector((joints[i, 0], joints[i, 1], joints[i, 2]))
bone.tail = Vector((tails[i, 0], tails[i, 1], tails[i, 2]))
if vertices is None or skin is None:
return
# must set to object mode to enable parent_set
bpy.ops.object.mode_set(mode='OBJECT')
objects = bpy.data.objects
for o in bpy.context.selected_objects:
o.select_set(False)
ob = objects['character']
arm = bpy.data.objects['Armature']
ob.select_set(True)
arm.select_set(True)
bpy.ops.object.parent_set(type='ARMATURE_NAME')
vis = []
for x in ob.vertex_groups:
vis.append(x.name)
#sparsify
argsorted = np.argsort(-skin, axis=1)
vertex_group_reweight = skin[np.arange(skin.shape[0])[..., None], argsorted]
if group_per_vertex == -1:
group_per_vertex = vertex_group_reweight.shape[-1]
if not do_not_normalize:
vertex_group_reweight = vertex_group_reweight / vertex_group_reweight[..., :group_per_vertex].sum(axis=1)[...,None]
for v, w in enumerate(skin):
for ii in range(group_per_vertex):
i = argsorted[v, ii]
if i >= J:
continue
n = names[i]
if n not in vis:
continue
ob.vertex_groups[n].add([v], vertex_group_reweight[v, ii], 'REPLACE')
def _clean_bpy(self):
import bpy # type: ignore
for c in bpy.data.actions:
bpy.data.actions.remove(c)
for c in bpy.data.armatures:
bpy.data.armatures.remove(c)
for c in bpy.data.cameras:
bpy.data.cameras.remove(c)
for c in bpy.data.collections:
bpy.data.collections.remove(c)
for c in bpy.data.images:
bpy.data.images.remove(c)
for c in bpy.data.materials:
bpy.data.materials.remove(c)
for c in bpy.data.meshes:
bpy.data.meshes.remove(c)
for c in bpy.data.objects:
bpy.data.objects.remove(c)
for c in bpy.data.textures:
bpy.data.textures.remove(c)
def _export_fbx(
self,
path: str,
vertices: Union[ndarray, None],
joints: ndarray,
skin: Union[ndarray, None],
parents: List[Union[int, None]],
names: List[str],
faces: Union[ndarray, None]=None,
extrude_size: float=0.03,
group_per_vertex: int=-1,
add_root: bool=False,
do_not_normalize: bool=False,
use_extrude_bone: bool=True,
use_connect_unique_child: bool=True,
extrude_from_parent: bool=True,
tails: Union[ndarray, None]=None,
):
'''
Requires bpy installed
'''
import bpy # type: ignore
self._safe_make_dir(path)
self._clean_bpy()
self._make_armature(
vertices=vertices,
joints=joints,
skin=skin,
parents=parents,
names=names,
faces=faces,
extrude_size=extrude_size,
group_per_vertex=group_per_vertex,
add_root=add_root,
do_not_normalize=do_not_normalize,
use_extrude_bone=use_extrude_bone,
use_connect_unique_child=use_connect_unique_child,
extrude_from_parent=extrude_from_parent,
tails=tails,
)
# always enable add_leaf_bones to keep leaf bones
bpy.ops.export_scene.fbx(filepath=path, check_existing=False, add_leaf_bones=False)
def _export_render(
self,
path: str,
vertices: Union[ndarray, None],
faces: Union[ndarray, None],
bones: Union[ndarray, None],
resolution: Tuple[float, float]=[256, 256],
):
import bpy # type: ignore
import bpy_extras # type: ignore
from mathutils import Vector # type: ignore
self._safe_make_dir(path)
# normalize into [-1, 1]^3
# copied from augment
assert (vertices is not None) or (bones is not None)
bounds = []
if vertices is not None:
bounds.append(vertices)
if bones is not None:
bounds.append(bones[:, :3])
bounds.append(bones[:, 3:])
bounds = np.concatenate(bounds, axis=0)
bound_min = bounds.min(axis=0)
bound_max = bounds.max(axis=0)
trans_vertex = np.eye(4)
trans_vertex = _trans_to_m(-(bound_max + bound_min)/2) @ trans_vertex
# scale into the cube [-1, 1]
scale = np.max((bound_max - bound_min) / 2)
trans_vertex = _scale_to_m(1. / scale) @ trans_vertex
def _apply(v: ndarray, trans: ndarray) -> ndarray:
return np.matmul(v, trans[:3, :3].transpose()) + trans[:3, 3]
if vertices is not None:
vertices = _apply(vertices, trans_vertex)
if bones is not None:
bones[:, :3] = _apply(bones[:, :3], trans_vertex)
bones[:, 3:] = _apply(bones[:, 3:], trans_vertex)
# bpy api calls
self._clean_bpy()
bpy.context.scene.render.engine = 'BLENDER_WORKBENCH'
bpy.context.scene.render.film_transparent = True
bpy.context.scene.display.shading.background_type = 'VIEWPORT'
collection = bpy.data.collections.new('new_collection')
bpy.context.scene.collection.children.link(collection)
if vertices is not None:
mesh_data = bpy.data.meshes.new(name="MeshData")
mesh_obj = bpy.data.objects.new(name="MeshObject", object_data=mesh_data)
collection.objects.link(mesh_obj)
mesh_data.from_pydata((vertices).tolist(), [], faces.tolist())
mesh_data.update()
def look_at(camera, point):
direction = point - camera.location
rot_quat = direction.to_track_quat('-Z', 'Y')
camera.rotation_euler = rot_quat.to_euler()
bpy.ops.object.camera_add(location=(4, -4, 2.5))
camera = bpy.context.object
camera.data.angle = np.radians(25.0)
look_at(camera, Vector((0, 0, -0.2)))
bpy.context.scene.camera = camera
bpy.context.scene.render.resolution_x = resolution[0]
bpy.context.scene.render.resolution_y = resolution[1]
bpy.context.scene.render.image_settings.file_format = 'PNG'
bpy.context.scene.render.filepath = path
bpy.ops.render.render(write_still=True)
# some AI generated code to draw bones over mesh
if bones is not None:
# TODO: do not save image after rendering
from PIL import Image, ImageDraw
img_pil = Image.open(path).convert("RGBA")
draw = ImageDraw.Draw(img_pil)
from bpy_extras.image_utils import load_image # type: ignore
bpy.context.scene.use_nodes = True
nodes = bpy.context.scene.node_tree.nodes
# nodes.clear()
img = load_image(path)
image_node = nodes.new(type='CompositorNodeImage')
image_node.image = img
for i, bone in enumerate(bones):
head, tail = bone[:3], bone[3:]
head_2d = bpy_extras.object_utils.world_to_camera_view(bpy.context.scene, camera, Vector(head))
tail_2d = bpy_extras.object_utils.world_to_camera_view(bpy.context.scene, camera, Vector(tail))
res_x, res_y = resolution
head_pix = (head_2d.x * res_x, (1 - head_2d.y) * res_y)
tail_pix = (tail_2d.x * res_x, (1 - tail_2d.y) * res_y)
draw.line([head_pix, tail_pix], fill=(255, 0, 0, 255), width=1)
img_pil.save(path)
def _trans_to_m(v: ndarray):
m = np.eye(4)
m[0:3, 3] = v
return m
def _scale_to_m(r: ndarray):
m = np.zeros((4, 4))
m[0, 0] = r
m[1, 1] = r
m[2, 2] = r
m[3, 3] = 1.
return m