【C++】OpenCVのcv::thresholdはなぜ高速なのか?自前の2値化を最適化して速度比較してみた

2値化処理速度比較OpenCVVSSIMD+OpenMP

本記事はアフィリエイト広告(PR)を含みます

2値化処理自体は一見単純な処理に見えますが、OpenCVの内部では様々な最適化処理が施されています。

  • 行ごとに画像を分割して並列処理
  • SIMDによる高速化
  • HAL(Hardware Abstraction Layer)と呼ばれるCPU命令セットや実行環境に応じて最適な処理を選択する機能
  • Intel CPUならIPPも使用可能

前回の記事ではSIMDとOpenMPで2値化処理を実装しました。
今回は自作の2値化処理とcv::thresholdの処理速度を比較してみます。

1. 前提条件

OSWindows 11 Home
CPUIntel Core Ultra 7 265KF
コンパイラMSVC 19.50
Visual Studio 2026 Community
Releaseビルド
OpenCV4.12.0(AVX2有効)
入力画像1920×1080
8bit
グレースケール
スポンサーリンク

2. 2値化処理

今回はSIMD+OpenMPとOpenCV以外に3種類追加した5種類の手法で比較してみます。

2.1. 分岐処理(条件判定)

まずは0か255に振り分けるもっとも単純な処理です。

void process_1(const cv::Mat& mat_src, cv::Mat& mat_dst, uchar th, uchar max_val) {
	mat_dst.create(mat_src.size(), mat_src.type());

	if (mat_src.isContinuous() && mat_dst.isContinuous()){
		const uchar* sp = mat_src.ptr<uchar>(0);
		uchar* dp = mat_dst.ptr<uchar>(0);
		const int total = (int)mat_src.total();

		for (int i = 0; i < total; i++) {
			dp[i] = (sp[i] > th) ? max_val : 0;
		}

		return;
	}

	for (int y = 0; y < mat_src.rows; y++) {
		const uchar* ucSP = mat_src.ptr<uchar>(y);
		uchar* ucDP = mat_dst.ptr<uchar>(y);

		for (int x = 0; x < mat_src.cols; x++) {
			ucDP[x] = (ucSP[x] > th) ? max_val : 0;
		}
	}
}

① 出力画像の確保

mat_dst.create(mat_src.size(), mat_src.type());

入力画像と同じサイズ、同じ型の出力画像を確保

② 連続メモリの判定

if (mat_src.isContinuous() && mat_dst.isContinuous())

ROIなどで切り取った画像の場合は連続したデータではない場合があるので念のため処理を分けます。

③ 連続メモリ時の処理

const uchar* sp = mat_src.ptr<uchar>(0);
uchar* dp = mat_dst.ptr<uchar>(0);
const int total = (int)mat_src.total();

for (int i = 0; i < total; i++) {
	dp[i] = (sp[i] > th) ? max_val : 0;
}

spとdpがそれぞれ入力と出力の先頭でtotalが総画素数を表します。
そして閾値でmax_valか0を選択します(三項演算子使用)。

if~elseでも実装可能ですが、2値化は条件分岐というより値の選択に近いので三項演算子を採用しています。

④ 非連続メモリ時の処理

for (int y = 0; y < mat_src.rows; y++) {
	const uchar* ucSP = mat_src.ptr<uchar>(y);
	uchar* ucDP = mat_dst.ptr<uchar>(y);

	for (int x = 0; x < mat_src.cols; x++) {
		ucDP[x] = (ucSP[x] > th) ? max_val : 0;
	}
}

各行の先頭ポインタを取得し、画素単位で2値化します。

2.2. 分岐なし

こちらは分岐を使わずに2値化処理を行います。
各画素に対して比較演算を行い0か1を算出し、max_valをかけることで2値化できます。

void process_2(const cv::Mat& mat_src, cv::Mat& mat_dst, uchar th, uchar max_val){
	mat_dst.create(mat_src.size(), mat_src.type());

	if (mat_src.isContinuous() && mat_dst.isContinuous()) {
		const uchar* sp = mat_src.ptr<uchar>(0);
		uchar* dp = mat_dst.ptr<uchar>(0);
		const int total = (int)mat_src.total();

		for (int i = 0; i < total; i++) {
			dp[i] = static_cast<uchar>((sp[i] > th) * max_val);
		}

		return;
	}

	for (int y = 0; y < mat_src.rows; y++) {
		const uchar* sp = mat_src.ptr<uchar>(y);
		uchar* dp = mat_dst.ptr<uchar>(y);

		for (int x = 0; x < mat_src.cols; x++) {
			dp[x] = static_cast<uchar>((sp[x] > th) * max_val);
		}
	}
}

2.3. LUT(ルックアップテーブル)

あらかじめ各画素が閾値ごと0と255に振り分けられるか判定するテーブル(LUT)を作成し、LUTを参照することで比較も演算もせず2値化可能になります。
※ 最初にLUTを作成する処理が必要。

void make_binary_lut(uchar* ucLUT, uchar th, uchar max_val) {
	for (int i = 0; i < 256; i++) {
		ucLUT[i] = (i > th) ? max_val : 0;
	}
}
void process_lut(const cv::Mat& mat_src, cv::Mat& mat_dst, const uchar* ucLUT) {
	if (!ucLUT) throw std::invalid_argument("LUT is null");

	mat_dst.create(mat_src.size(), mat_src.type());

	if (mat_src.isContinuous() && mat_dst.isContinuous()) {
		const uchar* sp = mat_src.ptr<uchar>(0);
		uchar* dp = mat_dst.ptr<uchar>(0);
		const int n = (int)mat_src.total();

		for (int i = 0; i < n; i++) {
			dp[i] = ucLUT[sp[i]];
		}

		return;
	}

	for (int y = 0; y < mat_src.rows; y++) {
		const uchar* sp = mat_src.ptr<uchar>(y);
		uchar* dp = mat_dst.ptr<uchar>(y);

		for (int x = 0; x < mat_src.cols; x++) {
			dp[x] = ucLUT[sp[x]];
		}
	}
}

2.4. SIMD+OpenMP

前回の記事で作成した自作の2値化処理です。

void custom_threshold(const cv::Mat& mat_src, cv::Mat& mat_dst, uchar thresh, uchar maxval) {
    mat_dst.create(mat_src.size(), mat_src.type());

    int nstripes = (int)std::max(1.0, mat_dst.total() / (double)(1 << 16));

#pragma omp parallel for schedule(static)
    for (int i = 0; i < nstripes; i++) parallel_threshold(mat_src, mat_dst, thresh, maxval, i, nstripes);
}

※ 詳細は前回の記事参照

2.5. cv::threshold

最後はOpenCVのcv::thresholdです。
今回はcv::THRESH_BINARY(単純な閾値判定)で比較します。

cv::threshold(mat_src, mat_dst, th, max_val, cv::THRESH_BINARY);

3.処理速度比較

1920×1080の8bit画像を毎回ランダムで生成し、各2値化処理をそれぞれ1000回ずつ行い処理速度を計測します。
※ sinkは内部最適化対策

int main(void) {
	using clock = std::chrono::steady_clock;
	constexpr int W = 1920;
	constexpr int H = 1080;
	constexpr int ITERS = 1000;
	constexpr uchar TH = 128;
	constexpr uchar MAXV = 255;

	volatile uint64_t sink = 0;

#if defined(__AVX2__)
	std::cout << "AVX2 enabled\n";
#elif defined(__SSE2__)
	std::cout << "SSE2 enabled\n";
#else
	std::cout << "No SIMD\n";
#endif

	// LUT作成は一度だけ
	uchar lut[256];
	make_binary_lut(lut, TH, MAXV);

	// src/dst(再確保を避ける)
	cv::Mat src(H, W, CV_8UC1);
	cv::Mat dst_branch(H, W, CV_8UC1);
	cv::Mat dst_branchless(H, W, CV_8UC1);
	cv::Mat dst_lut(H, W, CV_8UC1);
	cv::Mat dst_omp(H, W, CV_8UC1);
	cv::Mat dst_ocv(H, W, CV_8UC1);

	// 乱数生成器(固定シードで再現性確保)
	cv::RNG rng(123456);

	// ウォームアップ(スレッドプール/CPU周波数/ページフォルト対策)
	rng.fill(src, cv::RNG::UNIFORM, 0, 256);
	cv::threshold(src, dst_ocv, TH, MAXV, cv::THRESH_BINARY);
	custom_threshold(src, dst_omp, TH, MAXV);

	uint64_t t_branch = 0, t_branchless = 0, t_lut = 0, t_omp = 0, t_ocv = 0;

	for (int iter = 0; iter < ITERS; iter++) {
		// 毎回ランダム画像生成
		rng.fill(src, cv::RNG::UNIFORM, 0, 256);

		// サンプル位置
		int idx = iter & 1023;

		// 分岐あり
		{
			auto s = clock::now();
			process_1(src, dst_branch, TH, MAXV);
			auto e = clock::now();
			t_branch += (uint64_t)std::chrono::duration_cast<std::chrono::nanoseconds>(e - s).count();
			sink += dst_branch.ptr<uchar>(0)[idx];
		}

		// 分岐なし
		{
			auto s = clock::now();
			process_2(src, dst_branchless, TH, MAXV);
			auto e = clock::now();
			t_branchless += (uint64_t)std::chrono::duration_cast<std::chrono::nanoseconds>(e - s).count();
			sink += dst_branchless.ptr<uchar>(0)[idx];
		}

		// LUT
		{
			auto s = clock::now();
			process_lut(src, dst_lut, lut);
			auto e = clock::now();
			t_lut += (uint64_t)std::chrono::duration_cast<std::chrono::nanoseconds>(e - s).count();
			sink += dst_lut.ptr<uchar>(0)[idx];
		}

		// SIMD + OpenMP
		{
			auto s = clock::now();
			custom_threshold(src, dst_omp, TH, MAXV);
			auto e = clock::now();
			t_omp += (uint64_t)std::chrono::duration_cast<std::chrono::nanoseconds>(e - s).count();
			sink += dst_omp.ptr<uchar>(0)[idx];
		}

		// OpenCV
		{
			auto s = clock::now();
			cv::threshold(src, dst_ocv, TH, MAXV, cv::THRESH_BINARY);
			auto e = clock::now();
			t_ocv += (uint64_t)std::chrono::duration_cast<std::chrono::nanoseconds>(e - s).count();
			sink += dst_ocv.ptr<uchar>(0)[idx];
		}
	}

	std::cout << "[分岐あり]     " << t_branch << " ns\n";
	std::cout << "[分岐なし] " << t_branchless << " ns\n";
	std::cout << "[LUT]    " << t_lut << " ns\n";
	std::cout << "[SIMD+OpenMP] " << t_omp << " ns\n";
	std::cout << "[OpenCV] " << t_ocv << " ns\n";
	std::cout << "sink=" << sink << "\n";

	return 0;
}

① SIMDが有効か確認

設定が反映されているかを確認しておきます。
上手くいっていれば「AVX2 enabled」が表示されるはず。

#if defined(__AVX2__)
	std::cout << "AVX2 enabled\n";
#elif defined(__SSE2__)
	std::cout << "SSE2 enabled\n";
#else
	std::cout << "No SIMD\n";
#endif

② LUTを作成(1回だけ)

事前にLUTを作成しておきます。

uchar lut[256];
make_binary_lut(lut, TH, MAXV);

③ cv::Mat確保(1回だけ)

入力画像と各出力用のcv::Matを事前に確保しておきます。

cv::Mat src(H, W, CV_8UC1);
cv::Mat dst_branch(H, W, CV_8UC1);
cv::Mat dst_branchless(H, W, CV_8UC1);
cv::Mat dst_lut(H, W, CV_8UC1);
cv::Mat dst_omp(H, W, CV_8UC1);
cv::Mat dst_ocv(H, W, CV_8UC1);

④ 乱数入力作成

乱数を作成します。
固定シードにすることで再現性を確保します。

cv::RNG rng(123456);

⑤ ウォームアップ

OpenCVとSIMD+OpenMPは初回の実行に起動時間が含まれる場合があるので事前に1度だけ実行しておきます。

rng.fill(src, cv::RNG::UNIFORM, 0, 256);
cv::threshold(src, dst_ocv, TH, MAXV, cv::THRESH_BINARY);
custom_threshold(src, dst_omp, TH, MAXV);

⑥ 各2値化処理及び計測

毎回ランダムの入力画像を作成し、1~5の2値化処理を実行します。

for (int iter = 0; iter < ITERS; iter++) {
	// 毎回ランダム画像生成
	rng.fill(src, cv::RNG::UNIFORM, 0, 256);

	// サンプル位置
	int idx = iter & 1023;
	・・・
}

実行結果はこちらです。

AVX2 enabled
[分岐あり]    629256700 ns
[分岐なし]   646292400 ns
[LUT]       479005500 ns
[SIMD+OpenMP] 32230600 ns
[OpenCV]      45822200 ns
sink=623475

まずはそれぞれを1回分に直してみます。

処理時間[μs/回]
分岐あり629
分岐なし646
LUT479
SIMD+OpenMP32.2
OpenCV45.8

まず分岐ありより分岐なしのほうが遅くなるのは意外でした。
何回か試したら分岐ありのほうが遅くなったこともあるので誤差範囲でしょう。
分岐の有無で差は生まれないということが分かります。

LUTは分岐あり、なしより早いです。
参照しているだけなので妥当な結果だと思われます。

下2つは上3つと比べて圧倒的ですが、問題はSIMD+OpenMPのほうがOpenCVより早かったという結果です。
OpenCVの内部処理はあらゆる条件に対応するための条件処理が入っていますが、今回実装した処理は8bit1ch、cv::THRESH_BINARYに特化させたのでOpenCVを超えるベンチマークを叩き出せたのではないかと推測します。

スポンサーリンク

4. まとめ

今回は自作の2値化処理とOpenCVの2値化処理の処理速度比較を行いました。
結局OpenCVが最も早いという結果で終わるかと思いきや、今回は自作処理が勝利しました。

入力にcv::Matを使用するならcv::thresholdを使えばいいと言ってしまえばそれまでですが、条件を絞って最適化すればOpenCVより高速な処理を実装することが可能だということが分かったのでやる意味はあったのではないでしょうか。

今回は以上です。

5. 参考サイト

・OpenCVのGitHub
https://github.com/opencv/opencv

・二値化 | イメージングソリューション
https://imagingsolution.net/imaging/binarization/

6. 関連書籍

スポンサーリンク

コメント

タイトルとURLをコピーしました