1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[画像処理] アルゴリズムから画像処理を知ろう -濃淡処理編-

Last updated at Posted at 2025-01-06

はじめに

今回は画像処理についての内容になります。
画像処理技術というのは皆さん(エンジニアも含め一般の人も)が普段使用している技術
の一つになります。おそらく意識はしていないと思いますが、とても身近です。
例えば、スマホの画像加工アプリ、インスタグラム(画像投稿する時)、
スマホ標準のカメラアプリなどカメラで撮影したものは全て何らかの画像処理がされています。

今回はこのとても身近な技術についてなるべく分かりやすく説明していきます。
※自分の勉強も兼ねています

今回はカラー画像についての説明はしません。説明すると量が多くなるため。

画像とは

そもそもなんですが、画像とは何か?という説明から。
画像とは簡単に表すと数字の集まりです。

ん?どういうこと?と思われた方もいると思いますので画像を使って説明していきます。

今回は以下の画像を使用していきます。
outimg_標準画像.png

ではまず画像の概念を説明していきます。
まず上記画像をズームしてみると以下のように見えます。

正方形がたくさんありますね。この1つが画素という単位になります。
この画素が集まって、いわゆる”画像”になっています。

よく4Kとか耳にしたりすると思いますが、あれは横の画素が4000個程集まっているものを指します。
一般的な4Kのサイズとして以下になります。
3840 × 2160
これは横が3840個の画素で、縦が2160個の画素を表しています。

補足
解像度ってどういう意味?
よくこの言葉を耳にすると思いますが、これは画像や画面がどれだけ細かく情報
を表現できるかを示す指標になります。
解像度が高いと言えば画像、ディスプレイの面積あたりの画素数が多いことで、
解像度が低いと言えば画像、ディスプレイの面積あたりの画素数が少ないことになります。

次に、この1画素毎に注目すると、画素の色が沢山ありますよね。
黒色だったり白色、グレーだったりと。この色こそが先ほど述べた数字になります。
色の階調を数字で表しているということになります。
一般的には0〜255(1Byte)の256段階の表現になります。
真っ黒が0で真っ白が255、その間がグラデーションになります。

補足
この数字は何を表しているのか。感のいい方は気づいたと思いますが、
これは明るさを表しています。”輝度値”、”光量”とも言います。

先ほどの画像の中身を見ると以下のようになっています。

ちなみにこの画像のことをグレースケール画像と言います。
ん?モノクロ画像じゃないの?と思った方はいると思います。
グレースケール画像とモノクロ画像の違いは以下になります。

  • グレースケール画像:0〜255の256階調で表現する画像
  • モノクロ画像:0と1の2階調で表現する画像

簡単ですが、これで画像とはなにかのイメージはついたかと思います。
では次は実際に画像処理をしていきましょう。

説明する言語はC/C++で書いていきます。
OpenCVを画像の読み込み書き込み、ヒストグラムで使用していますが、
実際の画像処理には使用していません。
OpenCVを使用すると画像処理のアルゴリズムがわからないので。

補足
ヒストグラムとは
画像の各画素の明るさや色の分布を視覚的に表現したグラフのことになります。
もっと簡単に言うと、画像の中でどの輝度値や色がどのくらい使われているかを示したものになります。ヒストグラムの説明.png

イメージとして暗い画像ならグラフが左寄りになり、
明るい画像ならグラフが右寄りになります。

動作環境

PC : Macbook Pro(M4 Pro)
コンパイラ : g++
c++ : 17
OpenCV : 4.10.0_*
エディタ:VScode

ソースコード

全体のソースコードは以下に格納しています。

画像処理(濃淡変換)

今回は基本である各画素に対しての処理を行います。

まず、画像処理なしだと以下の式が成り立ちます。
つまり入力画像がそのまま出力画像として出てきます。
$$Po=Pi$$
$$Piは入力画素、Poは出力画素$$

では、これをベースに色々やっていきましょう。
そのまえに今回使用する画像とヒストグラムは以下になります。
元画像とヒストグラム.png

単調増加

単調増加のトーンカーブ処理になります。

$$Po=Pi*coeff$$
$$Piは入力画素、Poは出力画素、coeffはスカラー係数$$

式はほぼ先ほどのものと同じです。係数が加わっただけです。
係数を2倍にすると出力も2倍に、3倍にすると3倍に、 0.5倍にしても入力と出力の関係は保たれるため単調増加となります。

ソースコード
void toneCurve(Mat inImg, int height, int width, double coeff, Mat outImg)
{
    for (int y = 0; y < height; y++)
    {
        for (int x = 0; x < width; x++)
        {
            Vec3b &pix = inImg.at<Vec3b>(y, x);

            // 画素取得
            unsigned char blue = static_cast<unsigned char>(std::min(255.0, std::max(0.0, pix[BLUE] * coeff)));
            unsigned char green = static_cast<unsigned char>(std::min(255.0, std::max(0.0, pix[GREEN] * coeff)));
            unsigned char red = static_cast<unsigned char>(std::min(255.0, std::max(0.0, pix[RED] * coeff)));

            outImg.at<Vec3b>(y, x) = Vec3b(blue, green, red);
        }
    }
}

coeff=1
1倍なので元画像と同じになります
スクリーンショット 2024-12-30 0.27.06.png

coeff=2
2倍にすると大多数が255(最大値)に張り付きます ※飽和対策のためクリッピングしています
スクリーンショット 2024-12-30 0.59.46.png

coeff=0.5
0.5倍すると明るさが半分になります
スクリーンショット 2024-12-30 0.28.56.png

線形関数

線形関数処理になります。

$$Po=aPi+b$$
$$Piは入力画素、Poは出力画素、a,bは係数$$

この処理を施すとどうなるかですが、「明るい部分はより明るく」「暗い部分はより暗く」
コントラストを強調した画像を生成できます。

係数の役割
係数のaはコントラストの調整
係数のbは画像の明るさを調整

ソースコード
void effectLinear(Mat inImg, int height, int width, double a, double b, Mat outImg)
{
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            // 画素取得
            Vec3b &pix = inImg.at<Vec3b>(y, x);

            // 画像処理
            unsigned char blue  = static_cast<unsigned char>(std::min(255.0, std::max(0.0, a * pix[BLUE] + b)));
            unsigned char green = static_cast<unsigned char>(std::min(255.0, std::max(0.0, a * pix[GREEN] + b)));
            unsigned char red   = static_cast<unsigned char>(std::min(255.0, std::max(0.0, a * pix[RED] + b)));

            outImg.at<Vec3b>(y, x) = Vec3b(blue, green, red);
        }
    }
}

コントラスト比を上げつつ、画像全体を暗くした画像 a=1.5、b=-50
スクリーンショット 2024-12-30 1.41.31.png

コントラスト比を下げつつ、画像全体を明るくした画像 a=0.7、b=50
スクリーンショット 2024-12-30 1.45.26.png

そもそもコントラストと画像の明るさはどう違うのか?ですが、以下に例をあげます。

コントラストを上げたものと明るさを上げた画像を比較したいと思います。

・元のヒストグラム
画像処理なしのヒストグラム.png

・コントラストのみ上げたヒストグラム(a=1.2)
コントラストを上げたヒストグラム.png

X軸の幅が広がる=明るい部分と暗い部分の差が広がったということになります。

次に画像の明るさのみ上げた例です。

・明るさのみ上げたヒストグラム(b=50)
明るさを上げたヒストグラム.png

対して画像の明るさのみを上げた画像ではX軸の幅は元のヒストグラムと同じになります。
ではなにが変わったかと言うと、全体的に画素値が大きくなった変化になります。
純粋に全ての画素値に対して+50の値になったため、ヒストグラムとしては右にスライドしたイメージです。
※Y軸が固定じゃないため見づらいですが

・3画像の比較
比較.png

ガンマ補正

ガンマ補正とは
画像処理におけるガンマ補正とは、明るい部分と暗い部分を維持してその中間の明るさを調整することです。
※元々は人間の視覚特性とディスプレイや映像信号の特性を調整するために使用するのが目的

$$Po=255(\frac{Pi}{255})^γ$$
$$Piは入力画素、Poは出力画素、γはガンマ値$$

図で入力画素と出力画素の関係を示すと以下になります。

image.png

ではこの図のガンマ値を設定するとどうなるのかですが、以下になります。

ガンマ値 出力画像
0.5 明るくなる
1.0 変化なし
2.0 暗くなる
ソースコード
void effectGamma(Mat inImg, int height, int width, double gammaVal, Mat outImg)
{
    unsigned char LUT[256];

    // 1. LUTを作成
    for (int i = 0; i < 256; i++) {
        double tmp = i / 255.0;
        LUT[i]     = static_cast<unsigned char>(pow(tmp, gammaVal) * 255.0);
    }

    // 2. 各画素の処理
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            // 画素取得
            Vec3b &pix = inImg.at<Vec3b>(y, x);

            // 画像処理
            unsigned char blue  = LUT[pix[BLUE]];
            unsigned char green = LUT[pix[GREEN]];
            unsigned char red   = LUT[pix[RED]];

            outImg.at<Vec3b>(y, x) = Vec3b(blue, green, red);
        }
    }
}

大して難しいことはやっていないですが、先ほどまでの単純計算ではなくなったため
ソースコードの説明をします。

まず、大きく分けると2個の処理をやっています。

  1. LUTの作成
  2. 各画素の処理

順に説明していきます。

1.LUTの作成

そもそもLUTとはなんじゃ?という話ですが、これは「Look Up Table(ルックアップテーブル)」の略です。
で、何を格納しているテーブルかと言うと、
入力値に対するガンマ補正後の値を事前に計算して格納しているテーブルになります。

格納イメージは以下になります。
LUTの例.png
この例ではガンマ値が0.9なので添字と値が異なりますが、ガンマ値を1.0にすると値と添字が同じ値になります。
なので、ガンマ値が1.0では入力画素をそのまま出力することになります。

では、なぜこのようなことをしているかと言うと、計算効率を良くし処理速度を上げるためです。
もちろん1画素ずつ直接計算することも可能ですが、毎回浮動小数点の計算、指数計算をするは計算コストが高くなります。

計算量の違いを例に挙げます。
・直接計算の場合
 計算時間=全画素数×浮動小数点、指数計算

・LUTの場合
 計算時間=256回だけ(0〜255)

全体の画素数が少ないとまだマシですが、例えば解像度がフルHDの場合、1920×1080=200万回この計算をする必要出てきます。これでは非常にパフォーマンスが落ちてしまいます。

2.各画素の処理

こちらはすごく簡単で、先ほど作成したLUTに入力の画素値でアクセスし、値を取得&出力するだけです。

以下がガンマ補正をした画像になります。

ガンマ値=2.0
ガンマ補正_x2.png

ガンマ値=0.7
ガンマ補正_x07.png

シグモイド関数

どんな入力値でも 0から1の範囲に変換する特性を持っています。
入力の大きさが大きい値の場合、出力はほぼ1になり、小さい値の場合、出力はほぼ0になります。
と説明してもよくわからないと思いますので、以下に式と図を示します。

$$f(x)=\frac{1}{1+e^{-x}}$$

上記が一般的な式になります。
image.png

ただし、このままでは画像処理で使えないため少し式を変形させます。

$$Po=\frac{1}{1+e^{-k({Pi-x_0})}}$$
$$Piは入力画素、Poは出力画素、kはシグモイドの傾き、x_0はシグモイドの中心位置$$

パラメータのkとx0についてもう少し説明します。

  • k
    用途:傾きを調整する
    イメージ:傾きが小さいと、画素値が徐々に変化する
    画素値が大きいと、ある値を境に急激に明るくなったり暗くなったりする

  • x0
    用途:変化の中心点を調整する
    イメージ:0.5を設定すると、画素値のちょうど中間点(127付近)で変化する
    0.8を設定すると、画素値の明るい部分(204付近)で変化が強調される。

図のイメージ(図が少し汚ないですが...)
kが大きい場合
image.png

x0が大きい場合
image.png

ソースコード
void effectSigmoid(Mat inImg, int height, int width, double k, double x0, Mat outImg)
{
    unsigned char LUT[256];

    // LUTを作成
    for (int i = 0; i < 256; i++) {
        // 0~1に正規化
        double norm = i / 255.0;
        LUT[i]      = static_cast<unsigned char>((1.0 / (1.0 + std::exp(-k * (norm - x0)))) * 255.0);
    }

    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            // 画素取得
            Vec3b &pix = inImg.at<Vec3b>(y, x);

            // 画像処理
            unsigned char blue  = LUT[pix[BLUE]];
            unsigned char green = LUT[pix[GREEN]];
            unsigned char red   = LUT[pix[RED]];

            outImg.at<Vec3b>(y, x) = Vec3b(blue, green, red);
        }
    }
}

先ほどのガンマ補正と同じく、事前に計算したシグモイドをLUTへ格納し使用しています。

以下がシグモイド関数を使用した画像になります。

k=10,x0=0.5
シグモイド関数_k10.png

k=1,x0=0.8
シグモイド関数_x008.png

k=5,x0=0.8(明るい部分を抑える)
シグモイド関数_明るい部分を抑える.png

ヒストグラム均等化

ヒストグラム均等化とは、画像のコントラストを強調するための画像処理になります。

ヒストグラム均等化の図.png

色々やり方はあるそうなのですが、今回は累積分布関数(CDF)でやります。

まず、累積分布関数とはなにか?ですが、
ある値以下のデータが全体の中でどの程度の割合を占めるかを表したものです。

$$F(x)=P(X \leq x)$$
$$Xは確率変数(今回で言えば画像の画素値)、xはある値$$

ではこれをどのように適用するのかですが、以下の計算手順で求めていきます。

  1. ヒストグラムを正規化する
    ヒストグラムを全ピクセル数で割り、各画素値の頻度(確率)を計算する
    $$正規化ヒストグラム:h(i)=\frac{ヒストグラムの値(画素値(i)の数)}{全ピクセル数}$$

  2. 累積頻度を計算する
    各画素値iのCDFは、画素値0からiまでの正規化ヒストグラムの合計になる
    $$F(i)=\sum_{j=0}^i h(j)$$
    $$F(i)はi以下の画素が全体のどのくらいの割合を占めているか、h(j)は正規化された頻度$$

1の方は簡単にイメージできると思いますので、2の方を例にあげます。

↓例のヒストグラム

画素値 j 正規化頻度 h(j)
0 0.1
1 0.2
2 0.4
3 0.3

これを累積分布関数F(i)で計算すると以下になります。
・F(0) = h(0) = 0.1
・F(1) = h(0) + h(1) = 0.1 + 0.2 = 0.3
・F(2) = h(0) + h(1) + h(2) = 0.1 + 0.2 + 0.4 = 0.7
・F(3) = h(0) + h(1) + h(2) + h(3) = 0.1 + 0.2 + 0.4 + 0.3 = 1.0

結果の見方としては、以下のように捉えることができます。
F(i)=0.7の場合、i以下の画素が全体の70%を占める。
この情報を使用することにより、元のヒストグラムの頻度分布を累積割合に基づいて均等にスケーリングできます。

ちょっとごちゃごちゃしてわかりにくいですが、最後にこの計算式を順に適用していった例を示します。
ヒストグラム均等化の式のイメージ.png
この例はあくまで計算用なので値が極端ですが、計算の全体イメージはこんな感じです。

ソースコード

先ほどの説明とあわせて読んでみてください。

・1のヒストグラムの正規化処理

void calcNormHist(Mat inImg, int height, int width, float *hist)
{
    long histTmp[256] = {0};
    long sum          = 0;

    // ヒストグラム作成
    // 画像の全画素を走査
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            Vec3b &pix = inImg.at<Vec3b>(y, x);
            // グレースケールなためどれか一つの値を取得
            int pixVal      = pix[BLUE];
            histTmp[pixVal] = histTmp[pixVal] + 1;
            sum += 1;
        }
    }

    // ヒストグラムの正規化
    for (int i = 0; i < 256; i++) {
        hist[i] = static_cast<float>(histTmp[i]) / sum;
    }
}

・2の累積頻度計算処理(全体)

void histEqualization(Mat inImg, int height, int width, Mat outImg)
{
    float         hist[256];
    unsigned char histEq[256];
    float         sum;

    // ヒストグラムの正規化
    calcNormHist(inImg, height, width, hist);

    // iの画素値までの累積分布関数を計算
    for (int i = 0; i < 256; i++) {
        sum = 0.0;
        // 0~iまでのヒストグラムの和を計算
        for (int j = 0; j <= i; j++) {
            sum = sum + hist[j];
        }

        // ヒストグラムの累積分布関数を計算
        histEq[i] = static_cast<unsigned char>(255 * sum + 0.5);
    }

    // ヒストグラム均等化
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            // 画素取得
            Vec3b &pix = inImg.at<Vec3b>(y, x);

            // 画像処理
            unsigned char blue  = histEq[pix[BLUE]];
            unsigned char green = histEq[pix[GREEN]];
            unsigned char red   = histEq[pix[RED]];

            outImg.at<Vec3b>(y, x) = Vec3b(blue, green, red);
        }
    }
}

以下がヒストグラム均等化を使用した画像になります。

ヒストグラム均等化.png
全体的に画素値が散らばり、コントラストが強調された。

元画像と比べて陰影がはっきりし、絵が綺麗になりましね。
元の画像も改めて載せておきます。

元画像とヒストグラム.png

ネガ処理

最後にオマケでちょっと特殊な処理を紹介します。
ネガ処理というもので、要は明暗を反転する処理になります。

$$Po=255-Pi$$
$$Piは入力画素、Poは出力画素$$

image.png

ソースコード
void effectNega(Mat inImg, int height, int width, Mat outImg)
{
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            // 画素取得
            Vec3b &pix = inImg.at<Vec3b>(y, x);

            // 画像処理
            unsigned char blue  = 255 - pix[BLUE];
            unsigned char green = 255 - pix[GREEN];
            unsigned char red   = 255 - pix[RED];

            outImg.at<Vec3b>(y, x) = Vec3b(blue, green, red);
        }
    }
}

以下がネガ処理を使用した画像になります。

反転すると怖いですね...
ネガ変換.png

最後に

以上で各画素に対しての画像処理は終わりになります。
もっと他にも色々ありますが、やる気が出たらやるかもです。
でも次は他の画像処理(フィルタ)をやっていければと思います。

1
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?