第04回 射影変換

第1回のプログラムに変更を加え、元画像の一部を任意の形で選択し、その部分を実行画面のサイズに変形させるプログラムを作る。

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

プログラムを実行すると実行画面上に元画像が表示され、画面より一回り小さい赤枠と、その端点に赤丸が表示される。
赤丸は左ボタンでドラッグできる。
さらに実行画面上で右クリックすると、dataフォルダに4つの画像が作られる。
コンソールに「完了」が表示されてからキーボードの1~3のキーを押すとこれらの画像が画面に表示される。
(元画像は実行画面の大きさ800x600にリサイズされる)
(クリックだけですべての画像が作成されるので、キーボードを使うのは結果確認のためだけ)
0キーを押すと画像作成前の状態に戻り、端点の位置を再調整してから右クリックすれば、選びなおした範囲に合わせて出力画像が更新される。
キー 機能 特徴
0 範囲選択中に戻る 元画像と赤枠が表示され、ドラッグ移動できるようになる
1 12選択範囲.jpgが表示される 表示されるのは元画像にクリック時の赤枠を重ねたもの
2 3最近傍補間.jpgが表示される 表示されるのは赤枠部分を実行画面の形に変形させた画像
元の1ピクセルがそのまま拡大されたような粗いドットが見える
3 3双一次補間.jpgが表示される 表示されるのは赤枠部分を実行画面の形に変形させた画像
なめらかに拡大される
実行例

完了時点でdataフォルダにあるファイル

1. 枠と端点の円を表示させる

概要

これまでのプログラムでは枠を表示するのにrect関数を使っていたが、今回のように不規則な形の四角形はこの方法では表示できない。
ここでは beginShape関数、vertex関数、endShape関数を使って枠を描画する (参考)。
(不規則な四角形は quad関数 でも表示できるが、ここでは使わない)
また、端点の座標は PVector型の配列で扱い、その0~3番目の要素を左上、右上、右下、左下、つまり時計回りに対応させる。
こうすることで、枠と端点の円を効率よく表示できる。

課題 1

  1. Processingを起動する。
  2. 第1回の完成状態のコード (バグ修正済み) をコピー&ペーストする。
  3. 「img04」という名前で保存する。
  4. 適当に画像検索して元画像 (「被写体の形が長方形で、斜めに撮影されたために写真の中ではゆがんだ状態になっているもの」が含まれるもの。四隅が画像内に収まった平らな看板など) を用意する。
  5. 悪い例1 (ほぼ正面から撮影されていて、プログラムの変形機能がちゃんと働いているか確認できない)


    悪い例2 (「長方形のはずの部分」はあるが、小さすぎて変換後にぼやけてしまう)


    悪い例3 (「長方形のはずの部分」がない)


    悪い例4 (「長方形のはずの部分」の被写体に凹凸がある)
  6. 画像の形式に応じて以下の変更を加える。
    • JPG形式の場合→名前を元.jpgに変更
    • それ以外の場合→ペイントで開き、形式をJPGに指定して「元」という名前で保存
  7. 「元.jpg」をProcessingのウインドウにドラッグ&ドロップする。
  8. プログラム先頭部分の
    PImage[] img = new PImage[5];
    // 出力ファイル名
    String[] fName = {"元", "選択範囲", "1描画機能", "2最近傍補間", "3双一次補間"};
    
    PImage[] img = new PImage[4];
    // 出力ファイル名
    String[] fName = {"元", "12選択範囲", "3最近傍補間", "3双一次補間"};
    
    に変更する。
    (今回は課題1で枠を表示させ、課題2でそれをドラッグで変形できるようにし、課題3で画像の変換機能を実装するので、出力画像名をそれに合わせて変更する)

  9. プログラム先頭部分の
    float cx, cy; // 拡大の基準位置
    float s = 5;  // 拡大の倍率
    
    PVector[] c = new PVector[4]; // 選択範囲の端点の座標
    float a0, a1, a2, b0, b1, b2, c1, c2; // 射影変換の係数
    int dragP = -1; // ドラッグ中の端点の番号(-1ならドラッグ中でない)
    に変更する。
    (課題1の機能に必要なのは変更後の3行のうち最初の1行だけだが、あとの課題で使う変数もここで用意しておく)

  10. setup関数の
      // 1~4番を元画像と同じサイズの黒画像にする
        for (int i=1; i<=4; i++) {
      // 1~3番を元画像と同じ画像にする
        for (int i=1; i<=3; i++) {
    に変更する。
    (出力画像の数に合わせた変更。コメント文の修正も前回と同様)

  11. setup関数の
      strokeWeight(2*s); // 図形の枠の太さを実質2ptにする
      strokeWeight(2); // 図形の枠の太さを2ptにする
    に変更する。
    (スケール変換は行わないので数値をそのまま指定するだけでよくなる)

  12. setup関数の最後に
      c[0] = new PVector(width*0.2, height*0.2); // 左上
      c[1] = new PVector(width*0.8, height*0.2); // 右上
      c[2] = new PVector(width*0.8, height*0.8); // 右下
      c[3] = new PVector(width*0.2, height*0.8); // 左下
    
    を追加する。
    (これらが枠の端点の座標。ここではそれぞれ画面の上下左右の端から幅の2割だけ内側の値を指定している)

  13. draw関数の
      cx = mouseX; // 拡大基準の横位置の更新
      cy = mouseY; // 拡大基準の縦位置の更新
    
    を削除する。
    (今回は4つの端点で選択範囲が決まるので、中心の位置の情報は不要)

  14. draw関数の
        translate(cx, cy); // 原点をカーソル位置に移動
        scale(1/s);        // 拡大率の逆数のスケールをかける
        translate(-cx, -cy); // 枠内での中心位置移動
        rect(0, 0, width, height); // 元画像サイズの長方形を描く
    
        beginShape();
        for (int i=0; i<4; i++) {
          vertex(c[i].x, c[i].y);
          ellipse(c[i].x, c[i].y, 20, 20);
        }
        endShape(CLOSE);
    
    に変更する。
    (前回までの座標変換を使った方法とはまったく異なるので、translate関数、scale関数は使わない。描画もrect関数ではなくbeginShape関数、vertex関数、endShape関数で行う)
    (枠と同時に端点の円をellipse関数で表示する)

  15. drawToImage関数をまるごと削除する。
  16. (これまでとは異なり、Processingの機能で射影変換を行うことはできないので、これにあたる処理は行わない)

  17. nearestNeighbor関数とbilinear関数の
          PVector pos = getScaledPosition(new PVector(cx-0.5, cy-0.5), new PVector(i, j), s);
          PVector pos = getScaledPosition(new PVector(i, j));
    に変更する。
    (本質的な変更は課題3で行うが、とりあえずエラーなしで実行できるようにするため)
    (ただしこの時点ではまだこの2つの関数にはまだエラーが残る)

  18. getScaledPosition関数を、コメント文も含め次のように変更する (関数ごと消して以下のものをコピー&ペーストする)。
    // 実行画面からc[0]~c[3]で囲まれた四角形に変形させた場合のベクトルfの移動先のベクトルを返す(課題3で使用)
    PVector getScaledPosition(PVector f) {
      return new PVector(0, 0);
    }
    (これは仮の状態。本質的な処理は課題3で実装する)
    (これでnearestNeighbor関数、bilinear関数のエラーが解決する)

  19. mousePressed関数の
      drawToImage(s, 2);     // 描画機能でs倍に拡大した画像をimg[2]に保存
    を削除し、
      nearestNeighbor(s, 3); // 最近傍補間でs倍に拡大した画像をimg[3]に保存
      bilinear(s, 4);        // 双一次補間でs倍に拡大した画像をimg[4]に保存
    
    をコメントアウトする。


  20. プログラムを実行する。
    • このような赤枠が表示され、端点には4つの円が表示される。
    • まだクリックはしないこと。

2. 選択範囲の端点をドラッグ移動できるようにする

概要

端点の円をつかんでドラッグできるようにするには、
  1. マウスボタンを押した瞬間に、カーソル位置が4つの円のどれかに入っているかを調べ、入っていたらその端点のドラッグを開始する
  2. マウスのドラッグ中はドラッグ中の端点の座標をマウスカーソルの位置に合わせて変更する
  3. マウスのボタンを離した瞬間に、端点のドラッグを終了する
という3つの機能を実装する必要がある。
これまでの課題でも使ってきた mousePressed関数はまさにマウスのボタンを押した瞬間に実行されるので、ここに1に対応した命令を記述する。
このほかに、ドラッグ中は毎フレーム実行される mouseDragged関数、マウスのボタンを離した瞬間に実行される mouseReleased関数にそれぞれ2, 3に対応する命令を記述する。
また、これまでのようにマウスクリックで画像作成を行うようにしたいが、ドラッグ処理のときに画像が作られてしまわないように押されたボタンを判定して「左ボタンでドラッグ」「右ボタンで画像作成」のようにする。
端点には0~3の番号を割り当て、どの端点をドラッグしているかは int型の変数 dragP で扱い、たとえば dragPが0なら左上の端点がドラッグ中、1なら右上の端点がドラッグ中であるものとして処理を行う。値が-1のときはドラッグしていないものとする。

また、今回の課題では最終的に不規則な四角形に選択範囲をきれいに合わせたいので、変換の結果を確認してから微調整ができるようにしたい。
そのため、一度画像を作成して確認してから0キーで確定前の状態に戻れるようにする。具体的には、keyPressed関数で0キーが押されたかどうかを判定し、その場合は「画像作成後であることを表すフラグ」createdを初期状態の値 false に戻す。

課題 2

  1. プログラムの下の方にあるmousePressed関数の中の命令文全体を
      if (mouseButton == RIGHT) {
    
    }
    で囲む。
    (こうすることで画像を作る処理は右クリックでしか行われなくなる)

  2. 上記で追加した「}」の下に以下のコードを追加する。
      else if (!created){
        for (int i=0; i<4; i++) {
          if (dist(c[i].x, c[i].y, mouseX, mouseY)<10) {
            dragP = i;
          }
        }
      }
    (「else」は「押したのが右ボタンなら」という条件に対する「そうでなければ」なので、押したのが左ボタンで、なおかつ画像作成前状態のときだけこの中の処理が実行される)
    (dist関数は距離を求める関数。第1, 2引数で表される座標点と第3, 4引数で表される座標点との距離を返す。要するに、i番目の端点からマウスカーソルまでの距離が得られる)
    (それが10未満、つまりカーソルが端点の円の中にあったときに移動中フラグdragPの値をその端点番号に変更する)

  3. mousePressed関数の下に以下の2つの関数を追加する。
    // 端点のドラッグ
    void mouseDragged() {
      if (mouseButton == LEFT && dragP>=0) {
        c[dragP].x = mouseX;
        c[dragP].y = mouseY;
      }
    }
    
    // 端点のドラッグ終了
    void mouseReleased() {
      if (mouseButton == LEFT) {
        dragP = -1;
      }
    }
    (mouseDragged関数はドラッグ中に毎フレーム実行される。そのとき押されているボタンが左ボタンで、0~3の端点のどれかがドラッグ中ならその端点の位置をマウス位置に合わせる)
    (mouseReleased関数はマウスボタンを離した瞬間に実行される。離されたのが左ボタンのときに端点のドラッグを終了する)

  4. プログラムを実行し、4つの端点がどれもマウスの左ボタンのドラッグで移動できることを確認する。
    (この時点ではまだdataフォルダには元画像しかないはず)
  5. saveScreenshot関数の最後に以下のコードを追加する。
      img[n].updatePixels(); // n番目の画像を更新
    (これまでの課題ではプログラムを1回実行して画像を作ったらそれで終わりだったが、今回は端点の位置を調整して変換後の状態を確認し、また調整を繰り返す。そのため、2回目以降に表示される画像をここで更新しておく必要がある)

  6. keyPressed関数を以下のように変更する。
    void keyPressed() {
      int k = key-'0';
      if (created) {
        // 0キーで画像作成前の状態に戻す
        if (k==0){
          created = false;
          noFill();
          dragP = -1;
        }
        // 1~3キーで作成された画像を表示する
        if (k>=1 && k<=3) {
          background(0);
          image(img[k], 0, 0, width, height);
          fill(255, 0, 0);
          text(fName[k], 30, height-30);
          fill(255);
        }
      }
    }
    
    (これまでは0キーで元画像を表示するだけだったが、ここでは「画像作成前状態」に戻す機能として0キーを使う)

  7. プログラムを実行し、以下のような動作になることを確認する。
    • どれかの端点を左ボタンでドラッグして動かす
    • 右ボタンをクリックする (スクリーンショット「12選択範囲.jpg」が作成される)
    • 1キーを押してその画像を表示させる (左下に「12選択範囲」の赤文字が表示される。この状態では端点をドラッグできない)
    • 0キーを押して画像作成前状態にする (左下の文字が消える。端点はまたドラッグできるようになる)
    • どれかの端点を左ボタンでドラッグして動かす
    (dataフォルダには「12選択範囲.jpg」ができる。右クリックのたびにこの画像が更新される)

3. 射影変換の処理を実装する

概要

一般に、どのような射影変換も次の式で表すことができる。
\(\begin{eqnarray} x'&=&\frac{a_1x+b_1y+c_1}{a_0x+b_0y+1}\cr y'&=&\frac{a_2x+b_2y+c_2}{a_0x+b_0y+1} \end{eqnarray}\)

この式の8個の係数\(a_0, b_0, a_1, b_1, c_1, a_2, b_2, c_2\) がわかれば変換式が確定する。
今回のプログラムの場合は、選択範囲の端点 \((x_0, y_0)\) ~ \((x_3, y_3)\) が元画像の端点に対応する。

これを上の変換式に入れると、以下の8つの関係が得られる。
\(\begin{eqnarray} x_0&=&c_1\cr y_0&=&c_2\cr x_1&=&\frac{a_1w+c_1}{a_0w+1}\cr y_1&=&\frac{a_2w+c_2}{a_0w+1}\cr x_2&=&\frac{a_1w+b_1h+c_1}{a_0w+b_0h+1}\cr y_2&=&\frac{a_2w+b_2h+c_2}{a_0w+b_0h+1}\cr x_3&=&\frac{b_1h+c_1}{b_0h+1}\cr y_3&=&\frac{b_2h+c_2}{b_0h+1} \end{eqnarray}\)

これを \(a_0, b_0, a_1, b_1, c_1, a_2, b_2, c_2\) について解くと以下のようになる。プログラムではこの係数を使ってgetScaledPosition関数で出力画像の点に対応する元画像の座標を求める。
\(\begin{eqnarray} a_0&=&\frac{(x_1+x_3-x_0-x_2)(y_2-y_3)-(y_1+y_3-y_0-y_2)(x_2-x_3)} {w\left\{(x_2-x_1)(y_2-y_3)-(y_2-y_1)(x_2-x_3)\right\}}\cr b_0&=&\frac{(x_1+x_3-x_0-x_2)(y_2-y_1)-(y_1+y_3-y_0-y_2)(x_2-x_1)} {h\left\{(x_2-x_3)(y_2-y_1)-(y_2-y_3)(x_2-x_1)\right\}}\cr a_1&=&a_0x_1+\frac{x_1-x_0}{w}\cr b_1&=&b_0x_3+\frac{x_3-x_0}{h}\cr c_1&=&x_0\cr a_2&=&a_0y_1+\frac{y_1-y_0}{w}\cr b_2&=&b_0y_3+\frac{y_3-y_0}{h}\cr c_2&=&y_0\cr \end{eqnarray}\)

課題 3

  1. プログラムの一番下に以下の関数を追加する (これが概要の最後の8本の式に対応する計算)。
    // 端点の位置を元にして射影変換の係数を計算する
    void calcParameters() {
      // ピクセルの中央の位置を元にして計算するために端点の位置を修正
      for (int i=0; i<4; i++) {
        c[i] = c[i].sub(new PVector(0.5, 0.5));
      }
      a0 = (c[1].x + c[3].x - c[0].x - c[2].x) * (c[2].y - c[3].y);
      a0-= (c[1].y + c[3].y - c[0].y - c[2].y) * (c[2].x - c[3].x);
      a0/= w * ((c[2].x - c[1].x) * (c[2].y - c[3].y) - (c[2].y - c[1].y) * (c[2].x - c[3].x));
      b0 = (c[1].x + c[3].x - c[0].x - c[2].x) * (c[2].y - c[1].y);
      b0-= (c[1].y + c[3].y - c[0].y - c[2].y) * (c[2].x - c[1].x);
      b0/= h * ((c[2].x - c[3].x) * (c[2].y - c[1].y) - (c[2].y - c[3].y) * (c[2].x - c[1].x));
      a1 = a0 * c[1].x + (c[1].x - c[0].x)/w;
      b1 = b0 * c[3].x + (c[3].x - c[0].x)/h;
      c1 = c[0].x;
      a2 = a0 * c[1].y + (c[1].y - c[0].y)/w;
      b2 = b0 * c[3].y + (c[3].y - c[0].y)/h;
      c2 = c[0].y;
      // 端点の位置を元に戻す
      for (int i=0; i<4; i++) {
        c[i] = c[i].add(new PVector(0.5, 0.5));
      }
    }

  2. getScaledPosition関数の
      return new PVector(0, 0);
      float x = (a1*f.x+b1*f.y+c1) / (a0*f.x+b0*f.y+1);
      float y = (a2*f.x+b2*f.y+c2) / (a0*f.x+b0*f.y+1);
      return new PVector(x, y);
    に変更する (これが概要の最初の2本の式に対応する計算)。

  3. nearestNeighbor関数の
    // 最近傍補間(課題2)
    void nearestNeighbor(float s, int n) {
    // 最近傍補間(課題3)
    void nearestNeighbor(int n) {
    に変更する (引数 s は第1回で使ったスケールの係数なので今回は不要。コメント文の課題番号も実情に合わせて修正)。

  4. bilinear関数の
    void bilinear(float s, int n) {
    void bilinear(int n) {
    に変更する (こちらは引数 s の削除だけ)。

  5. nearestNeighbor関数とbilinear関数の最後に
      img[n].updatePixels(); // n番目の画像を更新
    を追加する (課題2でsaveScreenshot関数の最後に追加したものと同じ。これを入れることで再調整時にちゃんと更新された画像が表示されるようになる)。

  6. mousePressed関数の
      if (mouseButton == RIGHT) {
    の下に
        calcParameters();
    を追加する (4つの端点の座標を使って射影変換の係数を更新するのが calcParameters関数。これを呼び出すことで nearestNeighbor関数、bilinear関数で正しい計算ができる)。

  7. mousePressed関数の
        //  nearestNeighbor(s, 3); // 最近傍補間でs倍に拡大した画像をimg[3]に保存
        //  bilinear(s, 4);        // 双一次補間でs倍に拡大した画像をimg[4]に保存
    
        nearestNeighbor(2);    // 最近傍補間で変形した画像をimg[2]に保存
        bilinear(3);           // 双一次補間で変形した画像をimg[3]に保存
    に変更する (呼び出し先の関数の引数が1つだけになるため)。

  8. プログラムを実行し、赤枠を「被写体としては長方形で、斜めに撮影されたために写真の中ではゆがんだ状態になっているもの」の形にぴったり合わせて右クリックする。
    • コンソールに「完了」が表示されてから1キーを押すと選択範囲、2キー, 3キーを押すと最近傍補間、双一次補間で赤枠部分が実行画面の形に変形された画像が表示される。
    • 1回でぴったり合わせるのは困難なので、はじめは雑に端点を被写体に合わせてから右クリックで画像を作り、できたものを確認する。 0キーを押すとまた端点がドラッグできるようになる。「長方形のはずの部分」をきっちりと囲み、出力画像の歪みが完全に取れるまで繰り返す。
  9. たとえ出力画像の被写体部分の形がほぼ長方形になっていても、このように「ひとまわり大きく囲んだ状態」はNG。

提出

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