Однажды случилось мне несчастье обратить свой взор на одну заманчивую вакансию. Все бы ничего, но, как обычно, подкинули тестовое задание. Если кратко, то нужно было сгруппировать ссылки на одно и тоже приложение в разных маркетах. По ссылкам были такие приложения как Skype, Skype WiFi, Skype Qik, Viber, и две игры с одинаковым названием Skyward. Среди магазинов были Google Play, App Store и маркет Windows Phone. В задании было так же описание граблей, мол, не надо особо привязываться на названия приложений, название компании разработчика и т.д. «Но ведь одинаковые приложения легко узнаваемы на разных платформах тупо по иконке» — подумал я, и полез выяснять детали. Но не все так просто.

Так выглядят иконки в разных магазинах для вибера и скайпа:



Иконки, увы, отличаются по цветовому набору и размеру. Первоначальная идея захешировать иконки и сравнить хеши, конечно, еще послужит, но не в этом случае. Изначально я совершил ошибку, вытащив для анализа иконки, которые показываются у меня в браузере, и они достаточно маленького размера. Несколько позже, покопавшись, я нашел размеры от 300 до 350 пикселей, что добавило точности в измерениях. Вообще, код, которым тащатся картинки, достаточно прост.

Для моей задачи я сгуглил библиотеку OpenCV. Это очень навороченный инструмент для различного анализа изображений. Изначально я настолько увлекся, что полез изучать feature matching, но это несколько не то, что было нужно. А нужно мне было выделить контуры на изображениях и как-то их сравнить.

Чтобы построить контуры, надо соответствующе подготовить изображение — выделить на нем границы. Для этого используем детектор границ Кенни(Canny). Может быть правильно будет Канни, не знаю. Работает он вот так:



В случае с иконкой скайпа получились следующие результаты:



Может показаться, что из отличий остался только размер, однако это не так. Выделенные границы несколько отличаются, и приведение иконок к одному размеру только добавляет ошибок.
Единственная заморочка — это правильно подобрать пороги минимума и максимума для алгоритма. Значения 100 и 200 меня абсолютно устроили.

Далее находим контуры. Их можно сравнивать, вычисляя коэффициент совпадения двух контуров — очень полезное в моей задаче свойство. Есть нюанс — на данный коэффициент не влияет угол поворота контура, но погоды в моем случае это почти не сделает. Для скайпа из гугла результат построения контуров следующий:



Тут не два контура, их четыре. Контур строится со внешней и внутренней стороны ранее определенных границ. Контуры я искал с флагом RETR_LIST, то есть без иерархии, а потом отсортировал от левого верхнего края изображения.

Для моего алгоритма требуется так же посчитать общую длину контуров — в OpenCV есть для этого отдельная функция arcLength. Сам алгоритм сводится к тому, что если у двух изображений совпало более 80% процентов длины контуров, то считаем эти изображения иконкой одного приложения. Сами контуры сравниваются функцией matchShapes, чем меньше ее результат — тем лучше, в моем случае верхней границей совпадения контуров было значение 0.15.

Однако есть еще второй тип иконок, которые сравнить по данному алгоритму не удалось — это иконки игры Skyward:



На момент написания статьи эти иконки отличаются по цвету, но некоторое время назад в двух магазинах был первый цветовой вариант. Иконки отличались только по размеру, но из-за этого контуры не совпадали совсем, и по ним определить ничего не получалось. Однако, тут мне помогла библиотека imagehash. Для игры Skyward хэши получилось сравнить в лоб. Однако, с того момента, как цветовое решение иконок поменялось, данная фича не работает.

На мою идею «работодатель» никак не среагировал. Бывает.

Исходник
import numpy as np
import cv2
import requests
from collections import namedtuple
from bs4 import BeautifulSoup
import imagehash
from PIL import Image

def itunes_find(content):
    icon, name = None, None
    soup = BeautifulSoup(content)
    found = soup.find(id="title")
    name = found.div.h1.get_text()
    found = soup.find('img',{'class':'artwork', 'alt': name})
    imageurl = found['src-swap-high-dpi']
    icon_r = requests.get(imageurl)
    if icon_r.status_code == 200:
        img_array = np.asarray(bytearray(icon_r.content), dtype=np.uint8)
        icon = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
    return name, icon

def google_find(content):
    icon, name = None, None
    soup = BeautifulSoup(content)
    found = soup.find('div',{'class':'cover-container'})
    imageurl = found('img')[0]['src']
    icon_r = requests.get(imageurl)
    if icon_r.status_code == 200:
        img_array = np.asarray(bytearray(icon_r.content), dtype=np.uint8)
        icon = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
    found = soup.find('div',{'class':'document-title'})
    if not found:
        found = soup.find('h1',{'class':'document-title'})
    if not found:
        with open('olala1.html', 'w') as f:
            f.write(content)
    name = found.get_text()
    return name, icon

def windows_find(content):
    icon, name = None, None
    soup = BeautifulSoup(content)
    found = soup.find('img', {'class':'appImage xlarge'})
    imageurl = found['src']
    icon_r = requests.get(imageurl)
    if icon_r.status_code == 200:
        img_array = np.asarray(bytearray(icon_r.content), dtype=np.uint8)
        icon = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
    found = soup.find(id="application")
    name = found('h1')[0].get_text()
    return name, icon

class Entry:
    def __init__(self, url, name, icon):
        self.url = url
        self.name = name
        self.icon = icon
        self.icon_hash = None
        self.contours = None

items = {}

def _go(url):
    r = requests.get(url, headers = {'User-agent': 'Mozilla/5.0'}, verify=False)
    if r.status_code == 200:
        if url.startswith('https://itunes.apple.com'):
            name, icon = itunes_find(r.content)
        elif url.startswith('https://play.google.com'):
            name, icon = google_find(r.content)
        elif url.startswith('http://www.windowsphone.com'):
            name, icon = windows_find(r.content)
        if name and icon is not None:
            items[url] = Entry(url, name, icon)

url_list = [
'https://itunes.apple.com/en/app/skype-for-iphone/id304878510?mt=8',
'https://itunes.apple.com/en/app/skype-for-ipad/id442012681?mt=8',
'https://play.google.com/store/apps/details?id=com.skype.raider&hl=en',
'http://www.windowsphone.com/ru-ru/store/app/skype/c3f8e570-68b3-4d6a-bdbb-c0a3f4360a51',
'https://play.google.com/store/apps/details?id=com.skype.android.access&hl=en',
'https://itunes.apple.com/en/app/skype-wifi/id444529922?mt=8',
'https://play.google.com/store/apps/details?id=com.skype.android.qik&hl=en',
'https://itunes.apple.com/us/app/skype-qik-group-video-messaging/id893994044?mt=8',
'https://play.google.com/store/apps/details?id=com.viber.voip&hl=en',
'https://itunes.apple.com/en/app/viber/id382617920?mt=8',
'https://play.google.com/store/apps/details?id=com.viber.voip&hl=en',
'https://play.google.com/store/apps/details?id=com.ketchapp.skyward&hl=en',
'https://itunes.apple.com/us/app/skyward/id943273841?mt=8',
'https://play.google.com/store/apps/details?id=cz.george.mecheche&hl=en',
]

tr = 100
def _do():
    for u in url_list:
        _go(u)
    
    for item in items.itervalues():
        width = item.icon.shape[0]
        height = item.icon.shape[1]
        icon_c = cv2.cvtColor(item.icon, cv2.COLOR_BGR2RGB)
        pil_im = Image.fromarray(icon_c)
        item.icon_hash = imagehash.dhash(pil_im)
        edges = cv2.Canny(item.icon, tr, tr*2)
        def _s(x):
            x,y,w,h = cv2.boundingRect(x)
            return (x, y)
        contours, hierarchy = cv2.findContours(edges, cv2.RETR_LIST, 1)
        contours = sorted(contours, key = _s)
        item.contours = contours
        item.weight = sum([cv2.arcLength(cnt,True) for cnt in contours])

    matches = []
    ungrouped = []
    items_copy = items.values()
    while items_copy:
        group   = []
        item = items_copy[0]
        current = items_copy[1:]
        items_copy = []
        for other in current:
            if item.icon_hash == other.icon_hash:
                group.append(other.url)
            else:
                rating = 0
                count = min(len(item.contours), len(other.contours))
                for v in range(count):
                    result = cv2.matchShapes(item.contours[v], other.contours[v], 1, 0.0)
                    if result < 0.15:
                        l = cv2.arcLength(item.contours[v],True)
                        lo = cv2.arcLength(other.contours[v],True)
                        rating += min(l/item.weight, lo/other.weight)
                if rating > 0.8:
                    group.append(other.url)
                else:
                    items_copy.append(other)
        if group:
            group.append(item.url)
            matches.append(group)
        else:
            ungrouped.append(item.url)

    for v in matches:
        print 'Found group: %s'%', '.join(set([items[u].name.strip() for u in v]))
        print 'Urls:\n%s\n'%'\n'.join(v)
    print "Ungrouped:"
    for v in ungrouped:
        print 'Name %s'%items[v].name
        print 'Url %s'%v

_do()

Комментарии (11)


  1. extempl
    13.09.2015 06:23

    Вообще, сравнивать по иконкам, наверное, ещё хуже, чем по имени приложения — иконки меняются чаще. Возможно, ожидали, что сравнивать будете по описанию/URL, bundle ID вечный, например. Правда, виден только в гПлее.


    1. TimsTims
      13.09.2015 11:48
      +4

      А может и того хуже — чтобы вы скачивали приложение, вскрывали его, и матчили одинаковую логику :))
      А вообще попахивает тем, что работодатель с помощью вас решил свою проблему )


      1. jacob1237
        14.09.2015 00:37

        Надо сказать, что не с помощью него одного)
        «Работодатель» не этот случайно — toster.ru/user/street ?))


        1. nxsofsys
          14.09.2015 02:48

          Он самый =)


          1. jacob1237
            14.09.2015 10:52

            А какое было название «Компании»? У меня подозрения, что с момента собеседования со мной, оно поменялось)
            Похоже надо бы как-то уведомить администрацию brainstorage.


            1. nxsofsys
              14.09.2015 12:43

              1. salas
                14.09.2015 19:45
                +1

                Кажется, этот тред расцвечивает подзаголовок «Компания с распределенной командой...» новыми красками.


  1. vz10
    13.09.2015 15:11

    Интересно какое решение данной проблемы предполагал сам работодатель.


  1. rushter
    13.09.2015 19:16

    С контурами это вы сами придумали? :)
    Давно же всё есть, например: https://ru.wikipedia.org/wiki/SSIM.


    1. nxsofsys
      13.09.2015 21:24

      Не могли бы вы подсказать как практически применить в данном случае SSIM, т.к. в моих экспериментах c SSIM значок скайпа из магазина Windows Phone имеет больше сходства с изображением Кенни из этой же статьи, чем со значком скайпа из гугла. =)


  1. sphinks
    15.09.2015 13:48

    Тестовое задание от Гугла? Майкрософта? На худой конец от Яндекса? Контора, чье имя — весомый аргумент в пользу выполенения достаточно не простого задания, с компанием новой области знаний? Если нет, то мне кажется работодатель себя переоценивает.