Pull to refresh

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

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

Проблема


Основная задача, которую будем решать в рамках данного топика — подготовка сканов и фотографий письменных источников (книг, лекций и т.п.) для их печати, компактного хранения, упаковки в 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: Перенёс в «Алгоритмы». Если есть лучший вариант — пишите.
Tags:
Hubs:
+24
Comments 28
Comments Comments 28

Articles