PNM (PPM / PGM / PBM) 画像の読み込み

作成:

前回はPNM画像のファイルフォーマットの説明だったが、 実際にPNM画像のファイルを入出力する方法について説明していく。 今回は、ファイルの読み込み方法について。

なお、コード自体は GitHub にて公開しているので、それをピックアップしながら説明していく形になる。 PNM画像の入出力を記述しているのは pnm.c になる。

画像の入力先のデータ構造については、画像処理についてのページ 1 2 3 を参照して欲しい。

トークンの分離

フォーマットの説明で、テキスト領域のトークンの抽出が意外と面倒であったと思う。 任意個の空白で区切られていて、なおかつその間にコメント行がある可能性もある。 それらを適切に読み飛ばし、必要なトークンを抽出する処理がまず必要だ。

そのトークンを抽出する処理に必要な、 空白とコメントを読み飛ばして最初に現れる文字を返す処理が以下になる。

static int get_next_non_space_char(FILE *fp) {
  int c;
  int comment = FALSE;
  while ((c = getc(fp)) != EOF) {
    if (comment) {
      if (c == '\n' || c == '\r') {
        comment = FALSE;
      }
      continue;
    }
    if (c == '#') {
      comment = TRUE;
      continue;
    }
    if (!isspace(c)) {
      break;
    }
  }
  return c;
}

ファイルストリームから1文字ずつ読み出し、 '#'に遭遇すると、次の改行コードまでは全てスキップするモードとなる。 コメント中以外で空白でない文字を見つけたらその値を返えすというものだ。 当然、一番最初から空白以外の文字ならその文字が返る。 なお、EOFとなった場合はEOFがそのまま返る。 getcに、コメント、空白を読み飛ばす機能をつけたような関数だ。

コメント読み飛ばしについては、厳密には#から始まる行とは判定はしていない。 その前に空白文字があれば行の途中からでもコメント扱いになる。 ここは本来の仕様とは少し異なってしまうが、規則が緩くなる方向であり、 規則を守っているものは全部受け入れられるので問題ない。

そして、この関数を使って、トークンを取り出す処理が以下になる。

static int get_next_token(FILE *fp, char *buf, size_t size) {
  int i = 0;
  int c = get_next_non_space_char(fp);
  while (c != EOF && !isspace(c) && i < size - 1) {
    buf[i++] = c;
    c = getc(fp);
  }
  buf[i] = 0;
  return i;
}

空白を読み飛ばした文字を取り出し、次に空白が来るまで引数のバッファにその文字列を格納していく。 最後にきちんと文字列を終端することも忘れてはいけない。 終端のため格納領域に入れられる文字数はsize-1となる。 ここは少し技巧的な書き方をしてしまっているため、初級者には少しハードルが高いかもしれない。

アルゴリズムを追ってみていただければ分かると思うが、この関数を呼び出し、トークンが返った時点で、 ファイルストリームはそのトークンの末尾側のデリミタを1文字読み出し済みの状態になる。 もともとデリミタは使用しないため、次に読みだす時にデリミタから始まることを期待したコードにしなければこれで問題はない。

10進数のパース

PNMのトークンはマジックナンバーをのぞいて10進数文字列で構成されている。 実際にプログラム上で扱えるようにするには10進数文字列をパースして、整数値を取り出す必要がある。 この目的ではC言語のatoi()関数が使える。

#include <stdlib.h>
int atoi(const char *nptr);

しかし、atoi()は、入力に問題があるなどパースできなかった場合に0になるため、 エラーなのか本当に0なのかの違いが判定できない。 今回の目的では全て正の整数となるため、エラーとして負の値を返すパーサを自作する。

static int parse_int(const char *str) {
  int i;
  int r = 0;
  if (str[0] == 0) {
    return -1;
  }
  for (i = 0; str[i] != 0; i++) {
    if (!isdigit((int)str[i])) {
      return -1;
    }
    r *= 10;
    r += str[i] - '0';
  }
  return r;
}

特に難しい処理はしていないが、文字コードの扱いに慣れていないと、str[i] - '0'に違和感を覚えるかもしれない。 文字コードの0~9は連番となっているため、'0'を引くと、 そのまま数値としての0~9に変換できるという性質を利用した処理だ。

atoi()では、一部に数字以外が混ざっていてもエラーとはせず、そこまでの数字をパースする。 ここでは少しルールを厳しくして、数字以外が一部にでもあればエラーとするようにしている。これはどちらでも構わないとは思う。

これで必要なパーツは揃ったが、ついでに、読み出し処理ではトークンを取り出して、数値にパースという処理が連続するため、 これら処理をまとめた関数を作っておく。

static int get_next_int(FILE *fp) {
  char token[11];
  get_next_token(fp, token, sizeof(token));
  return parse_int(token);
}

バッファに使用する配列はintの最大値である2147483647が格納できるサイズということで11(10文字+終端)としている。 実際には幅や高さには制限が無いのだが、int値で表現できないようなサイズの画像を扱うことまでは考えていない。

以上で前準備が完了となる。 ややこしいのはここまでで、ここまでできればPNMはシンプルなフォーマットなので簡単だ。

輝度情報の正規化

次は難しいことではないのだが、 事前に作成した画像格納構造体では階調を0~255で表現するようにしている。 一方、PNMでは任意階調が可能なため、任意階調を0~255に正規化する処理が必要となる。

#define MIN(x, y) ((x) < (y) ? (x) : (y))

static uint8_t normalize(int value, int max) {
  // valueがmaxを超える場合はmaxとする
  return (MIN(value, max) * 255 + max / 2) / max;
}

0~maxの間の値を、0~255にするのだから、255をかけて、maxで割れば良い。 四捨五入のため、maxで割る前に、maxの1/2を足しておく。 厳密に四捨五入するにはmax+1の1/2とすべきなのだが、それほど厳密さは重要でないと判断しこのようにしている。

また、本来はフォーマット違反なのだが、最大値より大きな値が入っていた場合の対処として、 その値を最大値として扱うこととした。 そのため、valueとmaxの最小値をとっている。 最小値を取る処理はマクロで記述した。

画像の読み込み

準備が整ったところで画像の読み込みを行う。 PNMに限らず、ファイル名を指定して読み込む関数と、 オープン済みのファイルストリームから読み込む関数を分離する方針としている。 ファイル名を指定して開く関数は、オープンしてファイルストリームから読み込む関数を呼び出すだけなので、 ほぼテンプレートになる。

image_t *read_pnm_file(const char *filename) {
  FILE *fp = fopen(filename, "rb");
  if (fp == NULL) {
    perror(filename);
    return NULL;
  }
  image_t *img = read_pnm_stream(fp);
  fclose(fp);
  return img;
}

次にファイルストリームから読み込む関数。

image_t *read_pnm_stream(FILE *fp) {
  char token[4];
  int type;
  int width;
  int height;
  int max = 0;
  result_t result = FAILURE;
  image_t *img = NULL;
  get_next_token(fp, token, sizeof(token));
  type = token[1] - '0';
  if (token[0] != 'P' || type < 1 || type > 6 || token[2] != 0) {
    return NULL;
  }
  width = get_next_int(fp);
  height = get_next_int(fp);
  if (width <= 0 || height <= 0) {
    return NULL;
  }
  if (type != 1 && type != 4) {
    max = get_next_int(fp);
    if (max < 1 || max > 65535) {
      return NULL;
    }
  }
  // タイプに応じて初期化
  switch (type) {
    case 1:
    case 4:// pbmは2色のカラーパレット形式で表現する
      if ((img = allocate_image(width, height, COLOR_TYPE_INDEX)) == NULL) {
        return NULL;
      }
      img->palette_num = 2;
      img->palette[0] = color_from_rgb(255, 255, 255);
      img->palette[1] = color_from_rgb(0, 0, 0);
      break;
    case 2:
    case 5:
      if ((img = allocate_image(width, height, COLOR_TYPE_GRAY)) == NULL) {
        return NULL;
      }
      break;
    case 3:
    case 6:
      if ((img = allocate_image(width, height, COLOR_TYPE_RGB)) == NULL) {
        return NULL;
      }
      break;
  }
  switch (type) {
    case 1:  // ASCII 2値
      result = read_p1(fp, img);
      break;
    case 2:  // ASCII グレースケール
      result = read_p2(fp, img, max);
      break;
    case 3:  // ASCII RGB
      result = read_p3(fp, img, max);
      break;
    case 4:  // バイナリ 2値
      result = read_p4(fp, img);
      break;
    case 5:  // バイナリ グレースケール
      result = read_p5(fp, img, max);
      break;
    case 6:  // バイナリ RGB
      result = read_p6(fp, img, max);
      break;
  }
  if (result != SUCCESS) {
    free_image(img);
    return NULL;
  }
  return img;
}

順に処理を見ていこう

  get_next_token(fp, token, sizeof(token));
  type = token[1] - '0';
  if (token[0] != 'P' || type < 1 || type > 6 || token[2] != 0) {
    return NULL;
  }
  width = get_next_int(fp);
  height = get_next_int(fp);
  if (width <= 0 || height <= 0) {
    return NULL;
  }
  if (type != 1 && type != 4) {
    max = get_next_int(fp);
    if (max < 1 || max > 65535) {
      return NULL;
    }
  }

ここでヘッダを読み込んでいる。 get_next_tokenを使ってマジックナンバーを取り出し、チェック。 1文字目がP、2文字目は1~6、3文字目は空白、つまりトークンの終端、でなければエラー。 引き続いてget_next_intを使って、幅、高さ、(P1,P4以外)最大輝度、を順に読み込み、 値の範囲チェックを行っている。非常にシンプルな処理だ。

  switch (type) {
    case 1:
    case 4:// pbmは2色のカラーパレット形式で表現する
      if ((img = allocate_image(width, height, COLOR_TYPE_INDEX)) == NULL) {
        return NULL;
      }
      img->palette_num = 2;
      img->palette[0] = color_from_rgb(255, 255, 255);
      img->palette[1] = color_from_rgb(0, 0, 0);
      break;
    case 2:
    case 5:
      if ((img = allocate_image(width, height, COLOR_TYPE_GRAY)) == NULL) {
        return NULL;
      }
      break;
    case 3:
    case 6:
      if ((img = allocate_image(width, height, COLOR_TYPE_RGB)) == NULL) {
        return NULL;
      }
      break;
  }

次に、タイプ別に自前の画像格納構造体を作成する。 カラーとグレースケールは用意しているが、白黒2色は用意していないため、 カラーパレットのパレット2色で表現する。

そして、次がタイプ別の画像データ読み込みだが、 それぞれちょっとずつ違う処理になるので関数として分離した。

P1(PBM形式)

static result_t read_p1(FILE *fp, image_t *img) {
  int x, y;
  int tmp;
  for (y = 0; y < img->height; y++) {
    for (x = 0; x < img->width; x++) {
      tmp = get_next_non_space_char(fp);
      if (tmp == '0') {
        img->map[y][x].i = 0;
      } else if (tmp == '1') {
        img->map[y][x].i = 1;
      } else {
        return FAILURE;
      }
    }
  }
  return SUCCESS;
}

テキスト形式のモノクロ画像。 各ピクセルは0:白と1:黒で表現される、 問題は、文字と文字の間に空白があってもなくても良いということだが、 トークン取り出し用に作成したget_next_non_space_char()がそのまま使える。 空白以外ならその文字が、空白があるならそれを読み飛ばした先の文字が返ってくるのだ。

読み込んだ01は、数値の01ではなく、文字の01なのでそこを間違いなく処理する。 01以外の文字だった場合はエラーとする。

P2(PGM形式)

static result_t read_p2(FILE *fp, image_t *img, int max) {
  int x, y;
  int tmp;
  for (y = 0; y < img->height; y++) {
    for (x = 0; x < img->width; x++) {
      if ((tmp = get_next_int(fp)) < 0) {
        return FAILURE;
      }
      img->map[y][x].g = normalize(tmp, max);
    }
  }
  return SUCCESS;
}

テキスト形式のグレースケール画像。 こちらはよりシンプルになっており、一つづつトークンを整数値で取り出し、 正規化を行って各ピクセルの値として代入する。

P3(PPM形式)

static result_t read_p3(FILE *fp, image_t *img, int max) {
  int x, y;
  int tmp;
  for (y = 0; y < img->height; y++) {
    for (x = 0; x < img->width; x++) {
      if ((tmp = get_next_int(fp)) < 0) {
        return FAILURE;
      }
      img->map[y][x].c.r = normalize(tmp, max);
      if ((tmp = get_next_int(fp)) < 0) {
        return FAILURE;
      }
      img->map[y][x].c.g = normalize(tmp, max);
      if ((tmp = get_next_int(fp)) < 0) {
        return FAILURE;
      }
      img->map[y][x].c.b = normalize(tmp, max);
      img->map[y][x].c.a = 0xff;
    }
  }
  return SUCCESS;
}

PPM形式。 テキスト形式のフルカラー画像。 グレースケールとほぼ同じで、一ピクセルあたりRGB3回必要なので3回繰り返す。 なお、アルファ値には念のため0xffを代入しておく

P4(PBM形式)

static result_t read_p4(FILE *fp, image_t *img) {
  int x, y;
  uint8_t *row;
  int stride;
  stride = (img->width + 7) / 8;
  if ((row = malloc(stride)) == NULL) {
    return FAILURE;
  }
  for (y = 0; y < img->height; y++) {
    int pos = 0;
    int shift = 8;
    if (fread(row, stride, 1, fp) != 1) {
      free(row);
      return FAILURE;
    }
    for (x = 0; x < img->width; x++) {
      shift--;
      img->map[y][x].i = (row[pos] >> shift) & 1;
      if (shift == 0) {
        shift = 8;
        pos++;
      }
    }
  }
  free(row);
  return SUCCESS;
}

バイナリ形式のモノクロ画像。 ビット単位で取り出す必要が有るため、ちょっと複雑になっている。 まず、1行分のサイズは最後の繰り上げなどがあるため先に計算しておく、 そして、1行分のデータをまとめて読み出し、ビットシフトしながら取り出す。

最上位ビットを取り出すには、7ビット右シフトを行い、1とビット積をとる。 最下位ビットを取り出すには、0ビット右シフトを行い、1とビット積をとる。 という方法で1bitずつ取り出している。

1行分のバッファはmallocしているので、抜けるときはfreeを忘れずに。

P5(PGM形式)

static result_t read_p5(FILE *fp, image_t *img, int max) {
  int x, y;
  int tmp;
  uint8_t *row;
  uint8_t *buffer;
  int stride;
  int bpc = max > 255 ? 2 : 1;
  stride = img->width * bpc;
  if ((buffer = malloc(stride)) == NULL) {
    return FAILURE;
  }
  for (y = 0; y < img->height; y++) {
    if (fread(buffer, stride, 1, fp) != 1) {
      free(buffer);
      return FAILURE;
    }
    row = buffer;
    if (bpc == 1) {
      for (x = 0; x < img->width; x++) {
        img->map[y][x].g = normalize(*row++, max);
      }
    } else {
      for (x = 0; x < img->width; x++) {
        tmp = *row++ << 8;
        tmp |= *row++;
        img->map[y][x].g = normalize(tmp, max);
      }
    }
  }
  free(buffer);
  return SUCCESS;
}

バイナリ形式のグレースケール画像。 1行分のデータをまとめて読み込んで、1byteずつ取り出して、格納。 シンプルなのだが、最大値が256以上になると1色あたり2byte必要なので、その処理が入ることでちょっとややこしくなっている。 2byteはビッグエンディアンなので、先に来た方を上位バイトに格納している。

格納する際は正規化する。たとえ16bit深度の画像でもここで8bit深度に丸め込む。 1行分のバッファはmallocしているので、抜けるときはfreeを忘れずに。

なお、テキスト形式のところで16bit深度への対応について一切触れていないが、 テキスト形式では、エンディアンを考える必要はなくパースした結果が2byteになるだけだ。 その後の正規化処理まで2byte値を適切に処理できるのであれば特段のケアは不要だ。

*row++という記述にちょっと違和感を覚えるかもしれない。 技巧的な書き方をしているが++演算子は*演算子よりも結合優先度が高いため、 ++はポインターの指す値ではなく、ポインタ値に作用する演算子となる。 また、後置演算子にしているため、ポインター値として参照されるのはインクリメント演算の前のポインタ値となる。 そのため、現在のポインタ値を取り出した後、ポインタの値をインクリメントする。という動作になる。 このへんは演算子の結合優先度をよく知っていないと混乱を招くので、 *(row++)とカッコつきで記述した方がいいかもしれない。

P6(PPM形式)

static result_t read_p6(FILE *fp, image_t *img, int max) {
  int x, y;
  int tmp;
  uint8_t *row;
  uint8_t *buffer;
  int stride;
  int bpc = max > 255 ? 2 : 1;
  stride = img->width * 3 * bpc;
  if ((buffer = malloc(stride)) == NULL) {
    return FAILURE;
  }
  for (y = 0; y < img->height; y++) {
    if (fread(buffer, stride, 1, fp) != 1) {
      free(buffer);
      return FAILURE;
    }
    row = buffer;
    if (bpc == 1) {
      for (x = 0; x < img->width; x++) {
        img->map[y][x].c.r = normalize(*row++, max);
        img->map[y][x].c.g = normalize(*row++, max);
        img->map[y][x].c.b = normalize(*row++, max);
        img->map[y][x].c.a = 0xff;
      }
    } else {
      for (x = 0; x < img->width; x++) {
        tmp = *row++ << 8;
        tmp |= *row++;
        img->map[y][x].c.r = normalize(tmp, max);
        tmp = *row++ << 8;
        tmp |= *row++;
        img->map[y][x].c.g = normalize(tmp, max);
        tmp = *row++ << 8;
        tmp |= *row++;
        img->map[y][x].c.b = normalize(tmp, max);
        img->map[y][x].c.a = 0xff;
      }
    }
  }
  free(buffer);
  return SUCCESS;
}

バイナリ形式のフルカラー画像。 RGBで1ピクセルあたりグレースケールと同じ処理が3回必要になっただけ。

以上、少し長くなったが、これでPNMファイルは概ね読み込めるはずだ。 ある程度厳密に書いて、この程度のコード量で読み込めるのだからやはり画像形式としてはシンプルな部類だろう。

ここで紹介したコードについては、 GIMP および Paint Shop Pro の出力画像を読み込ませ確認を行ってはいるが、 きちんと動作することを保証するものではない。 特に、16bit深度のパターンについては、16bit深度のPNM画像が見つけられなかったため、 自作の画像を自前で読み込むという最低限の確認しかできていないので、問題がある可能性が高い。 何か問題を見つけた場合は、連絡いただけると幸いである。