catalinaの備忘録

ソフトウェアやハードウェアの備忘録。後で逆引きできるように。

RustとOpenCVを組み合わせた画像処理

rustが楽しくなってきました。かたりぃなです。 今回はrustでopencvを使って画像分類に挑戦しようと思います。

その前に。記事のタイトルをAIが生成してくれる機能がついたみたいなので試してみました。 だいたいあってる気がします。

それでは本編。

まず環境準備。 rustはインストール済みなので、アップグレードだけします。 opencvはソースからビルドするのめんどいのでバイナリ持ってきます。

rustのアップグレード

PS C:\myproject\tauriapp> rustc --version
rustc 1.70.0 (90c541806 2023-05-31)
PS C:\myproject\tauriapp> cargo --version
cargo 1.70.0 (ec8a8a0ca 2023-04-25)
PS C:\myproject\tauriapp> rustup --version
rustup 1.26.0 (5af9b9484 2023-04-05)

PS C:\myproject\tauriapp> rustup self update
info: checking for self-update
  rustup unchanged - 1.26.0
PS C:\myproject\tauriapp> rustup update stable

PS C:\myproject\tauriapp> rustc --version
rustc 1.76.0 (07dca489a 2024-02-04)
PS C:\myproject\tauriapp> cargo --version     
cargo 1.76.0 (c84b36747 2024-01-18)

opencvのインストール

参考資料 https://qiita.com/benki/items/f810495f9d430db80129

https://github.com/opencv/opencv/releases/tag/4.3.0 から https://github.com/opencv/opencv/releases/download/4.3.0/opencv-4.3.0-vc14_vc15.exe をダウンロードして展開。

D:\oss\ に展開指示すると、D:\oss\opencv というディレクトリが作られ、ここに展開されます。

以下の環境変数を設定 PATH=$PATH;D:\oss\opencv\build\x64\vc15\bin OPENCV_LINK_LIBS=opencv_world430 OPENCV_LINK_PATHS=D:\oss\opencv\build\x64\vc15\lib OPENCV_INCLUDE_PATHS=D:\oss\opencv\build\include

これでopencvのインストールまで完了です。

画像分類の手法の検討

手法はいろいろあって、すぐ思いつく範囲でも

などがあります。

今回は特徴表現による画像分類として、固有空間での画像分類を試してみようと思います。

固有空間での画像分類の概要

このアルゴリズムを使うのは初めてなのですが、自分が理解した範囲では 「PCAして次元圧縮した表現上で画像比較すれば早いよね」 くらいに解釈しました。

乱暴にいうと 「ビットマップのペイロード同士を比較ではなく、jpegのDCT係数だけ比較したほうが早いね」 くらいの感じかなと。根本的には基底ベクトルが異なるので、あくまでイメージです。

そんなわけでアルゴリズムとしては次のようになります。 便宜上、最近の機械学習のモデルのように学習フェーズと推論フェーズでわけて考えます。 (実際には学習ではなくただの事前準備ではありますが。。。)

学習フェーズ -> 平均画像, 固有値, 固有ベクトル, ラベル画像の係数が得られる。

  • PCA
    • ラベル画像の集合を1つのmatとして
    • 画像セット全体に対してPCA -> 平均画像、固有値固有ベクトル が得られる
  • 各画像の係数を求める
    • 各画像を固有空間へ射影する -> 固有値

推論フェーズ

  • 入力画像を固有空間へ射影する -> 固有値
  • 固有空間上で
    • 入力画像と各ラベル画像を比較し、固有値同士の差分を求める -> 固有空間上の画像diff
    • 固有空間上の画像diffに対し、固有ベクトルの寄与度を加味する -> 類似度を表す配列が得られる
  • 一番類似している画像ラベルを返す

こんな感じです。

それでは実際のコードで見てみます。

学習フェーズの全体像

ちょっと実験していてややこしくなっていますが、ラムダを返したかったのです。

ディレクトリの指定は実行時ではなく事前に行いたくて、引数をキャプチャしたラムダを返してます。 関数型言語でいうカリー化ってやつですね。

このラムダは画像が格納されたディレクトリからすべての画像を読み込み、固有値分解を行い、各画像を射影して係数を求めます。 Eigenの定義はこんな感じです。

pub struct Eigen{
    pub mean: Mat,
    pub eigen_vec: Mat,
    pub eigen_val: Mat,
}
// workdirから画像を読み込んで、固有値と係数を求める
fn fn_find_eigenvalue(cardimage_dir:&str) -> impl FnOnce() -> Result<(Eigen, Mat), Box<dyn std::error::Error>> + '_
{
    move ||{
        // 全ての画像を読み込む
        let images = read_images(&cardimage_dir)?;

        // 画像の事前処理
        let cropped_images = images.iter().map(
            preprocess_image
        ).collect::<Vec<Mat>>();

        // 固有値を求める
        let eigen = pca_images(&cropped_images)?;

        // 各画像の係数を求める
        let img_coeff = project_images(&cropped_images, &eigen)?;

        Ok((eigen, img_coeff))
    }
}

事前処理はこんな感じです。グレースケールにしてる理由は実験を早く回したいためです。

pub fn preprocess_image(img: &Mat) -> Mat{
    let cropped_image = Mat::roi(img, card_ilust_rect()).unwrap();  // イラスト部分のみ抽出
    let mut gray = Mat::default();
    cvt_color(&cropped_image, &mut gray, COLOR_RGB2GRAY, 0).unwrap();   // グレースケール化する
    let gray1d = gray.reshape(1,1).unwrap();                        // 一次元化
    let mut gray1df = Mat::default();
    let _ = gray1d.assign_to(&mut gray1df,  CV_32FC1);                             // float化
    gray1df
}

pca処理はこんな感じです。 26600ピクセル(190x140)の画像N個がVec[N]の型で与えられたとして、 Mat[N, 26600]にしてからPCAにかけています。 PCAの出力ベクトルは一旦256個としました。

pub fn pca_images(images: &Vec<Mat>) -> Result<Eigen, Box<dyn std::error::Error>>{
    // 形状を変更( vec<mat> => mat )
    let mut mat_images = Mat::default();
    for img in images{
        mat_images.push_back(img)?;
    }
    let mut mean = Mat::default();
    let mut eigen_vec = Mat::default();
    let mut eigen_val = Mat::default();
    pca_compute2(&mat_images, &mut mean, &mut eigen_vec, &mut eigen_val, 256)?;

    // 190x140
    // 平均画像の確認用
    // let mean2d = mean.reshape(1,400)?.clone();
    // let img = imwrite("mean.png", &mean2d, &Vector::new() )?;
    Ok(Eigen::new(mean, eigen_vec, eigen_val))
}

各画像の係数を求める関数です。 pca_projectをすべてのラベル画像に行っているだけですね。 ちなみにpca_compute2, pca_projectはopencvの関数です。

pub fn project_images(images: &Vec<Mat>, eigen: &Eigen) -> Result<Mat, Box<dyn std::error::Error>> {
    let mut projected_images = Mat::default();
    for img in images{
        let mut projected_image = Mat::default();
        pca_project(&img, &eigen.mean, &eigen.eigen_vec, &mut projected_image)?;
        projected_images.push_back(&projected_image)?;
    }
    Ok(projected_images)
}

この結果、 mean(平均画像), eigen_vec(固有ベクトル), eigen_value(固有値), img_coeff(固有空間上での各画像の係数) が得られました。

推論フェーズ

推論処理はこのようになりました。

fn classify_image(eigen:Eigen, coeff:Mat, img:&Mat) -> Result<usize, Box<dyn std::error::Error>> {
    // 前処理
    let preprocessed_img = preprocess_image(&img);

    // 固有空間へ射影する
    let mut projected_image = Mat::default();
    pca_project(&preprocessed_img, &eigen.mean, &eigen.eigen_vec, &mut projected_image)?;

    // 固有空間上での入力画像表現と分類先画像のcoefを比較
    let eigen1d = eigen.eigen_val.reshape(1,1)?.clone();
    let mut diff_images = Vec::new();
    for i in 0..img_coeff.rows(){
        if let Ok(row) = img_coeff.row(i){
            let diff = mat_absdiff(&row, &projected_image); // todo: 二乗和誤差のほうがいいのでは?
            let diff_residual = residual(&diff, &eigen1d);  // 固有値の寄与度を加味する
            diff_images.push(diff_residual);
        }
    }
    println!("diff_image_len = {:?}", diff_images.len() );

    // coefとの誤差が最小であるインデックス = 目的の画像である
    // todo : 足きりしたほうがいい?(=信頼度が低い予測は捨てる)
    let mut min_val = std::f32::MAX;
    let mut min_index= 0;
    for i in 0..diff_images.len(){
        if diff_images[i] < min_val {
            min_val = diff_images[i];
            min_index = i;
        }
    }
    Ok(min_index)
}

前半部分の特徴空間への射影までは推論フェーズと同じです。 後半の固有空間上での比較は絶対値の差分での比較となっています。よく使われる二乗和誤差のほうがいいのかもしれませんが自信ないです。

というわけで、このコードでとりあえずの画像分類ができました。 全画像を比較するよりは高速に動作するので、一つの方法としてアリでは?と思っています。

それでは今回はこれくらいで。