1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

歩くアニメのGIFが「走って見える」原因は、コマの長さが10ms単位に切り捨てられていたことでした(Python・Pillow)

1
Posted at

個人でWebサイトやアプリを作っています(屋号は sesebox です)。

いま、キャラクターに服やアクセサリーを着せて育てるスマホアプリを作っています。キャラが歩いたり走ったりするアニメーションを、Pythonの画像ライブラリ Pillow でGIFにして確認しているのですが、「歩き」のはずのGIFが、どう見ても走っているように見えるという問題が起きました。

脚の動きや絵の枚数を疑っていましたが、原因は絵ではなくGIFの仕様でした。同じところで詰まる人がいそうなので書いておきます。

何が起きたか

正面から見た歩きのアニメを、**1秒に40コマ(1コマ25ミリ秒)**のつもりで作っていました。

frames[0].save(
    "walk_front.gif",
    save_all=True,
    append_images=frames[1:],
    duration=25,   # 1コマ25ms = 40fps のつもり
    loop=0,
)

ところが、できたGIFを再生すると明らかに速い。歩きというより小走りです。

横から見た歩きは自然に見えていたので、最初は「正面の脚の振り方が大きすぎるのかな」と絵のほうを直そうとしていました。

原因:GIFのコマの長さは「10ミリ秒単位」

GIFという画像形式は、各コマの表示時間を 1/100秒(=10ミリ秒)単位の整数で持っています(GIF89aの仕様で決まっています)。

つまり、25ミリ秒というコマの長さは、GIFの中に書き込めません。 書けるのは 10ms・20ms・30ms…のような10の倍数だけです。

実際に Pillow(12.3.0)で、いろいろな値を渡して保存し、読み直してみました。

from PIL import Image

frames = [Image.new("RGB", (8, 8), (i * 20, 0, 0)) for i in range(8)]
for d in (15, 25, 33, 45, 50):
    frames[0].save("t.gif", save_all=True, append_images=frames[1:],
                   duration=d, loop=0)
    print(d, "->", Image.open("t.gif").info["duration"])
15 -> 10
25 -> 20
33 -> 30
45 -> 40
50 -> 50

10の倍数に切り捨てられています。 45ミリ秒も、50ではなく40になりました。

25ミリ秒のつもりが20ミリ秒になったので、再生速度は 25 ÷ 20 = 1.25倍。1秒に40コマのつもりが、実際は50コマで再生されていたわけです。歩きが小走りに見えるのも当然でした。

調べてみると、ほかのアニメも同じ状態でした。

アニメ 作ったつもり 実際の再生
正面の歩き 40fps(25ms) 50fps(20ms)=1.25倍速
背面の歩き 30fps(33.3ms) 33.3fps(30ms)
横の歩き・走り 48fps(約20.8ms) 50fps(20ms)

横の歩きが自然に見えていたのは、48fps → 50fps とずれがたまたま小さかったからでした。正面だけおかしく見えた理由もこれで説明がつきます。

どう直したか

やったことは2つです。

1. コマの長さを最初から10の倍数にする

「何fpsで作るか」から考えるのをやめて、「1コマ何ミリ秒か」を10の倍数(20ms・30ms・40ms…)から選ぶようにしました。

1コマの長さ 1秒あたりのコマ数
20ms 50コマ
30ms 約33.3コマ
40ms 25コマ

そのうえで、1つのポーズを何コマ見せるかを整数にそろえるようにしました。たとえば20msで1ポーズ3コマなら、1ポーズ60ms=1秒に約16.7ポーズです。歩く速さは「1秒に何ポーズ進むか」で決まるので、これを基準に調整しました。

2. 作ったGIFの再生時間を、ファイルから読み直して確かめる

意図した値ではなく、実際にファイルに書かれた値で確認するようにしました。

from PIL import Image

im = Image.open("walk_front.gif")
n = im.n_frames
d = im.info["duration"]          # 1コマ目の長さ(ms)
print(f"{n}コマ × {d}ms = 1ループ {n * d / 1000:.2f}秒")

コマごとに長さを変えている場合は、im.seek(i) で1コマずつ移動して im.info["duration"] を足し合わせると、1ループの正確な秒数が出せます。

わかったこと

  • GIFのコマの長さは10ミリ秒単位。 それ以外の値は保存の時点で丸められる(Pillowでは切り捨てだった)
  • 「40fpsで作った」と思っていても、GIFになった時点で別の速さに変わっていることがある
  • 動きが「速い」「遅い」と感じたら、絵を直す前に実際の再生速度を数字で見る

絵の動き(脚の角度や振り幅)ばかり疑っていましたが、実は書き出しの段階で速さが変わっていました。アニメの見た目の違和感は、まず数字で確かめるのが近道だと思いました。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?