Готовый перевод Mastering OpenCV with Practical Computer Vision Projects / Опановуюємо OpenCV через практичні проекти комп'ютерного зору: Розділ 5. Розпізнавання номерних пластин з використанням SVM та нейронних мереж
У цьому розділі ми познайомимось з процесом створення застосунку автоматичного розпізнавання номерних пластин (ANPR). Існують різноманітні підходи та техніки, що підходять для певних ситуацій, наприклад, інфрачервоних камер, фіксованих позицій автомобілів, умов освітлення і так далі. Можна зробити так, аби застосунок ANPR розпізнавав номерні знаки на фотографіях, знятих за 2-3 метри відстані до машини, у звичайних умовах освітлення, з непаралельно розміщеною землею та мінімальними викривленнями перспективи на пластині.
Найважливішою метою розділу є пояснення сегментації зображення та виокремлення особливих точок, основи розпізнавання шаблонів, а також два важливі алгоритми, метод опорних векторів та штучні нейронні мережі. Далі покриємо такі теми:
ANPR
Пошук пластини
Розпізнавання пластини
Основи ANPR
Автоматичне розпізнавання номерних пластин (ANPR), також відоме як визначення дозвільних знаків (ALPR), ідентифікація транспорту (AVI) чи пошук автомобільних пластин (CPR), - це спосіб спостереження, в основі якого лежить оптичне розпізнавання символів (OCR) та інші методи, наприклад сегментація та виявлення, метою яких є зчитування пластин реєстрації транспорту.
Найкращі результати від застосування системи ANPR можна отримати для інфрачервоної камери, адже кроки сегментування, необхідні для виявлення та розпізнавання символів, у цьому випадку прямі та мінімізують похибки. Так відбувається завдяки законам поширення світла, основний серед яких вказує на рівність кута падіння та відбиття; таке просте відображення можна побачити на гладкій поверхні, подібній до плоского дзеркала. Відбиття світла від грубих поверхонь, таких як папір, формує інший тип, відомий як дифузне чи розсіяне відображення. Більш номерних пластин мають особливу характеристику з назвою ретрорефлектор - поверхня пластини зроблена із матеріалу, покритого тисячами крихітних напівсфер, що змушують світло відбиватися у напрямі джерела, як показано далі:
Що ж до камери з фільтром, пов'язаним зі вбудованим проєктором інфрачервоного світла, то в цьому випадку отримаємо досить високоякісне зображення, яке можна сегментувати та поступово знайти потрібні об'єкти, а також виконати не залежний від умов освітлення процес розпізнавання, як показано на наступному малюнку:
У цьому розділі ми не використовуємо інфрачервоні фотографії, винятково звичайні. Робимо це для того, щоб отримати не найкращі результати, а вищий рівень виявлення похибок розпізнавання та хибних появ, на відміну від результатів застосування інфрачервоної камери; однак, послідовність кроків для підходів однакова.
Кожна країна має власні розміри номерних пластин та специфікації; щоб здобути кращі результати та знизити рівень похибок, варто розумітися на цьому. Алгоритми даного розділу мають пояснити основи ANPR та специфікації для номерних пластин Іспанії, але результати викладок можна поширити на будь-яку іншу країну.
Далі працюватимемо з дозвільними пластинами Іспанії. У цій країні існує три різні розміри та форми номерних знаків; розглянемо найпоширеніші (великі), з розмірами 520x110 мм. Дві групи символів розділені вільним простором довжиною 41 мм, а між собою вони розміщені на відстані 14 мм. Перша частина включає 4 числа, а друга - три літери за винятком голосних A, E, I, O, U, а також Ñ і Q; усі символи мають розміри 45x77 мм.
Ці дані важливі для процесу сегментації символів, адже ми маємо змогу перевірити символ разом із пустим простором, щоб впевнитися у тому, що ми знайшли саме символ, а не інший сегмент зображення. Далі зображено подібну пластину:
Алгоритм ANPR
До пояснення коду ANPR маємо визначити головні кроки та завдання алгоритму. Процес складається з двох головних кроків: пошуку та розпізнавання. Метою першого є виявлення положення пластини на всьому кадрі камери. Коли її знайдено, відповідний сегмент передається на другий крок (розпізнавання), який застосовує алгоритм OCR для визначення літер та цифр на пластині.
На наступному малюнку бачимо два головні кроки алгоритму, виявлення та розпізнавання номерного знака. Після виконання цих кроків програма зображає виявлені символи пластини на поверхні кадрів. Ці алгоритми можуть повернути неякісні або взагалі жодних результатів.
На кожному етапі з попереднього малюнка визначаємо три додаткові кроки, які зазвичай використовуються в алгоритмах розпізнавання шаблона:
1. Сегментація: виявляємо та виокремлюємо зони інтересу.
2. Пошук особливих точок: для кожного здобутого фрагмента формуємо набір потрібних характеристик.
3. Класифікація: знаходимо символи чи класифікуємо зображення на "пластини" та "не пластини".
Далі зображено відповідні етапи розпізнавання шаблона:
Крім нюансів головного застосунку, метою якого є виявлення та розпізнавання номерної пластини ліцензії на керування автомобілем, коротко пояснимо дві задачі, про які зазвичай не розповідають:
Як натренувати систему розпізнавання шаблонів
Як оцінити таку систему
Разом з тим, ці питання можуть виявитися значно важливішими за програму, адже якщо ми не натренуємо систему розпізнавання шаблона коректно, вона може не справитися із задачею; до того ж, різні шаблони потребують різних способів тренування та оцінки. Щоб досягнути кращих результатів, маємо протестувати наявну систему в інших середовищах та за відмінних умов, з інакшими наборами особливих точок. Іноді дві задачі об'єднуються в одну, адже ключові точки можуть продукувати різні результати, як побачимо на етапі оцінки.
Виявлення пластини
На цьому етапі маємо знайти всі пластини на поточному кадрі. Щоб виконати завдання, виділимо два кроки: сегментацію та класифікацію сегментів. Етап пошуку особливих точок не пояснений, адже ми використовуємо фрагмент зображення як відповідний вектор.
На першому етапі (сегментація) застосуємо різні фільтри, морфологічні операції, алгоритми пошуку контурів та перевірки, щоб виокремити ті частини зображення, які можуть включати пластину.
На другому (класифікація), до кожного фрагмента застосуємо метод опорних векторів (SVM). Перед тим, як запрограмувати головний застосунок, тренуємо розпізнавання двох класів об'єктів - пластин та не пластин. Працюємо з паралельними кольоровими фронтальними зображеннями, які мають довжину 800 пікселів та зняті за 2-4 метри від автомобіля. Ці вимоги важливі для коректної сегментації. Ми зможемо виявити потрібні фрагменти, якщо створимо алгоритм, стійкий до зміни масштабу.
Далі зображені всі процеси, які використовуються для розпізнавання пластин:
Фільтр Собеля
Операція порогу
Морфологічна операція закриття
Маска однієї заповненої зони
Можливі знайдені пластини відмічені червоним кольором (зобр. особливих точок)
Знайдені пластини після використання класифікатора SVM
Сегментація
Сегментація - це процес поділу зображення на декілька сегментів, призначений для спрощення аналізу та виокремлення особливих точок.
Важливою особливістю застосування цього процесу для пластини є величезна кількість вертикальних меж, якщо, звісно, маємо фронтальний знімок пластини без повороту чи викривлення перспективи. Це можна використати на першому кроці процесу, щоб видалити фрагменти, які не мають жодних вертикальних меж.
До пошуку вертикальних меж маємо конвертувати кольорове зображення у відтінки сірого, бо колір не допоможе вирішити цю задачу, та видаляємо можливий шум, згенерований камерою. З цією метою застосуємо розмиття Гауса розмірності 5х5. Інакше отримаємо безліч вертикальних меж, які завадять розпізнаванню.
// конвертуємо зображення у відітінки сірого
Mat img_gray;
cvtColor(input, img_gray, CV_BGR2GRAY);
blur(img_gray, img_gray, Size(5,5));
Далі використаємо фільтр Собеля та вирахуємо першу похідну по горизонталі. Це математична функція, яка дозволить знайти вертикальні межі на зображенні. Оголошення відповідної функції в бібліотеці OpenCV має такий вигляд:
void Sobel(InputArray src, OutputArray dst, int ddepth, int xorder,
int yorder, int ksize=3, double scale=1, double delta=0, int
borderType=BORDER_DEFAULT )
У цьому випадку ddepth відповідає глибині зображення, xorder - це порядок похідної по x, yorder - порядок похідної по y, ksize - розмір ядра, що рівний 1, 3, 5 або 7, scale - опціональний множник для знайдених значень, delta - опціональне значення, що додається до результату, borderType - метод інтерполяції пікселів.
У нашому випадку використаємо такі значення: xorder = 1, yorder = 0, ksize = 3.
// Номерні пластини щільно заповнені вертикальними межами. Знайдемо їх:
Mat img_sobel;
Sobel(img_gray, img_sobel, CV_8U, 1, 0, 3, 1, 0);
Після застосування фільтра Собеля, активуємо поріг зі значенням, визначеним за методом Отсу, щоб в результаті здобути необхідне бінарне зображення. Алгоритм для вхідного 8-бітного кадру автоматично визначає оптимальне значення порогу:
//зображення із застосованим порогом
Mat img_threshold;
threshold(img_sobel, img_threshold, 0, 255,
CV_THRESH_OTSU+CV_THRESH_BINARY);
Щоб визначити метод Отсу для порогової функції, якщо об'єднаємо параметр типу зі значенням CV_THRESH_OTSU, вказане значення порогу буде проігноровано.
Підказка
Якщо визначено параметр CV_THRESH_OTSU, функція threshold поверне оптимальне значення порогу згідно з алгоритмом Отсу.
Застосувавши морфологічну операцію закриття, ми видалимо незаповнений простір між вертикальними межами та об'єднаємо фрагменти з великою кількістю вертикальних ліній. В результаті отримаємо сегменти зображення з можливими пластинами.
Спершу опишемо структурний елемент, необхідний для використанння морфологічної операції. Скористаємось функцією getStructuringElement, щоб визначити прямокутний фрагмент з розмірами 17x3 пікселів; в інших випадках це значення може відрізнятися:
Mat element = getStructuringElement(MORPH_RECT, Size(17, 3));
Та використаємо його для морфологічної операції закриття, шляхом виклику фукнції morphologyEx:
morphologyEx(img_threshold, img_threshold, CV_MOP_CLOSE, element);
В результаті застосування цих функцій отримаємо частини зображення з можливими пластинами; більшість з них не матимуть номерних знаків. Ці фрагменти можна розбити на дві групи шляхом аналізу поєднаних компонентів або за рахунок функції findContours. Остання застосовує різні методи та значення, щоб виокремлювати контури бінарного зображення. Ми маємо лише отримати зовнішні, з відношеннями ієрархії, та будь-які результати апроксимації полігонів:
// Знаходимо контури можливих пластин
vector< vector< Point> > contours;
findContours(img_threshold,
contours, // вектор контурів
CV_RETR_EXTERNAL, // отримуємо зовнішні контури
CV_CHAIN_APPROX_NONE); // усі пікселі кожного контуру
Для кожного контура вирахуємо обмежувальний прямокутник мінімальної площі. Для цього бібліотека OpenCV постачає функцію minAreaRect. Вона знаходить об'єкт RotatedRect, що описує прямокутник з можливістю повороту. Потім, скориставшись ітератором для кожного вектора контура, проаналізуємо утворені фігури та зробимо попередні перевірки, перш ніж класифікувати фрагмент:
//Починаємо ітерацію над кожним знайденим контуром
vector<vector<Point> >::iterator itc= contours.begin();
vector<RotatedRect> rects;
//Видаляємо фрагменти з необмеженою площею чи відношенням сторін.
while (itc!=contours.end()) {
//Формуємо обмежувальний прямокутник
RotatedRect mr= minAreaRect(Mat(*itc));
if( !verifySizes(mr)){
itc= contours.erase(itc);
}else{
++itc;
rects.push_back(mr);
}
}
Далі підтверджуємо коректність знайдених зображень залежно від площі та відношення сторін. Вважаємо, що фрагмент відповідає пластині, якщо значення відношення близьке до 520/110 = 4.727272 (ширина поділена на висоту) з похибкою, що складає 40 відсотків, та площею прямокутника, що відповідає висоті від 15 до 125 пікселів. ЦІ параметри залежать від розмірів зображення та позиції камери:
bool DetectRegions::verifySizes(RotatedRect candidate ){
float error=0.4;
//Розміри пластини в Іспанії: 52x11. Відношення сторін: 4,7272
const float aspect=4.7272;
//Встановлюємо мінімальну та максимальну площі. Інші фрагменти відкидаються.
int min= 15*aspect*15; // мінімальна площа
int max= 125*aspect*125; // максимальна площа
//Отримаємо лише фрагменти, що відповідають значенню відношення.
float rmin= aspect-aspect*error;
float rmax= aspect+aspect*error;
int area= candidate.size.height * candidate.size.width;
float r= (float)candidate.size.width / (float)candidate.size.height;
if(r<1)
r= 1/r;
if(( area < min || area > max ) || ( r < rmin || r > rmax )){
return false;
}else{
return true;
}
}
Можна вдосконалити алгоритм, скориставшись властивістю білого фону пластини. Усі вони мають однаковий колір фону, тому можна використати заповнення кольором, щоб отримати більш точний обмежувальний прямокутник.
На першому кроці маємо отримати декілька вихідних точок поблизу центральної. Далі знаходимо мінімальне значення серед ширини та висоти, яке використаємо далі, для пошуку випадкових позицій навколо центра фрагменту.
Ми бажаємо виділити білий фрагмент, для цього потрібно, щоб серед декількох вихідних точок був як мінімум один піксель потрібного кольору. Щоб сформувати зображення маски для зберігання найближчого регіону для виокремлення, скористаємося функцією floodFill:
for(int i=0; i< rects.size(); i++){
//Щоб знайти найкращий прямокутник для кожного фрагмента
//Застосуємо алгоритм floodfill, бо пластина має білий фон
//А потім отримаємо більш чіткі контури
circle(result, rects[i].center, 3, Scalar(0,255,0), -1);
//шукаємо мінімальне значення серед ширини та висоти
float minSize=(rects[i].size.width < rects[i].size.height)?
rects[i].size.width:rects[i].size.height;
minSize=minSize-minSize*0.5;
//налаштовуємо rand та отримуємо 5 точок навколо центра алгоритма floodfill
srand ( time(NULL) );
//налаштовуємо параметри floodfill та інші змінні
Mat mask;
mask.create(input.rows + 2, input.cols + 2, CV_8UC1);
mask= Scalar::all(0);
int loDiff = 30;
int upDiff = 30;
int connectivity = 4;
int newMaskVal = 255;
int NumSeeds = 10;
Rect ccomp;
int flags = connectivity + (newMaskVal << 8 ) +
CV_FLOODFILL_FIXED_RANGE + CV_FLOODFILL_MASK_ONLY;
for(int j=0; j<NumSeeds; j++){
Point seed;
seed.x=rects[i].center.x+rand()%(int)minSize-(minSize/2);
seed.y=rects[i].center.y+rand()%(int)minSize-(minSize/2);
circle(result, seed, 1, Scalar(0,255,255), -1);
int area = floodFill(input, mask, seed, Scalar(255,0,0), &ccomp,
Scalar(loDiff, loDiff, loDiff), Scalar(upDiff, upDiff, upDiff),
flags);
}
Починаючи з вихідної точки, функція floodFill заповнює об'єднані частини зображення певним кольором, зберігає результат у вигляді маски, а також встановлює нижні й верхні межі різниці між яскравістю / кольором пікселя для заповнення та його сусідами чи вихідною точкою:
int floodFill(InputOutputArray image, InputOutputArray mask, Point
seed, Scalar newVal, Rect* rect=0, Scalar loDiff=Scalar(), Scalar
upDiff=Scalar(), int flags=4 )
Параметр newVal визначає новий колір, яким ми бажаємо заповнити визначені фрагменти зображення. Значення loDiff та upDiff позначають максимальні нижні та верхні межі різниці між яскравістю / кольором пікселя для заповнення та його сусідами чи вихідною точкоою.
Флаг є комбінацією таких значень:
Молодші біти - зберігають значення спорідненості, 4 (за замовчуванням) або 8, використувається функцією. Спорідненість визначає, які сусіди пікселя враховані.
Старші біти - нуль або комбінація наступних значень:
CV_FLOODFILL_FIXED_RANGE та CV_FLOODFILL_MASK_ONLY.
CV_FLOODFILL_FIXED_RANGE встановлює різницю між поточним та вихідним пікселями.
CV_FLOODFILL_MASK_ONLY лише заповнить маску і не змінить власне зображення.
Отримавши маску відсічення, на основі маски знайдемо прямокутник мінімальної площі та перевіримо коректність розмірів. Для кожного з них шукаємо положення білого пікселя та використовуємо функцію minAreaRect, щоб виокремити найближчий потрібний фрагмент:
//Перевіряємо нову маску заповнення на коректність.
//Отримаємо всі знайдені точки мінімального прямокутника
vector<Point> pointsInterest;
Mat_<uchar>::iterator itMask= mask.begin<uchar>();
Mat_<uchar>::iterator end= mask.end<uchar>();
for( ; itMask!=end; ++itMask)
if(*itMask==255)
pointsInterest.push_back(itMask.pos());
RotatedRect minRect = minAreaRect(pointsInterest);
if(verifySizes(minRect)){
…
Завершивши процес сегментації та здобувши важливі частини зображення, можна виокремити кожен виявлений фрагмент, анулювати можливі повороти, знайти необхідні прямокутники, змінити їх розміри та вирівняти значення світла на фрагментах.
Для початку маємо згенерувати матрицю перетворень за допомогою функції getRotationMatrix2D, щоб видалити можливі повороти. Особливу увагу слід приділити висоті, адже клас RotatedRect здатний повертати аналогічні прямокутники, але з поворотом 90 градусів. Варто перевірити відношення сторін. Якщо його значення менше одиниці, здійснюємо поворот:
//Отримуємо матрицю повороту
float r= (float)minRect.size.width / (float)minRect.size.height;
float angle=minRect.angle;
if(r<1)
angle=90+angle;
Mat rotmat= getRotationMatrix2D(minRect.center, angle,1);
Маючи дану матрицю, можемо виконати над вхідним зображенням афінні перетворення (такі, що переводять паралельні лінії у паралельні) за допомогою функції warpAffine, для якої встановлюємо вхідне та вихідне зображення, матрицю перетворень, розміри результату (у цьому випадку такі ж) та використаний метод інтерполяції. За необхідності також можна визначити метод та значення границі.
//Створюємо та обертаємо зображення
Mat img_rotated;
warpAffine(input, img_rotated, rotmat, input.size(), CV_INTER_CUBIC);
Після цього відсікаємо фрагмент шляхом виклику функції getRectSubPix, яка копіює частину зображення заданої довжини та ширини з центром у певній точці. Якщо кадр , маємо замінити значення розмірів шляхом виклику C++ функції swap.
// Відсікаємо фрагмент зображення
Size rect_size=minRect.size;
if(r < 1)
swap(rect_size.width, rect_size.height);
Mat img_crop;
getRectSubPix(img_rotated, rect_size, minRect.center, img_crop);
Отримані результати не підходять для тренування та класифікації, адже мають різні розміри. До того ж, кожному зображенню відповідають власні мови освітлення, що накладає додаткові відмінності. Щоб виправити це, варто змінити розміри до однакових значень ширини та висоти, а також вирівняти світлову гістограму.
Mat resultResized;
resultResized.create(33,144, CV_8UC3);
resize(img_crop, resultResized, resultResized.size(), 0, 0,
INTER_CUBIC);
// Вирівнюємо гістограму
Mat grayResult;
cvtColor(resultResized, grayResult, CV_BGR2GRAY);
blur(grayResult, grayResult, Size(3,3));
equalizeHist(grayResult, grayResult);
Для кожного знайденого фрагмента зберігаємо відсічене зображення та його позицію у векторі:
output.push_back(Plate(grayResult,minRect.boundingRect()));
Класифікація
Підготувавши та розділивши зображення на можливі сегменти, вирішимо, які з них вказують на номернимі знаки, а які ні. Для цього застосуємо метод опорних векторів (Support Vector Machine, SVM). Це алгоритм розпізнавання шаблонів, який відноситься до сімейства алгоритмів контрольованого навчання, призначених, у першу чергу, для бінарної класифікації. Цей спосіб заснований на використанні даних з відмітками. На першому етапі маємо натренувати алгоритм певною їх кількістю, причому кожен набір має бути описаний окремим класом.
SVM формує один чи більше гіперплощин, що використовуються для розрізнення кожного типу даних. Класичним прикладом є набір двовимірних точок; метод опорних векторів знаходить оптимальну лінію, яка розділяє його на два класи.
Спочатку варто натренувати класифікатор; завдання маємо завершити раніше за розробку головного застосунку, тому його називають ще офлайн-тренуванням. Це не легка робота, адже потребує достатньої кількості даних, причому великий набір не завжди дає кращі результати. Що ж до нашого випадку, маємо шукати інші шляхи через відсутність відкритої бази даних номерів. Мусимо зробити сотні знімків автомобілів, обробити їх та сегментувати. Для тренування нашої системи ми використали 75 зображень з номерними пластинами та 35 без них, розміри кожного складали 144x33 пікселів. Приклад можна бачити на наступному малюнку. Звісно, це не великий набір даних, але достатній для встановлених нами вимог. Для справжньої ж програми знадобиться значно більше даних.
Щоб зрозуміти суть машинного навчання, застосуємо алгоритм класифікатора до особливих зон зображення (майте на увазі, існують кращі способи для тренування SVM, серед яких розрізняють метод головних компонент, перетворення Фур'є, аналіз текстури і так далі).
Застосуємо клас DetectRegions, щоб підготувати зображення для тренування системи, а також встановимо значення savingRegions у положення true, щоб мати можливість їх зберігати. Скористаємось командами bash у файлі segmentAllFiles.sh, щоб здійснити процес над усіма кадрами. Він постачається разом з вихідним кодом книги.
Щоб спростити подальші дії, збережему дані підготовлених та оброблених зображень у файлі XML для прямого використання у функції SVM в подальшому. Код trainSVM.cpp створює його на основі тек розміщення і певної кількості файлів зображень.
Підказка
Тренувальні дані для алгоритму машинного навчання OpenCV зберігаються в матриці розмірності NxM з N зображень та M особливих зон. Кожному набору даних відповідає певний ряд.
В іншій матриці Nx1 зберігаються класи, кожен з яких визначений певним плаваючим значенням.
Бібліотека OpenCV дозволяє легко працювати з файлами у форматі XML чи JSON за рахунок використання класу FileStorage, який дозволяє зберігати та зчитувати змінні, структури чи інші типи даних. Завдяки цій функції можна зчитати матрицю тренувальних зображень, вектор класів та зберегти їх у SVM_TrainingData та SVM_Classes:
FileStorage fs;
fs.open("SVM.xml", FileStorage::READ);
Mat SVM_TrainingData;
Mat SVM_Classes;
fs["TrainingData"] >> SVM_TrainingData;
fs["classes"] >> SVM_Classes;
Далі маємо налаштувати параметри SVM, які визначають спосіб роботи алгоритму; для цього скористаємось структурою CvSVMParams. Таке відображення тренувальних даних підвищує їх подібність з метою формування лінійного розподілу набору даних. На цьому етапі завдяки ефективному використанню функції kernel підвищується розмірність даних. Ми ж застосуємо тип CvSVM::LINEAR, тобто відображення не проводиться.
//Встановлюємо параметри SVM
CvSVMParams SVM_params;
SVM_params.kernel_type = CvSVM::LINEAR;
Потім створюємо та тренуємо класифікатор. Бібліотека OpenCV визначає клас CvSVM для методу опорних векторів та налаштовуємо його тренувальними даними, класами та значеннями параметрів:
CvSVM svmClassifier(SVM_TrainingData, SVM_Classes, Mat(), Mat(),
SVM_params);
Класифікатор здатний прогнозувати можливе відсічене зображення шляхом використання функції класу SVM, яка повертає ідентифікатор i. У даному випадку клас кадрів зі знаками позначено одиницею, а клас без них нулем. Тоді до кожного знайденого фрагмента застосуємо метод опорних векторів, щоб розрізнити пластини та не пластини, а також зберегти коректні відповіді. Наступний код відноситься до головного застосунку, цей процес називають онлайн-обробкою:
vector<Plate> plates;
for(int i=0; i< possible_regions.size(); i++)
{
Mat img=possible_regions[i].plateImg;
Mat p= img.reshape(1, 1);// конвертуємо img в один ряд особливих зон
p.convertTo(p, CV_32FC1);
int response = (int)svmClassifier.predict( p );
if(response==1)
plates.push_back(possible_regions[i]);
}
Розпізнавання пластин
Наступний крок обробки номерних знаків націлений виокремити символи шляхом оптичного розпізнавання (OCR). На кожному зображенні класу пластин виділяємо сегменти із символами та використовуємо алгоритм машинного навчання з назвою "штучна нейронна мережа" (Artificial Neural Network, ANN), щоб розпізнати їх. Також у цій частині ми навчимось оцінювати алгоритм класифікації.
Сегментація OCR
Спочатку використаємо фрагмент зображення пластини з вирівненою гістограмою як параметр функції OCR, потім застосуємо фільтр порогу, причому результат операції слугуватиме вхідними даними для алгоритму пошуку контурів; цей процес виглядатиме наступним чином:
Процес сегментації кодується так:
Mat img_threshold;
threshold(input, img_threshold, 60, 255, CV_THRESH_BINARY_INV);
if(DEBUG)
imshow("Порогове зображення пластини", img_threshold);
Mat img_contours;
img_threshold.copyTo(img_contours);
//Знаходимо контури можливих символів
vector< vector< Point> > contours;
findContours(img_contours,
contours, // вектор контурів
CV_RETR_EXTERNAL, // отримуємо зовнішні контури
CV_CHAIN_APPROX_NONE); // усі пікселі кожного контура
Ми використовуємо параметр CV_THRESH_BINARY_INV, щоб інвертувати результат порогової функції, у такому разі білі пікселі вхідного зображення стануть чорними та навпаки. Це важливо для розпізнавання контурів символів, адже алгоритм шукає лише білі пікселі.
Для кожного знайденого контура можемо здійснити необхідні перевірки та видалити фрагменти з некоректними розмірами чи відношенням сторін. У нашому випадку відповідне значення складає 45/77, і ми можемо прийняти до 35 відсотків похибки для перевернутих чи викривлених символів. Якщо площа більша за 80 відсотків, цей фрагмент вважаємо чорним блоком, а не символом. Щоб зарахувати відповідні фрагменти, можна скористатися функцією countNonZero, яка підраховує кількість пікселів зі значенням вище нуля.
bool OCR::verifySizes(Mat r)
{
// відношення сторін символа рівне 45x77
float aspect=45.0f/77.0f;
float charAspect= (float)r.cols/(float)r.rows;
float error=0.35;
float minHeight=15;
float maxHeight=28;
// відношення для числа 1 дещо інше,
// воно дорівнює приблизно 0.2
float minAspect=0.2;
float maxAspect=aspect+aspect*error;
// площа пікселів
float area=countNonZero(r);
// площа обмежувального прямокутника
float bbArea=r.cols*r.rows;
// відсоток пікселів на даній площі
float percPixels=area/bbArea;
if(percPixels < 0.8 && charAspect > minAspect && charAspect <
maxAspect && r.rows >= minHeight && r.rows < maxHeight)
return true;
else
return false;
}
Коли сегментований символ пройде перевірку, встановимо для нього ті ж розміри й позицію, які мають інші символи, та збережемо у векторі шляхом виклику відповідної функції допоміжного класу CharSegment. Він зберігає сегментоване зображення та позицію, необхідну для впорядкування, адже алгоритм пошуку контурів не здатен повертати їх у правильному порядку.
Виокремлення особливих точок
Наступний етап обробки сегментованих символів полягає у пошуку особливих зон для тренування та класифікації алгоритму штучної нейронної мережі.
На відміну від пошуку особливих точок при розпізнаванні методом опорних векторів, у цьому випадку ми працюємо не з усіма пікселями, а лише з особливими зонами, які розглядають у процесі оптичного розпізнавання. Вони включають гістограми горизонтального та вертикального накопичення, а також фрагмент низької роздільної здатності. Тау особливість можна бачити на наступному малюнку, кожен елемент якого має роздільну здатність 5x5 пікселів та відповідні гістограми накопичень:
Для кожного символа рахуємо кількість пікселів у ряду чи колонці з ненульовим значенням, за допомогою функції countNonZero, та зберігаємо результат у новій матриці mhist. Потім нормалізуємо її, вирахувавши максимальне значення серед елементів матриці завдяки функції minMaxLoc та розділивши всі елементи на нього викликом convertTo. Створимо функцію ProjectedHistogram, призначену для формування накопичувальних гістограм, роль вхідних даних у яких відіграватимуть бінарні зображення та необхідний тип - горизонтальний чи вертикальний:
Mat OCR::ProjectedHistogram(Mat img, int t)
{
int sz=(t)?img.rows:img.cols;
Mat mhist=Mat::zeros(1,sz,CV_32F);
for(int j=0; j<sz; j++){
Mat data=(t)?img.row(j):img.col(j);
mhist.at<float>(j)=countNonZero(data);
}
// Нормалізуємо гістограму
double min, max;
minMaxLoc(mhist, &min, &max);
if(max>0)
mhist.convertTo(mhist,-1 , 1.0f/max, 0);
return mhist;
}
Для інших особливих зон потрібні фрагменти низької роздільної здатності. Замість повного зображення символа використаємо невелику копію з розмірами, наприклад, 5x5 пікселів. Далі тренуємо систему символами, відповідні значення яких рівні 5x5, 10x10, 15x15 чи 20x20, а також оцінюємо, який з них повертає кращі результати. Потім створимо матрицю розмірності Mx1, у якій кожній колонці відповідатиме одна особлива зона.
Mat OCR::features(Mat in, int sizeData)
{
// Гістограми особливих зон
Mat vhist=ProjectedHistogram(in,VERTICAL);
Mat hhist=ProjectedHistogram(in,HORIZONTAL);
// зменшена матриця
Mat lowData;
resize(in, lowData, Size(sizeData, sizeData) );
int numCols=vhist.cols + hhist.cols + lowData.cols *
lowData.cols;
Mat out=Mat::zeros(1,numCols,CV_32F);
// присвоюємо значення особливим точкам
int j=0;
for(int i=0; i<vhist.cols; i++)
{
out.at<float>(j)=vhist.at<float>(i);
j++;
}
for(int i=0; i<hhist.cols; i++)
{
out.at<float>(j)=hhist.at<float>(i);
j++;
}
for(int x=0; x<lowData.cols; x++)
{
for(int y=0; y<lowData.rows; y++)
{
out.at<float>(j)=(float)lowData.at<unsigned char>(x,y);
j++;
}
}
return out;
}
Класифікація OCR
На етапі класифікації використаємо алгоритм штучної нейронної мережі. Точніше кажучи, найпоширеніший серед них, багатошаровий перцептрон (Multi-Layer Perceptron, MLP) Румельхарта. MLP складається з мережі нейронів, що має рівні входу, виходу та один чи більше прихованих. Кожен з них має один чи більше нейронів, що пов'язані з попереднім та наступним рівнями.
Подальший приклад присвячений 3-рівневому перцептрону (це бінарний класифікатор, що відображає вектор вхідних дійсних чисел в єдине бінарне вихідне значення) з трьома входами, двома виходами та прихованим рівнем з п'яти нейронів.
Всі нейрони в MLP подібні, кожен з них має декілька входів (зв'язки з попередніми нейронами) та декілька виходів з однаковим значенням (зв'язки з наступними нейронами). Кожен нейрон вираховує вихідне значення як суму зважених входів плюс зміщення, та змінюється обраною функцією активації:
Існують три розповсюджені функції активації: тотожна, сигмоїда та гаусіан; найважливішою з них є друга. Її значення альфа та бета рівні одиниці:
Натренована нейронна мережа має вектор вхідних особливих зон. Вона передає значення до прихованого рівня та обчислює результати з урахуванням ваги та функції активації. Потім вихідні дані передаються далі, аж поки не дістануться до вихідного рівня, що має певну кількість класів нейронів.
Вага кожного рівня, синапси та нейрон вираховуються та навчаються шляхом тренування алгоритму штучної нейронної мережі. Щоб покращити здібності класифікатора, сформуємо матриці, як і у випадку методу опорних векторів, але відмітки в цьому разі дещо інші. Замість матриці розмірності Nx1, де N позначає кількість рядів тренувальних даних, а 1 - колонок, ми використовуємо числовий ідентифікатор. Маємо створити матрицю NxM, де N - це примірники, а M - класи (10 цифр та 20 літер у нашому випадку), та встановити одиницю у позиції (i, j) за умови, якщо ряд даних i має клас j.
Закодуємо функцію OCR::train, яка створить всі необхідні матриці та натренує систему на основі матриці тренувальних даних, класів та певної кількості прихованих нейронів у відповідних рівнях. Дані завантажуються з файлу XML, аналогічно до процесу SVM.
Щоб налаштувати клас штучної нейронної мережі, маємо визначити кількість нейронів на кожному рівні. У нашому випадку використовуємо лише один прихований рівень. Наступним кроком є визначення матриці з 1 ряду та 3 колонок, перша комірка якої позначає кількість особливих точок, друга - прихованих нейронів, а третя - класів.
Бібліотека OpenCV визначає клас CvANN_MLP для нейронної мережі. Функція create дозволить налаштувати екземпляр, визначивши кількість рівнів та нейронів, функцію активації, а також параметри альфа й бета:
void OCR::train(Mat TrainData, Mat classes, int nlayers)
{
Mat layerSizes(1,3,CV_32SC1);
layerSizes.at<int>(0)= TrainData.cols;
layerSizes.at<int>(1)= nlayers;
layerSizes.at<int>(2)= numCharacters;
ann.create(layerSizes, CvANN_MLP::SIGMOID_SYM, 1, 1); //ann -
це глобальна змінна класу
// Підготуємо об'єкт trainClasses
// Створюємо матрицю з n тренувальними даними та m класами
Mat trainClasses;
trainClasses.create(TrainData.rows, numCharacters, CV_32FC1 );
for( int i = 0; i < trainClasses.rows; i++ )
{
for( int k = 0; k < trainClasses.cols; k++ )
{
// якщо дані i мають клас k
if( k == classes.at<int>(i) )
trainClasses.at<float>(i,k) = 1;
else
trainClasses.at<float>(i,k) = 0;
}
}
Mat weights (1, TrainData.rows, CV_32FC1, Scalar::all(1));
// Навчаємо класифікатор
ann.train( TrainData, trainClasses, weights );
trained=true;
}
Після тренування можемо класифікувати будь-яку особливу точку пластини за допомогою функції OCR::classify:
int OCR::classify(Mat f)
{
int result=-1;
Mat output(1, numCharacters, CV_32FC1);
ann.predict(f, output);
Point maxLoc;
double maxVal;
minMaxLoc(output, 0, &maxVal, 0, &maxLoc);
// Маємо дізнатися, яке положення у вихідних даних займає
// значення maxVal, значення x відповідає класу.
return maxLoc.x;
}
Клас CvANN_MLP використовує функцію прогнозування для класифікації вектора особливих точок. На відміну від схожого методу SVM, алгоритм штучної нейронної мережі повертає ряд, кожна комірка якого відповідає певному класу, а значення описують вірогідність приналежності вхідної особливої точки до нього.
Щоб отримати кращі результати, можна застосувати функцію minMaxLoc, щоб отримати максимальну та мінімальну відповіді, а також позицію в матриці. Клас символа позначається значенням x більшої змінної.
Щоб завершити розпізнавання пластини, впорядковуємо символи та повертаємо стрічку шляхом виклику функції str() класу Plate, яку можемо зобразити на поверхні початкового кадра:
string licensePlate=plate.str();
rectangle(input_image, plate.position, Scalar(0,0,200));
putText(input_image, licensePlate, Point(plate.position.x,
plate.position.y), CV_FONT_HERSHEY_SIMPLEX, 1, Scalar(0,0,200),2);
Оцінювання системи
Проєкт завершено, але під час тренування алгоритму машинного навчання, наприклад OCR, ми повинні знати, які особливі зони чи параметри краще використати та як зменшити похибки класифікації, пошуку та розпізнавання.
Потрібно перевірити дію системи за різних умов та оцінити отримані результати, а потім обрати такі значення параметрів, за яких кількість похибок буде мінімальною.
У цьому розділі завдання оптичного розпізнавання символів можна оцінити на основі розміру шаблонів низької роздільної здатності та кількості нейронів прихованого рівня.
Ми закодували застосунок evalOCR.cpp, у якому використали файл тренувальних даних у форматі XML, згенерований програмою trainOCR.cpp. Файл OCR.xml зберігає тренувальні матриці розмірності 5x5, 10x10, 15x15 та 20x20 зі зменшеними шаблонами зображень.
Mat classes;
Mat trainingData;
//Зчитуємо файл з тренувальними даними.
FileStorage fs;
fs.open("OCR.xml", FileStorage::READ);
fs[data] >> trainingData;
fs["classes"] >> classes;
Код оцінки опрацьовує кожну зменшену матрицю особливої зони та отримує 100 випадкових рядів для тренування, а також інші, для тестування алгоритму та перевірки помилок.
Перед тренуванням системи тестуємо кожен випадковий елемент на коректність відповіді. Якщо вона неправильна, додаємо одиницю до змінної, що позначає кількість помилок, а потім ділимо результат на кількість примірників. В результаті матимемо відношення похибки від 0 до 1 при тренуванні випадковими даними:
float test(Mat samples, Mat classes)
{
float errors=0;
for(int i=0; i<samples.rows; i++)
{
int result= ocr.classify(samples.row(i));
if(result!= classes.at<int>(i))
errors++;
}
return errors/samples.rows;
}
Застосунок повертає в командній стрічці вихідне відношення похибки для кожного розміру примірників. Для якісної оцінки маємо натренувати програми різними випадковими рядами; в результаті це дасть різні тестові значення похибок. Потім додамо їх та знайдемо середнє значення. Щоб виконати це завдання, створимо скрипт bash для автоматизації процесу:
#!/bin/bash
echo "#ITS \t 5 \t 10 \t 15 \t 20" > data.txt
folder=$(pwd)
for numNeurons in 10 20 30 40 50 60 70 80 90 100 120 150 200 500
do
s5=0;
s10=0;
s15=0;
s20=0;
for j in {1..100}
do
echo $numNeurons $j
a=$($folder/build/evalOCR $numNeurons TrainingDataF5)
s5=$(echo "scale=4; $s5+$a" | bc -q 2>/dev/null)
a=$($folder/build/evalOCR $numNeurons TrainingDataF10)
s10=$(echo "scale=4; $s10+$a" | bc -q 2>/dev/null)
a=$($folder/build/evalOCR $numNeurons TrainingDataF15)
s15=$(echo "scale=4; $s15+$a" | bc -q 2>/dev/null)
a=$($folder/build/evalOCR $numNeurons TrainingDataF20)
s20=$(echo "scale=4; $s20+$a" | bc -q 2>/dev/null)
done
echo "$i \t $s5 \t $s10 \t $s15 \t $s20"
echo "$i \t $s5 \t $s10 \t $s15 \t $s20" >> data.txt
done
Код формує файл data.txt, який зберігає всі результати кожного розміру та кількість прихованих нейронів відповідного рівня. Цей файл можна використати для побудови графіка за допомогою gnuplot. Результат можна бачити на наступному малюнку:
Вочевидь, найнижчий рівень похибок складає 8 відсотків, для 20 нейронів у прихованому рівні та символів, що видобуваються з фрагментів зображень 10x10 пікселів.
Висновки
У цьому розділі ми дізналися, як працює програма автоматичного розпізнавання номерних знаків, а також приділили увагу двом важливим крокам: локалізації та розпізнаванню пластини.
На першому етапі ми зрозуміли, як сегментувати зображення на фрагменти, які можуть мати пластину, а також як застосувати просту евристику та метод опорних векторів для класифікації фрагментів на номерні знаки та не знаки.
На другому кроці ми навчилися сегментувати фрагменти з використанням алгоритму пошуку контурів, сформували вектор особливих зон для кожного символа та використали штучну нейронну мережу для класифікації кожної з них. Ми також зрозуміли, як оцінити алгоритм шляхом тренування з різними випадковими даними, а також як перевірити його для різних значень параметрів та особливих зон.
Внимание! Этот перевод, возможно, ещё не готов.
Его статус: перевод редактируется
Переведено на Нотабеноиде
http://notabenoid.org/book/81521/500317
Переводчики: Atronax
Настройки
Готово:
