アニメーションPNG(APNG)を作る

こんなのが作りたかった

Webアプリケーションで時間がかかる処理を行っている間、なんかくるくる回っているやつがあるじゃないですか。たとえばこんなの。背景のロードバイクの写真はこのクルクルが透過画像であることを示すために付けています。

自作のWebアプリケーションでこういうのを使いたいとき、素材集のサイトとかで探せばあるかもしれませんが、ちょっと探してみてもあまり気に入ったのがなかったので自作しました。

このクルクルはAPNG(Animated Portable Network Graphics)、日本だとアニメーションPNGとも呼ばれるPNGの拡張フォーマットで作っています。

APNGはPNGの拡張フォーマットなので、プログラムでこのクルクルの画像を描画して複数のPNGを作り、その画像データ部分をAPNGのフォーマットに埋め込むことで作成できます。

そういうプログラムをC#で書きました。GitHubに上げてあります。

https://github.com/kmaebashi/GenerateAPng

このプログラムは、PNG/APNGのフォーマットの解析のために作っていたプログラムから発展させて作ったものなので、Console.WriteLine()でいろいろ表示したりするコードが残っています。これは、PNGやAPNGのフォーマットを知りたい人にはあったほうがよいと考えあえて残しています。

元画像

このクルクルは、以下の12枚の画像からできています。

(アニメーションではない)PNGとして、こういう画像を作るのは、C#ならSystem.Drawing以下の名前空間の各種メソッドを使って行えます。上の例ならこんな感じ。

// 1コマ分のPNG画像を生成する。
static byte[] CreatePng(int frame)
{
    const int Width = 400;
    const int Height = 400;
    const int NumOfLine = 12;
    byte[] ret = null;

    using (Bitmap bitmap = new Bitmap(Width, Height))
    using (Graphics g = Graphics.FromImage(bitmap))
    using (Brush backBrush = new SolidBrush(Color.FromArgb(0, 0, 0, 0)))
    {
        g.FillRectangle(backBrush, 0, 0, Width, Height);
        for (int i = 0; i < NumOfLine; i++)
        {
            int alpha = (int)(255 * ((NumOfLine - i) / (double)NumOfLine));
            Pen pen = new Pen(Color.FromArgb(alpha, 255, 255, 255), 20);
            double angle = (NumOfLine - i + frame) * Math.PI * 2 / NumOfLine - (Math.PI / 2);
            g.DrawLine(pen,
                       (int)(Width / 2 + Math.Cos(angle) * Width * 0.2),
                       (int)(Height / 2 + Math.Sin(angle) * Height * 0.2),
                       (int)(Width / 2 + Math.Cos(angle) * Width * 0.5),
                       (int)(Height / 2 + Math.Sin(angle) * Height * 0.5));
        }
        using (Bitmap resizedBitmap = new Bitmap(APngWidth, APngHeight))
        using (Graphics resizeG = Graphics.FromImage(resizedBitmap))
        using (MemoryStream ms = new MemoryStream())
        {
            resizeG.InterpolationMode = InterpolationMode.HighQualityBicubic;
            resizeG.DrawImage(bitmap, 0, 0, APngWidth, APngHeight);
            // resizedBitmap.Save("test" + frame.ToString("00") + ".png", ImageFormat.Png);
            resizedBitmap.Save(ms, ImageFormat.Png);
            ret = ms.ToArray();
        }
    }
    return ret;
}

このCreatePng()メソッドに、フレームの番号(0~11)を引数で与えて呼び出すことで、上で挙げたような12個の透過PNG画像が作れます。三角関数便利※1!!

ここでは、いったん400×400のビットマップに図形を描画したうえで、それを100×100(APngWidth, APngHeightがもっと上の方で定義されている)に縮めています。そうした方がアンチエイリアスがかかって斜めの線がきれいに見えるからです。31行目には、確認用にファイルに吐いた形跡が残っていますが、気にしないでください。

この手のクルクルは、今だとこんなのとか

こんなのの方がよく見かけるかもしれませんが、

描画プログラムを自分で持っていれば、すぐに作れるのがよいですね。GitHubのソースには、上のふたつはCreatePng2(), CreatePng3()メソッドとして同梱してあります。

PNGフォーマットの基本

PNGのフォーマットについては、WikipediaとかPNGの仕様書とかを見れば概略はつかめるかと思います。

おおまかに言えば、PNGは、「PNGであることを示す8バイトのシグニチャ」の後ろに、複数の「チャンク」が続く、という構造になっています。

シグニチャは16進なら「89 50 4e 47 0d 0a 1a 0a」ですが、なぜこのようになっているかについては上記Wikipediaの説明がわかりやすいかと思います(仕様で読むならこちら)。

そして、シグニチャに続くチャンクの構造は以下の通りです。

上で作ったクルクル画像の1枚のPNGを解析したところ、以下のチャンクが入っていました。

チャンク形式長さ意味
IHDR 13 イメージヘッダ。すべてのPNGデータの先頭に格納されており、画像の幅、高さといった情報が格納されています。
sRBG 1 標準RBGカラースペース。ここでは深入りしません。
gAMA 4 イメージガンマ。ここでは深入りしません。
pHYs 9 物理的なピクセル寸法。深入りしません。
IDAT 1320 イメージデータ。このチャンクだけサイズが大きいことからもわかるように、ここに画像データ本体が入っています。
ひとつの画像を複数のIDATチャンクに分割することもできるのですが(メモリの少ない表示ソフトのためらしい)、ここではそれは考えないことにします。
IEND 0 PNGの終了を示すためのチャンクです。

APNGフォーマット

APNGのフォーマットについては、日本語訳が以下にあります(Internet Archiveより)。

これを見ると、APNGは以下の3つの補助チャンクでアニメーションを実現していることがわかります。

「acTL」Animation Controlチャンク
このアニメーション全体のフレーム数と繰り返し回数(0だと無限に繰り返す)を格納しています。
「fcTL」Frame Controlチャンク
フレームごとの制御情報を格納しています。フレームの画像の大きさや、このフレームで何分の1秒待つか、等。fcTLと次のfdATは、通しで振られるシーケンス番号を保持しています。
「fdAT」Frame Dataチャンク
フレームの画像データを保持します。目的は普通のPNGのIDATチャンクと同じですが、fdATは先頭の4バイトにシーケンス番号が格納されています。

APNGはAPNGに対応していないブラウザやWindowsのエクスプローラとかが表示するための「デフォルト画像」を持ちますが、デフォルト画像はfdATではなくIDATに持ちます(APNGに対応していないということはfdATなど知らないということなので)。デフォルト画像は、以下のいずれかの持ち方ができます。

今回のクルクルでは、フレームの1枚目をデフォルト画像にする上記(1)の方法でよいと思いますが、その場合、APNG全体の構造は以下のようになります。

シーケンス番号 チャンク
(なし) IHDR
(なし) その他のチャンク
(なし) acTL
0 fcTL 1フレーム目の制御情報
(なし) IDAT 1フレーム目の画像データ
1 fcTL 2フレーム目の制御情報
2 fdAT 2フレーム目の画像データ
3 fcTL 3フレーム目の制御情報
4 fdAT 3フレーム目の画像データ
(なし) IEND

APNGの補助チャンクとしては最初にアニメーション全体を制御するacTL(Animation Control)チャンクが登場し、その後、フレームの制御チャンク(fcTL)とそのフレームの画像データ(最初だけIDAT、2枚目からはfdAT)が続くことがわかります。fcTLとfdATは通しでシーケンス番号が振られています。

では、クルクルを作る

今回のクルクルでは、12枚の画像を使いますが、これらは中の図形の角度が違うだけで、大きさもすべて同じで、同じように描画しています。そこで以下の方法が使えるはずです。

以下、コードを抜粋していきます。まずは上の方法をそのままコードにしている部分。

    using (FileStream fs = new FileStream("apng.png", FileMode.Create, FileAccess.Write))
    {
        int sequence = 0;
        for (int i = 0; i < NumOfFrame; i++)
        {
            byte[] onePng = CreatePng(i);
            List<Chunk> chunkList = ReadPng(onePng);
            Chunk idat = SearchIdat(chunkList);
            if (i == 0)
            {
                WriteHeader(fs, chunkList);
                WriteActl(fs);
                WriteFctl(fs, sequence++);
                WriteChunk(fs, idat);
            }
            else
            {
                WriteFctl(fs, sequence++);
                WriteFdat(fs, sequence++, idat);
            }
        }
        WriteIend(fs);
    }

今回のAPNGのフレーム数は12なので、NumOfFrameは12です。12回ループを回して、上述のCreatePngメソッドでPNGデータを作り、それを読み取ってチャンクの一覧を作っています。ここで登場するChunkクラスの定義は以下。

class Chunk
{
    public String Name;
    public byte[] Data;
}

あとは初回だけ「IDATのひとつ前までのチャンクをすべて出力」しているのが11行目のWriteHeader()※2、その後acTLとfcTLとIDATを出力しています。2回目以降のループでは、fcTLとfdATを出力している、というのがメソッド名からわかるかと思います。

「IDATのひとつ前までのチャンクをすべて出力」するWriteHeader()の中身は以下の通り。

private static void WriteHeader(FileStream fs, List<Chunk> chunkList)
{
    fs.WriteByte(0x89);
    fs.WriteByte((byte)'P');
    fs.WriteByte((byte)'N');
    fs.WriteByte((byte)'G');
    fs.WriteByte(0x0D);
    fs.WriteByte(0x0A);
    fs.WriteByte(0x1A);
    fs.WriteByte(0x0A);

    foreach (Chunk chunk in chunkList)
    {
        if (chunk.Name == "IDAT")
        {
            break;
        }
        WriteChunk(fs, chunk);
    }
}

PNGのシグニチャを出力した後、IDATの手前までのすべてのチャンクを出力しています。Chunkクラスの情報をもとにチャンクを出力するWriteChunk()の実装はこちら。

private static void WriteChunk(FileStream fs, Chunk chunk)
{
    WriteInt32(fs, (uint)(chunk.Data.Length));
    byte[] buf = new byte[chunk.Data.Length + 4];
    StringToByteArray(chunk.Name, buf, 0);
    Array.Copy(chunk.Data, 0, buf, 4, chunk.Data.Length);

    fs.Write(buf, 0, buf.Length);
    uint crc = Crc32.calcCrc(buf);
    WriteInt32(fs, crc);
}

先頭で、チャンクのサイズをFileStreamに書き込んでいるWriteInt32()の実装はこちら。32ビットの整数をビッグエンディアンでファイルに書き込むために、ビットシフトで上位ビットを下位8ビットに持ってきて、0xffと&を取ってバイト値として取り出す、という操作をしています。

private static void WriteInt32(FileStream fs, uint value)
{
    fs.WriteByte((byte)((value >> 24) & 0xff));
    fs.WriteByte((byte)((value >> 16) & 0xff));
    fs.WriteByte((byte)((value >> 8) & 0xff));
    fs.WriteByte((byte)(value & 0xff));
}

さて、こんな感じでFileStreamにデータをどんどん流し込んでいけばよい、のなら簡単ですが、チャンクを出力するときはCRCを計算する必要があり※3、CRCを計算するメソッドであるCrc32.CalcCrc()が引数をbyte[]で取り、かつCRCは4文字のチャンク形式とチャンクデータをくっつけたデータについて算出する必要があるので、WriteChunk()では、データ量に4を足したサイズのバッファを確保して、そこにチャンク形式とチャンクデータを格納してCRCを算出し出力しています。

WriteActl()は以下の通り。

private static void WriteActl(FileStream fs)
{
    WriteInt32(fs, 8);
    byte[] buf = new byte[12];
    StringToByteArray("acTL", buf, 0);
    Int32ToByteArray(NumOfFrame, buf, 4); // num_frames
    Int32ToByteArray(0, buf, 8); // num_plays
    fs.Write(buf, 0, buf.Length);
    uint crc = Crc32.CalcCrc(buf);
    WriteInt32(fs, crc);
}

上述の通りacTLにはフレーム数と繰り返し数(0にすると無限に繰り返す)を格納します。今回はNumOfFrames(12)と0を出力しています。

WriteFctl()は以下の通り。

private static void WriteFctl(FileStream fs, int sequence)
{
    WriteInt32(fs, 26);
    byte[] buf = new byte[30];
    int offset = 0;
    StringToByteArray("fcTL", buf, offset);
    offset += 4;
    Int32ToByteArray((uint)sequence, buf, offset);
    offset += 4;
    Int32ToByteArray(APngWidth, buf, offset); // width
    offset += 4;
    Int32ToByteArray(APngHeight, buf, offset); // height
    offset += 4;
    Int32ToByteArray(0, buf, offset); // x_offset
    offset += 4;
    Int32ToByteArray(0, buf, offset); // y_offset
    offset += 4;
    Int16ToByteArray(100, buf, offset); // delay_num
    offset += 2;
    Int16ToByteArray(1000, buf, offset); // delay_den
    offset += 2;
    buf[offset++] = 0;  // dispose_op
    buf[offset++] = 0;  // blend_op
    Debug.Assert(offset == 30);

    fs.Write(buf, 0, buf.Length);
    uint crc = Crc32.CalcCrc(buf);
    WriteInt32(fs, crc);
}

fcTLには割といろいろな情報を出力していますが、今回は全フレームすべて同じ画像サイズで全体を書き換えるので、内容も固定です。

width
フレームの画像の幅。APngWidth(100)です。
height
フレームの画像の幅。APngHeight(100)です。
x_offset
widthとheightに元画像より小さな値を指定して、x_offsetやy_offsetに正の値を指定すれば、「大きな画像の中を小さな画像が動くアニメ」を効率的に生成できるのかと思いますが、今回は全体が書き変わるので0です。
height
これも0です。
delay_num
フレームを描画した後の待ち時間の分子。今回は分母が1000で分子が100なので、1/10秒です。
delay_den
フレームを描画した後の待ち時間の分母。
dispose_op
次のフレームをレンダリングする前に出力バッファをどうするかの指定。1は「完全に透過な黒で塗りつぶす」という意味ですが、今回はどうせすべて書き換えるので0(何もしない)にしても結果は変わらないようです。
blend_op
描画時にアルファブレンドで合成するか、完全に出力バッファを入れ替えるかを指定する。0は「完全に置き換える」の方です。

次はWriteFdat()です。

private static void WriteFdat(FileStream fs, int sequence, Chunk chunk)
{
    WriteInt32(fs, (uint)(chunk.Data.Length + 4));
    byte[] buf = new byte[chunk.Data.Length + 8];
    StringToByteArray("fdAT", buf, 0);
    Int32ToByteArray((uint)sequence, buf, 4);
    Array.Copy(chunk.Data, 0, buf, 8, chunk.Data.Length);

    fs.Write(buf, 0, buf.Length);
    uint crc = Crc32.CalcCrc(buf);
    WriteInt32(fs, crc);
}

このメソッドは引数でシーケンス番号とIDATのチャンクを受け取っています。IDATのデータの前に4バイトのシーケンス番号を埋め込んでいるのがわかります。

他にもいくつかメソッドはありますし、読み込みの方のメソッドはほとんど説明していませんが、あとは(必要なら)自力で読んでください。

おまけ

今回のプログラムには、既存のPNGファイルを解析するReadPngFile()というメソッドが含まれています。それを使っていくつかのPNG/APNGを解析した結果を載せておきます。

今回のクルクルの最初の1枚のPNG画像
解析結果
WikipediaのAPNGの項に載っているビーチボールのAPNG画像
解析結果
今回作ったクルクルのAPNG画像
解析結果

おまけ2

ところで、うちのWindows10でも気付いたらMicrosoft Copilotが動くようになってたので、ちょっと頼んでみました。

プロンプト「複数のPNG画像からアニメーションPNGを作るプログラムをC#で書いて」

回答:

了解です。C#を使って複数のPNG画像からアニメーションPNG (APNG) を作成するには、ImageMagickのライブラリを使うのが便利です。以下の例は、ImageMagickの.NETラッパーであるMagick.NETを利用したプログラムです。
まず、NuGetからMagick.NETをインストールしてください。Visual StudioのNuGetパッケージマネージャーでMagick.NET-Q8-AnyCPUを検索してインストールします。 インストール後、以下のコードを参考にしてください:

using ImageMagick;
using System;
using System.IO;

class Program
{
    static void Main()
    {
        // 入力画像のディレクトリパス
        string inputDir = @"path\to\your\images";
        
        // 出力APNGファイルのパス
        string outputFilePath = @"path\to\output\animation.png";

        // MagickImageCollectionを使用して画像を読み込む
        using (var images = new MagickImageCollection())
        {
            foreach (var filePath in Directory.GetFiles(inputDir, "*.png"))
            {
                // 画像を読み込み、フレームに追加
                var image = new MagickImage(filePath);
                image.AnimationDelay = 100; // フレーム間の遅延を設定 (100ms)
                images.Add(image);
            }

            // アニメーションPNGとして保存
            images.Write(outputFilePath);
        }

        Console.WriteLine("APNGファイルが作成されました: " + outputFilePath);
    }
}

このプログラムでは、指定したディレクトリ内のPNG画像を読み込み、MagickImageCollectionに追加します。各画像にはアニメーションの遅延時間(この例では100ミリ秒)を設定し、最終的にアニメーションPNGとして保存します。
まずはImageMagickをインストールして、このコードを試してみてくださいね。他に気になることがあれば教えてください。

……試してませんが、なんだかちゃんと動きそうです。これはこれですごいと思いますが、私の求めたものではないので聞きなおしました。

プロンプト「複数のPNG画像からアニメーションPNGを作るプログラムをC#でスクラッチで書いて」

回答:

了解です!スクラッチでAPNGを作成するのはかなりの挑戦ですが、基本的な手順を示すプログラムを以下に書きました。APNGは、PNGの拡張形式であり、複数のPNGフレームを含むことができます。この例では、RawPNGEncoderやAPNG特有のチャンクを手動で生成します。
このプログラムは、PNG画像を結合し、各フレームをAPNGとしてエンコードします。APNGの仕様に準拠するため、少し複雑になりますが、基本の考え方を示しています。

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;

class Program
{
    static void Main()
    {
        string[] imagePaths = Directory.GetFiles(@"path\to\your\images", "*.png");
        string outputFilePath = @"path\to\output\animation.png";

        List<Image> images = new List<Image>();
        foreach (string imagePath in imagePaths)
        {
            images.Add(Image.FromFile(imagePath));
        }

        using (var outputStream = new FileStream(outputFilePath, FileMode.Create))
        {
            WriteAPNG(images, outputStream, 100); // 100msのフレーム間遅延
        }
        
        Console.WriteLine("APNGファイルが作成されました: " + outputFilePath);
    }

    static void WriteAPNG(List<Image> images, Stream outputStream, int frameDelay)
    {
        using (BinaryWriter writer = new BinaryWriter(outputStream))
        {
            // APNGヘッダーを書く
            writer.Write(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }); // PNGシグネチャ
            
            // IHDRチャンクを書く
            WriteIHDRChunk(writer, images[0].Width, images[0].Height);

            // 各フレームの画像データをチャンクとして追加
            foreach (var image in images)
            {
                WriteIDATChunk(writer, image);
                WritefcTLChunk(writer, image, frameDelay);
            }
            
            // IENDチャンクを書く
            writer.Write(new byte[] { 0, 0, 0, 0 });
            writer.Write(new byte[] { 73, 69, 78, 68 });
            writer.Write(new byte[] { 174, 66, 96, 130 });
        }
    }

    static void WriteIHDRChunk(BinaryWriter writer, int width, int height)
    {
        // IHDRチャンクを書きます...
    }

    static void WriteIDATChunk(BinaryWriter writer, Image image)
    {
        // IDATチャンクを書きます...
    }

    static void WritefcTLChunk(BinaryWriter writer, Image image, int frameDelay)
    {
        // fcTLチャンクを書きます...
    }
}

このコードは概要として、主要なAPNGチャンク(IHDR、IDAT、fcTL、IEND)を書きます。ただし、細部のエンコーディングやCRC計算、データの圧縮などは省略してあります。これらを実装するにはさらに深い知識が必要ですので、興味があればさらに掘り下げてみてください。

……もっともらしいこと言ってますが、メソッドの中身が空っぽなのはさておき、PNGシグネチャの後fcTLの前にIDATを吐いてははダメだし、acTLは出してないし、既存のPNGからAPNGを作るというお題なのに既存のPNGからIDATを取り出す部分の処理もないし、いやまあこれでもすごいとは思いますけど、さすがにちょっとなあ。

公開日: 2024/10/19



不具合等ありましたら、掲示板にご連絡願います。

ひとつ前 | ひとつ後 | ひとつ上のページへ戻る | トップページへ戻る