Blender, захват движения, нейронные сети

Blender отличный 3d редактор, открытый документированный код, убирает ограничения в реализации творческих фантазий. Большая «фанатская база» сгенерировала решения под разные задачи, ускоряя творческий процесс. Периодически получаю практический опыта в Blender, главное в саморазвитие, ставить цель c желаемым результатом, повторение действий из уроков не рабочий способ получения знаний для меня. Выбираю цель, с учетом собственного интереса, предварительно проверяю на отсутствие готового решения, что бы не лишить себя этапов развития. Моим критериям соответствует — анимация персонажа в Blender, с использованием нейронных сетей. Существуют статьи, видео, рабочие коммерческие решения, но нет готового подходящего мне, только части головоломки которые нужно собрать.

Примерный алгоритм:

  1. Получить координаты движения суставов человека из видео, с помощью искусственных нейронных сетей.

  2. Обработать координаты, использовать фильтр Калмана для получения приемлемого результата.

  3. Создать скелет из координат в Blender.

  4. Анимировать скелет.

ВАЖНО! Последовательность подачи информации соответствует ходу моих изначальных действий

Захват движения суставов человека с помощь искусственных нейронных сетей, популярное направление машинного обучения, огромное количество реализаций с различными характеристиками скорости и точности. Главный упор моего повествования будет на создание скелета и анимации движения в Blender, поэтому какую архитектуры использовать не важно. Несколько пазлов моей мозаики обнаружил в хранилище https://github.com/haryo-s/blender_lifting, автор кода использует статью CVPR 2017 года Lifting from the Deep: Convolutional 3D Pose Estimation from a Single Image. Haryo Sukmawanto, проделал отличную работу, но ограничился созданием скелета по изображению без анимации. Фрагмент нужный мне, внес незначительные изменения.

import bpy
from mathutils import Vector, Matrix
import numpy as np
import math
from math import radians, ceil

# Importing global modules
import time
import subprocess
import sys
import json
import pickle
import os, random
import numpy as np

def create_armature(coordinates, name, scale=1.0):
    """
    Creates an armature skeleton data object from the input coordinates acquired from lift_image(). 
    The skeleton's bones will have been appropriately labelled and parented.
    The skeleton data object will be implemented into the scene as well.
    :param coordinates: List of vertex coordinates from lifter
    :param name: Base name of the bezier_curves 
    :param scale: Scale of the skeleton 
    """
    # Setting the scale to a hundredth already as the distances from lifting are considerably large.
    scale = scale * 0.01  
    arm_dat_name = 'Armature_dat_' + name
    arm_obj_name = 'Armature_obj_' + name
    arm_dat = bpy.data.armatures.new(arm_dat_name)
    arm_obj = bpy.data.objects.new(arm_obj_name, arm_dat)    
    
    bpy.context.scene.collection.objects.link(arm_obj)
    bpy.context.view_layer.objects.active = arm_obj
    
    bpy.ops.object.mode_set(mode='EDIT', toggle=False)
    
    edit_bones = bpy.data.armatures[arm_dat_name].edit_bones
    b = edit_bones.new('base')
    b.head = [coordinates[0][0]*scale, coordinates[0][1]*scale + 100*scale, coordinates[0][2]*scale]
    b.tail = [coordinates[0][0]*scale, coordinates[0][1]*scale,             coordinates[0][2]*scale]
    bone_nr = 0
    for conn in CONNECTIONS:        
        b = edit_bones.new(LIFTING_BONE_NAMES[bone_nr])
        b.head = [scale*item for item in coordinates[conn[0]]]
        b.tail = [scale*item for item in coordinates[conn[1]]]
        b.parent = edit_bones[LIFTING_BONE_PARENTS[str(LIFTING_BONE_NAMES[bone_nr])]]
        b.use_connect = True
        bone_nr += 1
        
    bpy.ops.object.mode_set(mode='OBJECT')
    return arm_obj       

Haryo, использует обученную сеть из https://github.com/DenisTome/Lifting-from-the-Deep-release/ — хранилище подразумевает работу с единичным изображением, изменил код для своей задачи, получить набор координат движения суставов человека из последовательности кадров видео.

import cv2
import json
from lifting import PoseEstimator

"""
хороший вариант сохранения массива с помощью numpy,
я решил сохранять примитивно.
"""
cap = cv2.VideoCapture("test_video.mp4")
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
pose_estimator = PoseEstimator((h,w,3), SESSION_PATH, PROB_MODEL_PATH)
pose_estimator.initialise()
outfile = open('data_c.txt', 'w')
while True:
    ret, image = cap.read()
    if ret == False:
        break 
    pose_2d, visibility, pose_3d = pose_estimator.estimate(image)
    try:
      for ii in pose_3d:
          coordinates = []
          for i in range(len(ii[0])):
              coordinates.append([ii[0][i], ii[1][i], ii[2][i]])
          outfile.write(str(json.dumps(coordinates))+"\n")  
    except Exception as e:
      print (e)
outfile.close()
cap.release()
cv2.destroyAllWindows()

Из полученных данных создаю скелет.

# получить координаты
def get_c(returned_val):
    with open(returned_val) as f:
         f = f.readlines()
         ls = []         
         for i in f:
            ls.append(json.loads(i))
         return ls

coordinates = get_c("data_c.txt")
scene.frame_start = 0
scene.frame_end = len(coordinates)
ARM = create_armature(coordinates[0], "A")

созданный скелет, структура костисозданный скелет, структура кости

Анимация. Наивно предположил изменять координаты head и tail кости, используя keyframe_insert, ротацию и локацию. Естественно, не рабочий вариант, keyframe_insert фиксирует изменения в режиме поз, а изменения head и tail работают в режиме редактирования. Могу получать значения head и tail в режиме поз, но воздействовать не могу. Продолжил собирать информацию, документация Blender, комментарии Stack Exchange, статьи… Появилось две идеи, которые нужно реализовать что бы проверить:

  • двойник скелета;

  • ограничение костей скелета, примитивными объектами.

Двойник скелета. Создаю два скелета, первый скелет изменяю в режиме редактирования, на второй переношу изменения в режиме позы.

# простая версия - копирование матрицы позы
def get_pose(ARM, ARM2):
    for bone in ARM.pose.bones: 
            ARM.pose.bones[bone.name].matrix = ARM2.pose.bones[bone.name].matrix       
            ARM.pose.bones[bone.name].rotation_mode = 'XYZ'
            ARM.pose.bones[bone.name].keyframe_insert(data_path='rotation_euler', frame=frame)
            scene.frame_set(frame)
    scene.frame_set(frame)

# сложная версия - генерирование матрици позы, с учетом расположения в пространстве
def snap_rotation(source, target):
    Ms = source.id_data.convert_space(
        pose_bone=source,
        matrix=source.matrix,
        from_space='POSE',
        )
    Mt = target.id_data.convert_space(
        pose_bone=target,
        matrix=target.matrix,
        from_space='POSE',
        ) 
         
    _, q, _ = Ms.decompose()
    t, _, s = Mt.decompose()
    M = (
        Matrix.Translation(t) @
        q.to_matrix().to_4x4() @ 
        Matrix.Diagonal(s.to_4d())
        )
    target.matrix = target.id_data.convert_space(
        pose_bone=target,
        matrix=M,
        to_space='POSE',
        )

def change_arm(coordinates, name, scale=1.0):
    scale = scale * 0.01  
    arm_dat_name = 'Armature_dat_' + name
    arm_obj_name = 'Armature_obj_' + name
    bpy.ops.object.mode_set(mode='EDIT', toggle=False)
    edit_bones = bpy.data.armatures[arm_dat_name].edit_bones
    bone_nr = 0
    for conn in CONNECTIONS:        
        b = edit_bones[LIFTING_BONE_NAMES[bone_nr]]
        b.head = [scale*item for item in coordinates[conn[0]]]
        b.tail = [scale*item for item in coordinates[conn[1]]]
        bone_nr += 1
    bpy.ops.object.mode_set(mode = 'POSE')
    # простая версия
    # get_pose(ARM, ARM2)
    
    # сложная версия
    for bone in ARM.pose.bones:
        ARM.pose.bones[bone.name].rotation_mode = 'QUATERNION'
        snap_rotation(bpy.data.objects[arm_obj_name].pose.bones[bone.name],
                      ARM.pose.bones[bone.name])            
        ARM.pose.bones[bone.name].keyframe_insert(data_path='rotation_quaternion', frame=frame)
        scene.frame_set(frame) 
   
ARM = create_armature(coordinates[0], "A")
ARM2 = create_armature(coordinates[0], "B")
for frame in range(len(coordinates)):
            scene.frame_set(frame)
            COR = coordinates[frame]
            change_arm(COR, "B") 

без фильтра Калманабез фильтра Калмана

Идея с двойником показалась рабочей, детальное изучение выявило проблему — вращение в оси кости. При выставлении координат head и tail в режиме редактирования, программа автоматически вращает кость (параметр roll), эти вращения присутствуют в режиме позы, вместе с нужными вращениями мы переносим артефакты. Существенный дефект.

параметр rollпараметр roll

Ограничение костей скелета, примитивными объектами. Создать примитивный объект, создать ограничение кости к объекту. В последовательности кадров, из координат суставов кости, вычислить направляющий вектор, передать координаты вектора объекту. Сохранять изменения вращения кости keyframe_insert получаемые от воздействия объекта.

def create_armature(coordinates, name, scale=1.0):
    """
    Creates an armature skeleton data object from the input coordinates acquired from lift_image(). 
    The skeleton's bones will have been appropriately labelled and parented.
    The skeleton data object will be implemented into the scene as well.
    :param coordinates: List of vertex coordinates from lifter
    :param name: Base name of the bezier_curves 
    :param scale: Scale of the skeleton 
    """
    # Setting the scale to a hundredth already as the distances from lifting are considerably large.
    scale = scale * 0.01  
    arm_dat_name = 'Armature_dat_' + name
    arm_obj_name = 'Armature_obj_' + name
    arm_dat = bpy.data.armatures.new(arm_dat_name)
    arm_obj = bpy.data.objects.new(arm_obj_name, arm_dat)
    bpy.context.scene.collection.objects.link(arm_obj)
    bpy.context.view_layer.objects.active = arm_obj
    bpy.ops.object.mode_set(mode='EDIT', toggle=False)
    edit_bones = bpy.data.armatures[arm_dat_name].edit_bones
    b = edit_bones.new('base')
    b.head = [coordinates[0][0]*scale, coordinates[0][1]*scale + 100*scale, coordinates[0][2]*scale]
    b.tail = [coordinates[0][0]*scale, coordinates[0][1]*scale,             coordinates[0][2]*scale]
    bone_nr = 0
    for conn in CONNECTIONS:        
        b = edit_bones.new(LIFTING_BONE_NAMES[bone_nr])
        b.head = [scale*item for item in coordinates[conn[0]]]
        b.tail = [scale*item for item in coordinates[conn[1]]]
        b.parent = edit_bones[LIFTING_BONE_PARENTS[str(LIFTING_BONE_NAMES[bone_nr])]]
        b.use_connect = True
        bone_nr += 1
    bpy.ops.object.mode_set(mode='POSE')
    bone_nr = 0
    for conn in CONNECTIONS:    
        b = arm_obj.pose.bones[LIFTING_BONE_NAMES[bone_nr]]    
        v1 = Vector(coordinates[conn[0]])
        v2 = Vector(coordinates[conn[1]]) 
        # направляющий вектор
        v = v2 - v1 
        v.normalize() 
        vGoal = v + b.tail 
        # примитивный объект
        bpy.ops.object.mode_set(mode='OBJECT')
        bpy.ops.object.empty_add(type='PLAIN_AXES', location= vGoal) 
        target_axis = bpy.context.active_object
        temp_list.append(target_axis.name)
        # создать ограничение
        neckCopyRot = arm_obj.pose.bones[LIFTING_BONE_NAMES[bone_nr]].constraints.new('DAMPED_TRACK') 
        neckCopyRot.target = target_axis 
        target_axis.select_set(False) 
        bpy.context.view_layer.objects.active = arm_obj
        bpy.ops.object.mode_set(mode='POSE')   
        bone_nr += 1 
    # в режиме редактирования установить позу из режима поз    
    bpy.context.view_layer.objects.active = arm_obj
    bpy.data.objects['Armature_obj_A'].select_set(True)
    bpy.ops.object.mode_set(mode = 'POSE')
    bpy.ops.pose.select_all(action='SELECT')
    bpy.ops.pose.armature_apply(selected=False)
    bpy.ops.object.mode_set(mode = 'OBJECT')        
    return arm_obj
  
def change_arm(coordinates, scale=1.0):
    bpy.ops.object.mode_set(mode='POSE', toggle=False)    
    RIG = bpy.context.active_object.id_data
    bone_nr = 0
    for conn in CONNECTIONS:
        v1 = Vector(coordinates[conn[0]])
        v2 = Vector(coordinates[conn[1]])      
        v = v2 - v1 
        v.normalize() 
        vGoal = v + ARM.pose.bones[LIFTING_BONE_NAMES[bone_nr]].tail       
        bpy.ops.object.mode_set(mode='OBJECT')
        J = bpy.data.objects[temp_list[bone_nr]]
        J.location = vGoal
        ARM.select_set(True)
        bpy.context.view_layer.objects.active = ARM
        bpy.ops.object.mode_set(mode='POSE')
        RIG.data.bones[LIFTING_BONE_NAMES[bone_nr]].select = True
        bpy.ops.pose.visual_transform_apply()
        # сохранение изменения локации примитивного объекта
        J.keyframe_insert(data_path="location", frame=frame)
        # сохранение изменения вращения кости
        ARM.pose.bones[LIFTING_BONE_NAMES[bone_nr]].keyframe_insert(data_path="rotation_quaternion", frame=frame)
        bone_nr += 1                                                          
        scene.frame_set(frame)  

без фильтра Калманабез фильтра Калмана

Получил приближенный к желаемому, результат. В глаза бросаются артефакты, используемая нейронная сеть работает с одним кадром, нет зависимости координат между кадрами для сглаживания. Современные алгоритмы решают эту проблему разными способами, а я использовал фильтр Калмана. Этот алгоритм в распоряжении человечества, долго будет актуальным в разных задачах.

class KalmanFilter:
    def __init__(self):  
      	# значение инициализации
        self.x_P = 0.9
        self.x_Q = 0.08
        
        self.y_P = 0.9
        self.y_Q = 0.08
        
        self.z_P = 0.9
        self.z_Q = 0.08
        
        self.x_priori_estimated_covariance = 1 
        self.y_priori_estimated_covariance = 1
        self.z_priori_estimated_covariance = 1
        # X 
        self.x_estimated_value_dict = {'base': 0, 'pelvis_r': 0, 'thigh_r': 0, 'calf_r': 0, 'pelvis_l': 0, 
                                       'thigh_l': 0, 'calf_l': 0, 'waist': 0, 'chest': 0, 
                                       'neck': 0, 'head': 0, 'shoulder_l': 0, 'arm_l': 0, 
                                       'forearm_l': 0, 'shoulder_r': 0, 'arm_r': 0, 'forearm_r': 0}

        self.x_post_estimated_covariance_dict = {'base': 1, 'pelvis_r': 1, 'thigh_r': 1, 'calf_r': 1, 'pelvis_l': 1, 
                                                 'thigh_l': 1, 'calf_l': 1, 'waist': 1, 'chest': 1, 
                                                 'neck': 1, 'head': 1, 'shoulder_l': 1, 'arm_l': 1, 
                                                 'forearm_l': 1, 'shoulder_r': 1, 'arm_r': 1, 'forearm_r': 1}
        # Y 
        self.y_estimated_value_dict = {'base': 0, 'pelvis_r': 0, 'thigh_r': 0, 'calf_r': 0, 'pelvis_l': 0, 
                                       'thigh_l': 0, 'calf_l': 0, 'waist': 0, 'chest': 0, 
                                       'neck': 0, 'head': 0, 'shoulder_l': 0, 'arm_l': 0, 
                                       'forearm_l': 0, 'shoulder_r': 0, 'arm_r': 0, 'forearm_r': 0}

        self.y_post_estimated_covariance_dict = {'base': 1, 'pelvis_r': 1, 'thigh_r': 1, 'calf_r': 1, 'pelvis_l': 1, 
                                                 'thigh_l': 1, 'calf_l': 1, 'waist': 1, 'chest': 1, 
                                                 'neck': 1, 'head': 1, 'shoulder_l': 1, 'arm_l': 1, 
                                                 'forearm_l': 1, 'shoulder_r': 1, 'arm_r': 1, 'forearm_r': 1}
                                                 
        # Z                                         
        self.z_estimated_value_dict = {'base': 0, 'pelvis_r': 0, 'thigh_r': 0, 'calf_r': 0, 'pelvis_l': 0, 
                                       'thigh_l': 0, 'calf_l': 0, 'waist': 0, 'chest': 0, 
                                       'neck': 0, 'head': 0, 'shoulder_l': 0, 'arm_l': 0, 
                                       'forearm_l': 0, 'shoulder_r': 0, 'arm_r': 0, 'forearm_r': 0}

        self.z_post_estimated_covariance_dict = {'base': 1, 'pelvis_r': 1, 'thigh_r': 1, 'calf_r': 1, 'pelvis_l': 1, 
                                                 'thigh_l': 1, 'calf_l': 1, 'waist': 1, 'chest': 1, 
                                                 'neck': 1, 'head': 1, 'shoulder_l': 1, 'arm_l': 1, 
                                                 'forearm_l': 1, 'shoulder_r': 1, 'arm_r': 1, 'forearm_r': 1}

    # перезагрузка значений
    def x_reset(self, P, Q): 
        self.x_P = P
        self.x_Q = Q

    def y_reset(self, P, Q):
        self.y_P = P
        self.y_Q = Q

    def z_reset(self, P, Q):
        self.z_P = P
        self.z_Q = Q

    # входные текущие значения поступающие в фильтр   
    def cal_X(self, current_value, label):  
        self.current_value = current_value
        self.x_priori_estimated_covariance = self.x_post_estimated_covariance_dict[label]
        x_kalman_gain = self.x_priori_estimated_covariance / (self.x_priori_estimated_covariance + self.x_P)  
        output = self.x_estimated_value_dict[label] + x_kalman_gain * (
                    current_value - self.x_estimated_value_dict[label])  
        self.x_estimated_value_dict[label] = output
        self.x_post_estimated_covariance_dict[label] = (1 - x_kalman_gain) * self.x_priori_estimated_covariance + self.x_Q
        self.x_priori_estimated_covariance = self.x_post_estimated_covariance_dict[label]
        return output  

    def cal_Y(self, current_value, label):  
        self.current_value = current_value
        self.y_priori_estimated_covariance = self.y_post_estimated_covariance_dict[label]
        y_kalman_gain = self.y_priori_estimated_covariance / (self.y_priori_estimated_covariance + self.y_P)  
        output = self.y_estimated_value_dict[label] + y_kalman_gain * (
                    current_value - self.y_estimated_value_dict[label])  
        self.y_estimated_value_dict[label] = output
        self.y_post_estimated_covariance_dict[label] = (1 - y_kalman_gain) * self.y_priori_estimated_covariance + self.y_Q
        self.y_priori_estimated_covariance = self.y_post_estimated_covariance_dict[label]
        return output

    def cal_Z(self, current_value, label): 
        self.current_value = current_value
        self.z_priori_estimated_covariance = self.z_post_estimated_covariance_dict[label]
        z_kalman_gain = self.z_priori_estimated_covariance / (self.z_priori_estimated_covariance + self.z_P)  
        output = self.z_estimated_value_dict[label] + z_kalman_gain * (
                    current_value - self.z_estimated_value_dict[label])  
        self.z_estimated_value_dict[label] = output
        self.z_post_estimated_covariance_dict[label] = (1 - z_kalman_gain) * self.z_priori_estimated_covariance + self.z_Q
        self.z_priori_estimated_covariance = self.z_post_estimated_covariance_dict[label]
        return output


class KalmanProcess():
    def __init__(self):
        self.kalman_filter_X = KalmanFilter()
        self.kalman_filter_Y = KalmanFilter()
        self.kalman_filter_Z = KalmanFilter()

    def reset_kalman_filter_X(self, P, Q):
        self.kalman_filter_X.x_reset(P, Q)

    def reset_kalman_filter_Y(self, P, Q):
        self.kalman_filter_Y.y_reset(P, Q)

    def reset_kalman_filter_Z(self, P, Q):
        self.kalman_filter_Z.z_reset(P, Q)


    def do_kalman_filter(self, X, Y, Z, label):
        X_cal = self.kalman_filter_X.cal_X(X, label)
        Y_cal = self.kalman_filter_Y.cal_Y(Y, label)
        Z_cal = self.kalman_filter_Z.cal_Z(Z, label)
        return X_cal, Y_cal, Z_cal
      
kalman_process = KalmanProcess()
_dict = {'base': 0, 'pelvis_r': 0, 'thigh_r': 0, 'calf_r': 0, 'pelvis_l': 0, 
         'thigh_l': 0, 'calf_l': 0, 'waist': 0, 'chest': 0, 
         'neck': 0, 'head': 0, 'shoulder_l': 0, 'arm_l': 0, 
         'forearm_l': 0, 'shoulder_r': 0, 'arm_r': 0, 'forearm_r': 0}

list_dict = list(_dict.keys())   
# получить координаты
def lift_image(returned_val):
    with open(returned_val) as f:
         f = f.readlines()
         ls = []         
         for i in f:
            ls.append(json.loads(i))
         return ls
         
returned_val = "data_c.txt"
coordinates = lift_image(returned_val)
_file = open("data_c_kalman.txt","w")
for i in coordinates:
    coordinates = []
    for iz, z in enumerate(i):
        out_kalman_x, out_kalman_y, out_kalman_z = kalman_process.do_kalman_filter(z[0], z[1], z[2], list_dict[iz])
        coordinates.append([out_kalman_x, out_kalman_y, out_kalman_z])
    _file.write(str(coordinates)+"\n")  
_file.close()    

с фильтром Калманас фильтром Калмана

https://github.com/naturalkind/natural-motion

Простая и интересная задача, помогла лучше изучить механизмы работы Blender. Развитие цифровых вселенных, удивительная тенденция, сегодня переносим свой образ и движение в цифровой мир, завтра переносим сознание, если считаете что перенос сознания не возможен, докажите.

© Habrahabr.ru