動く画像(APNG)が80KBに収まらないときの直し方|同じ絵・同じコマ数で91.3KB→61.2KBになった手順と、弾かれた理由10個

AI・テクノロジー

動く画像(APNG)を作って、規格の「80KB以内」に収まらず丸一日ためした記録です。結論から書くと、原因は絵の側ではなく、書き出す道具の側でした。同じ8コマ・同じ絵で 91.3KB → 61.2KB になり、色を落とす必要もなくなりました。

この記事は「うまくいったやり方」だけでなく、途中で間違えた判断もそのまま書いています。同じ所で止まる人がいるはずだからです。

最初に起きたこと:91.3KB。80KBに収まらない

作りたかったのは、320×270・8コマ・2.0秒・ループ1回の動く画像です。1個あたり 80KB以内という決まりがありました。

Python の Pillow で、素直にこう書きました。

f[0].save(path, save_all=True, append_images=f[1:],
          duration=250, loop=1, disposal=2)

出てきたのが 91.3KB。11KB超過です。

間違えた1つ目:色を落として通そうとした

「重いなら色を減らせばいい」と考えて、出力の色数を下げていきました。実測はこうです。

色数 できたファイル
24色 91.3KB
16色 87.9KB
14色 85.7KB
12色 83.7KB
10色 79.8KB(やっと規格内)
8色 70.4KB

10色でようやく79.8KB。ただし 絵は目に見えて荒れました。輪郭がギザギザになり、平らな面にまだらが出ます。24色と10色を並べて見比べると、素人目にもはっきり違いました。

ここで「80KBに収めるには画質を捨てるしかない」と結論を出しかけました。これが間違いでした。

間違えた2つ目:コマをまとめる設定で軽くしようとした

次に試したのが、APNG の保存オプションです。APNG は「前のコマとの差分だけ」を書ける仕組みを持っています。そこで disposal と blend を変えてみました。

書き方 大きさ コマ数
disposal=2(消してから描く) 68.7KB 6コマ
disposal=0, blend=1(上に重ねる) 45.9KB 4コマ
disposal=0, blend=0(置き換える) 91.2KB 8コマ

軽くなった2つは、どちらも コマ数が減っていました。理由は単純で、Pillow は同じ中身のコマが続くと1つにまとめるからです。こちらは「A・A・B・B…」のように同じ絵を2回ずつ並べていたので、まとめられていました。

これは軽くなったのではなく、動きが変わってしまっている状態です。しかも APNG の決まりでは コマ数は5〜20なので、4コマになると規格から外れます。数字(KB)だけ見て喜んでいたら、規格外の物を出すところでした。

本当の原因:書き出す道具が違った

手元には、以前この形式のために自分で書いた書き出し用の関数がありました。そちらに差し替えただけで、こうなりました。

書き出し方 大きさ コマ数 色
Pillow の save 91.3KB 8 24色
自前の書き出し 61.2KB 8 24色のまま

同じ8コマ、同じ絵、同じ色数で 30KB 減りました。色を落とす必要は最初から無かったのです。

「重いから画質を落とす」と考えた時点で、作り方を疑う道を自分でふさいでいました。数字が出ていると、その数字の中だけで解こうとしてしまいます。

APNG で引っかかった決まり、4つ

作りながら分かった「これが無いと弾かれる」点を並べます。どれも実際に一度ずつ弾かれて覚えました。

1. 解像度(pHYs)が入っていないと弾かれる

公式の決まりに「画像の解像度は72dpi以上」とあります。ところが自作の書き出しには pHYs というチャンクが無く、解像度が「不明」のままでした。ffmpeg で作った物には入っていたので、そこが差でした。

72dpi は「1インチ(2.54cm)あたり72画素」なので、1メートルあたりに直すと 2835画素です。この数字を pHYs に書きます。

2. 再生時間が「ぴったり」でないと弾かれる

再生時間は 1〜4秒ちょうどである必要がありました。ここで最初につまずいたのが、2.0秒のつもりが2.52秒になっていたことです。4回続けて「適切な形式ではありません」と返されました。

原因は割り切れない割り算でした。2秒を6コマで割ると 333.33…ミリ秒になり、端数が出ます。APNG は1コマの時間を 分子/分母の分数で持てるので、2000 / 6000 のように書けば誤差ゼロになります。ミリ秒に直してから丸めると、そこでずれます。

3. 全コマが同じ大きさでないと弾かれる

差分だけを書く設定にすると、コマごとに大きさが変わります。手元の点検で「コマの大きさがちがう 7個」と出て、これも弾かれる形でした。書き出す前に、全コマの大きさが揃っているかを自分で確かめる行を入れました。

4. 違う絵が2種類だと「チカチカ」して見える

これは規格ではなく、自分で作った点検が教えてくれたことです。A と B の2枚を交互に出すと、8コマあっても目にはチカチカとした点滅に見えます。3種類以上(例:手を上げる→まん中→振る)にすると、動きとして読めるようになりました。

もう一つの落とし穴:コマごとに色を減らすと「ちらつく」

これは数字では見つけられなかった問題です。

色を減らす処理を 1コマずつ別々にかけていました。絵はほとんど同じなのに、コマごとに選ばれる色がわずかに変わります。結果、動かすと車体の線の太さや、タイヤの中心の色が ちらちら変わって見えました。

コマ同士の違いを数えると 2.7%しかありません。数字だけ見ていたら通していたはずです。差分を赤く塗った画像を作って初めて、変化が絵の全体に散っているのが見えました。

直し方は単純で、全コマを1枚に縦に並べてから色の表を1つ作り、その表で全コマを塗り直すだけです。

narabe = Image.new("RGB", (w, h * len(koma)))
for i, x in enumerate(koma):
    narabe.paste(x.convert("RGB"), (0, i * h))
iro_hyou = narabe.quantize(colors=24, method=Image.FASTOCTREE)
out = [x.convert("RGB").quantize(palette=iro_hyou, dither=Image.NONE)
       for x in koma]

自分で数える手順(これをやらないと気づけない)

できあがった物を、目と数字の両方で確かめます。順番はこうしました。

  1. 機械で数える:大きさ(320×270)・コマ数・再生時間・ループ回数・ファイルサイズ・解像度の有無
  2. コマ同士の差を測る:1コマ目と2コマ目で、変わった画素が何パーセントか
  3. 変わった場所を絵にする:差のある画素を赤く塗った画像を作る
  4. 目で見る:3コマを横に並べて拡大表示する

③がいちばん効きました。数字では2.7%でも、赤く塗ると「動いてほしい所」以外が真っ赤になっているのが一目で分かります。コードにすると10行ほどです。

import numpy as np
a = np.asarray(koma[0]).astype(int)
b = np.asarray(koma[1]).astype(int)
d = (np.abs(b - a).max(axis=2) > 30)
print(f"変わった画素 {d.sum() / d.size * 100:.1f}%")
ys, xs = np.where(d)
print(f"囲み x{xs.min()}-{xs.max()} y{ys.min()}-{ys.max()}")

「囲み」を見るのが大事です。動かしたい部分だけが変わっているなら、囲みは小さくまとまります。画面いっぱいに広がっていたら、意図しない所まで変わっています。実際、直す前は囲みが x28〜288・y114〜257(ほぼ全体)でしたが、直した後は x175〜215・y138〜200(手のあたりだけ)になりました。

1枚の絵から、動きを作るときのコツ

「人物だけ動かして、背景の物は1画素も動かさない」ということをやりました。人物と背景が1枚の絵に描かれている場合、絵ぜんぶを揺らすと当然どちらも動きます。

やり方は、元の絵をそのまま渡して「ここだけ変えて」と頼み、返ってきた絵から“変わった場所のかたまり”だけを元の絵に貼り直す、というものです。こうすると、それ以外は元の絵のままなので、1画素も変わりません。

ここで1つ落とし穴がありました。絵ごとに「中身を囲む枠」で切り抜いていたので、動かした部分の位置が変わると枠も変わり、中央に置いたときに絵ぜんぶがずれていたのです。実測で 19.5%ずれていました。3枚で同じ枠を使うようにしたら 2.1%に収まりました。

透明の扱いで3回 弾かれた話(式が1つ違っていた)

大きさの前に、もっと単純な所で3回 弾かれています。返ってきた理由は「イラストの内部が透過されています」でした。

絵は不透明に描いたつもりでした。実際、画面で見るかぎり透けている所はありません。原因は、ファイルを軽くするために入れた「透明の濃さを段階に丸める」処理でした。書いていた式はこうです。

# これが間違い
a[:, :, 3] = (a[:, :, 3] // (256 // dan)) * (256 // dan)

8段階にするつもりで 256 // 8 = 32、そして 255 // 32 = 7、7 * 32 = 224。つまりいちばん不透明な画素が224にしかならないのです。255(完全な不透明)が1点も作れません。

段階を16に増やしても同じで、240で止まります。段階の数ではなく、式そのものが違っていました。 0〜255を段階に「均等に割り当てる」形に直すと、いちばん上がちゃんと255になります。

# 直したあと
lv = np.round(a[:, :, 3].astype(float) / 255.0 * (dan - 1))
a[:, :, 3] = np.clip(lv / (dan - 1) * 255.0, 0, 255).astype(np.uint8)

この手の間違いは、画面で見ても分かりません。224と255の差は目では区別できないからです。数えて初めて見つかります。確かめ方は1行です。

print("いちばん不透明な値:", np.array(im)[:, :, 3].max())   # 255 でなければ おかしい

自分の物差しが間違っていて、正しい絵を全部はじいた話

「返ってきた絵が使えるかどうか」を機械で判定する部分も作りました。ここで、最初に置いた物差しが間違っていて、正しい絵を3枚とも捨てました。

置いた基準はこうでした。

  • 変わった画素が全体の3.5%を超えたら、作り直す
  • 変わった所を囲む四角が、画面の30%を超えたら、作り直す

2つ目が失敗でした。両手を上げている絵では、左手と右手が離れているので、手だけが動いても囲みは当たり前に広くなります。実測で 26%・32%・43% と出て、3枚とも「変わりすぎ」と判定されました。

実際に変わった画素は 0.2%・1.3%・2.0%。つまりほとんど変わっていないのに、はじいていたわけです。

直したのは判定の軸です。「囲みの広さ」は記録するだけにして、判定は「いちばん大きいかたまりの形」で行うようにしました。動かしたい物(手)なのか、動いてほしくない物(背景の大きな物)なのかは、かたまりの大きさで見分けがつきます。

直したあと、手元にあった54枚で試したところ、はじかれる物は0枚でした。前の基準なら数枚 捨てていた計算です。

ここで学んだのは、「止める側」だけでなく「通す側」も試すということです。止めたい物が止まるかは試していましたが、通したい物が通るかを試していませんでした。守りを作ったその日に、自分の仕事を自分で止めたことになります。

動きの確認は GIF で行う(APNG は再生されないことがある)

作った APNG を人に見せるとき、そのままでは止まった1枚の絵に見えることがあります。APNG に対応していない表示環境だと、1コマ目だけが表示されるためです。

確認用には、同じコマから GIF も書き出すようにしました。中身は同じで、こちらは多くの場所で動いて見えます。

g = [x.convert("RGB") for x in koma]
g[0].save("kakunin.gif", save_all=True, append_images=g[1:],
          duration=250, loop=0)

実測では、8コマ・2秒の同じ動きで APNG 61.2KB / GIF 19.9KB でした。GIF のほうが軽いですが、色数と透明の扱いが弱いので、提出するのは APNG、見てもらうのは GIFと使い分けています。

作り直しに費用がかかる場合、関所は「捨てる理由」で決める

絵を生成するのに1枚あたり費用がかかる場合、作り直し1回がそのまま出費になります。こちらの実測では、使っていた生成の仕組みで 1枚あたり25〜30円でした。96枚作る予定だったので、作り直しが増えるほど積み上がります。

ここで、費用の見積もりを間違えた話も書いておきます。1枚6円だと思い込んで「全部で約580円」と伝え、実際は4〜5倍でした。 安いほうのモデルの値段を、別のモデルに当てはめていたためです。しかも前払い式で残高を見る方法が無いのに、残高を確かめずに金額を言っていました。途中で残高が尽きて止まりました。

この2つから決めたことがあります。

  • 残高が見えない仕組みでは、「いくらでできます」と言い切らない。「1枚◯円なので◯枚で◯円ぶん。ただし今の残高は見る方法がないので、途中で止まったらその時に知らせます」と書く
  • 関所は「捨てる理由が本当に結果に出るか」で決める。 こちらの場合、後の工程で「動かしたい部分だけを貼り直す」ので、それ以外が多少ちがっても結果には出ません。だから厳しく見る意味があるのは「貼る部分が、狙った物になっているか」だけでした

関所をゆるめたぶん、作り直しは減ります。ただしゆるめた理由を、後から自分が読んで分かるように書き残すことは必要でした。理由を書かずにゆるめると、次に困った時に「なぜこうなっているのか」が分からなくなります。

途中で止まったときに、読める形で止まるようにする

残高が尽きた時、画面に出たのは長いエラーの羅列でした。どこまで出来ていて、あと何枚なのかが分かりません。

そこで、止まり方そのものを直しました。

try:
    e = ichimai(...)
except Exception as err:
    if "RESOURCE_EXHAUSTED" in str(err) or "402" in str(err):
        print("🔴 前払い残高が尽きました(◯◯ の所で止まりました)")
        zangaku_gire = True
        break
    raise

あわせて、終わりに「できた枚数/できなかった枚数/まだ手つかずの枚数」を必ず出すようにしました。実際に次に止まった時は、こう出ました。

🔴 前払い残高が尽きました(17番の所で止まりました)
できた 0枚 / できなかった 0枚 / 既にあって飛ばした 32枚 / まだ手つかず 16枚
⚠️ 残り 16枚 は もう一度この道具を流せば続きから作ります

止まること自体は防げません。止まった時に、次に何をすればいいかが分かる形にしておくほうが現実的でした。

点検の項目(そのまま使えます)

最後に、動く画像を出す前に機械で数えている項目を並べます。どれも一度ずつ弾かれて追加したものです。

見るもの 合格の形 見落とすとどうなるか
大きさ 決められた縦横(例 320×270) 登録時にエラー
コマ数 5〜20 まとめられて4コマになり規格外
再生時間 1〜4秒ちょうど 2.52秒で4回 弾かれた
ループ 指定どおり(例 1回) 止まらない動画になる
ファイルの大きさ 上限以内(例 80KB) 登録できない
解像度 pHYs あり・72dpi以上 「適切な形式ではありません」
全コマの大きさ すべて同じ 「コマの大きさがちがう」
いちばん不透明な値 255 「内部が透過されています」
違う絵の種類 3種類以上 チカチカした点滅に見える
余白 決められた画素以上 端が切れる

この表は、弾かれるたびに1行ずつ増えたものです。最初から全部を思いつけたわけではありません。逆に言えば、一度弾かれた理由を点検に足していけば、同じ理由で二度 止まることはなくなります。

コマの並べ方を、実際に3通り試した

3種類の絵(元・まん中・振る)を、8コマにどう並べるかで見え方が変わります。実際に試したのは次の3つです。

並べ方 見え方 コマ数
A A B B A A B B ゆっくり2回 振る まとめられて4コマ
A B A B A B A B 速く4回 振る(やや忙しい) 8コマ
A C B C A C B C 上げる→まん中→振る が読める 8コマ

採用したのは3つ目です。同じ絵が続かないのでコマがまとめられず、違う絵が3種類あるので点滅にも見えません。2秒で2往復になり、動きとして自然に読めました。

並べ方を決めるときは、「何回動くか」より先に「同じ絵が続いていないか」を確かめたほうが早いです。続いているとコマ数が勝手に減り、規格から外れます。

やってみて分かったこと

  • 重さの問題を「画質の問題」だと思い込まない。 同じ絵・同じコマ数で30KB違いました。書き出す道具を先に疑ったほうが早かったです
  • 軽くなった時こそ、コマ数を数える。 45.9KBは嬉しい数字でしたが、中身は4コマで規格外でした
  • 2.7%の差でも、目にははっきり見える。 数字と目は別の物差しです。どちらか一方では足りません
  • 差分を絵にする手順を入れる。 10行で書けて、いちばん多くの間違いを拾いました

最終的に出来たのは、320×270・8コマ・2.0秒・ループ1回・61.2KB・24色の動く画像です。色を落とさずに規格内に収まりました。

同じ形式で詰まっている方は、まず書き出している関数を変えて1回だけ試すことをおすすめします。こちらはそれで解けました。

✍️ この記事を書いた人

チケットナビ編集部

先払い買取・金券売買の最新情報を初心者にもわかりやすくお届けします。業者の比較、買取率、トラブル対策など、安全に現金化するための情報を徹底調査して発信しています。

コメント

タイトルとURLをコピーしました