子供の幼稚園でイベントがある度にカメラマンさんが写真を撮ってくれてスナップスナップというサイトで販売されるのですが、膨大な量から自分の子供が写った写真だけを見つけるのが非常に面倒くさい。
自分の場合はMacで見れるのでまだいくらかマシなんですが、スマホだと本当に面倒くさい様ですね。
ということで、Face APIを利用して元になる顔データとアップされている写真に写っている全ての人間の顔を比較し、一致する顔があれば(==写っていれば)カートに放り込んでくれるスクリプトを書きました。
API呼び出しの回数に制限があって気軽には使えないんですが、LINE Botにも出来たらいいなと思ってます。
使ったもの
- Face - Find Detect(Microsoft Azure Cognitive Services)
- Face - Find Similar(Microsoft Azure Cognitive Services)
- Capybara
- Poltergeist
実装
個人的にスクレイピングはRubyが楽なのでRubyで。年に何度かしか使わないのでコードは超適当です。
#!/usr/local/bin/ruby
# coding: utf-8
require 'capybara/poltergeist'
require 'net/http'
require 'json'
require 'fastimage'
AZURE_ACCESS_KEY = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' # Azureの管理画面で取得
SNAPSNAP_LOGIN_ADDRESS = 'hoge@hoge.com' # スナップスナップのログインメールアドレス
SNAPSNAP_LOGIN_PASS = 'password1234' # スナップスナップのログインパスワード
SNAPSNAP_CLASS_NAME = 'くま' # 取得したい組の名前「うさぎ」「りす」とか
RESOURCE_FACE_IMAGE_URL = 'https://hoge.com/photo.jpg' # 取得したい人物が一人だけ写った写真のURL。正面からの撮影。
# 写真に含まれる顔のIDの配列を返す。API呼び出し数が限度超えてたらnil
def getFaceIds(imageUrl)
uri = URI('https://westus.api.cognitive.microsoft.com/face/v1.0/detect')
uri.query = URI.encode_www_form({
})
request = Net::HTTP::Post.new(uri.request_uri)
request['Content-Type'] = 'application/json'
request['Ocp-Apim-Subscription-Key'] = AZURE_ACCESS_KEY
request.body = '{"url":"' + imageUrl + '"}';
response = Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https') do |http|
http.request(request)
end
case response
when Net::HTTPSuccess, Net::HTTPRedirection then
else
return nil
end
json = JSON.parse(response.body)
result = Array.new
json.each do |face|
result.push(face['faceId'])
end
return result
end
# 元となる顔のIDと比較する顔のIDの配列を渡し、近い顔と自信度の配列を返す。API呼び出し数が限度超えてたらnil
def getSimilarFaceExists(resouceId, targetIds)
uri = URI('https://westus.api.cognitive.microsoft.com/face/v1.0/findsimilars')
uri.query = URI.encode_www_form({
})
request = Net::HTTP::Post.new(uri.request_uri)
request['Content-Type'] = 'application/json'
request['Ocp-Apim-Subscription-Key'] = AZURE_ACCESS_KEY
request.body = '{
"faceId":"' + resouceId + '",
"faceIds": ' + JSON.generate(targetIds) + ',
"maxNumOfCandidatesReturned":10,
"mode": "matchPerson"
}';
response = Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https') do |http|
http.request(request)
end
case response
when Net::HTTPSuccess, Net::HTTPRedirection then
else
return nil
end
json = JSON.parse(response.body)
result = Array.new
json.each do |face|
# 0.8を超えてたら一致と判定
if face['confidence'] > 0.8
return true
end
end
return false
end
# スナップスナップにログイン
loginId = SNAPSNAP_LOGIN_ADDRESS
loginPassword = SNAPSNAP_LOGIN_PASS
Capybara.register_driver :poltergeist do |app|
Capybara::Poltergeist::Driver.new(app, {:js_errors => false, :timeout => 5000 })
end
session = Capybara::Session.new(:poltergeist)
session.driver.headers = {
'User-Agent' => "Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.99 Safari/537.36"
}
session.visit "http://snapsnap.jp/"
session.find("input[name='email']").native.send_key(loginId)
session.find("input[name='password']").native.send_key(loginPassword)
submit = session.find("input[alt='ログイン']")
submit.trigger('click')
# コンテンツがJSで読み込まれるのを待つ
sleep(5)
# 扱いやすいようNokogiriでパース
doc = Nokogiri::HTML.parse(session.html)
# アップされているイベントの配列
eventList = Array.new
# イベントページへのリンクを追加
doc.xpath('//div[@class="event_list ng-scope"]').each do |node|
link = node.xpath('.//a')
eventList.push(link.attribute("href").value)
end
# とりあえず最初のイベントのみ対象
eventList.first(1).each do |event|
session.visit event
end
# コンテンツがJSで読み込まれるのを待つ
sleep(5)
doc = Nokogiri::HTML.parse(session.html)
# 組の配列
categoryList = Array.new
# 組や集合写真などへのリンクを全部追加
categories = doc.xpath('//div[@class="event_list"]')
categories.xpath('.//td').each do |node|
if node.inner_text.length > 0 then
categoryList.push({'categoryName' => node.inner_text, 'categoryUrl' => session.current_host + node.xpath('.//a').attribute("href").value})
end
end
# とりあえず対象の組だけを対象に
categoryList.each do |category|
if category['categoryName'] == SNAPSNAP_CLASS_NAME then
session.visit category['categoryUrl']
break
end
end
# コンテンツがJSで読み込まれるのを待つ
sleep(5)
# モーダルでお知らせダイアログが開くので閉じる
a = session.find('a', text: '閉じる').click
# オートページャーなので最後まで読み込む
for num in 1..20 do
session.execute_script("window.scrollBy(0, $(window).height())")
sleep(0.2)
end
doc = Nokogiri::HTML.parse(session.html)
# 写真のリスト
photoList = Array.new
photos = doc.xpath('//li[@class="photo_loaded"]').each do |node|
photo = node.xpath('.//img')
# 写真の向きを取得しておく
wh = FastImage.size(photo.attribute("src").value)
anchor = node.xpath('.//p[@class="anchor"]')
# photoidはカートに入れる時に必要
photoList.push({'photoId' => anchor.attribute('data-photoid').value, 'thumbnailUrl' => photo.attribute("src").value, 'isVertical' => wh[0] < wh[1]})
end
# 元になる顔のIDを取得
resourceFaceId = getFaceIds(RESOURCE_FACE_IMAGE_URL)[0]
# スナップスナップ上の写真と比較。とりあえず最初の30枚のみ
photoList.first(30).each do |photo|
# 画像URLから画像のIDを取得
m = photo['thumbnailUrl'].match(/w1_(.+?)\//)
str = m[1]
# 大きいサイズの画像を取得。縦の時は最後に/3/が必要
imageUrl = 'http://image3.photochoice.net/r/tn_' + str + '/pc_watermark_6_h/' + (photo['isVertical'] ? '3/' : '')
# 写っておる人間全員の顔IDを取得
faceIds = getFaceIds(imageUrl)
# 1分に20回しか呼び出せないのでエラー出た時は回避
if faceIds == nil then
sleep(61)
faceIds = getFaceIds(imageUrl)
end
# 横顔は認識されないことも多い
if faceIds.length == 0 then
next
end
isSimilarFaceExists = getSimilarFaceExists(resourceFaceId, faceIds)
# 1分に20回しか呼び出せないのでエラー出た時は回避
if isSimilarFaceExists == nil then
sleep(61)
isSimilarFaceExists = getSimilarFaceExists(resourceFaceId, faceIds)
end
# もし一人でも似ている可能性があれば
if isSimilarFaceExists
# カートに追加
button = session.find(:xpath, '//li[@id="cart-add-' + photo['photoId'] + '"]')
a = button.find(:xpath, './/a')
a.trigger('click')
p photo['photoId'] + 'がカートに追加されました。'
end
end
デモ
ターミナルから叩くと5分位で終わります。大体写真10枚毎に1分間に20リクエストのRate Limit超えちゃう感じですね。
これが
こうなります。
集合写真でもしっかり入りますね。
正面から写ってる写真に関してはほぼ100%カートに入りますね。
他のFace APIだと同じ人物の顔の写真をまとめてPersonというオブジェクトを作り、それを比較に使えるようなのですがFind Similarは使えないようです。
これが使えるようになるともっと精度が上がるかもしれませんね。
困っている方は是非お試しあれ。