画像処理

[OpenCV][Ruby]Webページのデザイン崩れ確認の自動化

はじめに

この記事は、OpenCV Advent Calendar 2016の12日目の記事です。
コンピュータビジョンのライブラリOpenCVのアドベントカレンダーです。OpenCVの知られざる機能(マイナーとも言う)、こんなプラットフォームでOpenCVを動かした、分かりづらいバグに遭遇した、こんな便利な機能があるなどの情報を中心に書いてます。http://qiita.com/advent-calendar/2015/opencv (昨年のAdvent Calendar)あと、今年は昨年と違い執筆希望者多数で、以下の記事もAdvent Cale...
OpenCV Advent Calendar 2016 - Qiita - Qiita
他の皆さんの内容とくらべてかなり簡単な内容です。

解決したい問題

仕事でWebサイトの開発・運用をしていたとき、サイトのリニューアル時のデザインがブラウザ間の挙動の違いによって崩れたり崩れなかったりして、何度もデプロイしては目視で確認したりしていました。 そのとき目視で確認するのが大変だったので楽にやりたい自動化したい、というのがやりたいことです。

解決方法

サイトのソースを見る方法では難しいので、OpenCVを使って画像処理で、こうなって欲しいという結果のスクリーンショットとブラウザのレンダリングの結果のスクリーンショットの類似度を見て判断する、という力技な方法を採用することにしました。 その結果出来たのがこのgemです。
image_match - Check web page design using image file automatically
zuqqhi2/image_match - GitHub
以下gemの中身について解説していきます。 このgemでは以下の4つの関数を提供しています。上2つはテンプレートマッチ(後述)を使って期待値(テンプレート画像)と実際のスクリーンショットがほぼ完全に一致するものを探します。下2つは、SURF特徴量(後述)を使って上2つよりもアバウトなマッチングを行います。
  1. perfect_match
    • 対象の画像が用意していた画像と完全に一致するかどうか。
  2. perfect_match_template
    • 対象の画像の中の一部が用意していたテンプレート画像と完全に一致するかどうか。
  3. fuzzy_match_template
    • 用意していたテンプレート画像と似ている箇所が対象の画像の中にあるかどうか。
  4. match_template_ignore_size
    • 対象の画像の中の一部が、サイズの違いを無視した場合に、用意していたテンプレート画像と一致するかどうか。
上2つを使う場合と、下2つを使う場合についてそれぞれ説明します。

動作環境

  • 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
このコードは以下の流れで処理されます。
  1. 探索される画像とテンプレート画像の読み込み
  2. テンプレートマッチング
  3. 最もマッチした(類似度が高い)箇所を赤い枠で囲んだ画像を出力
重要な箇所はすべてOpenCVがやってくれるので短く簡潔なコードになりました。 このコードを実行してみます。 実際に実行するためには以下のステップを踏む必要があります。
  1. テンプレート画像を用意
  2. ブラウザでスクリーンショットを取って、テンプレートマッチングを実行するコードの記述
  3. 結果をもとにしきい値(limit_similarity)の調整
テスト対象は適当にこのブログのトップページにします。 そしてテンプレート画像は以下のちょうちょの画像にします。
次はコードを記述する前に利用するライブラリをインストールするためにGemfileを書きます。
source 'https://rubygems.org'

gem 'capybara', '~> 2.1'
gem 'poltergeist', '1.12.0'
gem 'image_match', '0.0.3'
そして以下のようなコードを書きます。
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
後半の数行以外は、Capybaraとpoltergeistというライブラリを使ってphantomjsというヘッドレスブラウザで、実際にサイトにアクセスしてスクリーンショットを取ってきているだけです。 そして、テンプレート画像と類似度0.9以上で類似する箇所が見つかった場合に、”Find!”と標準出力に出力しています。  この時点でディレクトリ構成は
.
├── Gemfile
├── template.jpg
└── sample.rb
 となっていて、実行するには以下のコマンドを叩きます。
bundle install --path=.bundle
bundle exec ruby main.rb
# Output > Find!
実行結果は以下のようになりました(実際に実行すると”Unsafe JavaScript attempt…”というメッセージが大量に出ますが無視してください。私のサイトが悪いだけです。)。
limit_similarity を調整すればテンプレート画像が色違い程度であれば認識してくれます。  ちなみに、perfect_matchの方は、テンプレート画像が一部ではなくサイトのスクリーンショットと同じサイズの場合の関数です。

SURF特徴量によるデザインチェック

テンプレートマッチだけでだいたい大丈夫なのですが、limit_similarityをかなり減らさないとマッチしない場合があり、これでいいのか?という感じになる場面もありました。 テンプレートマッチングはサイズが違う場合、画像が回転している場合などはうまくいきません。Webサイトのデザインの例だと、特定のブラウザでは特定の箇所のサイズが他のブラウザと違うなどといった場合です。 特徴量というのは、雑に説明すると、とあるアルゴリズムによって画像の特徴のある部分についてその特徴を表す数値の集まりです。SURF特徴量は、回転・サイズ・歪み・明るさの変化に強くかつある程度高速に計算することができる特徴量です。詳細な説明は例えば以下など参照してください。
MIRU2013のチュートリアル「画像局所特徴量SIFTとそれ以降のアプローチ」 第16回画像の認識・理解シンポジウム MIRU2013 2013年7月29日 http://cvim.ipsj.or.jp/miru2013/tutorial.php#ts4
MIRU2013チュートリアル:SIFTとそれ以降のアプローチ - 
OpenCVでSURF特徴量を取得するだけであれば以下のように簡単に出来ます。
param = CvSURFParams.new(500)
template_keypoints, template_descriptors = template.extract_surf(param)
fuzzy_match_templateは単純に特徴量同士で、探索される画像とテンプレート画像を比較しています。 match_template_ignore_sizeはかなり力技なことをやっていて、以下の流れで処理を行います。
  1. 画像読み込み
  2. SURF特徴量の計算
  3. SURF特徴量同士の比較からサイズの違いを計算
  4. サイズの違いを考慮してテンプレートマッチングを実行
SURF特徴量を計算したあと、いろいろ無視してサイズの違いだけに目を向けています。 コードは以下のような感じです。
##
#
# 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
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行差し替えるだけです。
<pre>
# 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)
これで、後はテストコード化して、JenkinsなどのCI環境でテストを自動的に実行させる設定を入れるだけで、ある程度デザインの目視チェックを自動する仕組みが出来ます。 また、この記事では簡単のためにCapybara+poltergeistを使いましたが、SeleniumRCを使えばFirefoxやChromeなどを動かせるので、特定のブラウザでのみデザインが崩れる場合にも対処できます。
Webdrvier Remoteを利用してマルチブラウザテストを実施
Webdriver RemoteでFirefox,IE,Chrome上で自動ブラウザテスト - ズッキーニのプログラミング実験場

おわりに

実際に作ったgemは仕事で使ってみたところ、実際にいくつかデザイン崩れを検知できたので多少役に立ちました。 OpenCV初心者なので変なことをやってるかもしれませんが、何らかの参考になれば幸いです。 明日は@UnaNancyOwenさんの記事です。
zuqqhi2

某Web系の会社でエンジニアをやっています。 学術的なことに非常に興味があります。 趣味は楽器演奏、ジョギング、読書、料理などなど手広くやっています。