Есть некий устоявшийся бизнес-процесс, в котором пользователю надо напечатать документ, заполнить его, расписаться и отсканировать. Однако по какой-то причине пользователь часто забывает расписаться в документе (или вообще не знает, что должен это сделать), что вызывает много проблем и ненужных действий.
Нужно как-то уменьшить процент не подписанных документов, при этом не надоев пользователю лишними сообщениями и напоминаниями. В этом посте я хочу рассказать об одном решении этой проблемы, а именно об исследовании изображения, распознавании места для подписи и определения её наличия или отсутствия. В качестве инструмента решения этой задачи я выбрал уже известную связку opencv+javacv и алгоритм Виолы-Джонса, о которых я рассказывал в предыдущем посте.
Нужно как-то уменьшить процент не подписанных документов, при этом не надоев пользователю лишними сообщениями и напоминаниями. В этом посте я хочу рассказать об одном решении этой проблемы, а именно об исследовании изображения, распознавании места для подписи и определения её наличия или отсутствия. В качестве инструмента решения этой задачи я выбрал уже известную связку opencv+javacv и алгоритм Виолы-Джонса, о которых я рассказывал в предыдущем посте.
Постановка задачи
Есть заявление. Я не уверен, что имею право его выкладывать, но с точки зрения этой проблемы оно точно такое же, как и многие другие.Есть какие-то поля, где-то надо что-то заполнить, отметить и т.д. Вот какой-то пример из интернета:
А внизу будет место для подписи:

- Поиск места, где должна быть подпись
- Анализ этой области и определение наличия или отсутствия подписи
Поиск места для подписи
Чтобы искать место для подписи его нужно как-то предварительно пометить. В процессе поиска решения я попробовал несколько вариантов:
Сначала были попытки искать 4 угловых точки, чтобы подпись не влияла на поиск, потом пробовал искать весь блок в целом, чтобы усложнить структуру искомого изображения (Простые фигуры сложны для алгоритма, т.к. они чаще встречаются). Второй вариант оказался продуктивнее. Ограничивать крестами область подписи вообще была бредовой идеей, т.к. таких крестов в любом заявлении найти можно очень много, в том числе и в отрицательной выборке, о каком обучении тогда идет речь? В итоге я пришел к тому, что лучше всего ищется вот такой вот вариант подписи:
Простой прямоугольник зрительно выделяет место для подписи, а двойные боковые границы усложняют фигуру, обеспечивая качественный процесс обучения. Для приготовления положительной выборки пришлось подготовить 594 таких изображения с различными придуманными подписями. Вот небольшой пример из положительной выборки:Разумеется, вырезать из такого скана по одному изображению, да и еще описывать его в good.txt вручную очень долго, поэтому пришлось написать инструменты автоматизации этого процесса, которые мне значительно ускорили процесс. (я повторял эти действия для различных способов выделения подписи)
Отрицательную выборку я сделал скачав электронный вариант каких-то двух книг про Assembler. В итоге у меня было было 594 положительных изображения и 1518 отрицательных. Экспериментальным путем удалось успешно обучить алгоритм на следующих параметрах (остальные по умолчанию):
minHitRate 0.99
height 30
width 43
maxFalseAlarm 0.4
Выборка оказалась хорошей и алгоритм закончил свою работу за 9 стадий (считая от 0), достигнув максимального уровня ложной тревоги. Суммарное время обучение около 3 суток, правда последние сутки была 10 стадия, которая потом была прервана.
Если читать официальную документацию opencv, то перед тем, как производить поиск они советуют совершить преобразование equalizeHist с изображением. Это позволяет улучшить яркость и контраст изображения перед последующим поиском. Заявление после преобразование выглядело примерно так:
В моем случае оказалось так, что на части заявлений место для подписи обнаруживалось только после такого преобразования, а на другой части только если это преобразование НЕ делать. Т. е. это преобразование могло как помогать, так и портить работу алгоритма. В итоге, я сначала искал место для подписи в необработанном изображении, а потом, в случае если место найти не удалось, повторял поиск для обработанного изображения.
Алгоритм еще не проходил серьезного тестирования на разных сканерах, заявлениях, но у меня он нашел место для подписи на 24 заявлениях из 24. Справедливо заметить, что иногда происходят ложные тревоги на некоторых последовательностях печатных символов. С этим я борюсь просто выбирая самую большую найденную область, это работает. Далее я приступил к задаче определения наличия и отсутствия подписи.
Определение наличия подписи
Первое что я сделал, это сузил площадь поиска в 4 раза. Это позволило мне не обращать внимание на рамку и свести задачу к тому, есть ли что-либо на белом фоне или нет. Рисунок снизу наглядно показывает допустимость такого шага. (Маловероятно, что человек распишется в углу за границей рамки)
Первая мысль была просто считать количество темных пикселей. Но это неправильно, т.к. сканеры бывают разные, бумага бывает разной и поэтому никто не знает, что значит "темный" пиксель. Я поступил следующим образом: у каждого пикселя есть цвет, причем поскольку картинка черно-белая, то этот цвет представлен одним числом. 255 - белый, 0 - черный. Для удобства я ввел параметр "чернота" пикселя, который вычисляется по формуле:
blackness = 255 - color
Код
package ru.kosdev;
import org.bytedeco.javacpp.opencv_core;
import org.bytedeco.javacpp.opencv_imgproc;
import org.bytedeco.javacpp.opencv_objdetect;
import java.nio.ByteBuffer;
import static org.bytedeco.javacpp.opencv_imgcodecs.CV_LOAD_IMAGE_GRAYSCALE;
import static org.bytedeco.javacpp.opencv_imgcodecs.imread;
import static org.bytedeco.javacpp.opencv_imgcodecs.imwrite;
import static org.bytedeco.javacpp.opencv_imgproc.rectangle;
/**
* Created by Константин on 21.08.2015.
*/
public class SignDetector {
private final int squareDeviation;
private opencv_objdetect.CascadeClassifier classifier;
/**
* Создание детектора
* @param cascadeFileName - имя файла каскада
* @param deviationLevel - уровень стандартного отклонения для обнарукжения подписи
*/
public SignDetector(String cascadeFileName, int deviationLevel) {
classifier = new opencv_objdetect.CascadeClassifier(cascadeFileName);
this.squareDeviation = deviationLevel*deviationLevel;
}
/**
* Проверяет, есть ли подпись в скане.
* @param srcFName - имя исходного файла
* @param drawFileName - имя файла для сохранения результата рисования
* @return
*/
public boolean detectSign(String srcFName, String drawFileName) {
//читаем изображение из файла
opencv_core.Mat mat = imread(srcFName, CV_LOAD_IMAGE_GRAYSCALE);
//сюда складываем области изображения, в которых может быть подпись
opencv_core.RectVector rectVector = new opencv_core.RectVector();
//поиск фрагментов
classifier.detectMultiScale(mat, rectVector);
boolean hasFound = rectVector.size() > 0;
if (!hasFound) {
//попробуем обработать изображение и опять найти
opencv_core.Mat equalized = new opencv_core.Mat();
opencv_imgproc.equalizeHist(mat, equalized);
classifier.detectMultiScale(equalized, rectVector);
hasFound = rectVector.size() > 0;
}
if (hasFound) {
//находим самую большую область и уменьшаем ее в два раза
opencv_core.Rect rect = reduceTwice(getMaxRect(rectVector));
if (drawFileName != null) {
//нарисуем рабочую область и сохраним в отдельный
drawRect(mat, drawFileName, rect);
}
//поиск подписи
return searchSign(mat, rect);
}
System.out.println("Sign place is not found");
return false;
}
/**
* Рисует прямоугольник и сохраняет в файл
* @param mat - исходная матрица
* @param resultFName - имя файла
* @param rect - прямоугольник
*/
private void drawRect(opencv_core.Mat mat, String resultFName, opencv_core.Rect rect) {
int height = rect.height();
int width = rect.width();
int x = rect.tl().x();
int y = rect.tl().y();
opencv_core.Point start = new opencv_core.Point(x, y);
opencv_core.Point finish = new opencv_core.Point(x+width, y + height);
rectangle(mat, start, finish, opencv_core.Scalar.all(0));
imwrite(resultFName, mat);
}
/**
* Поиск подписи в фрагменте изображения
* @param mat - матрица изображения
* @param rect - выделенная область
* @return
*/
private boolean searchSign(opencv_core.Mat mat, opencv_core.Rect rect) {
int x0 = rect.x();
int y0 = rect.y();
int x1 = rect.width() + x0;
int y1 = rect.height() + y0;
ByteBuffer byteBuffer = mat.getByteBuffer();
//считаем среднюю "черноту" пикселя
long blackness = 0;
for (int y = y0; y <= y1; y++) {
for (int x = x0; x <= x1; x++) {
long index = y*mat.step() + x*mat.channels();
int color = byteBuffer.get((int)index) & 0xFF;
blackness += (255 - color);
}
}
float background = blackness/rect.width()/rect.height();
//считаем стандартное отклонение "черноты" пикселей
long squareDev = 0;
for (int y = y0; y <= y1; y++) {
for (int x = x0; x <= x1; x++) {
long index = y*mat.step() + x*mat.channels();
int color = byteBuffer.get((int)index) & 0xFF;
squareDev += (background - (255-color))*(background - (255-color));
}
}
squareDev = squareDev/rect.width()/rect.height();
return squareDev > squareDeviation;
}
/**
* Возвразает максимальный прямоугольник
* @param rectVector
* @return
*/
private opencv_core.Rect getMaxRect(opencv_core.RectVector rectVector) {
int maxWidth = 0;
opencv_core.Rect result = null;
for (int i = 0; i <= rectVector.size(); i++) {
opencv_core.Rect currentRect = rectVector.get(i);
int width = currentRect.width();
if (width > maxWidth) {
maxWidth = width;
result = currentRect;
}
}
return result;
}
/**
* Уменьшает размер прямоугольника в два раза, сохраняя положение центра
* @param big
* @return
*/
private opencv_core.Rect reduceTwice(opencv_core.Rect big) {
int height = big.height();
int width = big.width();
int x = big.tl().x();
int y = big.tl().y();
return new opencv_core.Rect(x+width/4, y+height/4, width/2, height/2);
}
}
Пока алгоритм еще не прошел полноценного тестирования, но на моих 24 различных примерах он успешно работает.
UPD: на тестировании на 92 заявлений алгоритм ошибся в 3 случаях. Модернизация алгоритма с добавлением еще 3 преобразований позволила уменьшить количество ошибок до 1 случая из выборки. Преобразования следующие:
- перевернуть на 180 градусов.
- повернуть на 5 градусов влево
- повернуть на 5 градусов вправо




Ты, получается, создавал свой xml файл для инициализации CascadeClassifier? Можешь его прикрепить сюда или скинуть на почту? rblbl@mail.ru
ОтветитьУдалить