Автоматизация очистки снимков документов с помощью Sikuli

    Некоторое время назад меня попросили расширить один давний комментарий до полноценного топика. Не думаю, что сам по себе он достаточно интересен, но у меня возникла идея: почему бы не совместить полезное с приятным и не познакомиться поближе с одним любопытным инструментом, новость о котором недавно облетела все айтишные ресурсы.

    Проблема


    Основная задача, которую будем решать в рамках данного топика — подготовка сканов и фотографий письменных источников (книг, лекций и т.п.) для их печати, компактного хранения, упаковки в djvu и т.п.
    Photoshop и FineReader рассматривать не будем. Хотя они и предоставляют ряд полезных инструментов, но стоят денег, вообще говоря.
    При наличии сканера обычно всё просто: получаются изображения достаточно хорошего качества, чтобы можно было обойтись минимальной обработкой.
    С фотографиями интереснее: добавляются проблемы с освещением и геометрические искажения. Увы, исправление геометрических искажений автоматизировать, как минимум, сложно. А вот с освещением и фоном вполне можно побороться. Чем и займёмся.

    Инструменты


    Paint.NET — растровый графический редактор для Windows, с поддержкой слоёв и фильтров.
    Sikuli — по сути, это средство для автоматизации взаимодействия с графическим интерфейсом. Плюс дополнительные возможности для проведения тестирования приложений, но в рамках этой статьи мы их не касаемся. Будем использовать Sikuli для того, чтобы компенсировать отсутствие полноценной поддержки макросов в Paint.NET.
    Главной killer feature Sikuli должна быть наглядность и простота создаваемых скриптов, по принципу «Что ты видишь, так оно и работает» («What you see is how it works»). Правда, несколько портит впечатление общая сырость проекта. Я работал с версией 0.09. В вышедшей недавно версии 0.10 основные грабли убраны, но многих привычных вещей, вроде функции Undo в редакторе, по прежнему нет.
    К слову, недавно наткнулся на проект QAliber. Видимо, он имеет ряд преимуществ в плане взаимодействия с тестируемым интерфейсом и общей проработанности. Но наглядность… В общем, можно посмотреть и почувствовать разницу :) Хотя, наверное, при случае попробую воспользоваться именно QAliber.

    Архитектура Sikuli включает несколько слоёв, написанных на различных языках:
    • Верхний уровень — Jython API. По сути, скрипты Sikuli представляют собой программы на Python'е, и обращаются к функциям, предоставляемым Jython API. (Каждый проект хранится в папке %scriptname%.sikuli. Внутри папки находится файл %scriptname%.py и изображения в формате PNG.) Автор упоминает о возможности реализации верхнего уровня на любом другом языке работающем поверх JVM. Можно работать с Sikuli Java API напрямую из своей программы.
    • Средний уровень — Java API. Работает с клавиатурой и мышью, а также взаимодействует с библиотекой OpenCV для поиска заданных графических шаблонов на экране.
    • Соответственно, нижний, платформозависимый уровень — библиотека OpenCV, реализованная на C/C++.
    Я описал архитектуру не совсем так, как автор, но главное, что представление о системе можно составить.

    Теория


    Поскольку наша задача — это, по сути, отделение полезного сигнала от шума, то для объяснения идеи можно воспользоваться подходящими аналогиями: полосовой фильтр и система активного шумоподавления.

    Простой фильтр Threshold действует как полосовой фильтр, просто «срезая» пиксели с яркостью ниже заданной границы (устанавливая яркость в 0 для них, и в 255 — для всех остальных). Более продвинутый Levels устанавливает две границы, между которыми значения изменяются плавно.
    В случае, если яркость внутри снимка меняется в широких пределах, одним только полосовым фильтром «срезать» шум, не теряя полезный сигнал, не удастся. Нужен более хитрый метод.

    Принцип действия систем активного шумоподавления в двух словах можно выразить так: "(Сигнал + Шум) — (Шум) = (Сигнал)".
    (Сигнал + Шум) — это наш снимок. (Шум) — это фон, всё кроме текста. (Сигнал) — это, соответственно, текст.
    Вначале у нас есть только (Сигнал + Шум), но получить из него просто (Шум) в нашем случае можно, если воспользоваться определённым свойством полезного сигнала (текста): он состоит из тонких линий.
    Необходимо выбрать фильтр, который аккуратно «замылит» текст, чтобы изображение выглядело как чистый лист. В качестве такого фильтра подойдёт Median Blur (который в Paint.Net почему-то находится в меню Noise, как средство борьбы с шумом. Ну а мы его будем использовать с противоположной целью, удаляя полезный сигнал :)
    Правда, с иллюстрациями всё может быть не так гладко, и их придётся обрабатывать отдельно...

    Алгоритм действий такой:
    1. Применить к исходному изображению фильтр Median Blur, чтобы получить чистый фон, без текста;
    2. Вычислить разницу между исходным и полученным в п.1 изображениями;
    3. Инвертировать полученное в п.2 изображение (нам нужен тёмный текст на белом фоне);
    4. Применить фильтр Levels, чтобы выровнять контрастность и избавиться от незначительного шума, оставшегося после пп.1-2.
    Здесь могли быть красивые схемы и иллюстрации, но я так и не смог примирить свой перфекционизм с дизайнерскими способностями (вернее, их отсутствием). Надеюсь, смысл достаточно прозрачен и без картинок.


    Автоматизация


    Итак, задача для автоматизации — с помощью Sikuli последовательно открыть и обработать в Paint.NET набор изображений по описанному алгоритму.
    Я не придумал ничего лучше, чем заранее открыть папку с изображениями и предоставить Sikuli пройтись по иконкам, запуская Paint.NET через контекстное меню...

    Открываем Sikuli IDE и начинаем новый скрипт с объявления необходимых переменных:
    patterns = [,,]
    openwith_img = 
    paintnet_img = 
    waitfor_img = 
    edited_text = "_edited"
    base_timeout = 30000
    negation_mode = 
    difference_mode = 

    • patterns — массив с изображениями тех форматов файлов, которые будем обрабатывать;
    • openwith_img, paintnet_img — пункты контекстного меню, по которым будем кликать;
    • waitfor_img — операция открытия Paint.NET займёт некоторое время, и считается завершённой при появлении этого фрагмента на экране;
    • edited_text — суффикс, который будет добавлен к именам обработанных файлов;
    • base_timeout — базовое значение времени ожидания всех ресурсоёмких операций (в миллисекундах), чтобы не менять таймауты по всему скрипту в случае необходимости;
    • negation_mode, difference_mode — пока я писал скрипт, экспериментировал с этими двумя режимами смешивания слоёв. Поэтому мне было удобно их объявить в виде переменных.

    Тут необходимо обратить внимание на фундаментальную проблему подхода Sikuli — ограниченную переносимость скриптов.
    Почти наверняка у вас отличаются иконки графических форматов. Их придётся добавить в скрипт самостоятельно. На остальные изображения могут повлиять ОС и используемое оформление (VisualStyle). В моём случае это Windows XP и Opus OS от b0se.

    Далее следуют все необходимые функции.
    def OpenWith(x, y, w):
       rightClick(x)
       click(openwith_img)
       click(y)
       wait(w, timeout=base_timeout*3)

    Открытие файла через контекстное меню. Функция должна получать три паттерна: иконка файла, пункт меню, соответствующий необходимому приложению (Paint.NET, например), и фрагмент, появление которого на экране соответствует завершению загрузки.
    Да простят меня хабрапользователи за бессмысленные названия переменных.

    def SaveFile(suffix):
       type("f", KEY_ALT)
       click()
       type(Key.END + suffix)
       sleep(1)
       type(Key.ENTER)
       sleep(1)
       type(Key.ENTER)
       sleep(7)

    Сохранение файла в Paint.NET. Нажимаем Alt+F, чтобы попасть в меню File. (В скрипте я не использую всех возможных клавиатурных сочетаний для навигации по меню, хотя это несколько сократило бы скрипт и уменьшило число графических фрагментов. Я столкнулся с тем, что сочетания с Ctrl+Shift не всегда срабатывали в Sikuli, поэтому действовал более надёжным путём.)
    После клика по пункту меню «Save As...» фокус ввода окажется на поле ввода имени файла. Дописываем к нему суффикс. Я не придумал надёжного признака завершения сохранения, и поэтому в конце функции вставил бездействие на достаточный срок (7 секунд).

    def DoBlackWhite():
       type("a", KEY_ALT)
       click()
       wait(, timeout=base_timeout)

    Фильтр Ч/Б — первый из фильтров, которые нам понадобятся. По Alt+A открываем меню Adjustments и выбираем нужный пункт. Фильтр работает без параметров. Ждём, пока соответствующая отметка появится в панели History. (Очень удобная панель оказалась.)

    def DoDuplicateLayer():
       type("l", KEY_ALT)
       click()
       wait(, timeout=base_timeout)

    Клонирование слоя. Процесс аналогичен. В нашем случае не потребуется переключаться между слоями. Это хорошо, иначе пришлось бы ещё повозиться с панелью Layers.

    def DoInvertColors():
       type("a", KEY_ALT)
       click()
       wait(, timeout=base_timeout)

    Фильтр Негатив. Аналогично предыдущим.

    def DoOilPaint(a, b):
       type("c", KEY_ALT)
       click()
       click()
       sleep(0.1)
       type(a + Key.TAB + Key.TAB + Key.TAB + b + Key.ENTER)
       wait(, timeout=base_timeout*2)

    Фильтр Oil Painting. Изначально я использовал его, но в конечном счёте отказался в пользу Median Blur. Тем не менее сохраню для истории :)
    (Нет смысла в данном случае переживать из-за мёртвого кода. Вдруг кому пригодится… На самом деле все функции для работы с Paint.NET стоило бы вынести в отдельный файл, если бы Sikuli поддерживал соответствующую возможность.)
    Это первый фильтр, имеющий диалог настроек. В функцию передаётся пара необходимых параметров, которые вводятся в соответствующие поля формы.

    def DoMedian(a, b):
       type("c", KEY_ALT)
       click()
       click()
       sleep(0.1)
       type(a + Key.TAB + Key.TAB + Key.TAB + b + Key.ENTER)
       wait(, timeout=base_timeout*2)

    Фильтр Median Blur находится в меню Effects > Noise. Настраивается аналогично предыдущему, и нам очень полезен.

    def DoLayerBlend(mode):
       type(Key.F4)
       click()
       click(mode)
       type(Key.ENTER)
       wait(, timeout=base_timeout)
       type("m", KEY_CTRL)
       wait(, timeout=base_timeout)

    Смешивание слоёв. По F4 открываем диалог свойств слоя и выбираем нужный режим смешивания (переданный в качестве параметра). Тут же склеиваем слои по Ctrl+M.

    def DoLevels(iwp, ibp, ogamma):
       k_del = Key.DELETE + Key.DELETE + Key.DELETE + Key.DELETE
       type("a", KEY_ALT)
       click()
       type(k_del)
       type(iwp)
       type(Key.TAB + Key.TAB)
       type(k_del)
       type(ogamma)
       type(Key.TAB)
       type(k_del)
       type(ibp)
       sleep(0.1)
       type(Key.ENTER)
       wait(, timeout=base_timeout)

    Фильтр Levels. Диалог позволяет настроить пять параметров: Input White Point, Input Black Point, Output White Point, Output Black Point, Output Gamma. На выходе фильтра нам необходимо получить максимальный контраст, поэтому OWP и OBP не трогаем. Остальное передаём в качестве параметров.
    Поведение полей ввода на этом диалоге отличается от остальных диалогов. Приходится специально очищать их, имитируя нажатия на Delete.

    def DoFilter():
       DoBlackWhite()
       DoDuplicateLayer()
       DoMedian("35""50")
       DoLayerBlend(difference_mode)
       DoInvertColors()
       DoLevels("235""200""1")

    Начинаем собирать все заготовки в единое целое. Собственно, весь остальной состав скрипта существует ради обеспечения работы этой функции. Тут производится вызов последовательности фильтров с необходимыми параметрами.
    (Параметры DoLevels() рекомендуется подстроить для каждого набора изображений, хотя я в конце статьи привожу примеры выполненные за один проход с указанными параметрами...)

    def RunTaskOverImage(x):
       OpenWith(x, paintnet_img, waitfor_img)
       sleep(2)
       DoFilter()
       sleep(1)
       SaveFile(edited_text)
       sleep(1)
       closeApp("paint.NET")
       sleep(1)

    Открытие, обработка, сохранение, закрытие отдельного файла. В качестве параметра передаётся найденный регион, содержащий иконку файла, (или паттерн) который будет обрабатываться.

    def main():
       for pat in patterns:
          setThrowException(False)
          find_regs = findAll(Pattern(pat).similar(0.95))
          setThrowException(True)
          if find_regs:
             for region in find_regs:
                RunTaskOverImage(region)

    Поиск всех файлов на экране, и обработка найденных.
    setThrowException() — функция позволяет изменить поведение Sikuli в случае, когда findAll() не находит ни одного региона, соответствующего паттерну. В данном случае нам не страшно, если какой-либо паттерн не найден на экране.
    Pattern(pat).similar(0.95) — поиск паттернов осуществляется с некоторым допустимым отклонением. Это должно по возможности компенсировать различие настроек интерфейса на разных машинах. Коэффициент по умолчанию — 0.7 — это слишком мягкое условие. В итоге все мои иконки считались одинаковыми, и скрипт пытался выполниться три раза по кругу (по числу паттернов в массиве). 1.0, однако, тоже не стоит ставить: OpenCV может пропустить даже нужные иконки в этом случае.

    sleep(1)
    main()
    popup("done")

    Финальный аккорд: вызываем функцию main() и сообщаем о завершении выполнения скрипта.
    Функция main() выделена для удобства отладки. Вместо неё можно подставить вызов любой из описанных функций, и отлаживать отдельно.

    Скачать архив с исходным кодом
    Просмотреть исходный код полностью

    Тестирование



    Для тестов использовались: картинка из комментариев, по мотивам которых написан этот топик; пара произвольных снимков из своего архива; случайный снимок из интернета.

    До После До После


    Замер скорости проводился на ноутбуке с Pentium M 2 ГГц и 2 ГБ RAM. Время выполнения скрипта над 4 тестовыми изображениями:
    • Прогон 1: 6:32
    • Прогон 2: 6:57
    • Прогон 3: 6:47
    • Прогон 4: 6:38

    Среднее время: 6 минут 43 секунды. Среднее время обработки одного изображения: 1 минута 41 секунда.
    Основное время съедают фильтры. Но, думаю, за счёт оптимизации скрипта можно было бы сэкономить десяток секунд на изображение...

    Выводы


    1. Если человек может выделить полезную информацию из поступающего потока данных (прочесть текст, разобрать капчу...), значит может быть составлен и алгоритм для вычислительной машины, выделяющий эту информацию. Сложность и универсальность этого алгоритма — отдельный вопрос. Чем больше мы хотим, тем больше деталей придётся учесть в алгоритме. Описанный алгоритм позволяет очистить снимки текста в более тяжелых случаях, чем простой фильтр Threshold, однако тоже имеет свои ограничения.
    2. Рассматривать Sikuli IDE, как серьёзный инструмент, на сегодняшний день сложно. И не потому, что «программирование с картинками» — глупая затея. Просто использование Computer Vision при работе с интерфейсом не очень надёжно, а имеющийся инструментарий при этом не очень удобен и может ещё добавить хлопот даже при решении простейших задач. В другой раз при возникновении подобной задачи попробую QAliber.
    3. Для ряда задач, думаю, Sikuli Java API пригодится в качестве удобной обёртки над OpenCV для использования в собственных средствах тестирования и т.п.


    Ресурсы


    Официальный сайт Paint.NET
    Официальный сайт Sikuli. Ссылки для скачивания, документация, и.т.д.
    Блог с анонсами и примерами скриптов
    Документация по Sikuli версии 0.10
    Страница Sikuli на LaunchPad

    P.S.: Спасибо free0u за поддержку. Прошу прощения у тех, кого заставил ждать и кому эта статья больше пригодилась бы до сессии, нежели после.

    UPD: Перенёс в «Алгоритмы». Если есть лучший вариант — пишите.

    Similar posts

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 28

      0
      Монументально.
      Не было макросов в Paint.NET, но таки выкрутился. Надо бы протестировать у себя это все.
      Спасибо.
        +3
        Мне такой способ кажется менее удобным и очевидным, чем, например, Gimp, для которого можно писать скрипты на:
        • scheme (поддержка встроена)
        • python
        • perl
        • lua

        И тоже абсолютно бесплатно.
          +2
          Я и не спорю. Просто привык к Paint.NET, ну и с Sikuli хотелось разобраться, just for fun.
            0
            Кстати, еще есть программа phatch — позволяет делать то же самое, только в гораздо более наглядном виде и без знания скриптовых языков. Просто выбирается последовательность фильтров с параметрами. Тоже проще и нагляднее.

            Скрипты, зависящие от темы ОС, ждущие 7 секунд, чтобы файл «гарантированно» сохранился, и прочие хаки, не лучший пример для демонстрации, по-моему. Напоминает карточный домик — малейшее изменение в окружении, и придется долго сидеть выискивая ошибку. И трудоемкость создания первой версии скрипта тоже кажется более высокой, чем в phatch.
              0
              Отрицательный результат — тоже результат. Я примерно об этом в выводах и написал.

              В доках phatch'а не обнаружил работу со слоями. Без них в данном случае он бесполезен.
            +1
            есть пример алгоритма, как очищать от шума, используя Gimp?
            +2
            Снимаю шляпу — действительно круто. Оказывается от Sikuli даже на нынешнем этапе развития есть практическая польза. Но 1 минута 41 секунда на одно изображение — это плачевно.

            Надо будет показать людям, берущим мои конспекты на фото сессии =)
              +2
              Спасибо за способ очистки текста от шума. Как раз надо распечатать отфотканую книгу — возьмем на вооружение. Однако, да, если бы это скриптом под Gimp — было бы интереснее.
                +1
                Поставлю Вам плюсик в карму если тоже самое сделаете для Gimp :)
                • UFO just landed and posted this here
                  • UFO just landed and posted this here
                      +1

                      Как-то так. Пришлось подбирать параметры:
                      DoMedian(«45», «50»)
                      DoLevels(«239», «220», «1»)
                • UFO just landed and posted this here
                    +1
                    У Sikuli тоже есть функционал для тестирования, просто это за рамки статьи выходит.
                    Хех. Что характерно, разработчики обоих программ на Маках сидят :)
                    +1
                    То что вы сделали называется HighPass :-)
                      +1
                      Интересная методика, но слишком всё сложно.
                      Я как-то написал маленький скрипт, который делает то же самое.
                      Использовал свободную графическую библиотеку GD2.
                      Если интересно — поищу и выложу.
                        +1
                        В личке просили выложить.
                        kartz.ru/2010/06/23/image-filtering/
                          0
                          В целом, логика та же самая.
                          Для каждой точки вычисляем среднюю яркость в квадрате, окружающем эту точку. Если яркость данной точки больше (с учётом смещения), в новой картинке делаем её белой. Иначе – чёрной.

                          В моём случае с этой же целью используется фильтр Median и смешивание слоёв. Вроде бы Ваш вариант должен быть быстрее (это я так, осторожно, пока не занялся собственно сравнением), но результат довольно грубый. Сохраняя полутона можно заметно улучшить читаемость (и заново изобрести фильтр Levels...)

                          Меня больше пример впечатлил.
                          По моему алгоритму не очень хороший результат получается. Внизу слева и справа не видно ничего. Фильтр Median эффективен в случае импульсных «помех» и на размытом тексте может не сработать как ожидается.

                          Приличный результат смог получить, несколько доработав процесс:

                          Но при этом общее время обработки увеличивается раза в полтора.
                        +1
                        автору спасибо за топик, считаю статью в высшей степени полезной. добавил в избранное.
                          +1
                          Спасибо за снимки лекций! Ностальгия… :-)
                            +4
                            convert imageIn.png ( +clone -gaussian-blur 50x50 ) -compose difference -composite -negate -level 80%,97% imageOut.png
                              0
                              Уточните, пожалуйста, это под что и куда?
                                  0
                                  Ужасающе жрет процессор)
                                    +2
                                    Операции с графикой вообще несколько ресурсоёмкие.
                                    Собственно, основное время тут тратится на блюр/медиан, что с imagemagick'ом, что c пайнтом, что с фотошопом.
                                    Можно попробовать сравнить на этой задаче imagemagick, ps, paint, gimp и ещё что-нить.
                                +1
                                Мой плюс за красивое решение.

                                Если энтузиазм не пропадёт, попробую сравнить (это к коментарию).
                                  0
                                  Рискну предположить, что PS окажется быстрее всего )
                                0
                                Ну и название, Сыкуля…
                                А в целом круто. Продумански.

                                Only users with full accounts can post comments. Log in, please.