はじめに
この記事は、OpenCV Advent Calendar 2016の12日目の記事です。 他の皆さんの内容とくらべてかなり簡単な内容です。解決したい問題
仕事でWebサイトの開発・運用をしていたとき、サイトのリニューアル時のデザインがブラウザ間の挙動の違いによって崩れたり崩れなかったりして、何度もデプロイしては目視で確認したりしていました。 そのとき目視で確認するのが大変だったので楽にやりたい自動化したい、というのがやりたいことです。解決方法
サイトのソースを見る方法では難しいので、OpenCVを使って画像処理で、こうなって欲しいという結果のスクリーンショットとブラウザのレンダリングの結果のスクリーンショットの類似度を見て判断する、という力技な方法を採用することにしました。 その結果出来たのがこのgemです。 以下gemの中身について解説していきます。 このgemでは以下の4つの関数を提供しています。上2つはテンプレートマッチ(後述)を使って期待値(テンプレート画像)と実際のスクリーンショットがほぼ完全に一致するものを探します。下2つは、SURF特徴量(後述)を使って上2つよりもアバウトなマッチングを行います。- perfect_match
- 対象の画像が用意していた画像と完全に一致するかどうか。
- perfect_match_template
- 対象の画像の中の一部が用意していたテンプレート画像と完全に一致するかどうか。
- fuzzy_match_template
- 用意していたテンプレート画像と似ている箇所が対象の画像の中にあるかどうか。
- match_template_ignore_size
- 対象の画像の中の一部が、サイズの違いを無視した場合に、用意していたテンプレート画像と一致するかどうか。
動作環境
- OS
- Ubuntu 14.04 LTS
- Ruby
- 2.3.3
- OpenCV
- 2.4.9(ruby-opencvがOpenCV3系に対応していないので古いですが2系でやりました)
テンプレートマッチングによるデザインチェック
単純に画像の比較をすると言ったらまずこれが真っ先に浮かぶのではないでしょうか、というような方法です。 以下のように、見つけたい画像(テンプレート画像)を、探索の対象となる画像の左上からずらしながら見比べていくという方法です。 見比べる(類似度を計算する)方法にはいろいろあります。 今回はいくつか試した中で一番安定していた正規化相互相関という方式を使いました。 このコードは以下のように書きました。このコードは以下の流れで処理されます。## # # Calculate matching score of 1st input image and 2nd input image. # The 2nd input image size must be smaller than 1st input image. # This function is robust for brightness. # # @param [String] scene_filename Scene image file path # @param [String] template_filename template image file path which you want find in scene image # @param [Float] limit_similarity Accepting similarity (default is 90% matching) # @param [Boolean] is_output if you set true, you can get match result with image (default is false) # @return [Boolean] true if matching score is higher than limit_similarity # false otherwise # def perfect_match_template(scene_filename, template_filename, limit_similarity=0.9, is_output=false) raise ArgumentError, 'File does not exists.' unless File.exist?(scene_filename) and File.exist?(template_filename) raise ArgumentError, 'limit_similarity must be 0.1 - 1.0.' unless limit_similarity >= 0.1 and limit_similarity <= 1.0 raise ArgumentError, 'is_output must be true or false.' unless is_output == false or is_output == true scene, template = nil, nil begin scene = IplImage.load(scene_filename) template = IplImage.load(template_filename) rescue raise RuntimeError, 'Couldn\'t read image files correctly' return false end return false unless scene.width >= template.width and scene.height >= template.height result = scene.match_template(template, :ccoeff_normed) min_score, max_score, min_point, max_point = result.min_max_loc if is_output from = max_point to = CvPoint.new(from.x + template.width, from.y + template.height) scene.rectangle!(from, to, :color => CvColor::Red, :thickness => 3) scene.save_image(Time.now.to_i.to_s + "_match_result.png") end return (max_score >= limit_similarity ? true : false) end
- 探索される画像とテンプレート画像の読み込み
- テンプレートマッチング
- 最もマッチした(類似度が高い)箇所を赤い枠で囲んだ画像を出力
- テンプレート画像を用意
- ブラウザでスクリーンショットを取って、テンプレートマッチングを実行するコードの記述
- 結果をもとにしきい値(limit_similarity)の調整
そして以下のようなコードを書きます。source 'https://rubygems.org' gem 'capybara', '~> 2.1' gem 'poltergeist', '1.12.0' gem 'image_match', '0.0.3'
後半の数行以外は、Capybaraとpoltergeistというライブラリを使ってphantomjsというヘッドレスブラウザで、実際にサイトにアクセスしてスクリーンショットを取ってきているだけです。 そして、テンプレート画像と類似度0.9以上で類似する箇所が見つかった場合に、”Find!”と標準出力に出力しています。 この時点でディレクトリ構成はrequire 'capybara' require 'capybara/poltergeist' require 'image_match' include ImageMatch # Get current google web page image url = 'http://160.16.71.152:12080/' Capybara.javascript_driver = :poltergeist Capybara.register_driver :poltergeist do |app| Capybara::Poltergeist::Driver.new app, js_errors: false end Capybara.default_selector = :xpath session = Capybara::Session.new(:poltergeist) session.driver.headers = {'User-Agent' => "Mozilla/5.0 (Macintosh; Intel Mac OS X)"} session.visit(url) session.save_screenshot('screenshot.png', full: true) # Compare logo (output match result image) # When you write perfect_match_template('./screenshot.png', './template.jpg'), nothing outputting result image if perfect_match_template('./screenshot.png', './template.jpg', 0.9, true) puts "Find!" else puts "Nothing..." end
となっていて、実行するには以下のコマンドを叩きます。. ├── Gemfile ├── template.jpg └── sample.rb
実行結果は以下のようになりました(実際に実行すると”Unsafe JavaScript attempt…”というメッセージが大量に出ますが無視してください。私のサイトが悪いだけです。)。 limit_similarity を調整すればテンプレート画像が色違い程度であれば認識してくれます。 ちなみに、perfect_matchの方は、テンプレート画像が一部ではなくサイトのスクリーンショットと同じサイズの場合の関数です。bundle install --path=.bundle bundle exec ruby main.rb # Output > Find!
SURF特徴量によるデザインチェック
テンプレートマッチだけでだいたい大丈夫なのですが、limit_similarityをかなり減らさないとマッチしない場合があり、これでいいのか?という感じになる場面もありました。 テンプレートマッチングはサイズが違う場合、画像が回転している場合などはうまくいきません。Webサイトのデザインの例だと、特定のブラウザでは特定の箇所のサイズが他のブラウザと違うなどといった場合です。 特徴量というのは、雑に説明すると、とあるアルゴリズムによって画像の特徴のある部分についてその特徴を表す数値の集まりです。SURF特徴量は、回転・サイズ・歪み・明るさの変化に強くかつある程度高速に計算することができる特徴量です。詳細な説明は例えば以下など参照してください。 OpenCVでSURF特徴量を取得するだけであれば以下のように簡単に出来ます。fuzzy_match_templateは単純に特徴量同士で、探索される画像とテンプレート画像を比較しています。 match_template_ignore_sizeはかなり力技なことをやっていて、以下の流れで処理を行います。param = CvSURFParams.new(500) template_keypoints, template_descriptors = template.extract_surf(param)
- 画像読み込み
- SURF特徴量の計算
- SURF特徴量同士の比較からサイズの違いを計算
- サイズの違いを考慮してテンプレートマッチングを実行
get_object_location関数で探索される画像とテンプレート画像の特徴量の算出、それをもとに対応する特徴点同士を見つけてテンプレート画像の位置を特定します。この段階で終了するとfuzzy_match_template関数と同じです。回転や歪みに強い性質をあえて打ち消すために、最後にテンプレートマッチングをしています。 以下でmatch_template_ignore_sizeを試してみます。 適当に先程のちょうちょの画像を75%のサイズに縮小します。 この画像を使って、先程と同じスクリプトを実行すると”Nothing…”と出力され、明確な誤りではないので例があまり良くないのですが、一番近いところは以下のように先程とくらべて若干ずれてます。 ちなみに、limit_similarityを0.4にしても”Nothing…”になります。 match_template_ignore_sizeにすると0.6で”Find!”となり(拡大してマッチングしているだけなので現状だと類似度はかなり下がってしまいます)、一番近いところは以下のように先程とあまり変わらない位置と大きさになっています。 コードは先程のコードから以下ように1行差し替えるだけです。## # # Calculate matching score of 1st input image and 2nd input image. # The 2nd input image size must be smaller than 1st input image. # This function is robust for brightness and size. # # @param [String] scene_filename Scene image file path # @param [String] template_filename template image file path which you want find in scene image # @param [Float] limit_similarity Accepting similarity (default is 90% matching) # @param [Boolean] is_output if you set true, you can get match result with image (default is false) # @return [Boolean] true if matching score is higher than limit_similarity # false otherwise # def match_template_ignore_size(scene_filename, template_filename, limit_similarity=0.9, is_output=false) raise ArgumentError, 'File does not exists.' unless File.exist?(scene_filename) and File.exist?(template_filename) raise ArgumentError, 'is_output must be true or false.' unless is_output == false or is_output == true dst_corners = get_object_location(scene_filename, template_filename) scene, template = nil, nil begin scene = IplImage.load(scene_filename) template = IplImage.load(template_filename) rescue raise RuntimeError, 'Couldn\'t read image files correctly' return false end return false unless scene.width >= template.width and scene.height >= template.height if dst_corners src_corners = [CvPoint.new(0, 0), CvPoint.new(template.width, 0), CvPoint.new(template.width, template.height), CvPoint.new(0, template.height)] resize_width = (dst_corners[1].x - dst_corners[0].x) - src_corners[1].x resize_height = (dst_corners[3].y - dst_corners[0].y) - src_corners[3].y template = template.resize(CvSize.new(template.width + resize_width, template.height + resize_height)) end result = scene.match_template(template, :ccoeff_normed) min_score, max_score, min_point, max_point = result.min_max_loc if is_output from = max_point to = CvPoint.new(from.x + template.width, from.y + template.height) scene.rectangle!(from, to, :color => CvColor::Red, :thickness => 3) scene.save_image(Time.now.to_i.to_s + "_match_result.png") end return (max_score >= limit_similarity ? true : false) end
<pre>
これで、後はテストコード化して、JenkinsなどのCI環境でテストを自動的に実行させる設定を入れるだけで、ある程度デザインの目視チェックを自動する仕組みが出来ます。 また、この記事では簡単のためにCapybara+poltergeistを使いましたが、SeleniumRCを使えばFirefoxやChromeなどを動かせるので、特定のブラウザでのみデザインが崩れる場合にも対処できます。# from #if perfect_match_template('./screenshot.png', './butterfly-small.jpg', 0.4, true) # to if match_template_ignore_size('./screenshot.png', './butterfly-small.jpg', 0.6, true)
この記事へのコメントはありません。