非圧縮png/apng生成
pngとapngの生成をやってみたので記録.コードが汚いのは許してください
圧縮の実装はしていません.圧縮まで実装すると流石に死ぬ
1. PNG
1.1 pngの構成
pngは主に以下の要素で成り立つ
- IHDR:ヘッダー
- IDAT:画像データ
- IEND:エンドポイント
また,その他重要な概念としてバイトオーダー反転とCRCとAdlerがある.
1.1.1 バイトオーダー反転
pngはバイト単位で扱われるがこれを反転して書き込む.ビッグエンディアン(大きい順)というメモリ配置の規格.
x86などの一般的なCPUはリトルエンディアンを採用しているため,PNG(ビッグエンディアン)に合わせるために反転が必要
メモリ上で 0x12 0x34 (リトルエンディアン) と並んでいる値を,PNGの仕様に合わせて 0x34 0x12 (ビッグエンディアン) の順序でファイルに書き込む必要がある
ビット数に応じたバイト反転を用意してやる必要がある
バイト反転コードサンプル
//32ビット整数のバイトオーダーを反転させる
uint32_t swap_uint32(uint32_t val) {
return (val >> 24) | // 最上位バイトを最下位へ
(val >> 8) | // 2番目のバイトを3番目へ
(val << 8) | // 3番目のバイトを2番目へ
(val << 24); // 最下位バイトを最上位へ
}
//16ビット整数のバイトオーダーを反転させる
uint16_t swap_uint16(uint16_t val) {
return (val >> 8) | (val << 8);
}
1.1.2 CRC
PNGの各チャンクの末尾に,データが壊れていないか確認するための4バイトのチェックサムをつける必要がある.データを巨大な数値と見なして,マジックナンバーで割った余りをつけている.
マジックナンバー
毎回ビット演算すると遅いので事前計算テーブル(crc_table)を使って高速化してる
crcサンプルコード
// CRCテーブル,256個の計算済み値
static uint32_t crc_table[256];
static int crc_table_computed = 0;//初期化されたか
//CRCテーブルの初期化関数
void make_crc_table(){
uint32_t c;
int n, k;
for (n = 0; n < 256; n++) {
c = (uint32_t)n;
for (k = 0; k < 8; k++) {
if (c & 1)
c = 0xEDB88320L ^ (c >> 1);//多項式
else
c = c >> 1;
}
crc_table[n] = c;
}
crc_table_computed = 1;
}
/*
CRC32を計算して返す
buf: 計算対象のデータ(チャンクタイプ + データ)
len: データの長さ
*/
uint32_t calc_crc32(const unsigned char *buf, size_t len) {
uint32_t crc = 0xFFFFFFFFL; //初期値は全ビット1
size_t n;
if (!crc_table_computed) {
make_crc_table();
}
for (n = 0; n < len; n++) {
crc = crc_table[(crc ^ buf[n]) & 0xFF] ^ (crc >> 8);
}
return crc ^ 0xFFFFFFFFL; // 最後に全ビット反転
}
//PNGファイルにCRCを書き込む関数
void write_chunk_crc(FILE *fp, const unsigned char *chunk_type_and_data, size_t length) {
// CRCを計算(リトルエンディアンのまま)
uint32_t crc_val = calc_crc32(chunk_type_and_data, length);
// ファイルに書き込むためにビッグエンディアンに変換
uint32_t crc_be = swap_uint32(crc_val);
// 書き込み
fwrite(&crc_be, 4, 1, fp);
}
1.1.3 Adler
PNGの各チャンクにはCRC32が必要だが,画像データを圧縮するzlib(Deflate)の仕様として圧縮データの末尾にはAdler-32という別のチェックサムが必要になる.
全バイトの値の総和を,素数で割ったものになる.16ビット符号なし整数で最大の素数の65521が使われる.
Adlerサンプルコード
uint32_t calc_adler32(const unsigned char *data, size_t len) {
uint32_t a = 1, b = 0; // 仕様によりaの初期値は1
size_t index;
for (index = 0; index < len; ++index) {
a = (a + data[index]) % 65521; // データの累積和
b = (b + a) % 65521; // 累積和の累積和
}
// bを上位,aを下位ビットに配置して返す
return (b << 16) | a;
}
ここまで用意してようやくチャンクが組み立てることができる
1.2 PNGシグネチャ(8バイト)
これを最初に入れておく.これがないとそもそもコンピュータが画像と認識してくれない.
const unsigned char signature[] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
fwrite(signature, 1, 8, fp);
1.3 IHDR
ヘッダーチャンク
作ってて知ったのだが,pngは思ったより設定項目が多い
設定項目
- width, height:画像サイズ
- bit depth:色値の精度,8bitか16bit.基本は8
- color type:グレースケール,RGB,RGBAがある
- compression method:常に0
- filter method:0でいい.隣のピクセルとの差分を使って圧縮を高めるやつ.画像によってはこれでものすごく圧縮できる.
0=None, 1=Sub(左ピクセルとの差分), 2=Up(上の行との差分), 3=Average(左と上の平均との差分), 4=Paeth(Paeth予測器) - interlace method:画像を7回に分けて粗→精細になるように並べる方式.
ネット回線が遅い環境で「徐々に表示される」ができるらしい.0/1でON/OFF
compression methodは,もっといい圧縮方法が来たときのために取ってあるらしい
ファイルにはデータの長さ,タイプの名前,ヘッダーデータ,CRCの順番で書き込む.
IHDRサンプルコード
void png_IHDR(int width, int height, FILE* fp){
// IHDRのデータ部(13バイト)
unsigned char ihdr_data[13];
uint32_t w_be = swap_uint32(width);
uint32_t h_be = swap_uint32(height);
// ヘッダーデータの用意
memcpy(ihdr_data + 0, &w_be, 4); // 幅
memcpy(ihdr_data + 4, &h_be, 4); // 高さ
ihdr_data[8] = 8; // Bit depth (8bit)
ihdr_data[9] = 2; // Color type (0=グレー,2=RGB,6=RGBA)
ihdr_data[10] = 0; // Compression method (0: Deflate)
ihdr_data[11] = 0; // Filter method (0: Standard)
ihdr_data[12] = 0; // Interlace method (0: None)
// チャンク書き込み
uint32_t len_be = swap_uint32(13);
fwrite(&len_be, 4, 1, fp);
// タイプ("IHDR")書き込み
const unsigned char type[] = "IHDR";
fwrite(type, 1, 4, fp);
// データ書き込み
fwrite(ihdr_data, 1, 13, fp);
// CRC計算
unsigned char crc_buf[17];
for(int i=0; i<4; i++) crc_buf[i] = type[i];
for(int i=0; i<13; i++) crc_buf[4+i] = ihdr_data[i];
uint32_t crc = calc_crc32(crc_buf, 17);
//CRC書き込み
crc = swap_uint32(crc);
fwrite(&crc, 4, 1, fp);
}
1.4 IDAT
画像データ
やることが多い
まず画像データには各色データの先頭にフィルタタイプを付ける必要がある.非圧縮・RGBなら(0x00+RGB)の4バイトずつになる.そのため,事前準備として画像データのフィルタリングが必要になる.
フィルタ処理サンプルコード
unsigned char* create_filtered_data(const unsigned char *raw_data, uint32_t width, uint32_t height, size_t *out_size) {
uint32_t bytes_per_pixel = 3;
uint32_t line_raw_size = width * bytes_per_pixel; // 元の1行サイズ
uint32_t line_filtered_size = line_raw_size + 1; // フィルタバイト込みの1行サイズ
size_t total_size = line_filtered_size * height; // 全体のサイズ
unsigned char *filtered_buf = (unsigned char *)malloc(total_size);
for (int y = 0; y < height; y++) {
// 書き込み先:行の先頭
unsigned char *line_head = &filtered_buf[y * line_filtered_size];
// フィルタタイプ (0x00: None)
line_head[0] = 0x00;
// 画素データをコピー
const unsigned char *src_line = &raw_data[y * line_raw_size];
memcpy(line_head + 1, src_line, line_raw_size);
}
*out_size = total_size;
return filtered_buf;
}
データが大きすぎるときはブロックを分割する.分割も 65521 を使った.
回線が遅いときに上から少しずつ読み込まれるのはこれが原因.interlace methodを使うときはやり方が変わると思われる.
zlib Headerは0x78 0x01を使っている.ヘッダの整合性(CMF*256+FLG % 31 == 0)を満たしてあればなんでもいいが,もしzlib実装を使うならzlibの出力を入れる必要があるらしい
idat初期化サンプルコード
int MAX_BLOCK_SIZE = 65521;
int line_size = config.width * bytes_per_pixel + 1;
size_t filtered_data_size = line_size * config.height;
// ブロックの必要数
size_t num_blocks = (filtered_data_size + MAX_BLOCK_SIZE - 1) / MAX_BLOCK_SIZE;
// IDATチャンクの中身の総サイズ
// zlibヘッダ(2) + (ブロックヘッダ(5) * ブロック数) + 生データ総量 + Adler32(4)
size_t idat_content_len = 2 + (num_blocks * 5) + filtered_data_size + 4;
unsigned char *idat_buf = (unsigned char *)malloc(idat_content_len);
size_t p = 0; // idat_buf への書き込み位置
size_t raw_ptr = 0; // filtered_data の読み込み位置
// zlib Header
idat_buf[p++] = 0x78;
idat_buf[p++] = 0x01;
書き込みの順番は
- BFINAL(このブロックが最後かどうか.1で最後,0ならまだ続く)
- サイズ
- サイズの反転
- フィルタ済み画像データ
わざわざサイズの反転も書き込まなきゃいけないのは面倒だけど,多分安全のためなんだろう.あと他はビッグエンディアン(反転)なのに,ここだけリトルエンディアンで書かなきゃいけない.まじでここだけ.一体なぜ
Geminiいわく
これはPNGの仕様ではなく,内部に含まれる Deflate (RFC 1951) の仕様 がリトルエンディアンを採用しているためです。
データバッファの末尾にAdlerを書き込むのも忘れてはいけない.
分割されたブロックの書き込みサンプルコード
for (size_t i = 0; i < num_blocks; i++) {
// 今回のブロックに入れるデータサイズを計算
size_t remaining = filtered_data_size - raw_ptr;
size_t chunk_size = (remaining > MAX_BLOCK_SIZE) ? MAX_BLOCK_SIZE : remaining;
// BFINAL判定: これが最後のブロックなら1,そうでなければ0
unsigned char bfinal = (raw_ptr + chunk_size == filtered_data_size) ? 0x01 : 0x00;
// Block Header // BFINAL(1bit) + BTYPE(2bit=00)
// bfinalが1なら 0x01, 0なら 0x00
idat_buf[p++] = bfinal;
// LEN (リトルエンディアンで書き込む)
uint16_t len = (uint16_t)chunk_size;
idat_buf[p++] = len & 0xFF; // 下位バイト
idat_buf[p++] = (len >> 8) & 0xFF; // 上位バイト
// NLEN (1の補数,これもリトルエンディアン)
uint16_t nlen = ~len;
idat_buf[p++] = nlen & 0xFF;
idat_buf[p++] = (nlen >> 8) & 0xFF;
// Data Copy
for (size_t j = 0; j < chunk_size; j++) {
idat_buf[p++] = filtered_data[raw_ptr++];
}
}
uint32_t adler = calc_adler32(filtered_data, filtered_data_size);
uint32_t adler_be = swap_uint32(adler);
memcpy(idat_buf + p, &adler_be, 4);
p += 4;
これでようやくIDATのデータ部分が完成する.
IHDRと同様,データの長さ,タイプ名,データ,CRCの順番で書き込む
idat書き込みサンプルコード
// Length
uint32_t chunk_len_be = swap_uint32((uint32_t)idat_content_len);
fwrite(&chunk_len_be, 4, 1, fp);
// Type
const unsigned char idat_type[] = "IDAT";
fwrite(idat_type, 1, 4, fp);
// Data
fwrite(idat_buf, 1, idat_content_len, fp);
// CRC (Type + Data)
unsigned char *crc_check_buf = (unsigned char *)malloc(4 + idat_content_len);
if(crc_check_buf) {
for(int i=0; i<4; i++) crc_check_buf[i] = idat_type[i];
for(size_t i=0; i<idat_content_len; i++) crc_check_buf[4+i] = idat_buf[i];
uint32_t crc_idat = calc_crc32(crc_check_buf, 4 + idat_content_len);
uint32_t crc_idat_be = swap_uint32(crc_idat);
fwrite(&crc_idat_be, 4, 1, fp);
free(crc_check_buf);
} else {
fprintf(stderr, "Memory allocation for CRC check failed.\n");
}
free(idat_buf);
1.5 IEND
終りを示すだけなのでやることは少ない.
今までと同様,データの長さ,タイプ名,データ,CRCの順番で書き込むのだが,そもそもデータサイズが0なので,データの長さは0でいいし,CRCもタイプ名だけでいい
iendサンプルコード
void png_IEND(FILE* fp){
// Length (0)
uint32_t len = 0;
fwrite(&len, 4, 1, fp); // 0はバイト反転しても0なのでそのまっま
// Type
const unsigned char type[] = "IEND";
fwrite(type, 1, 4, fp);
// CRC
uint32_t crc = calc_crc32(type, 4);
uint32_t crc_be = swap_uint32(crc);
fwrite(&crc_be, 4, 1, fp);
}
1.6 補足
テキストチャンクとかガンマチャンクとか,他にも付け加えられるやつはあるが,なくても良い
IHDR,IDAT,IENDの3つで基本成り立つ.
ちなみにテキストチャンクは通常バージョンと日本語対応バージョンがある.
後者だけ作った.
itxt_chunk
/* * iTXtチャンクを書き込む関数
* keyword: Latin-1 (ASCII) 文字列 (例: "Title", "Author")
* text: UTF-8 文字列 (日本語OK)
*/
void png_itxt_chunk(FILE *fp, const char *keyword, const char *text) {
if (!text || !keyword) return; // データが無ければ何もしない
size_t kw_len = strlen(keyword);
size_t text_len = strlen(text);
size_t data_len = kw_len + 1 + 1 + 1 + 1 + 1 + text_len;
// iTXtのデータ構造:
// Keyword + Null + CompFlag + CompMethod + LangTag + Null + TransKey + Null + Text
// 簡易実装のため,LangTagとTransKeyは空(Nullのみ)
// データ長の計算
// kw_len
// + 1 (Null separator)
// + 1 (Compression flag: 0)
// + 1 (Compression method: 0)
// + 1 (Lang tag separator: 空文字の終端)
// + 1 (Trans keyword separator: 空文字の終端)
// + text_len
// バッファ作成
unsigned char *buf = (unsigned char *)malloc(data_len);
size_t p = 0;
// 1. Keyword
memcpy(buf + p, keyword, kw_len);
p += kw_len;
buf[p++] = 0x00; // Null separator
// 2. Flags (非圧縮)
buf[p++] = 0; // Compression flag (0 = Uncompressed)
buf[p++] = 0; // Compression method (0)
// 3. Language Tag (空)
buf[p++] = 0x00; // Null separator only
// 4. Translated Keyword (空)
buf[p++] = 0x00; // Null separator only
// 5. Text (UTF-8)
memcpy(buf + p, text, text_len);
p += text_len;
{// --- ファイル書き込み ---
uint32_t len_be = swap_uint32((uint32_t)data_len);// Length
fwrite(&len_be, 4, 1, fp);
// Type ("iTXt")
fwrite("iTXt", 1, 4, fp);
// Data
fwrite(buf, 1, data_len, fp);
// CRC (Type + Data)
unsigned char *crc_buf = (unsigned char *)malloc(4 + data_len);
memcpy(crc_buf, "iTXt", 4);
memcpy(crc_buf + 4, buf, data_len);
uint32_t crc = swap_uint32(calc_crc32(crc_buf, 4 + data_len));
fwrite(&crc, 4, 1, fp);
free(crc_buf);
}
free(buf);
}
2. APNG
追加で必要なチャンクがある.acTL(アニメーション全体を管理する設定)とfcTL(フレームごとの設定),そしてfdAT(APNG用の画像データチャンク)
設定関係のチャンクは一旦いいとして,このfdATというのはIDATとほぼ何も変わらない.違いはデータの先頭にフレームナンバーが入ることだけである.
そして一番面倒な点として,apngのチャンク構成は以下のようになる
- IHDR
- acTL :全体のアニメーション設定
- fcTL(seq=0) :1フレーム目のアニメーション設定
- IDAT :1フレーム目の画像データ
- fcTL(seq=1) :2フレーム目のアニメーション設定
- fdAT(seq=2) :2フレーム目の画像データ
...(fcTLとfdATが交互に続く) - IEND
1フレームごとにシーケンス番号が振り分けられるのだが,最初のフレームだけはIDATチャンクが入り,シーケンス番号も入らない.これはapngに対応していないビューワーが,IDATを読み込んで1フレーム目だけを読み込み,それ以外のapng用のチャンクを無視するため.
2.1 acTL
全体の設定と言っても,「総フレーム数」と「ループする回数」を書き込むだけ
ちなみにループの回数を0にすると無限ループとなる.
acTLサンプルコード
void png_acTL(uint32_t num_frames, uint32_t num_plays, FILE *fp) {
unsigned char buf[8];
uint32_t frames_be = swap_uint32(num_frames);
uint32_t plays_be = swap_uint32(num_plays); // 0なら無限ループ
memcpy(buf, &frames_be, 4);
memcpy(buf + 4, &plays_be, 4);
// 書き込み処理 (Length, Type, Data, CRC)
uint32_t len = swap_uint32(8);
fwrite(&len, 4, 1, fp);
fwrite("acTL", 1, 4, fp);
fwrite(buf, 1, 8, fp);
// CRC
unsigned char crc_buf[12];
memcpy(crc_buf, "acTL", 4);
memcpy(crc_buf + 4, buf, 8);
uint32_t crc = swap_uint32(calc_crc32(crc_buf, 12));
fwrite(&crc, 4, 1, fp);
}
2.1 fcTL
各フレームごとの設定なのだが,結構項目が多い.下2つは初めて知った
- x_offset, y_offset :描画の位置のオフセット
部分的に更新されるアニメーション,スプライトの一部だけ差し替える,無駄なビットマップ転送を減らすなどができる - delay_num, delay_den :フレームが表示される時間 = delay_num / delay_den 秒
numが1,denが30なら30fpsになる. - dispose_op :フレーム消去のオプション.
NONE,BACKGROUND,PREVIOUSの3つがある- NONE:フレームをひたすら合成する.残像みたいになる.
- BACKGROUND:フレーム領域だけ透明に戻す.
- PREVIOUS:フレームを描く前の状態に巻き戻す.1フレームだけエフェクト入れてすぐ戻すとか?
- blend_op :アルファを無視して上書きするか,透明度を使った通常のアルファブレンド合成するか
特殊ルールとして,delay_denが 0 の場合は 100 とみなす仕様がある.
fcTLサンプルコード
void png_fcTL(uint32_t seq_num, uint32_t width, uint32_t height,
uint32_t x_offset, uint32_t y_offset,
uint16_t delay_num, uint16_t delay_den,
uint8_t dispose_op, uint8_t blend_op,
FILE *fp) {
uint32_t seq_be = swap_uint32(seq_num);
uint32_t w_be = swap_uint32(width);
uint32_t h_be = swap_uint32(height);
uint32_t x_off = swap_uint32(x_offset);
uint32_t y_off = swap_uint32(y_offset);
uint16_t d_num = swap_uint16(delay_num);
uint16_t d_den = swap_uint16(delay_den);
// データバッファへ書き込み
unsigned char buf[26];
size_t p = 0;
memcpy(buf + p, &seq_be, 4); p+=4;
memcpy(buf + p, &w_be, 4); p+=4;
memcpy(buf + p, &h_be, 4); p+=4;
memcpy(buf + p, &x_off, 4); p+=4;
memcpy(buf + p, &y_off, 4); p+=4;
memcpy(buf + p, &d_num, 2); p+=2;
memcpy(buf + p, &d_den, 2); p+=2;
buf[p++] = dispose_op;
buf[p++] = blend_op;
// 書き込み
uint32_t len = swap_uint32(26);
fwrite(&len, 4, 1, fp);
fwrite("fcTL", 1, 4, fp);
fwrite(buf, 1, 26, fp);
// CRC
unsigned char *crc_check = (unsigned char *)malloc(4 + 26);
memcpy(crc_check, "fcTL", 4);
memcpy(crc_check + 4, buf, 26);
uint32_t crc = swap_uint32(calc_crc32(crc_check, 30));
fwrite(&crc, 4, 1, fp);
free(crc_check);
}
2.2 fdAT
まじでIDATと変わらない.データバッファの最初にシーケンス番号を入れるのと,タイプ名が"fdAT"になるだけ
実装するときはブロック分割とか共通化してしまっていい.
fdATサンプルコード(一部)
void png_fdAT(uint32_t seq_num, const unsigned char *filtered_data, uint32_t width, uint32_t height, FILE *fp){
/* ---画像にフィルタかける--- */
// IDATチャンクの中身の総サイズ
// seq(4) + zlibヘッダ(2) + (ブロックヘッダ(5) * ブロック数) + 生データ総量 + Adler32(4)
size_t idat_content_len = 4 + 2 + (num_blocks * 5) + filtered_data_size + 4;
unsigned char *idat_buf = (unsigned char *)malloc(idat_content_len);
size_t p = 0; // idat_buf への書き込み位置
size_t raw_ptr = 0; // filtered_data の読み込み位置
//バッファの先頭に Sequence Number をセット
uint32_t seq_be = swap_uint32(seq_num);
memcpy(idat_buf, &seq_be, 4);
p += 4; // seqの分進める
// ---分割データ書き込み---
---Adler-32計算とバッファに書き込み--- */
/* ---Lengthの書き込み--- */
// Type,ここは名前変えとく
const unsigned char idat_type[] = "fdAT";
fwrite(idat_type, 1, 4, fp);
/* ---Type. Data, CRCの順番でファイルに書き込み--- */
}
3. おわり
これで非圧縮のpng/apngができる
車輪の再開発でしか無いが,png/apngの仕様を深く知ることができたし,いい経験になった気がする
あと,実際に作成した画像と,それをwebにあげてから再ダウンロードした画像のサイズを比べたら,192.4KB → 136.5KBとなっていた.圧縮にも興味はあるが,ハフマン木とか面倒そう
ビューワーを作ろうとしたらもっとグロそう.
interlace methodなんかはデータ配列から変わるらしいし
先人は偉大だ
生成したpng

apng(zennでは動かないかも)

コード
Discussion