🇯🇵 日本語 | 🇺🇸 English
広告スペース

C言語で画像処理

PPM/PGM の手書き読み書きから、グレースケール・フィルタ・エッジ検出まで、外部ライブラリ最小で実装する。

画像データの表現

デジタル画像は2次元の明度値(ピクセル)の並びです。Cでは次のように扱います:
連続メモリ: 多次元配列の深掘り で見た通り、C では img[i][j] は行優先で連続。画像バッファと相性が非常に良い。

PPM/PGM 形式の読み書き(ライブラリ不要)

PGM (Portable Graymap)PPM (Portable Pixmap) はヘッダとピクセル値だけの極めてシンプルな画像形式。自力で読み書きできるので学習に最適です。

PGM (グレースケール) のフォーマット

P5 # マジックナンバー(P5=バイナリPGM、P2=ASCII)
4 3 # 幅 高さ
255 # 最大値
<width × height バイトのピクセルデータ>

書き込み

#include <stdio.h>

int save_pgm(const char *path, int w, int h, const unsigned char *data) {
    FILE *fp = fopen(path, "wb");
    if (!fp) return -1;
    fprintf(fp, "P5\n%d %d\n255\n", w, h);
    fwrite(data, 1, (size_t)w * h, fp);
    fclose(fp);
    return 0;
}

読み込み

unsigned char *load_pgm(const char *path, int *w, int *h) {
    FILE *fp = fopen(path, "rb");
    if (!fp) return NULL;
    char magic[3];
    int maxval;
    if (fscanf(fp, "%2s %d %d %d", magic, w, h, &maxval) != 4) {
        fclose(fp); return NULL;
    }
    fgetc(fp);   // ヘッダ後の1バイトの改行を飛ばす
    size_t n = (size_t)(*w) * (*h);
    unsigned char *data = malloc(n);
    fread(data, 1, n, fp);
    fclose(fp);
    return data;
}

PPM (カラー) は1ピクセル3バイト

// 書き出し: P6 がバイナリPPM
fprintf(fp, "P6\n%d %d\n255\n", w, h);
fwrite(rgb_data, 3, (size_t)w * h, fp);   // RGB順
テスト用画像の作り方: macOS/Linux なら ImageMagick で convert input.png output.pgm、またはGIMP/Preview.app で「PGM/PPMとしてエクスポート」で作成できる。

グレースケール化・ネガポジ・二値化

RGB → グレースケール

輝度は Y = 0.299 R + 0.587 G + 0.114 B(ITU-R BT.601)。人間の目の感度を考慮した重み付き平均。
void to_gray(int w, int h,
             const unsigned char *rgb,
             unsigned char *gray) {
    for (int i = 0; i < w * h; i++) {
        int r = rgb[3*i + 0];
        int g = rgb[3*i + 1];
        int b = rgb[3*i + 2];
        gray[i] = (unsigned char)(0.299 * r + 0.587 * g + 0.114 * b);
    }
}

ネガポジ反転

void invert(int w, int h, unsigned char *img) {
    for (int i = 0; i < w * h; i++)
        img[i] = 255 - img[i];
}

二値化(白黒の2階調へ)

void binarize(int w, int h, unsigned char *img, int threshold) {
    for (int i = 0; i < w * h; i++)
        img[i] = (img[i] >= threshold) ? 255 : 0;
}
適切なしきい値: 固定値(例: 128)で良い場合もあるが、大津の手法で画像ごとに最適なしきい値を自動算出できる(ヒストグラムを使う)。

畳み込み(convolution)

各ピクセルと周囲を重み付きで合算するのが畳み込み。重みの並びをカーネルと呼びます。フィルタの基礎。

平滑化(box blur、3×3平均)

1/9
1/9
1/9
1/9
1/9
1/9
1/9
1/9
1/9
カーネルの中心が今のピクセル、周囲が隣接8画素。全体で1/9ずつ足すとぼやけた画像になる。

シャープ化

0
-1
0
-1
5
-1
0
-1
0
中心を強調し、隣接を引くことでエッジが際立つ。

汎用畳み込み関数

// 3x3カーネル専用のシンプル版
void convolve3(int w, int h,
               const unsigned char *src,
               unsigned char *dst,
               const double k[3][3]) {
    for (int y = 0; y < h; y++) {
        for (int x = 0; x < w; x++) {
            double sum = 0;
            for (int dy = -1; dy <= 1; dy++) {
                for (int dx = -1; dx <= 1; dx++) {
                    int ny = y + dy, nx = x + dx;
                    // 画像外は端のピクセルをクランプ(clamp)
                    if (ny < 0) ny = 0;
                    if (ny >= h) ny = h - 1;
                    if (nx < 0) nx = 0;
                    if (nx >= w) nx = w - 1;
                    sum += src[ny * w + nx] * k[dy + 1][dx + 1];
                }
            }
            // 0〜255にクリップ
            if (sum < 0) sum = 0;
            if (sum > 255) sum = 255;
            dst[y * w + x] = (unsigned char)sum;
        }
    }
}

// 使い方
double blur[3][3] = {
    {1/9.0, 1/9.0, 1/9.0},
    {1/9.0, 1/9.0, 1/9.0},
    {1/9.0, 1/9.0, 1/9.0}
};
convolve3(w, h, src, dst, blur);
注意: in-place では書かない(自分を読みながら自分に書くと結果が崩れる)。srcdst を別バッファにすること。

Sobel エッジ検出

横方向の勾配と縦方向の勾配をそれぞれ専用カーネルで計算し、その大きさをエッジ強度とします。

Sobel カーネル Gx / Gy

Gx:
-1
0
1
-2
0
2
-1
0
1
Gy:
-1
-2
-1
0
0
0
1
2
1

実装

#include <math.h>

void sobel(int w, int h,
           const unsigned char *src,
           unsigned char *dst) {
    static const int Gx[3][3] = {{-1,0,1},{-2,0,2},{-1,0,1}};
    static const int Gy[3][3] = {{-1,-2,-1},{0,0,0},{1,2,1}};

    for (int y = 1; y < h - 1; y++) {
        for (int x = 1; x < w - 1; x++) {
            int sx = 0, sy = 0;
            for (int dy = -1; dy <= 1; dy++) {
                for (int dx = -1; dx <= 1; dx++) {
                    int v = src[(y + dy) * w + (x + dx)];
                    sx += v * Gx[dy + 1][dx + 1];
                    sy += v * Gy[dy + 1][dx + 1];
                }
            }
            double mag = sqrt((double)sx * sx + (double)sy * sy);
            if (mag > 255) mag = 255;
            dst[y * w + x] = (unsigned char)mag;
        }
    }
}
実行方法: 入力PGM → sobel() → 出力PGM。画像ビューアで開くと、エッジ部分だけが明るく浮かび上がります。gcc -O2 sobel.c -o sobel -lm でコンパイル。

ヒストグラム

各明度値が画像中にどれだけ現れるかを数えた分布。コントラスト調整や自動しきい値決定に使います。
void histogram(int w, int h,
               const unsigned char *img,
               int hist[256]) {
    for (int i = 0; i < 256; i++) hist[i] = 0;
    for (int i = 0; i < w * h; i++) hist[img[i]]++;
}

// ターミナル上にヒストグラムを表示
void print_histogram(const int hist[256]) {
    int maxv = 0;
    for (int i = 0; i < 256; i++) if (hist[i] > maxv) maxv = hist[i];
    for (int i = 0; i < 256; i += 4) {  // 4階調ずつまとめる
        int sum = 0;
        for (int k = 0; k < 4; k++) sum += hist[i + k];
        int bars = maxv ? sum * 40 / maxv : 0;
        printf("%3d |", i);
        for (int b = 0; b < bars; b++) putchar('#');
        putchar('\n');
    }
}

コントラスト強調(線形ストレッチ)

// 最小値と最大値を0と255に伸ばす
void stretch_contrast(int w, int h, unsigned char *img) {
    int lo = 255, hi = 0;
    for (int i = 0; i < w * h; i++) {
        if (img[i] < lo) lo = img[i];
        if (img[i] > hi) hi = img[i];
    }
    if (hi == lo) return;
    for (int i = 0; i < w * h; i++) {
        img[i] = (unsigned char)((img[i] - lo) * 255 / (hi - lo));
    }
}

stb_image で PNG/JPG を扱う

PNG や JPG も扱いたい場合、stb_image(単一ヘッダファイルのパブリックドメインライブラリ)が最も手軽です。#include 1つでPNG/JPG/BMP/GIFの読み込みが可能。

入手

$ curl -O https://raw.githubusercontent.com/nothings/stb/master/stb_image.h $ curl -O https://raw.githubusercontent.com/nothings/stb/master/stb_image_write.h

最小例: PNGを読んでPGMで保存

#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv) {
    int w, h, ch;
    unsigned char *data = stbi_load(argv[1], &w, &h, &ch, 1);
    // 第5引数=1: 強制的にグレースケール1ch に変換
    if (!data) { fprintf(stderr, "load failed\n"); return 1; }

    FILE *fp = fopen("out.pgm", "wb");
    fprintf(fp, "P5\n%d %d\n255\n", w, h);
    fwrite(data, 1, (size_t)w * h, fp);
    fclose(fp);

    stbi_image_free(data);
    return 0;
}
$ gcc -O2 main.c -o convert -lm $ ./convert photo.jpg $ ls out.pgm photo.jpg
書き出し側: stb_image_write.h を使えば PNG で保存もできる: stbi_write_png("out.png", w, h, 1, data, w);

チャレンジ課題

課題1: チェッカーボードPGMを生成
400×400 の白黒チェッカーボード画像を自分で作って PGM で保存せよ。マス目は50ピクセル幅。
課題2: グレースケール変換ツール
./togray input.ppm output.pgm のように動くCLIツールを作れ。PPM(P6) を読み、輝度計算してPGMで書き出す。
課題3: Gaussian ブラー
5×5 のガウスカーネルを定義して、box blur より自然なぼかしを実装せよ(中心が最大、周辺に向けて減衰)。
課題4: モザイク
N×N のブロックごとに平均値で塗りつぶす関数を書け。N=1, 4, 16, 32 で結果を比較。
課題5: 大津の手法
ヒストグラムから自動で最適な二値化しきい値を計算する大津の手法を実装せよ(クラス間分散を最大化するしきい値を探索)。

関連ページ