🎉

MotionProで画像からアニメーション作った: 6.学習データ生成

に公開

いよいよ今回でこのシリーズの最終です。これだけ長いものを書いたのは初めてなので少し疲れましたが、自分でも結構勉強になりました。前回学習実行方法を書きその中である程度学習データにも触れました。
https://zenn.dev/takeofuture/articles/ce896ce06b44b3
今回は学習データを作成する手順をまとめました。
まず、必要ライブラリを導入します。

(motionpro)$ pip install einshape

また画像や動画のサイズを合わせるため以下のコマンドもインストールします

(motionpro)$ apt install ffmpeg

まずMotionPro/data/dot_single_video に動画をいれます(ここでは Sample.mp4)。
そのあと、サイズの正規化をします。そのうえでそれをベースに学習データを作ります

(motionpro)$ ffmpeg -y -i Sample.mp4 \
  -vf "scale=-2:320,crop=512:320,fps=8" -an -pix_fmt yuv420p \
  Sample_Norm.mp4
(motionpro)$ mv Sample_Norm.mp4 Sample.mp4

MotionPro のフォルダーにもどります。今のデータ構造が以下です

(motionpro)$ tree data/dot_single_video -L 2
data/dot_single_video
├── Sample.mp4
├── __pycache__
│   └── utils.cpython-310.pyc
├── checkpoints
│   ├── cvo_raft_patch_8.pth
│   ├── movi_f_cotracker2_patch_4_wind_8.pth
│   ├── movi_f_cotracker_patch_4_wind_8.pth
│   └── movi_f_raft_patch_4_alpha.pth
├── configs
│   ├── cotracker2_patch_4_wind_8.json
│   ├── cotracker_patch_4_wind_8.json
│   ├── dot_single_video_1105.yaml
│   ├── raft_patch_4_alpha.json
│   └── raft_patch_8.json
├── dot
│   ├── __init__.py
│   ├── __pycache__
│   ├── data
│   ├── models
│   └── utils
├── out
├── precess_dataset_with_dot_single_video_return_position.py
├── process_dataset_with_dot_single_video_wo_vis_return_flow.py
├── run_dot_single_video.sh
└── utils.py

以下のコマンドで学習データを生成します。

(motionpro)$ python data/dot_single_video/process_dataset_with_dot_single_video_wo_vis_return_flow.py \
    --dot_config data/dot_single_video/configs/dot_single_video_1105.yaml \
    --video_path data/dot_single_video/Sample.mp4 \
    --save_path data/dot_single_video/out \
    --eval_frame_fps 8 \
    --track_time 2

すると、上のコマンドのsave_pathで指定したフォルダーに1セットのデータができます。

(motionpro)$ tree data/dot_single_video/out
data/dot_single_video/out
└── Sample
    ├── Sample.jpg
    ├── Sample.mp4
    └── Sample.npy

ただこのままでスト学習に使えないため、16分割します。
以下分割するためのPYTHONを使います。
**split_dot_npy_to_per_frame.py (MotionProの直下に保存、そこで実行)

#!/usr/bin/env python3
# split_dot_npy_to_per_frame.py
import os, argparse, json, shutil, numpy as np
import cv2
from PIL import Image

def load_flow_npy(path):
    import numpy as np
    arr = np.load(path, allow_pickle=True)
    # 1) そのまま dict の場合
    if isinstance(arr, dict):
        for k in ("flow", "flows", "dense_flow"):
            if k in arr:
                return np.asarray(arr[k])
    # 2) 0次元 object 配列に dict が入っている場合
    if isinstance(arr, np.ndarray) and arr.dtype == object and arr.ndim == 0:
        obj = arr.item()
        if isinstance(obj, dict):
            for k in ("flow", "flows", "dense_flow"):
                if k in obj:
                    return np.asarray(obj[k])
        if isinstance(obj, np.ndarray):
            arr = obj  # 下で判定に回す
    # 3) 1次元 object 配列で各要素が (H,W,2) の場合(listをnp.saveしたようなケース)
    if isinstance(arr, np.ndarray) and arr.dtype == object and arr.ndim == 1:
        elems = [np.asarray(x) for x in arr]
        if all(e.ndim == 3 and e.shape[-1] == 2 for e in elems):
            return np.stack(elems, axis=0)
    # 4) すでに数値配列 (T,H,W,2) の場合
    if isinstance(arr, np.ndarray) and arr.ndim == 4 and arr.shape[-1] == 2:
        return arr
    # 5) 単一フロー (H,W,2) の場合は T=1 として扱う
    if isinstance(arr, np.ndarray) and arr.ndim == 3 and arr.shape[-1] == 2:
        return arr[None, ...]
    raise ValueError(f"Unrecognized .npy content: type={type(arr)}, shape={getattr(arr,'shape',None)}, dtype={getattr(arr,'dtype',None)}")

def hsv_viz(f, clip_pct=95):
    u, v = f[...,0], f[...,1]
    mag = np.sqrt(u*u + v*v)
    ang = (np.arctan2(v, u) * 180 / np.pi) % 360
    mag_clip = np.percentile(mag, clip_pct) if np.isfinite(mag).all() else 1.0
    if mag_clip <= 0: mag_clip = 1.0
    mag_norm = np.clip(mag / mag_clip, 0, 1)
    hsv = np.zeros((*mag.shape, 3), np.uint8)
    hsv[...,0] = (ang/2).astype(np.uint8)  # 0..180
    hsv[...,1] = 255
    hsv[...,2] = (mag_norm*255).astype(np.uint8)
    return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)

def main(npy_path, video_src=None, out_dir="split_out", mask_percentile=90):
    os.makedirs(out_dir, exist_ok=True)
    flow = load_flow_npy(npy_path)      # (T or T-1, H, W, 2)
    T = flow.shape[0]
    H, W = flow.shape[1], flow.shape[2]
    # per-frame flow, mask, 可視化を書き出し
    for i in range(T):
        f = flow[i]
        np.save(os.path.join(out_dir, f"flow_{i+1:02d}.npy"), f)
        # per-frame mask(フローマグの上位%を動きありと見なす)
        mag = np.linalg.norm(f, axis=2)
        thr = np.percentile(mag, mask_percentile)
        mask = (mag > thr).astype(np.uint8) * 255
        Image.fromarray(mask, "L").save(os.path.join(out_dir, f"visible_mask_{i+1:02d}.jpg"))
        # 参考:色付き可視化(任意)
        color = hsv_viz(f, clip_pct=max(mask_percentile, 95))
        cv2.imwrite(os.path.join(out_dir, f"flow_color_{i+1:02d}.png"), color)
    # 元動画も並べたい場合はコピー
    if video_src and os.path.exists(video_src):
        dst = os.path.join(out_dir, os.path.basename(video_src))
        if os.path.abspath(video_src) != os.path.abspath(dst):
            shutil.copy2(video_src, dst)
    print(f"OK: {out_dir} に flow_XX.npy / visible_mask_XX.jpg / flow_color_XX.png を出力しました。")
    print(f"T={T}, H={H}, W={W}")

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--npy", required=True, help="まとめflowの .npy(Sample.npy など)")
    ap.add_argument("--video", default=None, help="同じフォルダの動画 (任意)")
    ap.add_argument("--out", default="split_out", help="出力フォルダ")
    ap.add_argument("--mask_pct", type=int, default=90, help="マスクのパーセンタイル閾値(例:90)")
    args = ap.parse_args()
    main(args.npy, args.video, args.out, args.mask_pct)

実行

(motionpro)$ python split_dot_npy_to_per_frame.py \
  --npy   data/dot_single_video/out/Sample/Sample.npy \
  --video data/dot_single_video/out/Sample/Sample.mp4 \
  --out   data/dot_single_video/out/Sample/Sample_split \
  --mask_pct 90     # 必要に応じて調整

すると以下のフォルダーに分割された各種ファイルができます。

(motionpro)$ ls data/dot_single_video/out/Sample/Sample_split/
Sample.mp4   flow_07.npy  flow_14.npy        flow_color_05.png  flow_color_12.png    visible_mask_03.jpg  visible_mask_10.jpg
flow_01.npy  flow_08.npy  flow_15.npy        flow_color_06.png  flow_color_13.png    visible_mask_04.jpg  visible_mask_11.jpg
flow_02.npy  flow_09.npy  flow_16.npy        flow_color_07.png  flow_color_14.png    visible_mask_05.jpg  visible_mask_12.jpg
flow_03.npy  flow_10.npy  flow_color_01.png  flow_color_08.png  flow_color_15.png    visible_mask_06.jpg  visible_mask_13.jpg
flow_04.npy  flow_11.npy  flow_color_02.png  flow_color_09.png  flow_color_16.png    visible_mask_07.jpg  visible_mask_14.jpg
flow_05.npy  flow_12.npy  flow_color_03.png  flow_color_10.png  visible_mask_01.jpg  visible_mask_08.jpg  visible_mask_15.jpg
flow_06.npy  flow_13.npy  flow_color_04.png  flow_color_11.png  visible_mask_02.jpg  visible_mask_09.jpg  visible_mask_16.jpg

ここで生成されたファイルですが
*** flow_XX.npy
光流(オプティカルフロー)のベクトル場。配列形状は (H, W, 2)、float32です。
[..., 0] = u = Δx(右が +)、[..., 1] = v = Δy(下が +)※画像座標系
例えば flow_01.npy は「フレーム1 → フレーム2」 の動き、flow_02.npy は「2→3」…という対応。
16フレームなら フローは通常 15 本(01〜15)。

*** flow_color_XX.png
上のフロー(flow_XX.npy)を HSVで可視化したPNG(目視確認用のみ・学習では未使用)。
Hue(色相)=方向、明るさ/彩度=速度の大きさ
例:赤系=右、緑系=下、青系=左、紫系=上…

***visible_mask_XX.jpg
可視/動領域マスクのグレースケール画像(0〜255)。白(255)=使う/信頼できる、黒(0)=無視。
DOT:可視性(visibility)に基づく。
この分割スクリプトで作ったものは:フローマグ(‖flow‖)の上位% を1にした簡易マスク(--mask_pctで調整:今回は--mask_pct 90)。学習時、損失の重み付けや動き領域の選別に利用。

それでは再調整した動画からできた学習データをさらにフレーム分割したものを学習データフォルダーにまずフォルダー事コピーします。

(motionpro)$ cp -r data/dot_single_video/out/Sample/Sample_split data/folders/

不要なデータを削除し、フローは16フレーム間の15フローにして動画ファイル名をvideo.mp4と改名します。

(motionpro)$ rm data/folders/Sample_split/flow_color*.png
(motionpro)$ rm data/folders/Sample_split/flow_16.npy
(motionpro)$ rm data/folders/Sample_split/visible_mask_16.jpg
(motionpro)$ mv data/folders/Sample_split/Sample.mp4 data/folders/Sample_split/video.mp4

これで準備はできました。
学習させます。

(motionpro)$ python train_ddp_spawn.py \
    --base configs/train_debug_from_folder.yaml \
    --train True \
    --logdir all_results/train \
    --scale_lr False

batch_num が4になっていることがわかります。前回は3学習セットで3でしたが今回の子の学習データセットをいれたことで4になりました。

ちなみに、以下のエラーは無視してもOKです。

Exception ignored in: <function _MultiProcessingDataLoaderIter.__del__ at 0x7dbc3bdd1ea0>
Traceback (most recent call last):
  File "/root/.pyenv/versions/motionpro/lib/python3.10/site-packages/torch/utils/data/dataloader.py", line 1477, in __del__
    self._shutdown_workers()
  File "/root/.pyenv/versions/motionpro/lib/python3.10/site-packages/torch/utils/data/dataloader.py", line 1460, in _shutdown_workers
    if w.is_alive():
  File "/root/.pyenv/versions/3.10.16/lib/python3.10/multiprocessing/process.py", line 160, in is_alive
    assert self._parent_pid == os.getpid(), 'can only test a child process'
AssertionError: can only test a child process

これは学習が終わった後、Python 終了時に DataLoader のワーカープロセスを片付ける (del) タイミングで出るメッセージですがDataLoader は num_workers>0 だとサブプロセスを立てますが、Lightning/DeepSpeed の並列実行と組み合わさると、ワーカー終了時の「親プロセスID」が入れ替わって見えることがあります。その結果、multiprocessing 側のアサート
assert self._parent_pid == os.getpid(), 'can only test a child process'
が発火します。Exception ignored in: del と出ているとおり、処理自体は完了しています。

Discussion