BMP画像の書き出し(画像データ)

作成:

前回はヘッダ部分の出力処理の説明を行ったが、次に画像データの出力について説明する。 ソースコードは GitHub にて公開している。 BMPの入出力を記述しているのは bmp.c である。

ヘッダもそうだったが、画像情報についても基本的にデータの変換処理が必要な形式では出力しないため、 あるデータをフォーマットにしたがって出力していくだけでとなっている。 全体的に比較的シンプルな処理になっていると思う。 ただし、当然といえば当然だが、RLEでの圧縮処理は他に比べて複雑な処理が必要であり、行数としても長くなっている。

画像情報の出力処理

画像の出力については、RGBA の 32bit 出力、RGB の24bit出力、 インデックスカラーの出力、及びRLEの出力と4つのバリエーションがある。 ここでその分岐を行っている。

static result_t write_bitmap(FILE *fp, image_t *img, int bc, int compress) {
  int stride = (img->width * bc + 31) / 32 * 4;
  switch (bc) {
    case 32:
      return write_bitmap_32(fp, img, bc, stride);
    case 24:
      return write_bitmap_24(fp, img, bc, stride);
    case 8:
    case 4:
      if (compress) {
        return write_bitmap_rle(fp, img, bc, stride);
      } else {
        return write_bitmap_index(fp, img, bc, stride);
      }
    case 1:
      return write_bitmap_index(fp, img, bc, stride);
    default:
      break;
  }
  return FAILURE;
}

入力に引き続き、1行のサイズであるstrideの値が各処理で必要なため、ここで計算を行って引数で渡している。 RLEの出力ではこの値を使わないが、引数を合わせるために渡すような形をとっている。

32bitカラーの出力処理

static result_t write_bitmap_32(FILE *fp, image_t *img, int bc, int stride) {
  result_t result = FAILURE;
  int x, y;
  uint8_t *row = NULL;
  uint8_t *work;
  if ((row = calloc(stride, 1)) == NULL) {
    goto error;
  }
  for (y = img->height - 1; y >= 0; y--) {
    work = row;
    for (x = 0; x < img->width; x++) {
      *work++ = img->map[y][x].c.a;
      *work++ = img->map[y][x].c.b;
      *work++ = img->map[y][x].c.g;
      *work++ = img->map[y][x].c.r;
    }
    if (fwrite(row, stride, 1, fp) != 1) {
      goto error;
    }
  }
  result = SUCCESS;
  error:
  free(row);
  return result;
}

32bitカラーの出力処理、非常にシンプルであるが、1行分のデータを順に並べていく。 ヘッダの出力でABGRの順で出力する前提のカラーマスクを設定しているため、出力順序はそれに合わせる。 ヘッダで別の順序としてしている場合はそれに合わせた順序で出力すればよい。 データの整列はバッファ上で行い、最後にまとめて出力を行う。 32bitの場合は、画素数にかかわりなくパディングが必要ない。 そのため、もっとシンプルな処理にすることもできるのだが、他の処理と同様の記述にしている。

ここではバイトストリームは利用せず、ポインタを利用して配列への順次書き込みを行っている。 また、入力の時と同様にポインタのインクリメントと参照への代入を同時に実行するような記述にしている。 *work++++演算子が*演算子よりも結合順位が高く、かつ、後置演算子であるため、 workの参照先に対する代入を行った後に、ポインタ値がインクリメントされるという記述になる。 これも少し技巧的な書き方になってしまっているため、 カッコを利用したり、インデックス変数を別途用意するような記述にしてもいいだろう。

24bitカラーの出力処理

static result_t write_bitmap_24(FILE *fp, image_t *img, int bc, int stride) {
  result_t result = FAILURE;
  int x, y;
  uint8_t *row = NULL;
  uint8_t *work;
  if ((row = calloc(stride, 1)) == NULL) {
    goto error;
  }
  for (y = img->height - 1; y >= 0; y--) {
    work = row;
    for (x = 0; x < img->width; x++) {
      *work++ = img->map[y][x].c.b;
      *work++ = img->map[y][x].c.g;
      *work++ = img->map[y][x].c.r;
    }
    if (fwrite(row, stride, 1, fp) != 1) {
      goto error;
    }
  }
  result = SUCCESS;
  error:
  free(row);
  return result;
}

24bitでの出力は32bitの出力処理からアルファチャンネルの値の出力を削っただけになっている。 こちらの場合は1行を4Byte境界にするため、パディングが必要となるが、 1行単位でまとめて出力処理を行うため、特段の記述なしに対応できている。

インデックスカラーの出力処理

static result_t write_bitmap_index(FILE *fp, image_t *img, int bc, int stride) {
  result_t result = FAILURE;
  int x, y;
  uint8_t *row = NULL;
  uint8_t *work;
  if ((row = calloc(stride, 1)) == NULL) {
    goto error;
  }
  for (y = img->height - 1; y >= 0; y--) {
    int shift = 8;
    uint8_t tmp = 0;
    work = row;
    for (x = 0; x < img->width; x++) {
      shift -= bc;
      tmp |= img->map[y][x].i << shift;
      if (shift == 0) {
        shift = 8;
        *work++ = tmp;
        tmp = 0;
      }
    }
    if (shift != 8) {
      *work++ = tmp;
    }
    if (fwrite(row, stride, 1, fp) != 1) {
      goto error;
    }
  }
  result = SUCCESS;
  error:
  free(row);
  return result;
}

インデックスカラーについてもこれまでの方式と基本的には変わらないが、 4bitや1bitの場合は1Byte内にデータを詰め込む必要があり、その処理のため多少複雑化している。 シフト量をずらしながら1Byte内にデータを詰め込み、データが揃ったら出力、という処理になる。 最後の1Byteについては揃いきっていない可能性もあるのでこれも忘れないように出力を行う。 また、ここでもパディングが必要となる。 24bitの場合と同様に、予め1行のサイズを計算し、そのバッファ上で作業を行っているため。 このバッファをまとめて出力することで、特段の記述なしに対応できる。

インデックスカラーのRLE出力処理

最後にこの出力処理で一番複雑な箇所であるRLEで圧縮を行う出力処理について説明する。

static result_t write_bitmap_rle(FILE *fp, image_t *img, int bc, int stride) {
  result_t result = FAILURE;
  int i;
  int x, y;
  int num;
  int count;
  int reduction;
  uint8_t *work;
  uint8_t *raw = NULL;
  uint8_t *step = NULL;
  uint8_t *row = NULL;
  int image_size = 0;
  int cpb = 8 / bc; // 1Byteあたりの色数
  int count_max = 255 / cpb;
  stride = (img->width * bc + 7) / 8;
  raw = calloc(stride, 1);
  step = calloc(stride, 1);
  row = calloc(stride * 2, 1);
  if (raw == NULL || step == NULL || row == NULL) {
    goto error;
  }
  for (y = img->height - 1; y >= 0; y--) {
    // 非圧縮データを作る
    int shift = 8;
    uint8_t tmp = 0;
    work = raw;
    for (x = 0; x < img->width; x++) {
      shift -= bc;
      tmp |= img->map[y][x].i << shift;
      if (shift == 0) {
        shift = 8;
        *work++ = tmp;
        tmp = 0;
      }
    }
    if (shift != 8) {
      *work++ = tmp;
    }
    // 非圧縮データの連続数を記録する
    for (x = 0; x < stride; x += count) {
      count = 0;
      tmp = raw[x];
      while ((x + count < stride)
          && (count < count_max)
          && (tmp == raw[x + count])) {
        count++;
      }
      step[x] = count;
    }
    // 圧縮データを作る
    work = row;
    for (x = 0; x < stride; x += count) {
      if (step[x] < 2) {
        count = reduction = 0;
        while ((x + count < stride)
            && (count + step[x + count] <= count_max)
            && (step[x + count] <= 2)) {
          if (step[x + count] == 1) {
            reduction++;
          }
          count += step[x + count];
        }
        if (reduction > 2) { // 絶対モード
          *work++ = 0;
          num = count * cpb;
          if (num + x * cpb > img->width) {
            num--;
          }
          *work++ = num;
          for (i = x; i < x + count; i++) {
            *work++ = raw[i];
          }
          if (count % 2) {
            *work++ = 0;
          }
        } else { // エンコードモード
          for (i = x; i < x + count; i += step[i]) {
            num = step[i] * cpb;
            if (num + i * cpb > img->width) {
              num--;
            }
            *work++ = num;
            *work++ = raw[i];
          }
        }
      } else { // エンコードモード
        count = step[x];
        num = count * cpb;
        if (num + x * cpb > img->width) {
          num--;
        }
        *work++ = num;
        *work++ = raw[x];
      }
    }
    if (y == 0) { // EOF
      *work++ = 0;
      *work++ = 1;
    } else { // EOL
      *work++ = 0;
      *work++ = 0;
    }
    if (fwrite(row, work - row, 1, fp) != 1) {
      goto error;
    }
    image_size += work - row;
  }
  if (fseek(fp, 0, SEEK_SET) != 0) {
    goto error;
  }
  result = write_header(fp, img, bc, image_size, TRUE);
  error:
  free(raw);
  free(step);
  free(row);
  return result;
}

処理が複雑なだけあり、ローカル変数がやたらといっぱいある。 これについてはスキップし、具体的な処理について説明する。

  for (y = img->height - 1; y >= 0; y--) {

RLEの圧縮は行単位で行われる。そのため、各行について圧縮処理を行い、それを行数分繰り返すことになる。 行数分繰り返すという点に関しては非圧縮の出力と全く同じだ。

    int shift = 8;
    uint8_t tmp = 0;
    work = raw;
    for (x = 0; x < img->width; x++) {
      shift -= bc;
      tmp |= img->map[y][x].i << shift;
      if (shift == 0) {
        shift = 8;
        *work++ = tmp;
        tmp = 0;
      }
    }
    if (shift != 8) {
      *work++ = tmp;
    }

各行ごとの処理だが、まずは非圧縮のデータを作成する。 RLE8についてはわざわざ変換する必要性は薄いが、 RLE4の場合、圧縮対象は1Byte内に2画素分を詰め込んだ情報なので、一旦この変換を行っている。 当然だが、処理内容は非圧縮出力と全くおなじだ。

    for (x = 0; x < stride; x += count) {
      count = 0;
      tmp = raw[x];
      while ((x + count < stride)
          && (count < count_max)
          && (tmp == raw[x + count])) {
        count++;
      }
      step[x] = count;
    }

次にランレングス圧縮を行うため、同じ情報がどれだけ連続しているかを調べていく。 注意すべきなのは、連続していればいくらでもまとめられるわけではなく、RLEで扱える最大の連続数は255までである。 これを超えるようなら連続状態を分割して記録する必要がある。

また、連続数は画素数を表現する数値なので、RLE8であれば最大の連続数はByte数と同様になるが、 RLE4では連続数は連続したByte数の2倍の値を格納する必要がある。 この2倍した値が255以下になるようにする必要があるため、境界条件に気をつけてほしい。

ここで、連続数の記録には、非圧縮行データと同じ長さの配列stepへ格納している。 連続している色の先頭と同じ位置に連続数を格納する。 読み出す場合は、 step[0]=1 → step[1]=3 → step[4]=2 → step[6]=... といった具合に、ループ変数を格納されている値分インクリメントしながら読み出すことになる。

連続数の調査が終わると、次はそのデータをエンコードモードで出力するか、絶対モードで出力するかの判定が必要となる。 RLE のフォーマットを守るという観点からのみ言えば、 全てエンコードモードで出力したり、全て絶対モードで出力したりしてももちろん構わないが、 圧縮出力なので、きちんとデータサイズが小さくなるように心がけたい。

      if (step[x] < 2) {
        count = reduction = 0;
        while ((x + count < stride)
            && (count + step[x + count] <= count_max)
            && (step[x + count] <= 2)) {
          if (step[x + count] == 1) {
            reduction++;
          }
          count += step[x + count];
        }

まず、連続数が2以上の場合は、エンコードモードより小さくなることはないので、エンコードモードで確定。 連続数が1の場合、それが一つだけであればエンコードモードのほうが小さいが、 まとめて絶対モードにできるのであれば絶対モードにしたほうが小さい、 そこでまず、連続数が2以下の状態がどれだけ続くかを判定する。

継続数をカウントするcountの値は連続数分インクリメントされるため、 ループの継続条件はstepを加算した結果が上限値を超えていないかで判定する。 このように境界条件の設定が難しい箇所が多いので注意してほしい。

ここで、絶対モード開始は連続数1に対して、絶対モード継続は連続数2以下としているところに注意。 連続数2の場合、どちらのモードで出力しても圧縮効果は変わらないので、都合の良いほうで出力する。 絶対モードはデータ自体は非圧縮のままであるが、ヘッダとして2Byte必要であるため、 絶対モードで出力する場合は絶対モードセクションをできるだけ継続したほうがヘッダのオーバーヘッドを抑えることができる。 例えば、以下の様なデータがあった場合、

raw : 00 01 02 03 03 04 05 06
step: 01 01 01 02 xx 01 01 01

連続数1のものだけを絶対モードで出力したとすると、 間に連続数2の03があるため、ここで絶対モードが分割されて以下の様な出力になる。(反転している箇所がエンコードモード)

row : 00 03 00 01 02 00 02 03 00 03 04 05 06 00

ヘッダが2つ必要になり、更にパディングも必要になり、と無駄が多く、元のサイズよりも6Byteも大きくなってしまっている。 これを連続数2のものも絶対モードで出力するとすれば、

row : 00 08 00 01 02 03 03 04 05 06

と言った具合に、ひとつの絶対モードにまとめることができて、4Byte小さく出力可能だ。 ここで、最初に戻って絶対モード判定の入り口が連続数1のみにしているところに疑問を持つ人もいるだろう。 連続数2のものを絶対モードで出力するメリットは、絶対モードの区間を結合できる場合のみで、 それ以外の場合に絶対モードで出力した場合、圧縮効果が変わらないだけでなく、 絶対モードの長さが255を超える場合に返って大きくなってしまう可能性もある。 そのため、絶対モードの開始は連続数1のものとしている。

パディングの影響なども含めて考えれば、 連続数が3以上の場合であっても絶対モードで出力したほうが小さくなる場合が存在する。 しかし、判定が複雑になっていく割にはあまり圧縮効果は期待できないため、圧縮効果の突き詰めはここまでにしている。 興味があればそのような条件も組み込んでみてほしい。

いくつかのRLE出力のできるソフトで確認してみたが、あまり圧縮率をあげようという努力はなされていないようである。 例えばPaint Shop Proよりも、GIMPで出力したほうが圧縮率が高いのだが、 ここで紹介してるコードで圧縮を行ったほうが、GIMPよりもわずかに圧縮率が高い。 ここで紹介している方法もまだ突き詰める余地は残しているにもかかわらずである。 やはり、RLEはあまり圧縮には向いていないため、この方法で突き詰めてもあまりメリットが見いだせないということだろう。 可逆圧縮を行いたいならpngを使ったほうがよっぽど小さくできる上、 インデックスカラーのみという制約もなく、対応しているデコーダが多い。

さて、話は戻って、絶対モードで出力できる数をカウントするのだが、 その時、連続数1のものの数を別途reductionカウントしておく。 そして、この連続数1のものがいくつあったかで絶対モードかエンコードモードかを分岐させる。 具体的に言うと、2まではエンコードモードで出力した場合よりも小さくならないため、3以上の場合のみ絶対モードとする。 (パディングがあるため小さくなる条件は正確には4以上だが、この条件はどちらにしても圧縮率は変わらない。) 連続数2のものは絶対モードで出力してもエンコードモードと同じ大きさであるためカウントしない。

また、絶対モードとするには絶対モードの長さは3以上である必要がある(0~2は別のモードに利用されるため)が、 連続数1の個数reductionより、 絶対モードで出力する長さcountのほうが大きいため、reductionのチェックだけとしている。

          *work++ = 0;
          num = count * cpb;
          if (num + x * cpb > img->width) {
            num--;
          }
          *work++ = num;
          for (i = x; i < x + count; i++) {
            *work++ = raw[i];
          }
          if (count % 2) {
            *work++ = 0;
          }

絶対モードで出力する処理は上記のようになる。 はじめに0、次に長さを出力した後、絶対モードとして出力していくのだが、 横方向の画素数が奇数でRLE4の場合は、最後の1Byteを1画素でカウントする必要があるため、その補正処理を入れている。 そして、継続数分絶対モードで出力を行う。 最後に絶対モードのデータエリアの長さも偶数とする必要があるため、長さが奇数だった場合はパディングとして0を出力する。

        count = step[x];
        num = count * cpb;
        if (num + x * cpb > img->width) {
          num--;
        }
        *work++ = num;
        *work++ = raw[x];

エンコードモードで出力する処理は上記のようになる。 はじめに連続数、次に色情報となるのだが、こちらでも横方向の画素数が奇数かつRLE4の場合の調整を行っている。 また、count変数は次のループのインクリメント値を表現するため、 上記の処理自体には不要でも、値を代入しておく。

    if (y == 0) { // EOF
      *work++ = 0;
      *work++ = 1;
    } else { // EOL
      *work++ = 0;
      *work++ = 0;
    }

1行の圧縮処理が終わると、行末の符号を出力する。 ただし、全画像データの出力後は行末の代わりにファイルの終端符号を出力する必要がある。 その処理が上記になる。 (コメントとして書いているのは、EOL=End of Line、EOF=End of File、である。BMPの用語ではない)

    image_size += work - row;

1行分の出力が終わると、画像サイズを計算するために、 作業ポインタの進捗をimage_sizeに加算しておく。

  if (fseek(fp, 0, SEEK_SET) != 0) {
    goto error;
  }
  result = write_header(fp, img, bc, image_size, TRUE);

RLEでの出力が全て終わった後、やっと画像サイズが確定するため、最初に暫定で出力したヘッダを上書きして、修正を行う。 fseekを使って、ファイルの先頭に移動し、ヘッダ出力関数をコール。 これで出力完了だ。

BMP画像の出力についての説明は以上となる。 画像データの出力については、非圧縮であればシンプルなのだが、RLEでの出力はいろいろと面倒で複雑なものになっている。 実際RLEでの出力には対応していないソフトも多いし、 圧縮を行うなら他にいくらでももっと効率のよい方法が用意されているためあまり需要は高くないだろう。 しかし、 RLE の変換処理は複雑なだけにプログラミングスキルを鍛えるという意味で良い題材かもしれいない。 ここに示したコードにはまだ改善の余地はあると思うので、この処理を実装しようと思っている方は、 単にコピペするのではなく、より改善ができないか工夫してみるのも良いだろう。