Added Eye tracking and Visemes

This commit is contained in:
Yusarina
2024-12-15 20:14:26 +00:00
parent f4dc74d091
commit 87a351cea4
13 changed files with 1807 additions and 39 deletions
+867
View File
@@ -0,0 +1,867 @@
import os
import bpy
import copy
import math
import bmesh
import mathutils
import json
from bpy.types import Operator, Object, Context
from typing import Optional, Dict, Tuple, Set
from collections import OrderedDict
from random import random
from itertools import chain
from ..core.logging_setup import logger
from ..core.translations import t
from ..core.common import (
ProgressTracker,
get_active_armature,
get_all_meshes,
get_armature_list,
validate_armature,
validate_mesh_for_pose,
cache_vertex_positions,
apply_vertex_positions
)
VALID_EYE_NAMES = {
'left': ['LeftEye', 'Eye_L', 'eye_L', 'eye.L', 'EyeLeft', 'left_eye', 'l_eye'],
'right': ['RightEye', 'Eye_R', 'eye_R', 'eye.R', 'EyeRight', 'right_eye', 'r_eye']
}
class EyeTrackingBackup:
def __init__(self):
self.backup_path = os.path.join(bpy.app.tempdir, "eye_tracking_backup.json")
self.bone_positions: Dict[str, Dict[str, Tuple[float, float, float]]] = {}
def store_bone_positions(self, armature) -> bool:
try:
self.bone_positions = {
'LeftEye': {
'head': tuple(armature.data.bones['LeftEye'].head_local),
'tail': tuple(armature.data.bones['LeftEye'].tail_local)
},
'RightEye': {
'head': tuple(armature.data.bones['RightEye'].head_local),
'tail': tuple(armature.data.bones['RightEye'].tail_local)
}
}
with open(self.backup_path, 'w') as f:
json.dump(self.bone_positions, f)
return True
except Exception as e:
logger.error(f"Backup failed: {str(e)}")
return False
def restore_bone_positions(self, armature) -> bool:
try:
if not os.path.exists(self.backup_path):
return False
with open(self.backup_path, 'r') as f:
backup_data = json.load(f)
bpy.ops.object.mode_set(mode='EDIT')
for bone_name, positions in backup_data.items():
if bone_name in armature.data.edit_bones:
bone = armature.data.edit_bones[bone_name]
bone.head = positions['head']
bone.tail = positions['tail']
return True
except Exception as e:
logger.error(f"Restore failed: {str(e)}")
return False
class EyeTrackingValidator:
@staticmethod
def find_eye_vertex_groups(mesh_name: str) -> Tuple[str, str]:
mesh = bpy.data.objects.get(mesh_name)
if not mesh:
return None, None
left_group = None
right_group = None
for group in mesh.vertex_groups:
if any(name.lower() in group.name.lower() for name in VALID_EYE_NAMES['left']):
left_group = group.name
if any(name.lower() in group.name.lower() for name in VALID_EYE_NAMES['right']):
right_group = group.name
return left_group, right_group
@staticmethod
def validate_setup(context, mesh_name: str) -> Tuple[bool, str]:
armature = get_active_armature(context)
if not armature:
return False, t('EyeTracking.validation.noArmature')
mesh = bpy.data.objects.get(mesh_name)
if not mesh:
return False, t('EyeTracking.validation.noMesh', mesh=mesh_name)
if not mesh.data.shape_keys:
return False, t('EyeTracking.validation.noShapekeys')
left_group, right_group = EyeTrackingValidator.find_eye_vertex_groups(mesh_name)
missing_groups = []
if not left_group:
missing_groups.append(t('EyeTracking.validation.leftEye'))
if not right_group:
missing_groups.append(t('EyeTracking.validation.rightEye'))
if missing_groups:
return False, t('EyeTracking.validation.missingGroups', groups=', '.join(missing_groups))
required_bones = [context.scene.avatar_toolkit.head,
context.scene.avatar_toolkit.eye_left,
context.scene.avatar_toolkit.eye_right]
missing_bones = [bone for bone in required_bones if bone not in armature.data.bones]
if missing_bones:
return False, t('EyeTracking.validation.missingBones', bones=', '.join(missing_bones))
return True, t('EyeTracking.validation.success')
class CreateEyesButton(bpy.types.Operator):
bl_idname = 'avatar_toolkit.create_eye_tracking'
bl_label = t('EyeTracking.create.label')
bl_description = t('EyeTracking.create.desc')
bl_options = {'REGISTER', 'UNDO'}
mesh = None
@classmethod
def poll(cls, context):
if not get_all_meshes(context):
return False
toolkit = context.scene.avatar_toolkit
if not toolkit.head or not toolkit.eye_left or not toolkit.eye_right:
return False
if toolkit.disable_eye_blinking and toolkit.disable_eye_movement:
return False
return True
def execute(self, context):
toolkit = context.scene.avatar_toolkit
armature = get_active_armature(context)
with ProgressTracker(context, 100, "Creating Eye Tracking") as progress:
# Validate setup
validator = EyeTrackingValidator()
is_valid, message = validator.validate_setup(context, toolkit.mesh_name_eye)
if not is_valid:
self.report({'ERROR'}, message)
return {'CANCELLED'}
# Create backup
backup = EyeTrackingBackup()
if not backup.store_bone_positions(armature):
logger.warning("Failed to create backup")
try:
# Set active object and mode
context.view_layer.objects.active = armature
bpy.ops.object.mode_set(mode='EDIT')
progress.step("Setting up bones")
self.mesh = bpy.data.objects.get(toolkit.mesh_name_eye)
# Set up bones
head = armature.data.edit_bones.get(toolkit.head)
old_eye_left = armature.data.edit_bones.get(toolkit.eye_left)
old_eye_right = armature.data.edit_bones.get(toolkit.eye_right)
if not toolkit.disable_eye_blinking:
if not all([toolkit.wink_left, toolkit.wink_right,
toolkit.lowerlid_left, toolkit.lowerlid_right]):
self.report({'ERROR'}, t('EyeTracking.error.noShapeSelected'))
return {'CANCELLED'}
progress.step("Processing vertex groups")
# Create new eye bones
new_left_eye = armature.data.edit_bones.new('LeftEye')
new_right_eye = armature.data.edit_bones.new('RightEye')
# Parent them
new_left_eye.parent = head
new_right_eye.parent = head
# Calculate positions
fix_eye_position(context, old_eye_left, new_left_eye, head, False)
fix_eye_position(context, old_eye_right, new_right_eye, head, True)
progress.step("Processing shape keys")
# Process shape keys
if not toolkit.disable_eye_movement:
self.copy_vertex_group(old_eye_left.name, 'LeftEye')
self.copy_vertex_group(old_eye_right.name, 'RightEye')
# Handle shape keys
shapes = [toolkit.wink_left, toolkit.wink_right,
toolkit.lowerlid_left, toolkit.lowerlid_right]
new_shapes = ['vrc.blink_left', 'vrc.blink_right',
'vrc.lowerlid_left', 'vrc.lowerlid_right']
progress.step("Finalizing setup")
# Reset modes and cleanup
bpy.ops.object.mode_set(mode='OBJECT')
# Update scene properties
toolkit.eye_mode = 'TESTING'
self.report({'INFO'}, t('EyeTracking.success'))
return {'FINISHED'}
except Exception as e:
logger.error(f"Eye tracking setup failed: {str(e)}")
if backup.restore_bone_positions(get_active_armature(context)):
logger.info("Restored from backup")
return {'CANCELLED'}
class StartTestingButton(bpy.types.Operator):
bl_idname = 'avatar_toolkit.start_eye_testing'
bl_label = t('EyeTracking.testing.start.label')
bl_description = t('EyeTracking.testing.start.desc')
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
armature = get_active_armature(context)
return armature and 'LeftEye' in armature.pose.bones and 'RightEye' in armature.pose.bones
def execute(self, context):
armature = get_active_armature(context)
bpy.ops.object.mode_set(mode='POSE')
armature.data.pose_position = 'POSE'
global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot
eye_left = armature.pose.bones.get('LeftEye')
eye_right = armature.pose.bones.get('RightEye')
eye_left_data = armature.data.bones.get('LeftEye')
eye_right_data = armature.data.bones.get('RightEye')
# Save initial rotations
eye_left.rotation_mode = 'XYZ'
eye_left_rot = copy.deepcopy(eye_left.rotation_euler)
eye_right.rotation_mode = 'XYZ'
eye_right_rot = copy.deepcopy(eye_right.rotation_euler)
if not all([eye_left, eye_right, eye_left_data, eye_right_data]):
return {'FINISHED'}
# Reset shape keys
mesh = bpy.data.objects[context.scene.avatar_toolkit.mesh_name_eye]
for shape_key in mesh.data.shape_keys.key_blocks:
shape_key.value = 0
# Clear transforms
for pb in armature.data.bones:
pb.select = True
bpy.ops.pose.transforms_clear()
for pb in armature.data.bones:
pb.select = False
pb.hide = True
eye_left_data.hide = False
eye_right_data.hide = False
context.scene.avatar_toolkit.eye_rotation_x = 0
context.scene.avatar_toolkit.eye_rotation_y = 0
return {'FINISHED'}
class StopTestingButton(bpy.types.Operator):
bl_idname = 'avatar_toolkit.stop_eye_testing'
bl_label = t('EyeTracking.testing.stop.label')
bl_description = t('EyeTracking.testing.stop.desc')
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot
toolkit = context.scene.avatar_toolkit
if eye_left:
toolkit.eye_rotation_x = 0
toolkit.eye_rotation_y = 0
if not context.object or context.object.mode != 'POSE':
armature = get_active_armature(context)
bpy.ops.object.mode_set(mode='POSE')
armature = get_active_armature(context)
for pb in armature.data.bones:
pb.hide = False
pb.select = True
bpy.ops.pose.transforms_clear()
for pb in armature.data.bones:
pb.select = False
mesh = bpy.data.objects[toolkit.mesh_name_eye]
for shape_key in mesh.data.shape_keys.key_blocks:
shape_key.value = 0
eye_left = None
eye_right = None
eye_left_data = None
eye_right_data = None
eye_left_rot = []
eye_right_rot = []
return {'FINISHED'}
def set_rotation(self, context):
global eye_left, eye_right, eye_left_rot, eye_right_rot
toolkit = context.scene.avatar_toolkit
if not eye_left or not eye_right:
StartTestingButton.execute(StartTestingButton, context)
return None
eye_left.rotation_mode = 'XYZ'
eye_right.rotation_mode = 'XYZ'
x_rotation = math.radians(toolkit.eye_rotation_x)
y_rotation = math.radians(toolkit.eye_rotation_y)
eye_left.rotation_euler[0] = eye_left_rot[0] + x_rotation
eye_left.rotation_euler[1] = eye_left_rot[1] + y_rotation
eye_right.rotation_euler[0] = eye_right_rot[0] + x_rotation
eye_right.rotation_euler[1] = eye_right_rot[1] + y_rotation
return None
class ResetRotationButton(bpy.types.Operator):
bl_idname = 'avatar_toolkit.reset_eye_rotation'
bl_label = t('EyeTracking.reset.label')
bl_description = t('EyeTracking.reset.desc')
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
armature = get_active_armature(context)
return armature and 'LeftEye' in armature.pose.bones and 'RightEye' in armature.pose.bones
def execute(self, context):
toolkit = context.scene.avatar_toolkit
armature = get_active_armature(context)
toolkit.eye_rotation_x = 0
toolkit.eye_rotation_y = 0
global eye_left, eye_right, eye_left_data, eye_right_data
eye_left = armature.pose.bones.get('LeftEye')
eye_right = armature.pose.bones.get('RightEye')
eye_left_data = armature.data.bones.get('LeftEye')
eye_right_data = armature.data.bones.get('RightEye')
for eye in [eye_left, eye_right]:
eye.rotation_mode = 'XYZ'
for i in range(3):
eye.rotation_euler[i] = 0
return {'FINISHED'}
class AdjustEyesButton(bpy.types.Operator):
bl_idname = 'avatar_toolkit.adjust_eyes'
bl_label = t('EyeTracking.adjust.label')
bl_description = t('EyeTracking.adjust.desc')
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
armature = get_active_armature(context)
return armature and all(bone in armature.pose.bones for bone in ['LeftEye', 'RightEye'])
def execute(self, context):
toolkit = context.scene.avatar_toolkit
if toolkit.disable_eye_movement:
return {'FINISHED'}
mesh_name = toolkit.mesh_name_eye
mesh = bpy.data.objects.get(mesh_name)
if not mesh:
self.report({'ERROR'}, t('EyeTracking.error.noMesh'))
return {'CANCELLED'}
for eye in ['LeftEye', 'RightEye']:
if not any(g.group == mesh.vertex_groups[eye].index for v in mesh.data.vertices for g in v.groups):
self.report({'ERROR'}, t('EyeTracking.error.noVertexGroup', bone=eye))
return {'CANCELLED'}
armature = get_active_armature(context)
bpy.ops.object.mode_set(mode='EDIT')
new_eye_left = armature.data.edit_bones.get('LeftEye')
new_eye_right = armature.data.edit_bones.get('RightEye')
old_eye_left = armature.pose.bones.get(toolkit.eye_left)
old_eye_right = armature.pose.bones.get(toolkit.eye_right)
fix_eye_position(context, old_eye_left, new_eye_left, None, False)
fix_eye_position(context, old_eye_right, new_eye_right, None, True)
bpy.ops.object.mode_set(mode='POSE')
global eye_left, eye_right, eye_left_data, eye_right_data
eye_left = armature.pose.bones.get('LeftEye')
eye_right = armature.pose.bones.get('RightEye')
eye_left_data = armature.data.bones.get('LeftEye')
eye_right_data = armature.data.bones.get('RightEye')
return {'FINISHED'}
class StartIrisHeightButton(bpy.types.Operator):
bl_idname = 'avatar_toolkit.adjust_iris_height'
bl_label = t('EyeTracking.iris.label')
bl_description = t('EyeTracking.iris.desc')
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
armature = get_active_armature(context)
return armature and all(bone in armature.pose.bones for bone in ['LeftEye', 'RightEye'])
def execute(self, context):
toolkit = context.scene.avatar_toolkit
if toolkit.disable_eye_movement:
return {'FINISHED'}
armature = get_active_armature(context)
armature.hide_viewport = True
mesh = bpy.data.objects[toolkit.mesh_name_eye]
mesh.select_set(True)
context.view_layer.objects.active = mesh
bpy.ops.object.mode_set(mode='EDIT')
if len(mesh.vertex_groups) > 0:
bpy.ops.mesh.select_mode(type='VERT')
for vg_name in ['LeftEye', 'RightEye']:
vg = mesh.vertex_groups.get(vg_name)
if vg:
bpy.ops.object.vertex_group_set_active(group=vg.name)
bpy.ops.object.vertex_group_select()
bm = bmesh.from_edit_mesh(mesh.data)
for v in bm.verts:
if v.select:
v.co.y += toolkit.iris_height * 0.01
logger.debug(f"Adjusted vertex position: {v.co}")
bmesh.update_edit_mesh(mesh.data)
return {'FINISHED'}
class TestBlinking(bpy.types.Operator):
bl_idname = 'avatar_toolkit.test_blinking'
bl_label = t('EyeTracking.blink.test.label')
bl_description = t('EyeTracking.blink.test.desc')
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
toolkit = context.scene.avatar_toolkit
mesh = bpy.data.objects.get(toolkit.mesh_name_eye)
return (mesh and mesh.data.shape_keys and
all(key in mesh.data.shape_keys.key_blocks for key in ['vrc.blink_left', 'vrc.blink_right']))
def execute(self, context):
toolkit = context.scene.avatar_toolkit
mesh = bpy.data.objects[toolkit.mesh_name_eye]
shapes = ['vrc.blink_left', 'vrc.blink_right']
for shape_key in mesh.data.shape_keys.key_blocks:
shape_key.value = toolkit.eye_blink_shape if shape_key.name in shapes else 0
return {'FINISHED'}
class TestLowerlid(bpy.types.Operator):
bl_idname = 'avatar_toolkit.test_lowerlid'
bl_label = t('EyeTracking.lowerlid.test.label')
bl_description = t('EyeTracking.lowerlid.test.desc')
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
toolkit = context.scene.avatar_toolkit
mesh = bpy.data.objects.get(toolkit.mesh_name_eye)
return (mesh and mesh.data.shape_keys and
all(key in mesh.data.shape_keys.key_blocks for key in ['vrc.lowerlid_left', 'vrc.lowerlid_right']))
def execute(self, context):
toolkit = context.scene.avatar_toolkit
mesh = bpy.data.objects[toolkit.mesh_name_eye]
shapes = OrderedDict()
shapes['vrc.lowerlid_left'] = toolkit.eye_lowerlid_shape
shapes['vrc.lowerlid_right'] = toolkit.eye_lowerlid_shape
for shape_key in mesh.data.shape_keys.key_blocks:
shape_key.value = toolkit.eye_lowerlid_shape if shape_key.name in shapes else 0
return {'FINISHED'}
class ResetBlinkTest(bpy.types.Operator):
bl_idname = 'avatar_toolkit.reset_blink_test'
bl_label = t('EyeTracking.blink.reset.label')
bl_description = t('EyeTracking.blink.reset.desc')
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
toolkit = context.scene.avatar_toolkit
mesh = bpy.data.objects[toolkit.mesh_name_eye]
for shape_key in mesh.data.shape_keys.key_blocks:
shape_key.value = 0
toolkit.eye_blink_shape = 1
toolkit.eye_lowerlid_shape = 1
return {'FINISHED'}
def fix_eye_position(context, old_eye, new_eye, head, right_side):
toolkit = context.scene.avatar_toolkit
scale = -toolkit.eye_distance + 1
mesh = bpy.data.objects[toolkit.mesh_name_eye]
if not toolkit.disable_eye_movement:
if head:
coords_eye = find_center_vector_of_vertex_group(mesh, old_eye.name)
else:
coords_eye = find_center_vector_of_vertex_group(mesh, new_eye.name)
if coords_eye is False:
return
if head:
p1 = mesh.matrix_world @ head.head
p2 = mesh.matrix_world @ coords_eye
length = (p1 - p2).length
logger.debug(f"Eye distance: {length}")
x_cord, y_cord, z_cord = get_bone_orientations()
if toolkit.disable_eye_movement:
if head is not None:
new_eye.head[x_cord] = head.head[x_cord] + (0.05 if right_side else -0.05)
new_eye.head[y_cord] = head.head[y_cord]
new_eye.head[z_cord] = head.head[z_cord]
else:
new_eye.head[x_cord] = old_eye.head[x_cord] + scale * (coords_eye[0] - old_eye.head[x_cord])
new_eye.head[y_cord] = old_eye.head[y_cord] + scale * (coords_eye[1] - old_eye.head[y_cord])
new_eye.head[z_cord] = old_eye.head[z_cord] + scale * (coords_eye[2] - old_eye.head[z_cord])
new_eye.tail[x_cord] = new_eye.head[x_cord]
new_eye.tail[y_cord] = new_eye.head[y_cord]
new_eye.tail[z_cord] = new_eye.head[z_cord] + 0.1
def repair_shapekeys(mesh_name, vertex_group):
"""Fix VRC shape keys by slightly adjusting vertex positions"""
armature = get_active_armature(bpy.context)
mesh = bpy.data.objects[mesh_name]
mesh.select_set(True)
bpy.context.view_layer.objects.active = mesh
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.object.mode_set(mode='OBJECT')
bm = bmesh.new()
bm.from_mesh(mesh.data)
bm.verts.ensure_lookup_table()
logger.debug(f'Processing vertex group: {vertex_group}')
group = mesh.vertex_groups.get(vertex_group)
if group is None:
logger.warning(f'Group {vertex_group} not found, using fallback method')
repair_shapekeys_mouth(mesh_name)
return
vcoords = None
gi = group.index
for v in mesh.data.vertices:
for g in v.groups:
if g.group == gi:
vcoords = v.co.xyz
if not vcoords:
return
logger.info('Repairing shape keys')
moved = False
i = 0
for key in bm.verts.layers.shape.keys():
if not key.startswith('vrc.'):
continue
logger.debug(f'Repairing shape: {key}')
value = bm.verts.layers.shape.get(key)
for index, vert in enumerate(bm.verts):
if vert.co.xyz == vcoords:
if index < i:
continue
shapekey = vert
shapekey_coords = mesh.matrix_world @ shapekey[value]
shapekey_coords[0] -= 0.00007 * randBoolNumber()
shapekey_coords[1] -= 0.00007 * randBoolNumber()
shapekey_coords[2] -= 0.00007 * randBoolNumber()
shapekey[value] = mesh.matrix_world.inverted() @ shapekey_coords
logger.debug(f'Repaired shape: {key}')
i += 1
moved = True
break
bm.to_mesh(mesh.data)
if not moved:
logger.warning('Shape key repair failed, using random method')
repair_shapekeys_mouth(mesh_name)
def randBoolNumber():
return -1 if random() < 0.5 else 1
def repair_shapekeys_mouth(mesh_name):
mesh = bpy.data.objects[mesh_name]
mesh.select_set(True)
bpy.context.view_layer.objects.active = mesh
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.object.mode_set(mode='OBJECT')
bm = bmesh.new()
bm.from_mesh(mesh.data)
bm.verts.ensure_lookup_table()
moved = False
for key in bm.verts.layers.shape.keys():
if not key.startswith('vrc'):
continue
value = bm.verts.layers.shape.get(key)
for vert in bm.verts:
shapekey = vert
shapekey_coords = mesh.matrix_world @ shapekey[value]
shapekey_coords[0] -= 0.00007
shapekey_coords[1] -= 0.00007
shapekey_coords[2] -= 0.00007
shapekey[value] = mesh.matrix_world.inverted() @ shapekey_coords
moved = True
break
bm.to_mesh(mesh.data)
if not moved:
logger.error('Random shape key repair failed')
def get_bone_orientations():
"""Get bone orientation axes"""
return (0, 1, 2) # x, y, z coordinates
def find_center_vector_of_vertex_group(mesh, group_name):
"""Calculate center position of vertex group"""
group = mesh.vertex_groups.get(group_name)
if not group:
return False
vertices = []
for vert in mesh.data.vertices:
for g in vert.groups:
if g.group == group.index:
vertices.append(vert.co)
if not vertices:
return False
return sum((v for v in vertices), mathutils.Vector()) / len(vertices)
def vertex_group_exists(mesh_obj, group_name):
"""Check if vertex group exists and has weights"""
if not mesh_obj or group_name not in mesh_obj.vertex_groups:
return False
group = mesh_obj.vertex_groups[group_name]
for vert in mesh_obj.data.vertices:
for g in vert.groups:
if g.group == group.index and g.weight > 0:
return True
return False
def copy_vertex_group(self, vertex_group, rename_to):
"""Copy vertex group with new name"""
vertex_group_index = 0
for group in self.mesh.vertex_groups:
if group.name == vertex_group:
self.mesh.vertex_groups.active_index = vertex_group_index
bpy.ops.object.vertex_group_copy()
self.mesh.vertex_groups[vertex_group + '_copy'].name = rename_to
break
vertex_group_index += 1
def copy_shape_key(self, context, from_shape, new_names, new_index):
"""Copy shape key with new name"""
blinking = not context.scene.avatar_toolkit.disable_eye_blinking
new_name = new_names[new_index - 1]
# Rename existing shapekey if it exists
for shapekey in self.mesh.data.shape_keys.key_blocks:
shapekey.value = 0
if shapekey.name == new_name:
shapekey.name = shapekey.name + '_old'
if from_shape == new_name:
from_shape = shapekey.name
# Create new shape key
for index, shapekey in enumerate(self.mesh.data.shape_keys.key_blocks):
if from_shape == shapekey.name:
self.mesh.active_shape_key_index = index
shapekey.value = 1
self.mesh.shape_key_add(name=new_name, from_mix=blinking)
break
# Reset shape keys
for shapekey in self.mesh.data.shape_keys.key_blocks:
shapekey.value = 0
self.mesh.active_shape_key_index = 0
return from_shape
# Global state for eye tracking
eye_left = None
eye_right = None
eye_left_data = None
eye_right_data = None
eye_left_rot = []
eye_right_rot = []
class VertexGroupCache:
"""Cache for vertex group operations"""
_cache = {}
@classmethod
def get_vertex_indices(cls, mesh_name: str, group_name: str) -> Optional[set]:
cache_key = f"{mesh_name}_{group_name}"
if cache_key in cls._cache:
return cls._cache[cache_key]
mesh = bpy.data.objects.get(mesh_name)
if not mesh:
return None
group = mesh.vertex_groups.get(group_name)
if not group:
return None
indices = {v.index for v in mesh.data.vertices
if any(g.group == group.index for g in v.groups)}
cls._cache[cache_key] = indices
return indices
@classmethod
def clear_cache(cls):
cls._cache.clear()
class RotateEyeBonesForAv3Button(Operator):
"""Reorient eye bones for proper VRChat eye tracking"""
bl_idname = "avatar_toolkit.rotate_eye_bones"
bl_label = t("EyeTracking.rotate.label")
bl_description = t("EyeTracking.rotate.desc")
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
armature = get_active_armature(context)
return armature and all(bone in armature.pose.bones for bone in ['LeftEye', 'RightEye'])
def execute(self, context):
armature = get_active_armature(context)
straight_up_matrix = mathutils.Matrix.Rotation(math.pi/2, 3, 'X')
bpy.ops.object.mode_set(mode='EDIT')
for eye_name in ['LeftEye', 'RightEye']:
eye_bone = armature.data.edit_bones[eye_name]
new_matrix = straight_up_matrix.to_4x4()
new_matrix.translation = eye_bone.matrix.translation
eye_bone.matrix = new_matrix
bpy.ops.object.mode_set(mode='OBJECT')
return {'FINISHED'}
class ResetEyeTrackingButton(Operator):
"""Reset all eye tracking settings and state"""
bl_idname = 'avatar_toolkit.reset_eye_tracking'
bl_label = t('EyeTracking.reset.label')
bl_description = t('EyeTracking.reset.desc')
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot
eye_left = eye_right = eye_left_data = eye_right_data = None
eye_left_rot = eye_right_rot = []
context.scene.avatar_toolkit.eye_mode = 'CREATION'
return {'FINISHED'}
def validate_weights(mesh_obj: Object, vertex_group: str) -> bool:
"""Validate vertex group weights"""
group = mesh_obj.vertex_groups.get(vertex_group)
if not group:
return False
for vertex in mesh_obj.data.vertices:
for group_element in vertex.groups:
if group_element.group == group.index and group_element.weight > 0:
return True
return False
def get_eye_bone_names(armature: Object) -> Dict[str, str]:
"""Get standardized eye bone names"""
eye_bones = {'left': None, 'right': None}
for bone in armature.data.bones:
if any(name.lower() in bone.name.lower() for name in VALID_EYE_NAMES['left']):
eye_bones['left'] = bone.name
if any(name.lower() in bone.name.lower() for name in VALID_EYE_NAMES['right']):
eye_bones['right'] = bone.name
return eye_bones
def stop_testing(context: Context) -> None:
"""Stop eye tracking testing mode"""
global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot
if not all([eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot]):
return
armature = get_active_armature(context)
if not armature:
return
bpy.ops.object.mode_set(mode='POSE')
# Reset rotations
context.scene.avatar_toolkit.eye_rotation_x = 0
context.scene.avatar_toolkit.eye_rotation_y = 0
# Clear transforms
for bone in armature.data.bones:
bone.hide = False
bone.select = True
bpy.ops.pose.transforms_clear()
# Reset shape keys
mesh = bpy.data.objects.get(context.scene.avatar_toolkit.mesh_name_eye)
if mesh and mesh.data.shape_keys:
for shape_key in mesh.data.shape_keys.key_blocks:
shape_key.value = 0
# Clear globals
eye_left = eye_right = eye_left_data = eye_right_data = None
eye_left_rot = eye_right_rot = []
+335
View File
@@ -0,0 +1,335 @@
# MIT License
# This code was taken from Cats Blender Plugin Unoffical, some of this code is by the original developers, however was improved by myself.
# Didn't think it was necessary to re-make something that works well.
import bpy
from typing import Dict, List, Optional, Tuple, Any, Set
from bpy.types import Operator, Context, Object, ShapeKey
from collections import OrderedDict
from ..core.logging_setup import logger
from ..core.translations import t
from ..core.common import (
get_active_armature,
validate_armature,
get_all_meshes,
validate_mesh_for_pose
)
class VisemeCache:
"""Caches generated viseme shape data"""
_cache: Dict = {}
@classmethod
def get_cached_shape(cls, key: str, mix_data: List) -> Optional[List]:
cache_key = (key, tuple(tuple(x) for x in mix_data))
return cls._cache.get(cache_key)
@classmethod
def cache_shape(cls, key: str, mix_data: List, shape_data: List) -> None:
cache_key = (key, tuple(tuple(x) for x in mix_data))
cls._cache[cache_key] = shape_data
class VisemePreview:
"""Handles viseme preview functionality"""
_preview_data: Dict = {}
_active: bool = False
_preview_shapes: Optional[OrderedDict] = None
@classmethod
def start_preview(cls, context: Context, mesh: Object, shapes: List[str]) -> bool:
if not mesh or not mesh.data or not mesh.data.shape_keys:
return False
cls._active = True
cls._preview_data = {}
# Store original values
for shape_key in mesh.data.shape_keys.key_blocks:
cls._preview_data[shape_key.name] = shape_key.value
# Get properties from avatar_toolkit
props = context.scene.avatar_toolkit
shape_a = props.mouth_a
shape_o = props.mouth_o
shape_ch = props.mouth_ch
cls._preview_shapes = OrderedDict()
cls._preview_shapes['vrc.v_aa'] = {'mix': [[(shape_a), (0.9998)]]}
cls._preview_shapes['vrc.v_ch'] = {'mix': [[(shape_ch), (0.9996)]]}
cls._preview_shapes['vrc.v_dd'] = {'mix': [[(shape_a), (0.3)], [(shape_ch), (0.7)]]}
cls._preview_shapes['vrc.v_ih'] = {'mix': [[(shape_ch), (0.7)], [(shape_o), (0.3)]]}
cls._preview_shapes['vrc.v_ff'] = {'mix': [[(shape_a), (0.2)], [(shape_ch), (0.4)]]}
cls._preview_shapes['vrc.v_e'] = {'mix': [[(shape_a), (0.5)], [(shape_ch), (0.2)]]}
cls._preview_shapes['vrc.v_kk'] = {'mix': [[(shape_a), (0.7)], [(shape_ch), (0.4)]]}
cls._preview_shapes['vrc.v_nn'] = {'mix': [[(shape_a), (0.2)], [(shape_ch), (0.7)]]}
cls._preview_shapes['vrc.v_oh'] = {'mix': [[(shape_a), (0.2)], [(shape_o), (0.8)]]}
cls._preview_shapes['vrc.v_ou'] = {'mix': [[(shape_o), (0.9994)]]}
cls._preview_shapes['vrc.v_pp'] = {'mix': [[(shape_a), (0.0004)], [(shape_o), (0.0004)]]}
cls._preview_shapes['vrc.v_rr'] = {'mix': [[(shape_ch), (0.5)], [(shape_o), (0.3)]]}
cls._preview_shapes['vrc.v_sil'] = {'mix': [[(shape_a), (0.0002)], [(shape_ch), (0.0002)]]}
cls._preview_shapes['vrc.v_ss'] = {'mix': [[(shape_ch), (0.8)]]}
cls._preview_shapes['vrc.v_th'] = {'mix': [[(shape_a), (0.4)], [(shape_o), (0.15)]]}
return True
@classmethod
def update_preview(cls, context: Context) -> None:
if not cls._active or not cls._preview_shapes:
return
mesh = context.active_object
props = context.scene.avatar_toolkit
viseme_data = cls._preview_shapes.get(props.viseme_preview_selection)
if viseme_data:
cls.show_viseme(context, mesh, props.viseme_preview_selection, viseme_data['mix'])
@classmethod
def show_viseme(cls, context: Context, mesh: Object, viseme_name: str, mix_data: List) -> None:
if not cls._active:
return
# Get shape intensity from properties
intensity = context.scene.avatar_toolkit.shape_intensity
for shape_key in mesh.data.shape_keys.key_blocks:
shape_key.value = 0
for shape_name, value in mix_data:
if shape_name in mesh.data.shape_keys.key_blocks:
# Apply intensity to the preview value
mesh.data.shape_keys.key_blocks[shape_name].value = value * intensity
context.view_layer.update()
@classmethod
def end_preview(cls, mesh: Object) -> None:
if not cls._active:
return
for shape_name, value in cls._preview_data.items():
if shape_name in mesh.data.shape_keys.key_blocks:
mesh.data.shape_keys.key_blocks[shape_name].value = value
cls._active = False
cls._preview_data.clear()
cls._preview_shapes = None
class ATOOLKIT_OT_preview_visemes(Operator):
bl_idname = "avatar_toolkit.preview_visemes"
bl_label = t("Visemes.preview_label")
bl_description = t("Visemes.preview_desc")
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
@classmethod
def poll(cls, context: Context) -> bool:
armature = get_active_armature(context)
if not armature:
return False
valid, _ = validate_armature(armature)
return valid and context.active_object and context.active_object.type == 'MESH'
def execute(self, context: Context) -> Set[str]:
props = context.scene.avatar_toolkit
mesh = context.active_object
if props.viseme_preview_mode:
VisemePreview.end_preview(mesh)
props.viseme_preview_mode = False
else:
if not mesh.data.shape_keys:
self.report({'ERROR'}, t("Visemes.error.no_shapekeys"))
return {'CANCELLED'}
if VisemePreview.start_preview(context, mesh, [props.mouth_a, props.mouth_o, props.mouth_ch]):
props.viseme_preview_mode = True
props.viseme_preview_selection = 'vrc.v_aa'
return {'FINISHED'}
def validate_deformation(mesh, mix_data):
"""Validates if shape key deformations are within reasonable ranges"""
base_coords = [v.co.copy() for v in mesh.data.shape_keys.key_blocks['Basis'].data]
max_deform = 0
for shape_data in mix_data:
shape_name, value = shape_data
if shape_name in mesh.data.shape_keys.key_blocks:
shape_key = mesh.data.shape_keys.key_blocks[shape_name]
for i, v in enumerate(shape_key.data):
deform = (v.co - base_coords[i]).length * value
max_deform = max(max_deform, deform)
mesh_size = max(mesh.dimensions)
return max_deform < (mesh_size * 0.4)
class ATOOLKIT_OT_create_visemes(Operator):
bl_idname = "avatar_toolkit.create_visemes"
bl_label = t("Visemes.create_label")
bl_description = t("Visemes.create_desc")
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context: Context) -> bool:
armature = get_active_armature(context)
if not armature:
return False
valid, _ = validate_armature(armature)
return valid and context.active_object and context.active_object.type == 'MESH'
def execute(self, context: Context) -> Set[str]:
props = context.scene.avatar_toolkit
mesh = context.active_object
if not mesh.data.shape_keys:
self.report({'ERROR'}, t("Visemes.error.no_shapekeys"))
return {'CANCELLED'}
if props.mouth_a == "Basis" or props.mouth_o == "Basis" or props.mouth_ch == "Basis":
self.report({'ERROR'}, t("Visemes.error.select_shapekeys"))
return {'CANCELLED'}
try:
self.create_visemes(context, mesh)
self.report({'INFO'}, t("Visemes.success"))
return {'FINISHED'}
except Exception as e:
logger.error(f"Error creating visemes: {str(e)}")
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
def create_visemes(self, context: Context, mesh: Object) -> None:
"""Creates viseme shape keys by mixing existing shape keys"""
props = context.scene.avatar_toolkit
wm = context.window_manager
# Store original shape key names
shapes = [props.mouth_a, props.mouth_o, props.mouth_ch]
renamed_shapes = shapes.copy()
# Temporarily rename selected shapes to avoid conflicts
for shapekey in mesh.data.shape_keys.key_blocks:
if shapekey.name == props.mouth_a:
shapekey.name = f"{shapekey.name}_old"
props.mouth_a = shapekey.name
renamed_shapes[0] = shapekey.name
elif shapekey.name == props.mouth_o:
if props.mouth_a != props.mouth_o:
shapekey.name = f"{shapekey.name}_old"
props.mouth_o = shapekey.name
renamed_shapes[1] = shapekey.name
elif shapekey.name == props.mouth_ch:
if props.mouth_a != props.mouth_ch and props.mouth_o != props.mouth_ch:
shapekey.name = f"{shapekey.name}_old"
props.mouth_ch = shapekey.name
renamed_shapes[2] = shapekey.name
# Define viseme shape key data
shapekey_data = OrderedDict()
shapekey_data['vrc.v_aa'] = {'mix': [[(props.mouth_a), (0.9998)]]}
shapekey_data['vrc.v_ch'] = {'mix': [[(props.mouth_ch), (0.9996)]]}
shapekey_data['vrc.v_dd'] = {'mix': [[(props.mouth_a), (0.3)], [(props.mouth_ch), (0.7)]]}
shapekey_data['vrc.v_ih'] = {'mix': [[(props.mouth_ch), (0.7)], [(props.mouth_o), (0.3)]]}
shapekey_data['vrc.v_ff'] = {'mix': [[(props.mouth_a), (0.2)], [(props.mouth_ch), (0.4)]]}
shapekey_data['vrc.v_e'] = {'mix': [[(props.mouth_a), (0.5)], [(props.mouth_ch), (0.2)]]}
shapekey_data['vrc.v_kk'] = {'mix': [[(props.mouth_a), (0.7)], [(props.mouth_ch), (0.4)]]}
shapekey_data['vrc.v_nn'] = {'mix': [[(props.mouth_a), (0.2)], [(props.mouth_ch), (0.7)]]}
shapekey_data['vrc.v_oh'] = {'mix': [[(props.mouth_a), (0.2)], [(props.mouth_o), (0.8)]]}
shapekey_data['vrc.v_ou'] = {'mix': [[(props.mouth_o), (0.9994)]]}
shapekey_data['vrc.v_pp'] = {'mix': [[(props.mouth_a), (0.0004)], [(props.mouth_o), (0.0004)]]}
shapekey_data['vrc.v_rr'] = {'mix': [[(props.mouth_ch), (0.5)], [(props.mouth_o), (0.3)]]}
shapekey_data['vrc.v_sil'] = {'mix': [[(props.mouth_a), (0.0002)], [(props.mouth_ch), (0.0002)]]}
shapekey_data['vrc.v_ss'] = {'mix': [[(props.mouth_ch), (0.8)]]}
shapekey_data['vrc.v_th'] = {'mix': [[(props.mouth_a), (0.4)], [(props.mouth_o), (0.15)]]}
# Create progress tracker
total_steps = len(shapekey_data)
wm.progress_begin(0, total_steps)
# Create viseme shape keys
for index, (key, data) in enumerate(shapekey_data.items()):
wm.progress_update(index)
# Check cache first
cached_data = VisemeCache.get_cached_shape(key, data['mix'])
if cached_data:
continue
# Create new shape key
self.mix_shapekey(context, renamed_shapes, data['mix'], key)
# Cache the new shape key data
shape_data = [v.co.copy() for v in mesh.data.shape_keys.key_blocks[key].data]
VisemeCache.cache_shape(key, data['mix'], shape_data)
# Restore original shape key names
self.restore_shape_names(context, mesh, shapes, renamed_shapes)
# Cleanup and finalize
mesh.active_shape_key_index = 0
wm.progress_end()
def mix_shapekey(self, context: Context, shapes: List[str], mix_data: List, new_name: str) -> None:
"""Creates a new shape key by mixing existing ones"""
mesh = context.active_object
# Remove existing shape key if it exists
if new_name in mesh.data.shape_keys.key_blocks:
mesh.active_shape_key_index = mesh.data.shape_keys.key_blocks.find(new_name)
bpy.ops.object.shape_key_remove()
# Reset all shape keys
for shapekey in mesh.data.shape_keys.key_blocks:
shapekey.value = 0
# Set mix values
for shape_name, value in mix_data:
if shape_name in mesh.data.shape_keys.key_blocks:
shapekey = mesh.data.shape_keys.key_blocks[shape_name]
shapekey.value = value
# Create mixed shape key
mesh.shape_key_add(name=new_name, from_mix=True)
# Reset values and restore shape key settings
for shapekey in mesh.data.shape_keys.key_blocks:
shapekey.value = 0
if shapekey.name in shapes:
shapekey.slider_max = 1
def restore_shape_names(self, context: Context, mesh: Object, original_names: List[str], current_names: List[str]) -> None:
"""Restores original shape key names"""
props = context.scene.avatar_toolkit
# Restore mouth_a
if original_names[0] not in mesh.data.shape_keys.key_blocks:
shapekey = mesh.data.shape_keys.key_blocks.get(current_names[0])
if shapekey:
shapekey.name = original_names[0]
if current_names[2] == current_names[0]:
current_names[2] = original_names[0]
if current_names[1] == current_names[0]:
current_names[1] = original_names[0]
current_names[0] = original_names[0]
# Restore mouth_o
if original_names[1] not in mesh.data.shape_keys.key_blocks:
shapekey = mesh.data.shape_keys.key_blocks.get(current_names[1])
if shapekey:
shapekey.name = original_names[1]
if current_names[2] == current_names[1]:
current_names[2] = original_names[1]
current_names[1] = original_names[1]
# Restore mouth_ch
if original_names[2] not in mesh.data.shape_keys.key_blocks:
shapekey = mesh.data.shape_keys.key_blocks.get(current_names[2])
if shapekey:
shapekey.name = original_names[2]
current_names[2] = original_names[2]
# Update properties
props.mouth_a = current_names[0]
props.mouth_o = current_names[1]
props.mouth_ch = current_names[2]