第05回 平滑化

平滑化とは、おおざっぱにいうと画像をぼやけさせる処理のことである。
この処理は、単にぼやけた画像を作りたいときに使えるだけでなく、暗い場所で撮影したときに発生する高感度ノイズと呼ばれるザラザラのノイズを除去するのにも使える。

今回のプログラムの最終的な機能

プログラムを実行して少し待つとコンソールに「完了」が表示され、元画像にザラザラのノイズを加えたものが実行画面上に表示される。
dataフォルダには7つの画像が作られる。
そのあとでキーボードの0~6のキーを押すとこれらの画像が画面に表示される。
(すべての画像は自動的に作成されるので、キーボードを使うのは結果確認のためだけ)
キー 画像 意味 特徴
0 ノイズ.jpg 元画像を実行画面の半分の大きさにリサイズし、ノイズを加えた画像 ランダムな色の1000個のノイズがある
1 1移動平均1.jpg 移動平均フィルタ (半径1) で「ノイズ.jpg」を平滑化した画像 全体にぼやけ、ノイズが薄まる
2 1移動平均2.jpg 移動平均フィルタ (半径4) で「ノイズ.jpg」を平滑化した画像 1キーの画像よりさらにぼやけ、ノイズが薄まる
3 2_1ガウシアン4広.jpg ガウシアンフィルタ (半径4, \(\sigma=2\sqrt{2\log_e 2}\)) で「ノイズ.jpg」を平滑化した画像 2キーの画像よりぼやけ方は少ない
4 2_2ガウシアン4狭.jpg ガウシアンフィルタ (半径4, \(\sigma=\sqrt{2\log_e 2}\)) で「ノイズ.jpg」を平滑化した画像 3キーの画像よりぼやけ方は少ないが、ノイズが目立つ
5 3メディアン1.jpg メディアンフィルタ (半径1) で「ノイズ.jpg」を平滑化した画像 ほぼ完全にノイズが消える
6 3メディアン2.jpg メディアンフィルタ (半径2) で「ノイズ.jpg」を平滑化した画像 ほぼ完全にノイズが消える
細かい形状がつぶれてパステル調になる
キーと表示される画像

1. 移動平均フィルタ

概要

平滑化の処理のうちのひとつである移動平均フィルタでは、あるピクセルのまわりのピクセルの色を平均して変換後の画像のピクセルの色を決める (実際は赤・緑・青の成分それぞれについて平均をとるが、下の図では話を簡単にするため単色であらわしている)。

一番単純な方法では、対象のピクセルを囲む9個のピクセルの色を平均する。この場合、考慮するのが1ピクセル隣までなので「半径が1」であるという。


対象のピクセルの上下左右に2つ離れたところまでの25個のピクセルの色を平均する方法もある。この場合は「半径が2」であるといい、半径が1のときよりもぼやけ方が強くなる。


具体的に画素半径 radius の移動平均フィルタで (i, j) のピクセルの色を決めるには、横座標は i-radius~i+radius、縦座標はj-radius~j+radiusの範囲のピクセルの色を調べる必要がある。
ただし、計算に使うピクセルの数は対象のピクセルが画像の内側にあるときと周辺近くにあるときとで異なる。例えば半径1なら普通は9個のピクセルで色を決めるが、図の○の位置のピクセルだと6個のピクセルを使うことになる。


そのため、ここでは計算に使うピクセルの色を格納するのに ArrayList を使用する。ArrayListは配列と似ているが、要素を任意に追加したり削除したりできるものである。
r という整数型の ArrayListはの宣言の書式は以下の通り。
ArrayList<Integer> r = new ArrayList<Integer>();
この時点では r にはまったく要素が含まれない。このあとで例えば
r.add(3);
r.add(5);
のようにすると、r の0番目、1番目の要素がそれぞれ3, 5で、r の要素数が2になる。
さらに、IntSummaryStatisticsライブラリの関数を使えば要素の平均値を直接求められる。
具体的には、r が整数型の ArrayList だとすれば、r に含まれる要素の平均値は
r.stream().mapToInt(Integer::intValue).summaryStatistics().getAverage();
で求められる (値はdouble型)。

課題 1

  1. Processing 4.3を起動する。
    青森キャンパスの演習室では下図の赤枠のアイコンから起動する。黒い方ショートカットアイコンの古いバージョン (Processing 3.*) だと今回のプログラムはうまく動作しない。
    東京キャンパス、むつキャンパスや、オンデマンドで自分のPCで実行する場合に、もし古い方のProcessingしか入っていない場合はここから最新版のProcessingをダウンロードして展開し、「processing.exe」を実行する。
  2. 以下の未完成状態のコードをコピー&ペーストする。
  3. import java.util.ArrayList;
    import java.util.IntSummaryStatistics;
    import java.util.Collections;
    
    // 画像用の変数
    PImage[] img = new PImage[7];
    // 出力ファイル名
    String[] fName = {"ノイズ", "1移動平均1", "1移動平均4", "2_1ガウシアン4広", "2_2ガウシアン4狭", "3メディアン1", "3メディアン2"};
    int w, h;
    
    void setup() {
      size(800, 600);
      w = width/2;
      h = height/2;
      img[0] = loadImage("元.jpg");
      img[0].resize(w, h);
      for (int i=0; i<1000; i++) {
        int x=(int)(random(w));
        int y=(int)(random(h));
        img[0].pixels[x+w*y] = color(random(256), random(256), random(256));
      }
      // 1~6番をノイズ画像と同じ画像にする
      for (int i=1; i<=6; i++) {
        img[i] = img[0].get();
      }
      textFont(createFont("MS Pゴシック", 48));
      simpleMovingAverage(1, 1);// 移動平均フィルタ(半径1)でぼかした画像をimg[1]に保存
      simpleMovingAverage(4, 2);// 移動平均フィルタ(半径4)でぼかした画像をimg[2]に保存
      double s = Math.sqrt(2*Math.log(2)); // 隣の画素の重みが中心の画素1/2になるσの値
      gaussian(4, s*2, 3);      // ガウシアンフィルタ(半径4, σが2s)でぼかした画像をimg[3]に保存
      gaussian(4, s, 4);        // ガウシアンフィルタ(半径4, σがs)でぼかした画像をimg[4]に保存
      median(1, 5);             // メディアンフィルタ(半径1)でぼかした画像をimg[5]に保存
      median(2, 6);             // メディアンフィルタ(半径2)でぼかした画像をimg[6]に保存
      println("完了");
      image(img[0], 0, 0, width, height);
      img[0].save("data/"+ fName[0] +".jpg");
    }
    
    void draw() {
    }
    
    // 移動平均フィルタでぼかす(課題1)
    void simpleMovingAverage(int radius, int n) {
      for (int j=0; j<h; j++) {
        for (int i=0; i<w; i++) {
          ArrayList<Integer> r = new ArrayList<Integer>();
          ArrayList<Integer> g = new ArrayList<Integer>();
          ArrayList<Integer> b = new ArrayList<Integer>();
          // i±radius, j±radiusの範囲の平均色を求める
          for (int y=-radius; y<=radius; y++) {
            for (int x=-radius; x<=radius; x++) {
              if (i+x>=0 && i+x<w && j+y>=0 && j+y<h) {
                color c = img[0].pixels[i+x+(j+y)*w]; // (i, j)から(x, y)ずれた位置の色
                // rにcの赤成分を追加する
                // gにcの緑成分を追加する
                // bにcの青成分を追加する
              }
            }
          }
          // ar, ag, abにr, g, bの平均値をfloat化して入れる
          float ar = 0;
          float ag = 0;
          float ab = 0;
          img[n].pixels[i+j*w] = color(ar, ag, ab);
        }
      }
      img[n].save("data/"+ fName[n] +".jpg");
    }
    
    // ガウシアンフィルタでぼかす(課題2)
    void gaussian(int radius, double sigma, int n) {
    }
    
    // メディアンフィルタでぼかす(課題3)
    void median(int radius, int n) {
    }
    
    void keyPressed() {
      int k = key-'0';
      if (k>=0 && k<=6) {
        background(0);
        image(img[k], 0, 0, width, height);
        fill(255, 0, 0);
        text(fName[k], 30, height-30);
        fill(255);
      }
    }
    
  4. 「img05」という名前で保存する。
  5. 適当に画像検索して元画像 (空や建物の壁面のようなノッペリした部分と、細い木の枝や小さい文字のような細かい構造の両方を含むもの) を用意する。
  6. 画像の形式に応じて以下の変更を加える。
    • JPG形式の場合→名前を元.jpgに変更
    • それ以外の場合→ペイントで開き、形式をJPGに指定して「元」という名前で保存
  7. 「元.jpg」をProcessingのウインドウにドラッグ&ドロップする。
  8. simpleMovingAverage関数の「// rにcの赤成分を追加する」の左に以下のコードを追加する。
    r.add(int(red(c)));

  9. 同様に、「// gにcの緑成分を追加する」「// bにcの青成分を追加する」の左に必要なコードを追加する。
    • red関数は色から赤成分を取り出す関数。緑成分、青成分を取り出すのはgreen関数とblue関数。

  10. simpleMovingAverage関数の
          float ar = 0;
          float ag = 0;
          float ab = 0;
          float ar = (float)r.stream().mapToInt(Integer::intValue).summaryStatistics().getAverage();
          float ag = (float)g.stream().mapToInt(Integer::intValue).summaryStatistics().getAverage();
          float ab = (float)b.stream().mapToInt(Integer::intValue).summaryStatistics().getAverage();
    に変更する。
    (こうすることで、r, g, b という ArrayList に含まれる要素の平均値が変数 ar, ag, ab に入る)

  11. プログラムを実行する。
    • コンソールに「完了」が表示されてから1キー、2キーを押すと半径1, 4で平滑化した画像が表示される (反応しない場合は実行画面をクリックしてからキーを押す)。
    • 半径4の方が半径1の画像にくらべてぼやけ方が大きくなる。
    • ノイズは半径4の方が薄まって目立たなくなる。



2. ガウシアンフィルタ

概要

移動平均フィルタでは周辺のピクセルの色を混ぜて使うので、ノイズを消せるかわりにエッジがぼやけてしまう。これは、元ピクセルからの距離と関係なく色を混ぜてしまっているためである。
もう少し工夫して、中心のピクセルの影響は強く、そこから離れるにしたがって影響が弱くなるように「重みをつけた平均」を取れば、ぼやけ方を抑えられる。
このようなフィルタにはいくつか種類があるが、ガウシアンフィルタではその名の通り「ガウス関数」を重みとして使用する。
ガウス関数とは
\( \begin{eqnarray} \frac{1}{\sqrt{2\pi\sigma^2}}\exp\left(-\frac{x^2+y^2}{2\sigma^2}\right) \end{eqnarray} \)
のような形をした関数 (expはexponentialの略で、自然対数の底 \(e\) の累乗) で、グラフは \(\sigma\) が大きくなると横に広がり (青)、小さくなると尖った形になる (赤)。
\(\sigma\) が小さいと色の計算で中心の画素の影響がより大きくなるので、ぼやけ方は小さくなり、\(\sigma\) が大きくなると中心と周辺の影響の差が少なくなるので移動平均の結果に近く、ぼやけ方は大きくなる。

実際の処理では半径に応じて9個や25個のピクセルについて赤・緑・青それぞれの明るさにこの係数を掛けたものを足し上げ、最後にこの係数の和で割ることで重みのついた平均を求める。
なお、特定の \(\sigma\) ではこれらの係数が半径1では
1 2 1
2 4 2
1 2 1
半径2では
1 4 6 4 1
4 16 24 16 4
6 24 36 24 6
4 16 24 16 4
1 4 6 4 1
に比例した値になり、扱いやすいのでこれを係数として使うことも多いが、今回の課題では \(\sigma\) を変えてぼやけ方がどう変わるかを検証するため、これらの係数は使わず、ガウス関数の指数部分をそのまま使う。
(\(\frac{1}{\sqrt{2\pi\sigma^2}}\) の部分は最後の割り算のときに結局打ち消されるため)

課題 2

  1. simpleMovingAverage関数の中のコードをgaussian関数の中にコピー&ペーストする。
  2. gaussian関数の
          ArrayList<Integer> r = new ArrayList<Integer>();
          ArrayList<Integer> g = new ArrayList<Integer>();
          ArrayList<Integer> b = new ArrayList<Integer>();
          float ar = 0; // 赤成分の平均値
          float ag = 0; // 緑成分の平均値
          float ab = 0; // 青成分の平均値
          double sum = 0; // 重み係数の総和
    に変更する。
    (このあと、ガウス関数とそれぞれの点の色値をかけて足し上げたい。その場合はArrayListの要素として保存しても使い道がないので、ここでは色値と係数の総和を入れるための変数だけを用意する)

  3. gaussian関数のコメント文
    // i±radius, j±radiusの範囲の平均色を求める
    // i±radius, j±radiusの範囲の、重みをつけた平均色を求める
    に変更する。

  4. gaussian関数の
                color c = img[0].pixels[i+x+(j+y)*w]; // (i, j)から(x, y)ずれた位置の色
    の下に
                double gauss = Math.exp(-(x*x+y*y)/2/sigma/sigma); // (i, j)から(x, y)ずれた位置での重み係数
    を追加する。
    (これが上の3次元グラフの各グリッド点 (例えばx=-1, y=2など) での値に相当する)

  5. gaussian関数の
                r.add(int(red(c)));// rにcの赤成分を追加する
                g.add(int(green(c)));// gにcの緑成分を追加する
                b.add(int(blue(c)));// bにcの青成分を追加する
                ar += red(c) * gauss;// cの赤成分に重み係数をかけた値をarに加える
                ag += green(c) * gauss;// cの緑成分に重み係数をかけた値をagに加える
                ab += blue(c) * gauss;// cの青成分に重み係数をかけた値をabに加える
                sum += gauss;
    に変更する。
    (for文でこれを繰り返すことで、ar, ag, abには「色成分の値」×「そこでの重み係数の値」を足し上げた値、sumには重み係数の値を足し上げた値が入る)

  6. gaussian関数の
          // ar, ag, abにr, g, bの平均値をfloat化して入れる
          float ar = (float)r.stream().mapToInt(Integer::intValue).summaryStatistics().getAverage();
          float ag = (float)g.stream().mapToInt(Integer::intValue).summaryStatistics().getAverage();
          float ab = (float)b.stream().mapToInt(Integer::intValue).summaryStatistics().getAverage();
          // ar, ag, abを重み係数の総和で割る
          ar /= sum;
          ag /= sum;
          ab /= sum;
    に変更する。
    (こうすることで ar, ag, ab はガウス関数で重みをつけた平均値になる)

  7. プログラムを実行する。
    • コンソールに「完了」が表示されてから3キー、4キーを押すとノイズ画像を \(\sigma\) が大きいガウス関数、\(\sigma\) が小さいガウス関数で平滑化した画像が表示される (反応しない場合は実行画面をクリックしてからキーを押す)。
    • \(\sigma\) が小さいガウス関数で平滑化した画像 (4キーで表示される) は、もう一方にくらべてぼやけ方が小さくなる。
    • そちらはぼやけ方は小さくなるかわりにノイズはもう一方よりも目立ってしまう。





3. メディアンフィルタ

概要

移動平均フィルタ、ガウシアンフィルタには、どちらもノイズを消そうとすると本来の像がぼやけ、元の情報を残そうとするとノイズも消えないという弱点がある。
メディアンフィルタでは、平均を取るのではなく、範囲内のピクセルの色成分の中央値を取ることでノイズを消すという方法をとる。多くの場合、 ノイズは周囲のピクセルとは大きく色が異なっているので、これが中央値になることはほとんどなく、1ピクセルだけの孤立したノイズならほとんどのケースできれいに消せる。

半径2の場合なら、考慮する25個のピクセルの色成分を昇順に並べ替え、中央番目、つまり13番目 (プログラミングの数え方的には12番目) のピクセルの色を採用する。 (図は赤成分。緑・青成分についても同様にして中央番目の値を使う)

同様に、半径1の場合なら考慮する9個のピクセルのうち5番目 (プログラミングの数え方的には4番目) のピクセルの色を使う。
なお、ArrayListのsort関数を使えば要素を昇順に並べ替えることができる。具体的には、r という整数型の ArrayList の要素の並びがどうであっても、
Collections.sort(r);
を実行すれば 0番目に一番小さい要素、1番目に次に小さい要素、…というように順番が並び変わる。

課題 3

  1. simpleMovingAverage関数の中のコードをmedian関数の中にコピー&ペーストする。
  2. (コピー元が課題2の方ではなく課題1で作った関数であることに注意。この課題ではまたArrayListを使う)

  3. median関数のコメント文
    // i±radius, j±radiusの範囲の平均色を求める
    // i±radius, j±radiusの範囲の中央番目の色を求める
    に変更する。

  4. median関数の
          // ar, ag, abにr, g, bの平均値をfloat化して入れる
          float ar = (float)r.stream().mapToInt(Integer::intValue).summaryStatistics().getAverage();
          float ag = (float)g.stream().mapToInt(Integer::intValue).summaryStatistics().getAverage();
          float ab = (float)b.stream().mapToInt(Integer::intValue).summaryStatistics().getAverage();
          img[n].pixels[i+j*w] = color(ar, ag, ab);
          // r, g, bそれぞれの要素を小さい順に並べ替える
          Collections.sort(r);
          Collections.sort(g);
          Collections.sort(b);
          // それぞれの色成分の中央番目の色を(i, j)のピクセルに設定する
          img[n].pixels[i+j*w] = color(r.get(r.size()/2), g.get(g.size()/2), b.get(b.size()/2));
    に変更する。
    (sort関数を呼ぶことでr, g, bの中の要素は昇順にソートされる)
    (ArrayListの要素はget関数で順番を指定して取り出せる)
    (ArrayListの要素数はsize関数で取り出せる。rの要素数が25なら r.size()/2 は25/2 で 12 になる (端数は切り捨てられる) ため、r.get(r.size()/2) は rの12番目の要素、すなわち「真ん中番目の要素」になる)

  5. プログラムを実行する。
    • 処理が完了してコンソールに「完了」が表示されるまで少し時間がかかる。
    • コンソールに「完了」が表示されてから5, 6キーを押すと、ノイズ画像を半径1, 2のメディアンフィルタで平滑化した画像が表示される (反応しない場合は実行画面をクリックしてからキーを押す)。
    • 半径1でもノイズはほぼ完全に除去される。
    • 半径2だと細部の構造がつぶれ、パステル画のような質感になる。







提出

締切後提出用フォーム
※ 点数はつきますが、欠席だった場合は出席にはなりません。
※ コードはTeamsのClass Notebookに保存してください。
戻る