こんにちは。この連載では、AI生成技術と進化、3DCG制作現場への活用の可能性を探索していきます。今回取り上げるのは、いくつかあるモーション生成系モデルのうちのひとつ「T2M-GPT」です。

AI関連の研究は日々驚くほどのスピードで進化しています。可能な限り正確な情報を提供するよう心掛けていますが、私自身も学習中であるため、記事中に誤りが含まれる可能性があることをご理解いただければ幸いです。

記事の目次

    赤崎弘幸

    Jet Studio Inc.所属のCGディレクター。得意分野はキャラクターモデリング、リギング、ツール作成等。3DCGの制作現場目線でAI活用の可能性を探究中。(Twitter)→@akasaki1211

    1. T2M-GPTとは

    T2M-GPTは、テキストから人間の動きを生成するフレームワークで、VQ-VAEとGPTを組み合わせたアプローチだそうです。仕組みを解説する能力は私にはありませんが、つまりテキストからモーションを生成できるものだと思って差し支えないかと思います。同じモーション生成系の比較対象として拡散モデルベースのMDMMotionDiffuseが挙げられています。

    今回はこのモデルをWindowsローカル環境で実行し、生成されたモーションをMaya内のリグにあてはめるところまでやってみます。

    2. セットアップ

    GitHubに導入手順が書かれていますが、Windowsではそのまま実施できませんので手動で環境構築していきます。今回は参考になる解説記事などがあまりないため準備段階から記載していきます。OSやGPUの種類によっては一部異なる場合がありますので、あくまで一例として捉えていただけますと幸いです。AnacondaとCUDAは導入済みの前提で始めます。

    <テスト環境>
    ・Windows 10
    ・NVIDIA GeForce RTX 3060 12GB
    ・Anaconda3
    ・CUDA Toolkit 11.3
    まずはリポジトリをクローンします。

    git clone https://github.com/Mael-zys/T2M-GPT.git
    

    次に必要なモデルをダウンロードし、リポジトリ直下に次の画像のように配置します。
    VQTrans_pretrainedHuggingFaceにも同じものがあります)
    t2m

    配置図(赤枠がダウンロードしたもの)

    仮想環境を作成し、必要なパッケージのみ手動でインストールしていきます。GeForce RTX 3060に対応させるためPyTorchのみv1.12.1 CUDA 11.3に上げていますが、それ以外は environment.yml に記載のバージョンを踏襲しています。

    conda create -n t2m-gpt python=3.8.11
    conda activate t2m-gpt
    
    pip install torch==1.12.1+cu113 torchvision==0.13.1+cu113 torchaudio==0.12.1 --extra-index-url https://download.pytorch.org/whl/cu113
    
    pip install scipy==1.7.1
    pip install matplotlib==3.4.3
    pip install imageio==2.9.0
    pip install git+https://github.com/openai/CLIP.git
    


    3. モーション生成

    環境が整ったら、Colabのデモを参考にテキストからモーションを生成するスクリプトを書いて実行してみます。clip_text = [“a person ○○○”] の部分をいろいろ変更して試してみてください。生成されたモーションはmotion.npyファイルに保存され、確認用としてexample.gifも合わせて保存されます。

    import clip
    import torch
    import numpy as np
    import warnings
    
    import models.vqvae as vqvae
    import models.t2m_trans as trans
    import options.option_transformer as option_trans
    from utils.motion_process import recover_from_ric
    import visualization.plot_3d_global as plot_3d
    
    clip_text = ["a person is jumping"]
    
    args = option_trans.get_args_parser()
    
    args.dataname = 't2m'
    args.resume_pth = 'pretrained/VQVAE/net_last.pth'
    args.resume_trans = 'pretrained/VQTransformer_corruption05/net_best_fid.pth'
    args.down_t = 2
    args.depth = 3
    args.block_size = 51
    
    warnings.filterwarnings('ignore')
    
    print ('loading clip model \"ViT-B/32\"')
    clip_model, clip_preprocess = clip.load("ViT-B/32", device=torch.device('cuda'), jit=True, download_root='./')
    clip_model.eval()
    for p in clip_model.parameters():
        p.requires_grad = False
    
    
    net = vqvae.HumanVQVAE(args,
                        args.nb_code,
                        args.code_dim,
                        args.output_emb_width,
                        args.down_t,
                        args.stride_t,
                        args.width,
                        args.depth,
                        args.dilation_growth_rate)
    
    
    trans_encoder = trans.Text2Motion_Transformer(num_vq=args.nb_code,
                                    embed_dim=1024,
                                    clip_dim=args.clip_dim,
                                    block_size=args.block_size,
                                    num_layers=9,
                                    n_head=16,
                                    drop_out_rate=args.drop_out_rate,
                                    fc_rate=args.ff_rate)
    
    
    print ('loading checkpoint from {}'.format(args.resume_pth))
    ckpt = torch.load(args.resume_pth, map_location='cpu')
    net.load_state_dict(ckpt['net'], strict=True)
    net.eval()
    net.cuda()
    
    print ('loading transformer checkpoint from {}'.format(args.resume_trans))
    ckpt = torch.load(args.resume_trans, map_location='cpu')
    trans_encoder.load_state_dict(ckpt['trans'], strict=True)
    trans_encoder.eval()
    trans_encoder.cuda()
    
    mean = torch.from_numpy(np.load('./checkpoints/t2m/VQVAEV3_CB1024_CMT_H1024_NRES3/meta/mean.npy')).cuda()
    std = torch.from_numpy(np.load('./checkpoints/t2m/VQVAEV3_CB1024_CMT_H1024_NRES3/meta/std.npy')).cuda()
    
    text = clip.tokenize(clip_text, truncate=True).cuda()
    feat_clip_text = clip_model.encode_text(text).float()
    index_motion = trans_encoder.sample(feat_clip_text[0:1], False)
    pred_pose = net.forward_decoder(index_motion)
    
    pred_xyz = recover_from_ric((pred_pose*std+mean).float(), 22)
    xyz = pred_xyz.reshape(1, -1, 22, 3)
    xyz_np = xyz.detach().cpu().numpy()
    
    np.save('motion.npy', xyz_np)
    pose_vis = plot_3d.draw_to_batch(xyz_np, clip_text, ['example.gif'])
    
    モーション生成スクリプト

    example.gif:


    4. Mayaへ読み込み

    出力されたnpyファイルにはサイズが(1, フレーム数, 22, 3)のnumpy.ndarrayが保存されており、これは22個ある関節の位置x,y,zがフレーム数ぶん記録されているということになります。このデータをMayaへlocatorの位置アニメーションとして読み込んでみます。

    npyファイルのロードにnumpyを使用しますので、こちらを参考に事前にMayaへnumpyをインストールしておきます。今回はMaya 2024 を使用します。

    cd C:\Program Files\Autodesk\Maya2024\bin
    mayapy -m pip install numpy --target C:/Users/<username>/Documents/maya/2024/scripts/site-packages
    
    numpyインストールコマンド

    Mayaを起動し以下のスクリプトを実行すると、locatorと視覚化用のcurveが作成され、locatorのtranslateにキーフレームが打たれます。すでにlocator等が存在した場合はキーフレームの置き換えのみを行います。速度調整の関係でキーフレームは20fpsで作成されます。npyのパスは適宜変更してください。

    from typing import List
    import numpy as np
    
    from maya import cmds
    from maya.api import OpenMaya, OpenMayaAnim
    
    # ジョイントのラベルリスト
    JOINT_LABELS = [
        "Hip",
        "LeftLeg",
        "RightLeg",
        "Spine1",
        "LeftKnee",
        "RightKnee",
        "Spine2",
        "LeftFoot",
        "RightFoot",
        "Spine3",
        "LeftToe",
        "RightToe",
        "Neck",
        "LeftShoulder",
        "RightShoulder",
        "Head",
        "LeftArm",
        "RightArm",
        "LeftElbow",
        "RightElbow",
        "LeftHand",
        "RightHand",
    ]
    
    # キネマティックチェーンの名前、インデックス、カラーを定義
    KINE_CHAIN = [
        ("Spine", [0, 3, 6, 9, 12, 15], 1),
        ("LeftLeg", [0, 1, 4, 7, 10], 6),
        ("RightLeg", [0, 2, 5, 8, 11], 13),
        ("LeftArm", [9, 13, 16, 18, 20], 6),
        ("RightArm", [9, 14, 17, 19, 21], 13)
    ]
    
    def get_locators():
        """
        ジョイントラベルに基づいてlocatorとcurveを取得または作成します。
       
        Returns:
            tuple: locatorのリストとcurveのリスト
        """
        locators = []
        chains = []
    
        grp = "loc_grp"
        if not cmds.objExists(grp):
            grp = cmds.createNode("transform", n=grp)
       
        # 各ジョイントラベルに対してlocatorを作成または取得
        for lbl in JOINT_LABELS:
            loc = 'loc_{}'.format(lbl)
            # オブジェクトが存在しない場合、新しく作成
            if not cmds.objExists(loc):
                loc = cmds.spaceLocator(n=loc)[0]
                cmds.parent(loc, grp, r=False)
            locators.append(loc)
    
        # キネマティックチェーンに基づいてcurveを作成または取得
        for name, indices, color in KINE_CHAIN:
            crv = 'chain_{}'.format(name)
            if not cmds.objExists(crv):
                p = []
                k = []
                for i in range(len(indices)):
                    p.append((0,0,0))
                    k.append(i)
                crv = cmds.curve(d=1, p=p, k=k, n=crv)
                cmds.parent(crv, grp, r=False)
           
                crv_shape = cmds.listRelatives(crv, s=True, f=True)[0]
                cmds.setAttr(crv_shape + '.overrideEnabled', True)
                cmds.setAttr(crv_shape + '.overrideColor', color)
    
                for j, idx in enumerate(indices):
                    loc_shape = cmds.listRelatives(locators[idx], s=True, f=True)[0]
                    cmds.connectAttr('{}.worldPosition[0]'.format(loc_shape), '{}.controlPoints[{}]'.format(crv_shape,j), f=True)
            chains.append(crv)
       
        cmds.select(cl=True)
    
        return locators, chains
    
    def get_trans_animCurve(node :str):
        """
        指定されたノードのtx,ty,tzのanimCurveを取得または作成します。
       
        Args:
            node (str): ノード名
    
        Returns:
            list: animCurveのリスト
        """
        anim_curves = []
    
        # animCurveを取得または作成
        for attr in ['translateX', 'translateY', 'translateZ']:
            attr_name = '{}.{}'.format(node, attr)
    
            anim_curve = cmds.ls(cmds.listConnections(attr_name, s=True, d=False), type="animCurveTL")
    
            if anim_curve:
                anim_curves.extend(anim_curve)
            if not anim_curve:
                anim_curve = cmds.createNode('animCurveTL')
                cmds.connectAttr('{}.output'.format(anim_curve), attr_name, f=True)
                anim_curves.append(anim_curve)
       
        cmds.select(cl=True)
    
        return anim_curves
    
    def plot(data :List[List[List[float]]], scale :float=1.0):
        """
        与えられたデータを使用してアニメーションをプロットします。
       
        Args:
            data (List[List[List[float]]]): アニメーションデータ
            scale (float, optional): スケール値。デフォルトは1.0。
    
        Returns:
            list: プロットされたlocatorのリスト
        """
        locators, _ = get_locators()
    
        frames = len(data)
        print("frames: {} / points: {} / params: {}".format(frames, len(data[0]), len(data[0][0])))
    
        # アニメーション設定
        cmds.currentUnit(t="ntsc")
        cmds.playbackOptions(min=1, max=int(frames*1.5), ast=1, aet=int(frames*1.5))
        cmds.currentTime(1)
       
        # animCurveにキーフレームを追加
        for i, loc in enumerate(locators):
           
            # animCurve名を取得
            mslist = OpenMaya.MSelectionList()
            for anim_curve in get_trans_animCurve(loc):
                mslist.add(anim_curve)
    
            mfn_curves :List[OpenMayaAnim.MFnAnimCurve] = []
            # MFnAnimCurveを取得
            for j in range(3):
                mfn_curve = OpenMayaAnim.MFnAnimCurve(mslist.getDependNode(j))
                # 既存のキーフレームを削除
                for k in range(mfn_curve.numKeys):
                    mfn_curve.remove(0)
                mfn_curves.append(mfn_curve)
    
            # frame数ぶんキーフレームを追加
            for frame in range(frames):
                k_time = OpenMaya.MTime(frame+1, OpenMaya.MTime.k20FPS)
                for m, k_value in enumerate(data[frame][i]):
                    mfn_curves[m].addKey(k_time, k_value * scale)
    
        return locators
    
    if __name__ == "__main__":
        data = np.load("motion.npy")
        plot(data.tolist()[0], scale=100)
    
    motion.npyをMayaに読み込むスクリプト

    以下のGIFはスクリプト実行後のMayaビューポートです。



    5. リターゲット

    前項で読み込んだアニメーションをキャラクターリグにリターゲットしてみます。リターゲットと言っても、ここでは各IKコントローラをlocatorでコンストレイントする程度の少々強引なやり方です。体格が大きく異なると多少無理が生じますが、今回は目をつむります。

    前項のlocatorは各関節の位置情報だけしかありませんので、腰や胸など一部パーツにおいてはaimConstraintで事前に向きを取得出来るようにしておきます。3点あればaimVectorとupVectorでしっかり固定できるかと思います。

    プリミティブをコンストレイントしたところ

    自身のキャラクターリグをインポートし、腰、胸、首、手足のIK、腕脚のupVectorなど各コントローラをコンストレイントしていきます。ここでは、リグはmGearで作成したものを使用しています。手足だけでなく腰、胸、首など全体にIKが用意されているためこちらを選択しました。

    リグコントローラ拘束後

    一度拘束してしまえば、前項のスクリプトで別のモーションを読み込んでもそのまま差し変わるかと思います。試しに「a person is skipping rope.」で生成したmotion.npyを読み込み直してみました。ちなみに、髪の毛が勝手に揺れているのはリグの影響ですので今回のモーション生成とは関係ありません。



    6. モーション生成サーバを立ててみる

    せっかくここまで来たので、Mayaから直接テキストを打ち込みそのままキャラクターへ反映させてみたくなってきました。「3. モーション生成」で作成したスクリプトをFastAPIでサーバ化し、Mayaからリクエストできるようにしてみます。サーバはテキストを受け取りモーションを生成したら、npyやgifの保存は行わずにモーションデータ(floatのリスト)のみを即座に返すように設計してみます。仮想環境に追加でfastapiとuvicornをインストールし、次のようにスクリプトを改変します。

    pip install fastapi uvicorn
    FastAPIインストールコマンド

    import clip
    import torch
    import numpy as np
    import warnings
    from fastapi import FastAPI
    from pydantic import BaseModel
    import uvicorn
    
    import models.vqvae as vqvae
    import models.t2m_trans as trans
    import options.option_transformer as option_trans
    from utils.motion_process import recover_from_ric
    
    args = option_trans.get_args_parser()
    
    ##(中略)##
    ## args = の行から std = の行まで [3. モーション生成] のスクリプトと同じ ##
    
    std = torch.from_numpy(np.load('./checkpoints/t2m/VQVAEV3_CB1024_CMT_H1024_NRES3/meta/std.npy')).cuda()
    
    # リクエストデータを定義
    class RequestData(BaseModel):
        text: str
    
    # FastAPIのアプリインスタンスを作成
    app = FastAPI()
    
    # /t2mgpt/エンドポイントにPOSTリクエストを実行した場合の処理を定義
    @app.post("/t2mgpt/")
    async def t2mgpt(request_data: RequestData):
    
        # リクエストデータを取得
        data = request_data.model_dump()
        
        # テキストからモーションを生成
        text = clip.tokenize([data["text"]], truncate=True).cuda()
        feat_clip_text = clip_model.encode_text(text).float()
        index_motion = trans_encoder.sample(feat_clip_text[0:1], False)
        pred_pose = net.forward_decoder(index_motion)
        pred_xyz = recover_from_ric((pred_pose*std+mean).float(), 22)
        xyz = pred_xyz.reshape(1, -1, 22, 3)
        
        # listにして返す
        return xyz.detach().cpu().tolist()
    
    # FastAPIのアプリを起動
    uvicorn.run(app, host="127.0.0.1", port=8000)
    サーバスクリプト

    上記スクリプトを「t2mgpt_server.py」と言う名前(他の名前でも大丈夫です)で保存し実行します。

    python t2mgpt_server.py


    最初に一度だけ各種モデルがロードされるので数秒はかかると思います。次の画像のような表示になれば起動成功です。

    Mayaからサーバへリクエストを送信するため「4. Mayaへ読み込み」のスクリプトを改変します。先ほどはnpyファイルからモーションデータをロードしていましたが、今回はテキストをサーバへ送信しモーションデータを直接受け取ります。ついでに簡単なUIも用意してしまいましょう。受け取ったデータは同じくlocatorのキーフレームとして適用します。

    mayapy -m pip install requests --target C:/Users/<username>/Documents/maya/2024/scripts/site-packages
    requestsインストールコマンド

    from typing import List
    import time
    import requests
    import json
    
    from maya import cmds, OpenMayaUI
    from maya.api import OpenMaya, OpenMayaAnim
    
    from PySide2 import QtWidgets
    from shiboken2 import wrapInstance
    
    JOINT_LABELS = [
        ## [4. Mayaへ読み込み] のスクリプトと同じ ##
    
    KINE_CHAIN = [
        ## [4. Mayaへ読み込み] のスクリプトと同じ ##
    
    def get_locators():
        ## [4. Mayaへ読み込み] のスクリプトと同じ ##
    
    def get_trans_animCurve(node :str):
        ## [4. Mayaへ読み込み] のスクリプトと同じ ##
    
    def plot(data :List[List[List[float]]], scale :float=1.0):
        ## [4. Mayaへ読み込み] のスクリプトと同じ ##
    
    def maya_main_window():
        ptr = OpenMayaUI.MQtUtil.mainWindow()
        return wrapInstance(int(ptr), QtWidgets.QWidget)
    
    # UIクラス
    class UI(QtWidgets.QDialog):
    
        def __init__(self, parent=None, *args):
            super(UI, self).__init__(parent, *args)
        
            self.setWindowTitle('T2M-GPT Request')
    
            # テキスト入力フィールド
            label = QtWidgets.QLabel("text:")
            self.text_field = QtWidgets.QLineEdit()
            self.text_field.setStyleSheet("QLineEdit {font-size: 15px;}")
            self.text_field.setText("a person is jumping")
        
            # 生成ボタン
            button = QtWidgets.QPushButton("Generate")
            button.clicked.connect(self.generate)
    
            # 情報表示用
            self.info = QtWidgets.QLabel("")
        
            # UIレイアウト
            hlayout = QtWidgets.QHBoxLayout()
            hlayout.addWidget(label)
            hlayout.addWidget(self.text_field)
        
            vlayout = QtWidgets.QVBoxLayout()
            vlayout.addLayout(hlayout)
            vlayout.addWidget(self.info)
            vlayout.addWidget(button)
    
            self.setLayout(vlayout)
    
        # ボタンクリック時に実行する関数
        def generate(self, *args):
            # 現在の時間取得(計測用)
            st = time.time()
    
            # テキストフィールドから入力テキストを読み取って整形
            data = {"text": self.text_field.text()}
            
            # サーバーにリクエストを送信
            response = requests.post(
                "http://127.0.0.1:8000/t2mgpt/",
                data=json.dumps(data),
                headers={'Content-type': 'application/json'})
            
            # サーバ返答までにかかった時間
            generate_time = time.time() - st
    
            # 現在の時間取得(計測用)
            st = time.time()
    
            # サーバから帰ってきたモーションデータを使用してMayaシーン内にプロット
            plot(response.json()[0], scale=100)
            
            # プロットにかかった時間
            plot_time = time.time() - st
    
            # UIに時間を表示
            self.info.setText("Generate : {:.03f} sec / Plot : {:.03f} sec".format(generate_time, plot_time))
    
    
    if __name__ == "__main__":
        widget = UI(parent=maya_main_window())
        widget.show()
    リクエスト送信UI+モーション反映スクリプト



    1〜3秒程度でモーションが生成され、すぐにキャラクターに反映させることができました。なかなかの速度ですね!

    余談ですが、MDMでも同じく(1, フレーム数, 22, 3)のnumpy.ndarrayが得られますのでMayaへ読み込むスクリプトは同じものを使いまわせます。以下の動画はMDMのText to Motionを同様にAPIサーバにしてみたものです。

    (https://twitter.com/akasaki1211/status/1692961069235650615)

    7. 最後に

    今回の実験では、モーションを生成するだけでなく実際の利用をイメージしてDCCツールと連携するところまでやってみました。生成モーションのバリエーションはある程度限界がありそうでしたが(扱いに慣れていなかっただけかもしれませんが)、生成スピードと軽量さは特に魅力的に感じました。欲を言えば、もう少し何らかのキャラクター性を反映させたモーションが出したかったりもしますし、テキストによって無限にバリエーションが作れるような汎用性が欲しかったりもします。このあたりは学習データの内容や量で変わってくるような気がします。

    また、同じ生成系モデルではモーションを言語のように扱うMotionGPT、他にも検索にフォーカスしたTMR、MotionMatchingを応用し事前学習不要のGenMM、など人物モーションに限ってもいろいろなアプローチが出てきています。近々モーションライブラリやリアルタイムキャラクターコンテンツの形に影響が出てくるかもしれません。

    最後までお読みいただき、ありがとうございます。今回は3Dキャラクターモーションの生成について紹介させていただきました。次回以降もさまざまなAI技術と3DCGの応用について探求していきますので、お楽しみに!