プログラミング素人のはてなブログ

プログラミングも電気回路も専門外の技術屋の末端が勉強したことや作品をアウトプットするブログ。コードに間違いなど見つけられたら、気軽にコメントください。 C#、Python3、ラズパイなど。

C#で画像の台形補正をする

射影変換と言うらしいです

デジカメやスマホで、ブログ用の写真を撮っているときに、ライティングの映り込みなどを気にして斜めに写真をとると被写体が台形にひずみます。
これを補正するのを台形補正とか射影変換といいます。
C#でもOpenCVで実装されているので試してみました。
最終的には画像処理ソフトに組み込む予定です。
s51517765.hatenadiary.jp

サンプルコードを試す

C# - C# 上で射影変換するときに、OpenCVSharp4 を使いたい。 (Windows10, Visual Studio 2019, C#, .Net 4.7)|teratail
こちらのコードが動きそうでしたのでこれをもとに作っていきます。
NuGetでインストールできるOpenCvSharp4OpenCvSharp4.runtime.winOpenCvSharp4.WindowsOpenCvSharp4.WpfExtensionsの4つをインストールします。
f:id:s51517765:20210801142335p:plain

サンプルコードをもとに最低限OpenCVが動くコードが以下です。
サンプルコードに対して、ソース画像をFromFileから指定し、画像のサイズと、台形の座標を修正しています。

private void Form1_Load(object sender, EventArgs e)
{
    this.Size = new System.Drawing.Size(903, 508);

    System.Drawing.Point px1 = new System.Drawing.Point(142, 12); //直したい台形の座標
    System.Drawing.Point px2 = new System.Drawing.Point(81, 493);
    System.Drawing.Point px3 = new System.Drawing.Point(873, 503);
    System.Drawing.Point px4 = new System.Drawing.Point(809, 20);

    Image img = System.Drawing.Image.FromFile("1.jpg");

    // 元の画像を表示
    PictureBox p0 = new PictureBox();
    p0.Image = img;
    p0.Location = new System.Drawing.Point(30, 30);
    p0.Size = new System.Drawing.Size(903, 508);
    // this.Controls.Add(p0);

    // 元の画像に四角形を表示
    PictureBox p1 = new PictureBox();
    Bitmap bt1 = new Bitmap(903, 508);
    Graphics gp1 = Graphics.FromImage(bt1);
    gp1.DrawImage(img, 0, 0, 903, 508);

    Pen skyBluePen = new Pen(Brushes.DeepSkyBlue);
    skyBluePen.Width = 4.0F;

    gp1.DrawLine(skyBluePen, px1, px2);
    gp1.DrawLine(skyBluePen, px2, px3);
    gp1.DrawLine(skyBluePen, px3, px4);
    gp1.DrawLine(skyBluePen, px4, px1);

    p1.Image = bt1;
    p1.Location = new System.Drawing.Point(30, 30);
    p1.Size = new System.Drawing.Size(903, 508);
    this.Controls.Add(p1);

    // 四角形で切り取って表示
    System.Drawing.Point[] p2pt = { px1, px2, px3, px4 };
    GraphicsPath p2path = new GraphicsPath();
    p2path.AddPolygon(p2pt);
    Region p2region = new Region(p2path);
    Bitmap p2btm = new Bitmap(903, 508);
    Graphics gp2 = Graphics.FromImage(p2btm);
    gp2.Clip = p2region;
    gp2.DrawImage(img, gp1.VisibleClipBounds);

    PictureBox p2 = new PictureBox();
    p2.Image = p2btm;
    p2.Location = new System.Drawing.Point(450, 30);
    p2.Size = new System.Drawing.Size(903, 508);
    //   this.Controls.Add(p2);

    // p2の四角形を引き伸ばして表示する
    Mat src_img = BitmapConverter.ToMat((Bitmap)img);
    Mat dst_img = src_img;

    // 四角形の変換前と変換後の対応する頂点をそれぞれセットする
    Point2f[] src_pt = new Point2f[4];
    src_pt[0] = new Point2f(px1.X, px1.Y);
    src_pt[1] = new Point2f(px2.X, px2.Y);
    src_pt[2] = new Point2f(px3.X, px3.Y);
    src_pt[3] = new Point2f(px4.X, px4.Y);

    Point2f[] dst_pt = new Point2f[4];
    dst_pt[0] = new Point2f(0, 0);      //左上
    dst_pt[1] = new Point2f(0, 508);    //左下
    dst_pt[2] = new Point2f(903, 508);  //右下
    dst_pt[3] = new Point2f(903, 0);    //右上

    Mat map_matrix = Cv2.GetPerspectiveTransform(src_pt, dst_pt);

    // 指定された透視投影変換行列により,cvWarpPerspectiveを用いて画像を変換させる
    OpenCvSharp.Size mysize = new OpenCvSharp.Size(903, 508);
    InterpolationFlags OIFLiner = InterpolationFlags.Linear;
    BorderTypes OBTDefault = BorderTypes.Default;
    Cv2.WarpPerspective(src_img, dst_img, map_matrix, mysize, OIFLiner, OBTDefault);

    // 結果を表示する
    PictureBox p3 = new PictureBox();
    p3.Image = dst_img.ToBitmap();
    p3.Location = new System.Drawing.Point(905, 30);
    p3.Size = new System.Drawing.Size(903, 508);
    this.Controls.Add(p3);
}

結果は↓のようになります。
左が元の画像と対象物の認識範囲で右が対象物を抜き出し台形補正したものです。
f:id:s51517765:20210801142717j:plain

背景を残すように改良する

書類のスキャンとして使うときにはこれでよいですが、ブログに使う分には周りの背景もあってもいいのではないかと思います。
(あとは台形の認識を自動化するアルゴリズムを組むか、マウスクリックで入力させる必要はあります)
つまり、対象物の台形ひずみを修正し、これにあわせて背景を修正し、可能な限り背景を残すようにします。

完成形は↓のようになります。
左図の赤線が対象物の認識(ここでは書籍のエッジをなんらかの方法で入力したもの)を最大に拡張したものです。
縦長になっているのはPictureBoxとサイズがあっていないため表示が伸びているためです。
f:id:s51517765:20210803203110p:plain

これは、書籍を四角形(任意の形状)で取得し、重心を一致させた最大の相似の四角形を算出しこの範囲を台形補正します。
f:id:s51517765:20210803212750p:plain
認識した四角形の重心を算出し、

    System.Drawing.Point COG = new System.Drawing.Point(); //Center of Gravity
    COG.X = (px0.X + px1.X + px2.X + px3.X) / 4;
    COG.Y = (px0.Y + px1.Y + px2.Y + px3.Y) / 4

各頂点へ伸ばした線分を何倍(rate)した頂点を算出します。

for (int i = 0; i < 4; i++)
{
    px[i] = rate * (px_[i] - COG.X) + COG.X;
    py[i] = rate * (py_[i] - COG.Y) + COG.Y;
}

これが画像範囲であるようにします。
画像の外であれば、拡大率を小さくし、中であれば拡大率を大きくし、というようにBinary serachをし最大の拡大率を求めます。
Binary serachの終了条件はmaxとminが一致したときです。

double min = 1.01;
double max = 1000;
double rate = min + max / 2;

if (px[i] < 1 || imgSizeX - 1 < px[i] || py[i] < 1 || imgSizeY - 1 < py[i])
{
//画像の外
    max = rate;
    rate = min + (rate - min) / 2;
    break;
}
if (i == 3)
{
    min = rate;
    rate = rate + (max - rate) / 2;
}

if (Math.Abs(max - min) < 0.00001) break; //MaxとMinが一致したら

最大の四角形を求める関数の全体は↓です。

int imgSizeX, imgSizeY;

private void solve_squere_line(System.Drawing.Point px0, System.Drawing.Point px1, System.Drawing.Point px2, System.Drawing.Point px3, ref int[] x, ref int[] y)
{
    double[] px_ = { px0.X, px1.X, px2.X, px3.X };
    double[] py_ = { px0.Y, px1.Y, px2.Y, px3.Y };

    System.Drawing.Point COG = new System.Drawing.Point(); //Center of Gravity
    COG.X = (px0.X + px1.X + px2.X + px3.X) / 4;
    COG.Y = (px0.Y + px1.Y + px2.Y + px3.Y) / 4;

    double min = 1.01;
    double max = 1000;
    double rate = min + max / 2;

    double[] px = new double[4];
    double[] py = new double[4];

    int k = 0;//何回BinarySerachしたか

    while (true) //点が画像の中なら
    {
        k += 1;
        for (int i = 0; i < 4; i++)
        {
            px[i] = rate * (px_[i] - COG.X) + COG.X;
            py[i] = rate * (py_[i] - COG.Y) + COG.Y;
        }

        //画像のサイズの中かどうか
        for (int i = 0; i < 4; i++)
        {
            if (px[i] < 1 || imgSizeX - 1 < px[i] || py[i] < 1 || imgSizeY - 1 < py[i])
            {//画像の外
                max = rate;
                rate = min + (rate - min) / 2;
                break;
            }
            if (i == 3)
            {
                min = rate;
                rate = rate + (max - rate) / 2;
            }
        }
        if (Math.Abs(max - min) < 0.00001) break; //MaxとMinが一致したら
    }
    for (int i = 0; i < 4; i++)
    {
        x[i] = (int)px[i];
        y[i] = (int)py[i];
    }
    //k=27
}

private void Form1_Load(object sender, EventArgs e)
{
    this.Size = new System.Drawing.Size(903, 508);

    System.Drawing.Point px0 = new System.Drawing.Point(108, 311);   //直したい台形の座標
    System.Drawing.Point px1 = new System.Drawing.Point(46, 643);
    System.Drawing.Point px2 = new System.Drawing.Point(414, 639);
    System.Drawing.Point px3 = new System.Drawing.Point(350, 310);

    Image img = System.Drawing.Image.FromFile("11.jpg");

    imgSizeX = img.Width;
    imgSizeY = img.Height;
    int[] x = new int[4];
    int[] y = new int[4];
    solve_squere_line(px0, px1, px2, px3, ref x, ref y);
    px0.X = x[0];
    px0.Y = y[0];
    px1.X = x[1];
    px1.Y = y[1];
    px2.X = x[2];
    px2.Y = y[2];
    px3.X = x[3];
    px3.Y = y[3];


    // 元の画像に四角形を表示
    PictureBox p1 = new PictureBox();
    Bitmap bt1 = new Bitmap(img.Width, img.Height);
    Graphics gp1 = Graphics.FromImage(bt1);
    gp1.DrawImage(img, 0, 0, img.Width, img.Height);

    Pen skyBluePen = new Pen(Brushes.Red);
    skyBluePen.Width = 4.0F;

    gp1.DrawLine(skyBluePen, px0, px1);
    gp1.DrawLine(skyBluePen, px1, px2);
    gp1.DrawLine(skyBluePen, px2, px3);
    gp1.DrawLine(skyBluePen, px3, px0);

    p1.Image = bt1;
    p1.Location = new System.Drawing.Point(0, 0);
    p1.Size = new System.Drawing.Size(img.Width, img.Height);
    this.Controls.Add(p1);

    // 四角形で切り取って表示
    System.Drawing.Point[] p2pt = { px0, px1, px2, px3 };
    GraphicsPath p2path = new GraphicsPath();
    p2path.AddPolygon(p2pt);
    Region p2region = new Region(p2path);
    Bitmap p2btm = new Bitmap(468, 831);
    Graphics gp2 = Graphics.FromImage(p2btm);
    gp2.Clip = p2region;
    gp2.DrawImage(img, gp1.VisibleClipBounds);

    // p2の四角形を引き伸ばして表示する
    Mat src_img = BitmapConverter.ToMat((Bitmap)img);
    Mat dst_img = src_img;

    // 四角形の変換前と変換後の対応する頂点をそれぞれセットする
    Point2f[] src_pt = new Point2f[4];
    src_pt[0] = new Point2f(px0.X, px0.Y);
    src_pt[1] = new Point2f(px1.X, px1.Y);
    src_pt[2] = new Point2f(px2.X, px2.Y);
    src_pt[3] = new Point2f(px3.X, px3.Y);

    Point2f[] dst_pt = new Point2f[4];
    dst_pt[0] = new Point2f(0, 0);      //左上
    dst_pt[1] = new Point2f(0, 831);    //左下
    dst_pt[2] = new Point2f(468, 831);  //右下
    dst_pt[3] = new Point2f(468, 0);    //右上

    Mat map_matrix = Cv2.GetPerspectiveTransform(src_pt, dst_pt);

    // 指定された透視投影変換行列により,cvWarpPerspectiveを用いて画像を変換させる
    OpenCvSharp.Size mysize = new OpenCvSharp.Size(468, 831);
    InterpolationFlags OIFLiner = InterpolationFlags.Linear;
    BorderTypes OBTDefault = BorderTypes.Default;
    Cv2.WarpPerspective(src_img, dst_img, map_matrix, mysize, OIFLiner, OBTDefault);

    // 結果を表示する
    PictureBox p3 = new PictureBox();
    p3.Image = dst_img.ToBitmap();
    p3.Location = new System.Drawing.Point(500, 0);
    p3.Size = new System.Drawing.Size(468, 831);
    this.Controls.Add(p3);
}

まとめ

自作画像処理ソフトに追加する台形補正(射影変換)のプログラムを作りました。
台形補正自体はOpenCVに含まれていますが、これを最大化する修正をしました。
この最大化にあたっては相似の四角形をどのように算出するか?がポイントでしたが、ナスカの地上絵にも使われたといわれる拡大法ナスカの地上絵 - Wikipediaを使いました。
拡大法は基準点はどこでも(四角形の外でも)成立しますが、重心を基準とすることでもとの四角形と拡大した四角形の重心を一致させることができます。
この性質を利用しました。
また、Binary searchにかかる時間はどれくらいなのだろう?と調べてみましたが、今回のサンプルでは探索は27回(k=27)で時間としては気にするレベルではありませんでした。
一般には100万回オーダーにならないと気にするレベルの時間にはなりません。

参考

中央位置の計算は、最小位置 + (最大位置 - 最小位置) / 2 とした方が安全であるということがあるそうです。
ja.wikipedia.org