はじめに

OpenCV を使って画像を処理するプログラミングを練習していきます。最終的に 撮影したレシートを自動でトリミング できるところをゴールとして、画像の読み込みからエッジ検出、輪郭抽出までを4つの LESSON で順番に進めます。

前提となる環境とゴールは次の通りです。

  • OpenCV4(C++版)を使います
  • C++11 で書きます(iOS アプリへの移植も視野に入れたい都合)
  • macOS(Homebrew)でコンパイルします
  • JetBrains 社の IDE CLion で開発します
  • ゴール: スマートフォンで撮影したレシートの四隅を検出して、背景を透過した PNG として書き出す

なお、Python で OpenCV を触ったことがある人向けに、図形変換のおさらいとして 【Python】OpenCVで画像をアフィン変換【移動・拡大・回転・剪断】 も参考になります。

OpenCVのインストール

ここでは Homebrew で OpenCV 本体を入れます。あとから CLion に同じパスを教えてあげる前提なので、どのコマンドで入れたかを覚えておきます。

項目インストールコマンド
最新版brew install opencv
バージョン4brew install opencv@4

ダウンロードとビルドに、それなりの時間がかかるので気長に待ちましょう。

プロジェクトの準備

CLion 側で OpenCV を #include できる状態を作るのがこの節のゴールです。プロジェクトを作って、CMakeLists.txt に OpenCV を組み込んで、CLion から OpenCV のヘッダを解決できるようにします。

プロジェクト構造

CLion でC++11のプロジェクトを作成します。C++11をあえて選んでいるのは、iOSアプリへの移植も考慮に入れたいからです。

tree -I cmake-build-debug
.
├── CMakeLists.txt
├── main.cpp
└── sample.jpg

CMakeLists.txt

CMakeLists.txt の設定は以下です:

cmake_minimum_required(VERSION 3.28)
project(resitori_core)

set(CMAKE_CXX_STANDARD 11)

find_package(OpenCV REQUIRED) # 追加
include_directories(${OpenCV_INCLUDE_DIRS}) # 追加

add_executable(resitori_core main.cpp)

target_link_libraries(resitori_core ${OpenCV_LIBS}) # 追加

OpenCVのパスをCLionに設定しておく

プログラミング中に #include <opencv2/opencv.hpp> を読み込めるようにするため、CLionでは Preferences > Build, Execution, Deployment > CMake > CMake options に以下を追加しておきます:

-DCMAKE_PREFIX_PATH=/opt/homebrew/opt/opencv

インストールしたOpenCVの場所が分からない場合は、次のコマンドで確認:

brew --prefix opencv

なお CLion 自体のショートカットや初期設定は 【JetBrains系IDE】 IntelliJ IDEA の便利な設定【macOS】IntelliJ IDEA ショートカット チートシート にまとめてあるので、必要なら先に目を通しておきます。

LESSON 1. 画像を読み込む

まずは「OpenCV のヘッダが解決できて、ウィンドウに画像が表示できる」状態のところまで持っていきます。ここが通れば環境構築は完了です。

main.cpp

#include <opencv2/opencv.hpp>
#include <iostream>

int main() {
    cv::Mat image = cv::imread("/somewhere/sample.jpg");
    if (image.empty()) {
        std::cerr << "画像が読み込めませんでした。" << std::endl;
        return -1;
    }

    cv::imshow("表示テスト", image);
    cv::waitKey(0);
    return 0;
}

CLion でビルド(⌘F9)&実行(^R)すると画像ウィンドウが表示され、画像が表示されます。

LESSON 2. カラー画像 → グレースケール → 保存

カラー画像をグレースケールに変換して、結果を gray_sample.jpg として保存するところまで進めます。cv::cvtColorcv::imwrite の基本動作を押さえる回です。

#include <opencv2/opencv.hpp>
#include <iostream>

int main() {
    // 入力画像ファイルの読み込み
    cv::Mat colorImage = cv::imread("/somewhere/sample.jpg");
    if (colorImage.empty()) {
        std::cerr << "画像が読み込めません。" << std::endl;
        return -1;
    }

    // グレースケール変換
    cv::Mat grayImage;
    cv::cvtColor(colorImage, grayImage, cv::COLOR_BGR2GRAY);

    // グレースケール画像を保存
    bool result = cv::imwrite("gray_sample.jpg", grayImage);
    if (!result) {
        std::cerr << "保存に失敗しました。" << std::endl;
        return -1;
    }

    std::cout << "グレースケール画像を保存しました。" << std::endl;
    cv::imshow("表示テスト", grayImage);
    cv::waitKey(0);
    return 0;
}

CLion でビルド(⌘F9)&実行(^R)すると、グレースケール化された画像が、cmake-build-debug ディレクトリ内へ保存されます。

LESSON 3. Canny法でエッジ検出

OpenCV でエッジ検出を行うには、一般的に Canny法(Canny Edge Detection) を使います。以下に、カラー画像 → グレースケール → ガウシアンぼかし → エッジ検出 → 保存 という流れのコードを示します。次の LESSON 4 で輪郭を取り出す下準備にあたる回です。

#include <opencv2/opencv.hpp>
#include <iostream>

int main() {
    // 入力画像を読み込み
    cv::Mat colorImage = cv::imread("/somewhere/sample.jpg");
    if (colorImage.empty()) {
        std::cerr << "画像が読み込めません。" << std::endl;
        return -1;
    }

    // グレースケールに変換
    cv::Mat grayImage;
    cv::cvtColor(colorImage, grayImage, cv::COLOR_BGR2GRAY);

    // ノイズ除去(Canny前にぼかすと良い結果が得られる)
    cv::Mat blurred;
    cv::GaussianBlur(grayImage, blurred, cv::Size(5, 5), 1.4);

    // エッジ検出(Canny)
    cv::Mat edgeImage;
    cv::Canny(blurred, edgeImage, 50, 150);  // しきい値は適宜調整

    // エッジ画像を保存
    bool result = cv::imwrite("edges.jpg", edgeImage);
    if (!result) {
        std::cerr << "エッジ画像の保存に失敗しました。" << std::endl;
        return -1;
    }

    std::cout << "エッジ画像を保存しました。" << std::endl;

    // ウィンドウで表示(必要なら)
    cv::imshow("Edges", edgeImage);
    cv::waitKey(0);

    return 0;
}

LESSON 4. レシートの四隅を検出してマスクする

この LESSON が記事のゴールです。LESSON 3 までの「グレースケール → ぼかし → 輪郭が取れる前処理」を土台に、レシートの四隅を四角形として抽出して、それ以外を透明にした PNG (receipt_cropped.png) を出力します。

処理の流れは次のとおりです。

  1. 輪郭をすべて抽出
  2. 面積の大きい輪郭を対象に
  3. cv::approxPolyDP で四角形(四隅)に近似
  4. cv::fillConvexPoly を使ってマスク作成
  5. 元画像にマスクを適用して透明部分を生成
#include <opencv2/opencv.hpp>
#include <iostream>

int main() {
    // 入力画像を読み込み
    cv::Mat colorImage = cv::imread("/somewhere/sample.jpg");
    if (colorImage.empty()) {
        std::cerr << "画像が読み込めません。" << std::endl;
        return -1;
    }

    // グレースケール → ぼかし → 二値化(エッジ不要)
    cv::Mat gray, blurred, binary;
    cv::cvtColor(colorImage, gray, cv::COLOR_BGR2GRAY);
    cv::imwrite("1_gray.jpg", gray);
    cv::GaussianBlur(gray, blurred, cv::Size(5, 5), 0);
    cv::imwrite("2_blurred.jpg", blurred);
    cv::threshold(blurred, binary, 0, 255, cv::THRESH_BINARY + cv::THRESH_OTSU);
    cv::imwrite("3_binary.jpg", binary);

    // エッジ検出
    std::vector<std::vector<cv::Point>> contours;
    cv::findContours(binary, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

    std::vector<cv::Point> receiptContour;
    for (const auto& contour : contours) {
        double area = cv::contourArea(contour);
        if (area < 10000) continue; // 小さな輪郭を無視

        std::vector<cv::Point> approx;
        cv::approxPolyDP(contour, approx, cv::arcLength(contour, true) * 0.02, true);

        if (approx.size() == 4 && cv::isContourConvex(approx)) {
            receiptContour = approx;
            break;
        }
    }

    if (receiptContour.empty()) {
        std::cerr << "四角形の輪郭が見つかりませんでした。" << std::endl;
        return -1;
    }

    // 四隅マスク作成(白=不透明、黒=透明)
    cv::Mat mask = cv::Mat::zeros(colorImage.size(), CV_8UC1);
    cv::fillConvexPoly(mask, receiptContour, 255);
    cv::imwrite("5_mask.png", mask);

    // RGBA画像を生成(透明化)
    cv::Mat output(colorImage.size(), CV_8UC4);
    for (int y = 0; y < colorImage.rows; ++y) {
        for (int x = 0; x < colorImage.cols; ++x) {
            cv::Vec3b color = colorImage.at<cv::Vec3b>(y, x);
            uchar alpha = mask.at<uchar>(y, x);
            output.at<cv::Vec4b>(y, x) = cv::Vec4b(color[0], color[1], color[2], alpha);
        }
    }

    // PNG形式で保存(透過可能)
    bool saved = cv::imwrite("receipt_cropped.png", output);
    if (!saved) {
        std::cerr << "画像の保存に失敗しました。" << std::endl;
        return -1;
    }

    std::cout << "透過画像として保存しました。" << std::endl;

    return 0;
}

画像処理から見える「ちいさな進化」

OpenCVを使って、レシートの四角い形をちゃんと見つけて、背景をマスクで切り取り、最後はきれいにトリミングするところまでできた。ChatGPTの貢献は大きいが、こういうのが思ったとおりに動くと、やっぱり嬉しい。

この作業をしていて、ふと思ったことがある。「人類って、確実に進んでるよな」と。もちろん、それが良い方向なのか悪い方向なのかは簡単には言えない。でも、少なくともコンピュータの世界では、ちゃんと前に進んでいる。その証拠のひとつが、まさにOpenCVみたいなツールなんだと思う。

こういう地味な進化って、あんまり話題にならないけど、実はすごいことなんじゃないかと思う。OpenCVが登場し始めた頃、自分はまだ全然プログラミングもわかってなくて、「なんかすごそうだけど、難しそうだなあ」としか思えなかった。でも今は、C++にも少し慣れて、OpenCVの処理も分かるようになってきた。

今回の、レシートの画像から四隅を見つけて、まっすぐトリミングするっていう処理。人間がやるなら、まずレシートを見つけて、その輪郭に合わせて四角い枠を作って、それを画像に当てはめて……って、けっこう面倒。でも今では、それをプログラムが自動でやってくれる。しかも一瞬で。

こういう強力なツールが、普通の開発者の手にある時代ってのがすごい。昔は一部の専門家だけが扱えた技術が、今では当たり前のように使える。ツールが増えるたびに、自分の手札が増えていく感じがして、ちょっと楽しい。

関連記事

OpenCV と CLion、画像処理まわりを続けて触る場合は、次の記事も参考になります。

関連アイテム

C++ で OpenCV を一通り体系的に押さえたい場合は、入門書を1冊手元に置いておくと環境構築でつまずいたときの参照先になります。