【C言語】ターミナルでBMP画像ファイルを表示してみた

C言語,TIPS

コンテンツ

はじめに

前にBMPファイルをバイナリエディタを見たり、エスケープシーケンスを使ってみたりしたのでBMPファイルを読み込んでターミナルに表示するプログラムを作成しました。

プログラム実行例1
プログラム実行例2
プログラム実行例3

BMPファイルをターミナルに表示する

調べたBMPファイルの構造を参考に、読み込んでターミナルに表示するプログラムを作成しました。BMPファイルにもいくつか種類(圧縮形式がちがう)があるのですが、すべてに対応するのは面倒(公開されているライブラリを使った方がよいので)なので、Windowsのペイント3Dでbmp形式で保存した時のフォーマット(1ピクセルに24bit使用の非圧縮)のBMPファイルを読み込む仕様にしました。

とりあえずフローチャートを描く

今まで、あまりフローチャート等を描かずに考えながらプログラムを書いていましたが今回はフローチャートで思考を整理してから、作ってみようと思いました。

今回のプログラムの仕様(要件?)は次のような感じです。

  • BMPファイルの画像をターミナルに表示する
  • BMPファイルは一部のフォーマット(1ピクセルに24bit使用の非圧縮)のみに対応で問題ない
  • C言語で実装かつ標準ライブラリ(stdio.h, stdlib.h,…等)のみを使用

以上に沿って、先にプログラムの流れを考えたのでが以下になります。

ターミナルに画像を表示するプログラム(全体)のフローチャート
全体のフローチャート

上図の画像をターミナルに表示するの部分のフローチャートが以下になります。

ターミナルに画像を表示する部分のフローチャート
画像をターミナルに表示する部分のフローチャート

プログラムを作る

プログラム全体のフローチャートを作成したので、それに沿って作っていくだけです。全体の流れに沿って作成したMaine関数が以下です。本当に、フローチャートのまんまなので特に説明するとはありません。Main関数のコードの下で自作の関数を説明します。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
            
#define OK 0
#define NG 1
#define SBRERR -1

/* Main */
int main(int argc, char *argv[]){
    FILE *fp; // 開くファイルのポインタ
    unsigned int imgOffset, headerSize, compType = 0;
    short pixelBit = 0;
    imgdata *img;
    int tmp;
        
    img = (imgdata *)malloc(sizeof(imgdata));
        
    // コマンドライン引数チェック
    if (argc != 2) {
        printf("引数の数が違います。\n使い方\nコマンド BMPファイル名\n");
        return 0;
    }
        
    // ファイルを開く
    if ( (fp = fopen(argv[1], "rb")) == NULL) {
        printf("指定されたファイルを開けませんでした\n");
        return 0;
    }
        
    // BMPファイルかチェック
    tmp = isBmpFile(fp);
    if(tmp == NG){
        printf("BMPファイルでないです\n");
        return 0;
    }else if( tmp == SBRERR){
        printf("BMPファイルかのチェックでエラーが発生\n");
        return 0;
    }
        
    // イメージデータセットのオフセットを取得
    readXbyte(fp, &imgOffset, 10L, 4L);
        
    // 情報ヘッダのサイズ
    readXbyte(fp, &headerSize, 14L, 4L);
    if (headerSize <= 12){
        printf("未対応の情報ヘッダのBMPファイルです\n");
        return 0;
    }
        
    // 1pixelあたりのbit数
    readXbyte(fp, &pixelBit, 28L, 2L);
    // 圧縮タイプ
    readXbyte(fp, &compType, 30L, 4L);
    if ( pixelBit != 24 || compType != 0 ){
        printf("未対応のBMPファイルです\n");
         return 0;
    }
        
    // 画像の高さと幅を取得
    readXbyte(fp, &(img->width), 18L, 4L);
    readXbyte(fp, &(img->height), 22L, 4L);
        
    // 画像データを読み込む
    readImgdata(img, fp, imgOffset);
        
    // 画像を表示
    printImage(img);
        
    free(img->pixel);
    fclose(fp);
    return 0;
}
    

Main関数で使っている作った関数のコードが以下です。各関数は次のようになっています。

  • int isBmpFile(FILE *fp)
  • BMPファイルを開いたファイルの先頭2byteを読み込んで、’B’、’M’かチェックしてBMPファイルかどうかを返す関数です。OKがBMPファイルで、NGがBMPファイルでないことを示します。一つ下のreadXbyteで読み込めばよかったのですが、先にisBmpFile関数を作成してしまっていたので、普通にfreadで読み込んでいます。

  • int readXbyte(FILE *fp, void *ptr, long offset, long x)
  • BMPファイルの先頭からoffsetバイト進めて、そこからxバイト読み込む関数です。格納先は、ptrが示す箇所です。ptrは色んな型のポインタ変数を受け取れるようにvoid *ptrとしています。

  • int readImgdata(imgdata *img, FILE *fp, long offset)
  • BMPファイルから画像データの部分を読み込んで、構造体imgdataに格納する関数です。画像をターミナルに表示する部分のフローチャートでは、BMPファイルの画像データを読み込みながら出力する文字列を作成することになっていましたが、作成中に方針転換して一度画像データ部分を読み込んでしまってから、表示のとき文字列を作成することにしました。構造体imgdataの構造体pixelColorは、24bitの色の情報をusigned char型のr,g,bで保存しています。unsigned char型の理由は、char型だと数値にして文字列に埋め込む際に符号拡張で期待している値にならこと(負の数になる)があるためです。imgdataのpixelのメモリの確保は一次元の配列で確保して、要素番号を計算してアクセスしています。

    pixel配列の説明

  • void printImage(imgdata *img)
  • ターミナル上にどうやって表示するかというと、空白文字の背景の色を変えて画像の1ピクセルにします。WSL2のubuntuのターミナルは、エスケープシーケンスのESC[48;2;r;g;bm(r,g,bは0~255)で文字の背景を変更できた(この指定は対応していない端末エミュレータもある…)ので、空白文字の背景色をこれで変更します。画像データの1ピクセルの色情報から文字列を作成して、連結させて出力します。画像の行の出力が終わったら改行するのですが、背景色を黒に戻さないと改行からターミナルの端まで画像の行の終端の色が続くので、\e[48;2;0;0;0m\nで改行しています。

typedef struct {
    unsigned char r; // 符号なしじゃないと整数に変換するとき符号拡張で負の値になる数値がある
    unsigned char g;
    unsigned char b;
} pixelColor;
        
typedef struct {
    int height;
    int width;
    pixelColor *pixel;
} imgdata;

/* BMPファイルかチェック
 * 引数 : ファイルポインタ
 * 返り値 : OK, NG, SBRERR
 * すること : 先頭から2バイト読み込んでBMかチェック
*/
int isBmpFile(FILE *fp){
    char filetype[2];
    // ファイル位置を先頭に戻す
    if( (fseek(fp, 0L, SEEK_SET)) != 0 ){
        return SBRERR;
    }
    // ファイルの先頭から2バイト読み込む
    if( fread(filetype, sizeof(char), 2, fp) < 2){
        return SBRERR;
    }
    //printf("%c%c\n", filetype[0], filetype[1]);
    if( filetype[0] == 'B' && filetype[1] == 'M'){
        return OK;
    }else{
        return NG;
    }   
}

/* リトルエンディアンのデータをXbyte読み込む
 * 引数 : ファイルポインタ, 値を格納する変数のポインタ(void *ptr), 先頭からのオフセット, 読み込むバイト数
 * 返り値 : OK, SBRERR
 * 整数値を読み込む関数、C言語の整数値のバイトオーダはリトルエンディアンなので、memcpyでコピーすれば保存した整数と同じになる。
*/
int readXbyte(FILE *fp, void *ptr, long offset, long x){
    char *temp;
    long i;
    temp = (char *)malloc(x * sizeof(char));
    
    if (temp == NULL)return SBRERR;

    // ファイル位置を先頭からオフセット分移動
    if( (fseek(fp, offset, SEEK_SET)) != 0 ){
        return SBRERR;
    }
    
    // ファイルの位置からxバイト読み込む(temp配列の要素番号の大きい方にファイルポインタの先頭に近い方のバイトを格納)
    if( fread(temp, sizeof(char), x, fp) < x){
            return SBRERR;
    }
    // ptrの指すところにコピー... 以下で問題ない
    memcpy(ptr, temp, x);
    
    //for(i=0; ipixelは1次元の配列でアクセス時にうまく縦横i,jを使ってアクセス( i * 横幅 + j)
*/
int readImgdata(imgdata *img, FILE *fp, long offset){
    int i, j;
    long numOfLineByte;
    char *temp;

    // 画像データを保存するメモリ確保
    img->pixel = (pixelColor *)malloc(sizeof(pixelColor) * abs(img->height) * abs(img->width));
    
    // 行データのバイトサイズを計算(1ピクセルのbit数は24が前提 1ピクセル3byte)
    // 1*3+1 2*3+2 3*3+3 4*3+0
    numOfLineByte = abs(img->width) * 3 + abs(img->width) % 4;
    //printf("%ld\n", numOfLineByte);
    
    temp = (char *)malloc(sizeof(char) * numOfLineByte);
    if(temp == NULL){
        return SBRERR;
    }

    // 行ごと読み込んで img->pixel に格納
    if( (fseek(fp, offset, SEEK_SET)) != 0 ){
        return SBRERR;
    }
    for(i = 0; i < abs(img->height); i++){
        if(feof(fp) != 0)break;
        if( fread(temp, sizeof(char), numOfLineByte, fp) < abs(img->width) * 3){
            return SBRERR;
        }
        for(j = 0; j < abs(img->width); j++){
            if(img->height > 0){
                // 高さが正のとき、最初の行のデータは画像の一番下部分になっている
                img->pixel[(abs(img->height) - 1 - i) * abs(img->width) + j].b = temp[3*j];
                img->pixel[(abs(img->height) - 1 - i) * abs(img->width) + j].g = temp[3*j+1];
                img->pixel[(abs(img->height) - 1 - i) * abs(img->width) + j].r = temp[3*j+2];
            }else{
                // 高さが負のときは、配列の最初の方から詰めていく
                img->pixel[i * abs(img->width) + j].b = temp[3*j];
                img->pixel[i * abs(img->width) + j].g = temp[3*j+1];
                img->pixel[i * abs(img->width) + j].r = temp[3*j+2];
            }

            /*if(j==0){
                printf("pixel...b:%x g:%x r:%x\n", img->pixel[(abs(img->height) - 1 - i) * abs(img->width) + j].b, img->pixel[(abs(img->height) - 1 - i) * abs(img->width) + j].g, img->pixel[(abs(img->height) - 1 - i) * abs(img->width) + j].r);
            }*/
        }
    }
    free(temp);
    return OK;
}

/* 画像をターミナルに表示する
 * 引数 : imgdata
*/
void printImage(imgdata *img){
    // 1ピクセル "\e[48;2;255;255;255m " ... 20byte
    // 改行 "\e[48;2;0;0;0m\n" ... 14 byte... 黒に戻して改行
    int i, j;
    char *imageStr;
    char pixelStr[21];
    imageStr = (char *)malloc(sizeof(char) * 20 * abs(img->height) * abs(img->width) + abs(img->height) * 14 + 2); // 20bytes × 縦 × 横 + 改行文字列 + \0 と念のための余白
    imageStr[0] = '\0';
    for(i = 0; i < abs(img->height); i++){
        for(j = 0; j < abs(img->width); j++){
            sprintf(pixelStr, "\e[48;2;%d;%d;%dm ", (int)(img->pixel[i * abs(img->width) + j].r), (int)(img->pixel[i * abs(img->width) + j].g), (int)(img->pixel[i * abs(img->width) + j].b));
            strcat(imageStr, pixelStr);
        }
        strcat(imageStr, "\e[48;2;0;0;0m\n");
    }
    // 表示
    printf("\n%s\n", imageStr);
    free(imageStr);
    return;    
}

/* 画像を1行毎ターミナルに表示する
 * 引数 : imgdata
*/
void printImageType2(imgdata *img){
    // 1ピクセル "\e[48;2;255;255;255m " ... 20byte
    int i, j;
    char *imageStr;
    char pixelStr[21];
    imageStr = (char *)malloc(sizeof(char) * 20 * abs(img->width) + 2); // 20bytes × 横 + \0 と念のための余白
    imageStr[0] = '\0';
    for(i = 0; i < abs(img->height); i++){
        for(j = 0; j < abs(img->width); j++){
            sprintf(pixelStr, "\e[48;2;%d;%d;%dm ", (int)(img->pixel[i * abs(img->width) + j].r), (int)(img->pixel[i * abs(img->width) + j].g), (int)(img->pixel[i * abs(img->width) + j].b));
            strcat(imageStr, pixelStr);
        }
        // 表示
        printf("%s\e[48;2;0;0;0m\n", imageStr);
        imageStr[0] = '\0';
        sleep(1);
    }
    free(imageStr);
    return;    
}
    

感想

方針転換もありましたが、事前にフロチャートなどで思考をまとめておくとコード書くときかなりスムーズに進みました。簡単なものでも図にしたりはするのは重要だと認識しました。

プログラムの方ですが、ターミナルに画像が表示されるのはなんかいいと思いました。バイナリファイルを扱うライブライなどは、こういったことをどこかでやっているんだなとありがたく思いました。

C言語

Posted by 3流PG