手話認識入門3 - 追跡点の選定

This image is generated with ChatGPT-4, and edited by the author.
作成日:2024年01月25日(木) 00:00
最終更新日:2024年10月07日(月) 17:16
カテゴリ:手話言語処理
タグ:  手話認識入門 孤立手話単語認識 前処理 骨格追跡 Python

前処理内で認識に不要な追跡点を除去することで,モデル性能を改善する方法を紹介します.

こんにちは.高山です. 先日の記事で告知しました手話入門記事の第三回になります.
今回は,孤立手話単語認識モデルの改善手法を紹介します.
具体的には,前処理 (特徴量エンジニアリングや学習データのアクセス時など) において認識に不要な追跡点を除去 (有効な追跡点を選択) し,認識性能を改善します.

今回解説するスクリプトはGitHub上に公開しています

更新履歴 (大きな変更のみ記載しています)

  • 2024/09/18: カテゴリを変更しました
  • 2024/09/17: タグを更新しました
  • 2024/07/29: Gitスクリプトのダウンロード元を master から v0.1タグに変更
  • 2024/07/23: 第1節の構成を見直し
  • 2024/02/14: データセットのロード方法を変更
  • 2024/01/29: 1.3項に,図3: 抽出後のMediaPipeの追跡点を追加しました.

1. 機械学習ワークフローとの対応関係

図1は,先日の記事で説明した機械学習モデル構築のワークフローの何処が今回の説明箇所に該当するかを示しています.

機械学習モデル構築のワークフローと,本記事の説明範囲との関係を示す図です.画像の後に説明があります.
学習モデル構築のワークフローと紹介箇所

第一回の記事では特徴量エンジニアリングと学習用データセットのアクセス処理について紹介しました.
今回は,これらの前処理に追跡点の抽出処理を加えて認識性能の改善を試みます.

  • [Amershi'19]: S. Amershi, et al., "Software Engineering for Machine Learning: A Case Study," Proc. of the IEEE/ACM ICSE-SEIP, available here, 2019.

2. 追跡点の選定

2.1 MediaPipeの追跡点

本連載記事で使用している,KaggleのGoogle Isolated Sign Language Recognition (以下,GISLR) データセットでは,入力データとしてGoogleのMediaPipeによる追跡点を使用しています.

MediaPipeにはHolistic landmarks detectionという,全身を追跡する機能があります.
全身追跡機能を動画に対して適用すると,図2に示すように顔 (468点),身体 (33点),および両手 (各21点),合計543個の追跡点が得られます.

MediaPipeによる顔,身体,および手の追跡点配置を番号付きで描いた図です.画像前後の文章および文章内で参照しているリンク先に詳細な説明があります.
MediaPipeの追跡点

追跡点の詳細やMediaPipeの使用方法については,公式サイトで詳しく説明されています.
また,本サイトの過去記事でも取り上げておりますので,ご一読いただけましたら幸いです.

2.2 手話認識に利用する追跡点

MediaPipeが出力する543個の追跡点の中には,手話単語認識にはあまり有効でない点が含まれています.
例えば,顔追跡点には額,頬,および鼻梁などが含まれています.
これらの点は顔の詳細な3次元形状を捉えるためには有効ですが,手話の発話内容に関係した情報は得られません.

身体追跡点の顔部分は顔追跡点と内容が重なっており,また追跡精度もあまり良くありません.
下半身は (特別にその部位に言及する場合を除いて) 手話の発話内容に関係した情報は得られず,また,GISLRデータセットにおいては画面外の追跡点です.

今回は,これらの認識に不要な追跡点を除去して入力をコンパクトにすることで,認識性能を改善します.
(同時に,認識処理速度も改善します)
具体的には,下記の130個の追跡点を認識に用います.

  • 顔: 唇 (40点),鼻 (4点),両目 (各16点) の合計76点
  • 身体: 両肩,両肘,両手首,両手の甲の合計12点
  • 両手: 各21点 (除去を行わない)

図3に抽出処理後の追跡点を示します.

手話認識に利用する点を抽出した後の,追跡点配置を番号付きで示した図です.画像前後の文章に詳細説明があります.
抽出後のMediaPipeの追跡点

手話の構成要素 (手だけでなく,姿勢や顔も含む) をなるべく取りこぼさないように上記の設定にしました.
データセットやタスク構成次第では,もっと積極的に削った方が性能が良くなる場合もあります.
ただし,その場合は手話の構成要素を取りこぼす危険性も増えることに注意してください.

3. 前準備

3.1 データセットのダウンロード

ここからは実装方法の説明をしていきます.
まずは,前準備としてGoogle Colabにデータセットをアップロードします.

以前までは,gdown を用いてダウンロードしていたのですが,このやり方ですと多数の方がアクセスした際にトラブルになるようなので (多数のご利用ありがとうございます!),セットアップの方法を少し変えました.

まず最初に,データセットの格納先からデータをダウンロードし,ご自分のGoogle driveへアップロードしてください.

次のコードでGoogle driveをColabへマウントします.
Google Driveのマウント方法については,補足記事にも記載してあります.

1
2
3
from google.colab import drive

drive.mount("/content/drive")

ドライブ内のファイルをColabへコピーします.
パスはアップロード先を設定する必要があります.

# Copy to local.
!cp [path_to_dataset]/gislr_dataset_top10.zip gislr_top10.zip

データセットはZIP形式になっているので unzip コマンドで解凍します.

!unzip gislr_top10.zip
Archive:  gislr_top10.zip
   creating: dataset_top10/
  inflating: dataset_top10/16069.hdf5
  ...
  inflating: dataset_top10/sign_to_prediction_index_map.json

成功すると dataset_top10 以下にデータが解凍されます.
HDF5ファイルはデータ本体で,手話者毎にファイルが別れています.
JSONファイルは辞書ファイルで,TXTファイルは本データセットのライセンスです.

!ls dataset_top10
16069.hdf5  25571.hdf5  29302.hdf5  36257.hdf5  49445.hdf5  62590.hdf5
18796.hdf5  26734.hdf5  30680.hdf5  37055.hdf5  53618.hdf5  LICENSE.txt
2044.hdf5   27610.hdf5  32319.hdf5  37779.hdf5  55372.hdf5  sign_to_prediction_index_map.json
22343.hdf5  28656.hdf5  34503.hdf5  4718.hdf5   61333.hdf5

単語辞書には単語名と数値の関係が10単語分定義されています.

!cat dataset_top10/sign_to_prediction_index_map.json
{
    "listen": 0,
    "look": 1,
    "shhh": 2,
    "donkey": 3,
    "mouse": 4,
    "duck": 5,
    "uncle": 6,
    "hear": 7,
    "pretend": 8,
    "cow": 9
}

ライセンスはオリジナルと同様に,CC-BY 4.0 としています.

!cat dataset_top10/LICENSE.txt
The dataset provided by Natsuki Takayama (Takayama Research and Development Office) is licensed under CC-BY 4.0.
Author: Copyright 2024 Natsuki Takayama
Title: GISLR Top 10 dataset
Original licenser: Deaf Professional Arts Network and the Georgia Institute of Technology
Modification
- Extract 10 most frequent words.
- Packaged into HDF5 format.

次のコードでサンプルを確認します.
サンプルは辞書型のようにキーバリュー形式で保存されており,下記のように階層化されています.

- サンプルID (トップ階層のKey)
  |- feature: 入力特徴量で `[C(=3), T, J(=543)]` 形状.C,T,Jは,それぞれ特徴次元,フレーム数,追跡点数です.
  |- token: 単語ラベル値で `[1]` 形状.0から9の数値です.
1
2
3
4
5
6
7
8
9
with h5py.File("dataset_top10/16069.hdf5", "r") as fread:
    keys = list(fread.keys())
    print(keys)
    group = fread[keys[0]]
    print(group.keys())
    feature = group["feature"][:]
    token = group["token"][:]
    print(feature.shape)
    print(token)
['1109479272', '11121526', ..., '976754415']
<KeysViewHDF5 ['feature', 'token']>
(3, 23, 543)
[1]

3.2 モジュールのダウンロード

次に,過去の記事で実装したコードをダウンロードします.
本項は前回までに紹介した内容と同じですので,飛ばしていただいても構いません. コードはGithubのsrc/modules_gislrにアップしてあります (今後の記事で使用するコードも含まれています).

まず,下記のコマンドでレポジトリをダウンロードします.
(目的のディレクトリだけダウンロードする方法はまだ調査中です(^^;))

!wget https://github.com/takayama-rado/trado_samples/archive/refs/tags/v0.1.zip -O master.zip
--2024-01-21 11:01:47--  https://github.com/takayama-rado/trado_samples/archive/master.zip
Resolving github.com (github.com)... 140.82.112.3
...
2024-01-21 11:01:51 (19.4 MB/s) - ‘master.zip’ saved [75710869]

ダウンロードしたリポジトリを解凍します.

!unzip -o master.zip -d master
Archive:  master.zip
641b06a0ca7f5430a945a53b4825e22b5f3b8eb6
   creating: master/trado_samples-main/
  inflating: master/trado_samples-main/.gitignore  
  ...

モジュールのディレクトリをカレントディレクトリに移動します.

!mv master/trado_samples-main/src/modules_gislr .

他のファイルは不要なので削除します.

!rm -rf master master.zip gislr_top10.zip
!ls
dataset_top10 drive modules_gislr  sample_data

3.3 モジュールのロード

主要な処理の実装に先立って,下記のコードでモジュールをロードします.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Standard modules
import json
import math
import sys
import time
from functools import partial
from pathlib import Path
from typing import (
    Any,
    Dict
)

# Third party's modules
import numpy as np

import torch
from torch import nn
from torch.nn import functional as F
from torch.utils.data import (
    DataLoader)

from torchvision.transforms import Compose

# Local modules
sys.path.append("modules_gislr")
from modules_gislr.dataset import (
    HDF5Dataset,
    merge_padded_batch)
from modules_gislr.layers import (
    SimpleISLR
)
from modules_gislr.transforms import (
    ReplaceNan,
    ToTensor
)
from modules_gislr.train_functions import (
    test_loop,
    train_loop,
    val_loop
)
【コード解説】
- 標準モジュール
  - json: JSONファイル制御ライブラリ.辞書ファイルのロードに使用します.
  - math: 数学計算処理ライブラリ
  - sys: Pythonインタプリタの制御ライブラリ.
    今回はローカルモジュールに対してパスを通すために使用します.
  - functools: 関数オブジェクトを操作するためのライブラリ.
    今回はDataLoaderクラスに渡すパディング関数に対して設定値をセットするために使用します.
  - pathlib.Path: オブジェクト指向のファイルシステム機能.
    主にファイルアクセスに使います.osモジュールを使っても同様の処理は可能です.
    高山の好みでこちらのモジュールを使っています(^^;).
  - typing: 関数などに型アノテーションを行う機能.
    ここでは型を忘れやすい関数に付けていますが,本来は全てアノテーションをした方が良いでしょう(^^;).
- 3rdパーティモジュール
  - numpy: 行列演算ライブラリ
  - torch: ニューラルネットワークライブラリ
  - torchvision: PyTorchと親和性が高い画像処理ライブラリ.
    今回はDatasetクラスに与える前処理をパッケージするために用います.
- ローカルモジュール: sys.pathにパスを追加することでロード可能
  - dataset: データセット操作用モジュール
  - layers: ニューラルネットワークのモデルやレイヤモジュール
  - transforms: 入出力変換処理モジュール
  - train_functions: 学習・評価処理モジュール

4. 前処理の実装

下記のコードで使用する追跡点と座標値の抽出を行います.
特徴変換処理の一部として,Datasetクラスから呼び出す想定で実装しています.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class SelectLandmarksAndFeature():
    """ Select joint and feature.
    """
    def __init__(self, landmarks, features=["x", "y", "z"]):
        self.landmarks = landmarks
        _features = []
        if "x" in features:
            _features.append(0)
        if "y" in features:
            _features.append(1)
        if "z" in features:
            _features.append(2)
        self.features = np.array(_features, dtype=np.int32)
        assert self.features.shape[0] > 0, f"{self.features}"

    def __call__(self,
                 data: Dict[str, Any]) -> Dict[str, Any]:
        feature = data["feature"]
        # `[C, T, J]`
        feature = feature[self.features]
        feature = feature[:, :, self.landmarks]
        data["feature"] = feature
        return data
【コード解説】
- 引数
  - landmarks: 使用する追跡点を示す,インデクス配列
  - features: 使用する座標値を示す,文字列配列 [x/y/z]
- 5-14行目: 初期化処理.
  座標値については,"x","y","z"の文字列の組み合わせに応じて,対応するインデクス
  を抽出対象として`self.features`に格納しています.
- 18-23行目: 変換処理
  20-21行目でnumpyのインデキシング機能を利用して,座標値,追跡点の順で抽出を
  行っています.

追跡点抽出処理の実装ができましたので,動作確認をしていきます.
次のコードでは,使用する追跡点のインデクスを定義して,さらにそれらを配列として返す関数 get_fullbody_landmarks() を実装しています.
get_fullbody_landmarks() は抽出前のインデクスに対応した追跡点配列 use_landmarks と,抽出後のインデクスに対応した use_landmarks_filtered を返します.
今回の処理では use_landmarks を利用します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Define using landmarks.
USE_LIP = [0, 13, 14, 17, 37, 39, 40, 61, 78, 80,
           81, 82, 84, 87, 88, 91, 95, 146, 178, 181,
           185, 191, 267, 269, 270, 291, 308, 310, 311, 312,
           314, 317, 318, 321, 324, 375, 402, 405, 409, 415]
USE_NOSE = [1, 2, 98, 327]
USE_REYE = [33, 7, 163, 144, 145, 153, 154, 155, 133,
            246, 161, 160, 159, 158, 157, 173]
USE_LEYE = [263, 249, 390, 373, 374, 380, 381, 382, 362,
            466, 388, 387, 386, 385, 384, 398]
USE_FACE = np.sort(np.unique(USE_LIP + USE_NOSE + USE_REYE + USE_LEYE))

USE_LHAND = np.arange(468, 468+21)
# Use shoulder, arms, and hands.
USE_POSE = np.array([11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]) + 468 + 21

USE_RHAND = np.arange(468+21+33, 468+21+33+21)


def get_fullbody_landmarks():
    use_landmarks = np.concatenate([USE_FACE, USE_LHAND, USE_POSE, USE_RHAND])
    use_landmarks_filtered = np.arange(len(use_landmarks))
    return use_landmarks_filtered, use_landmarks

次のコードでデータセットからHDF5ファイルとJSONファイルのパスを読み込みます.

1
2
3
4
5
6
7
8
# Access check.
dataset_dir = Path("dataset_top10")
files = list(dataset_dir.iterdir())
dictionary = [fin for fin in files if ".json" in fin.name][0]
hdf5_files = [fin for fin in files if ".hdf5" in fin.name]

print(dictionary)
print(hdf5_files)
dataset_top10/sign_to_prediction_index_map.json
[PosixPath('dataset_top10/25571.hdf5'), ..., PosixPath('dataset_top10/27610.hdf5')]

次のコードで辞書ファイルをロードして,認識対象の単語数を格納します.

1
2
3
4
5
# Load dictionary.
with open(dictionary, "r") as fread:
    key2token = json.load(fread)

VOCAB = len(key2token)

比較のために,まずはデフォルト状態でHDF5DatasetとDataLoaderをインスタンス化し,データを取り出してみます.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
pre_transforms = Compose([ReplaceNan()])
transforms = Compose([ToTensor()])

batch_size = 2
feature_shape = (3, -1, 543)
token_shape = (1,)
merge_fn = partial(merge_padded_batch,
                   feature_shape=feature_shape,
                   token_shape=token_shape,
                   feature_padding_val=0.0,
                   token_padding_val=0)

dataset = HDF5Dataset(hdf5_files, pre_transforms=pre_transforms, transforms=transforms)

dataloader = DataLoader(dataset, batch_size=batch_size, collate_fn=merge_fn)
try:
    data = next(iter(dataloader))
    feature = data["feature"]

    print(feature.shape)
except Exception as inst:
    print(inst)
torch.Size([2, 3, 10, 543])

入力特徴量は [N, C, T, J] 形状なので,特徴次元数はXYZ座標値なので \(C=3\),追跡点次元数は \(J=543\) になっています.

次に,SelectLandmarksAndFeature() クラスを pre_transforms に適用して,同様にHDF5DatasetとDataLoaderをインスタンス化します.
追跡点抽出は動的なパラメータを持たない固定的な処理なので,pre_transforms に格納しています.
(つまり,データアクセス時の変換処理ではなく,特徴量エンジニアリングとして実装可能な処理になります)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
_, use_landmarks = get_fullbody_landmarks()
use_features = ["x", "y"]
pre_transforms = Compose([SelectLandmarksAndFeature(landmarks=use_landmarks, features=use_features),
                          ReplaceNan()])
transforms = Compose([ToTensor()])

batch_size = 2
feature_shape = (len(use_features), -1, len(use_landmarks))
token_shape = (1,)
merge_fn = partial(merge_padded_batch,
                   feature_shape=feature_shape,
                   token_shape=token_shape,
                   feature_padding_val=0.0,
                   token_padding_val=0)

dataset = HDF5Dataset(hdf5_files, pre_transforms=pre_transforms, transforms=transforms)

dataloader = DataLoader(dataset, batch_size=batch_size, collate_fn=merge_fn)
try:
    data = next(iter(dataloader))
    feature = data["feature"]

    print(feature.shape)
except Exception as inst:
    print(inst)
torch.Size([2, 2, 10, 130])

上の表示結果から,特徴次元数はXY座標値なので \(C=2\),追跡点次元数は \(J=130\) となり,問題なく動作していることが確認できました.

5. 学習と評価の実行

では,実際に学習・評価を行います.

まずは,モデルの動作チェックをします.
追跡点抽出の結果,入力追跡点数は130で,各追跡点はXY座標値を持っていますので,入力次元数は260になります.
出力次元数は単語数なので10になります.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Define model.
# in_channels: J * C (130*2=260)
#   J: use_landmarks (130)
#   C: use_channels (2)
# out_channels: 10
in_channels = len(use_landmarks) * len(use_features)
out_channels = VOCAB

model = SimpleISLR(in_channels, out_channels)
print(model)

# Sanity check.
logit = model(feature)
print(logit.shape)
SimpleISLR(
  (linear): Linear(in_features=260, out_features=64, bias=True)
  (activation): ReLU()
  (head): GPoolRecognitionHead(
    (head): Linear(in_features=64, out_features=10, bias=True)
  )
)
torch.Size([2, 10])

問題なく,\(N \times |L|\) 形状の出力が得られています.

次のコードで学習・バリデーション・評価処理それぞれのためのDataLoaderクラスを作成します.
ここでは,ID:16069の手話者データをバリデーションと評価処理用とし,その他を学習用データとしています.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Build dataloaders.
batch_size = 32
load_into_ram = True
test_pid = 16069
num_workers = 1

train_hdf5files = [fin for fin in hdf5_files if str(test_pid) not in fin.name]
val_hdf5files = [fin for fin in hdf5_files if str(test_pid) in fin.name]
test_hdf5files = [fin for fin in hdf5_files if str(test_pid) in fin.name]

train_dataset = HDF5Dataset(train_hdf5files, pre_transforms=pre_transforms,
    transforms=transforms, load_into_ram=load_into_ram)
val_dataset = HDF5Dataset(val_hdf5files, pre_transforms=pre_transforms,
    transforms=transforms, load_into_ram=load_into_ram)
test_dataset = HDF5Dataset(test_hdf5files, pre_transforms=pre_transforms,
    transforms=transforms, load_into_ram=load_into_ram)

train_dataloader = DataLoader(train_dataset, batch_size=batch_size, collate_fn=merge_fn, num_workers=num_workers)
val_dataloader = DataLoader(val_dataset, batch_size=batch_size, collate_fn=merge_fn, num_workers=num_workers)
test_dataloader = DataLoader(test_dataset, batch_size=1, collate_fn=merge_fn, num_workers=num_workers)

次に,Loss関数とモデルパラメータ更新の制御クラスをインスタンス化しています.
Loss算出には交差エントロピー誤差を用いて,パラメータ更新にはAdam法[Kingma'15]を用います.

1
2
loss_fn = nn.CrossEntropyLoss(reduction="mean")
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

次のコードで学習・評価処理を行います.
今回は10回各ループを繰り返してみて (ここでの繰り返し数は,エポックと呼ばれます),最小Lossと最大認識率を表示します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Train, validation, and evaluation.
epochs = 10
eval_every_n_epochs = 1
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using {device} for computation.")
model.to(device)

val_losses = []
test_accs = []
print("Start training.")
for epoch in range(epochs):
    print("-" * 80)
    print(f"Epoch {epoch+1}")

    train_loop(train_dataloader, model, loss_fn, optimizer, device)
    val_loss = val_loop(val_dataloader, model, loss_fn, device)
    val_losses.append(val_loss)

    if (epoch+1) % eval_every_n_epochs == 0:
        acc = test_loop(test_dataloader, model, device)
        test_accs.append(acc)
val_losses = np.array(val_losses)
test_accs = np.array(test_accs)
print(f"Minimum validation loss:{val_losses.min()} at {np.argmin(val_losses)+1} epoch.")
print(f"Maximum accuracy:{test_accs.max()} at {np.argmax(test_accs)*eval_every_n_epochs+1} epoch.")
Using cpu for computation.
Start training.
--------------------------------------------------------------------------------
Epoch 1
Start training.
loss:2.313739 [    0/ 3881]
loss:2.250355 [ 3200/ 3881]
Done. Time:3.097375600999996
Start validation.
Done. Time:0.2397716910000014
Validation performance:
 Avg loss:2.282643

Start evaluation.
Done. Time:0.775679347999997
Test performance:
 Accuracy:16.5%
--------------------------------------------------------------------------------
...
--------------------------------------------------------------------------------
Epoch 10
Start training.
loss:2.010410 [    0/ 3881]
loss:1.737789 [ 3200/ 3881]
Done. Time:3.4693860680000057
Start validation.
Done. Time:0.3935750869999879
Validation performance:
 Avg loss:2.088958

Start evaluation.
Done. Time:0.9345689209999932
Test performance:
 Accuracy:17.5%
Minimum validation loss:2.088958195277623 at 10 epoch.
Maximum accuracy:23.0 at 6 epoch.

問題なく学習・評価処理が実行できました.
表示結果から,6エポック目で23.0%程度の認識率を達成しており,認識率がわずかながら改善しています (第二回では15.0%程度).
また,学習ループの処理時間は3秒程度と大きく改善しています (第二回では18秒程度).

  • [Kingma'15]: D. P. Kingma, et al., "Adam: A Method for Stochastic Optimization," Proc. of the ICLR, available here, 2015.

今回は前処理で認識に不要な追跡点を除去することで,認識性能を改善する方法を紹介しましたが,如何でしたでしょうか?
認識性能というとモデルの改善に目が行きがちですが,前処理,後処理,学習方法,および各種のパラメータ調整など様々な観点があります.
特に今回のように入力をコンパクトにすると,認識性能だけでなく処理速度も向上する場合が多いです.

今回は手話の構成要素をなるべく取りこぼさずに捉えるように,130個の追跡点を抽出して利用しました.
この追跡点の選択については,少し議論の余地があるかもしれません.

手話言語には手の位置,形,動作で表される手指標識 (manual marker: MM) の他に,頭部や身体の姿勢,動き,表情,視線,口型などで表される非手指標識 (non-manual marker: NMM) があります.
NMMを捉えるためには顔の追跡点を利用する必要がありますが,今回利用した顔の追跡点は76点あり全体の半分以上を占めています.
この場合,認識においてはNMM (というよりも顔部分) の影響が強く出てしまう可能性が高いです.

また,孤立手話単語認識の場合は認識対象の単語構成次第では,顔がほぼ動かない場合もあります.
そのようなデータセットの場合は顔の追跡点を削った方が性能が良くなることが考えられます.
ただし,その設定を (NMMを活用している) 他のデータセットや連続手話単語認識,手話翻訳などにそのまま流用すると性能が悪化する可能性がありますので注意してください.

なお,顔を削除している場合,手話の知識がある方からは高い確率で "なぜ顔は使わないの? NMMは要らないの?",というツッコミが入ります.
データセットやタスク,技術構成の観点から正確に説明できないと,"手話のこと何も知らないのでは?" と判断されてしまう可能性があるので注意しましょう.
(はい.私のことです(^^;))

今回紹介した話が,これから手話認識を勉強してみようとお考えの方に何か参考になれば幸いです.