Blenderでモーションを線形補間して120fpsデータに変換するスクリプト

# 内容
モーションをより高いフレームレートにしたいとき、フレームごとのデータを編集する必要がありますが、そのためのコードと説明です。
Blenderではアニメーションデータをキーフレームから編集する方法が一般的らしいですが、内部のデータを扱うのは面倒なので、ボーンデータを編集して線形補間後のモーションを作成します。

# Blenderのボーンの種類
### Bone
Objectモードのボーン。**Read Only**なので、名前の変更やボーンの追加、削除ができません。
### EditBone
Editモードのボーン。**名前の変更やボーンの追加、削除ができます**が、フレームごとのデータ編集はできません。
### PoseBone
Poseモードのボーン。**フレームごとのデータ編集**ができます。

<br>
<br>
<br>

今回はフレームデータを編集するので、**PoseBone**を使用します。


# ボーン内のデータの種類
### 平行移動
- xyzの3次元ベクトルでそれぞれの軸の平行移動を表します
- 補間方法は**線形補間**
- PoseBone.locationでアクセス可能
### 回転
- 表現方法が複数あり、それぞれデータの次元が異なります
- 補間方法は**球面線形補間**
- アクセス方法は後述
### スケール
- xyzの3次元ベクトルでそれぞれの軸のスケールを表します
- 補間方法は**線形補間**
- PoseBone.scaleでアクセス可能


# Blenderの回転の種類
Blenderの数学モジュールであるmathutilsでは、回転を表現するために以下の4つのデータ型が用意されていますが、補間に適するものはQuaternionとMatrixの2つです。

### Euler
- オイラー角、BVHファイルに保存されている3次元の回転データ表現
- 補間方法が用意されていません
- PoseBone.rotation_eulerでアクセス可能

### Vector
- これ自体は普通のベクトルですが、回転角と回転軸（3次元ベクトル）の４次元ベクトルで回転を表現できます（angle axis representation）
- 通常の線形補間、球面線形補間の関数(lerp, slerp)がありますが、直交座標と極座標用の補間なので、今回の補間としては不適切です
- PoseBone.rotation_angle_axisでアクセス可能

### Quaternion
- (w,x,y,z)の形式で保存されるクオータニオン(四元数)
- 球面線形補間の関数(slerp)があります
- PoseBone.rotation_quaternionでアクセス可能

### Matrix
- トランスフォーム行列（同次座標系の4次行列）で、データの性質上、平行移動・回転・スケールの情報が入っており、decompose()でそれぞれに分解できます
- 補間の関数(lerp)があり、平行移動、回転、スケールの全てのデータを適切に線形補間できます
- PoseBone.matrixでアクセス可能

<br>
<br>
<br>

長々書きましたが、今回は回転だけでなく平行移動も補間したいので、**PoseBone.matrix**を使用します。



# コード例
- 入力されたBVHファイルのモーションを線形補間して、120fpsに変換します
(120fps以外の変換でも動作します)

- 処理の流れとしては、毎フレームずつ更新すると元データが消えて参照できなくなるので、
  - 全フレームデータ取得(get_motion_data)
  - 補間
  - 全フレームデータ更新(set_motion_data)
  
  という手順で更新します

```python:interp.py
import bpy
import os
import copy
import argparse


def init_scene():
    if bpy.context:
        scene = bpy.context.scene
        for object_ in scene.objects:
            bpy.data.objects.remove(object_, do_unlink=True)
    
    for action in bpy.data.actions:
        if action.users == 0:
            bpy.data.actions.remove(action)


def get_motion_data():
    matrices = []
    for f in range(bpy.context.scene.frame_start, bpy.context.scene.frame_end+1):
        matrices_frame = []
        bpy.context.scene.frame_set(f)
        for posebone in bpy.context.object.pose.bones:
            matrices_frame.append(copy.deepcopy(posebone.matrix))
        matrices.append(matrices_frame)
    return matrices
    

def set_motion_data(matrices):
    for f, matrices_frame in enumerate(matrices):
        bpy.context.scene.frame_set(f+1)
        for i, posebone in enumerate(bpy.context.object.pose.bones):
            posebone.matrix = matrices_frame[i]
            posebone.keyframe_insert(data_path='location')
            posebone.keyframe_insert(data_path='rotation_euler')
            bpy.context.view_layer.update()


def interp_bvh(src_path, tgt_path, tgt_fps=120):        
    bpy.ops.import_anim.bvh(filepath=src_path, update_scene_fps=True)
    
    ## データ取得
    src_mats = get_motion_data()

    ## 補間
    src_fps = bpy.context.scene.render.fps
    src_time_step = 1.0 / float(src_fps) # 元データの1フレームにかかる時間（秒）
    tgt_time_step = 1.0 / float(tgt_fps) # 出力したいFPSでの1フレームにかかる時間（秒）
     
    frame_range = bpy.context.object.animation_data.action.frame_range
    total_time = float(frame_range[1] - frame_range[0]) / float(src_fps) # モーションの合計時間（補間前後で変化しない）
    
    tgt_mats = [] # 更新後のデータを入れるリスト
    current_time = 0.0 # ループ内で更新される現在時間
    while current_time <= total_time:
        prev_frame = int(current_time // src_time_step) # 現在時間の前のフレーム
        next_frame = prev_frame + 1 # 現在時間の後のフレーム
        ratio = current_time % src_time_step * src_fps # 補間の割合
        
        tgt_mats_frame = []
        for prev_matrix, next_matrix in zip(src_mats[prev_frame], src_mats[next_frame]):
            tgt_mats_frame.append(prev_matrix.lerp(next_matrix, ratio)) # 各フレームの全関節について補間
            
        tgt_mats.append(tgt_mats_frame)
        current_time += tgt_time_step # 出力用のタイムステップで現在時間を更新
        
    ## データ更新
    set_motion_data(tgt_mats)
    
    bpy.context.scene.render.fps = tgt_fps
    bpy.ops.export_anim.bvh(filepath=tgt_path)


if __name__ ==  '__main__':
    input_path = "入力するBVHファイルのパス"
    output_path = "出力するBVHファイルのパス"
    interp_bvh(input_path, output_path, 120)
    # コマンドライン実行したい場合 (blender -b -P interp.py -i "input.bvh" -o "output.bvh" -f 120) 
    # parser = argparse.ArgumentParser()
    # parser.add_argument('-i', '--input_path')
    # parser.add_argument('-o', '--output_path')
    # parser.add_argument('-f' '--fps', default=120)
    # args = parser.parse_args()
    # interp_bvh(args.input_path, args.output_path, args.fps)
```
### 補足説明
- import時にupdate_scene_fps=Trueとすることで、シーンが入力ファイルのフレームレートに更新され、適切な補間の割合を計算できます。
- データ取得時の注意点としては、PoseBone.matrixには行列の参照が入っており、配列にそのまま格納した後に参照するフレームを変更すると、先ほど格納した配列内のデータも変更されてしまうため、copy.deepcopy()を使用してこれを回避します。
- データ更新時の注意点としては、PoseBoneに直接値を代入するだけではアニメーションデータを更新できないので、keyframe_insert()でアニメーションにPoseBoneのデータを挿入する必要があります。また、ボーンを更新するごとに、bpy.context.view_layer.update()で現在のシーンを更新します。毎フレーム毎ボーンでシーンを更新する必要があるので、その分処理速度は遅くなります。