Сегментиране на изображение с K-средства на Apache Spark и Scala

Компютърното зрение е един от най-вълнуващите клонове на науката за данни. Има много възможни приложения за прилагане на алгоритми и техники за машинно обучение и сегментирането на изображения е една от първите стъпки в това.

Сегментирането на изображението е едно от основните разработки за обработка на компютърно зрение. Много задачи за компютърно зрение изискват сегментиране на изображение, за да се разбере всяка част и по-лесно разбиране като цяло. Всеки сегмент съдържа набор от пиксели, които могат да представляват нещо. Сегментирането е основата за две важни приложения на анализа на изображения:

  • Откриване на обект: идентифицирайте обекти в изображението, коли, хора или животни и т.н. Целта тук е просто да идентифицирате, че има обект в този набор от пиксели, но не и да класифицирате правилно кой обект е бил то.
  • Класификация: след откриването на обект, целта е всеки открит обект да се класифицира в клас. Изображението по-долу илюстрира това приложение, като всеки обект е открит и класифициран.

Техники за сегментиране

Има много начини за постигане на сегментиране на изображение чрез Deep Learning, регионално базирани подходи (които ще се опитат да открият границите на обект), чрез прилагане на алгоритми за групиране и т.н. Всяка техника има своите предимства и недостатъци. Техниките за задълбочено обучение са доказали най-добрите резултати досега, но внедряването обикновено е сложно и изисква голям брой данни за обучение на модела. Разбира се, възможно е да се използват налични предварително обучени модели, но за използване в конкретно приложение ще е необходимо обучение, което може да отнеме време и обработка. Друг възможен начин е да се приложат алгоритми за групиране. Целта е да се идентифицират групи в данните, като се присвои желаната информация от всеки пиксел (позиции x и y, информация за цвета), за да се класифицират в една от групите въз основа на сходството на характеристиките. За разлика от Deep Learning, той е по-малко ефективен, тъй като вместо да анализира предварително дефинирани групи, клъстерирането работи итеративно за органично формиране на групи, което ще изисква повече човешка намеса. Въпреки това, тъй като клъстерирането е без надзор, то не изисква твърде много данни, което може да намали времето за разработка и да спести време за обработка в сравнение с аналозите на Deep Learning.

K-означава групиране

Преди да организираме данните в групи, трябва да разберем концепцията за разстоянието между две точки от данни в аналитичната геометрия. Има много начини за изчисляване на разстоянието, но тук ще се съсредоточа върху един от най-често използваните методи: Евклидово разстояние. Според wikipedia Евклидовото разстояние между две точки p и q е дължината на отсечката, която ги свързва. Ако p = (p1, p2,…, pn) и q = (q1, q2,…, qn) са две точки в евклидовото n-пространство , Питагоровата теорема може да се използва за изчисляване на разстоянието между две точки:

Нека използваме практически пример. Дадени са две точки p и q, където p=(5,3)и q = (4,2). Използвайки изображението по-долу като илюстрация, x1 = 5, y1 =3, x2 = 4, y2 = 2. Прилагайки формулата, имаме d(p,q) = (4–5)2+ (2–3)2 = 2.

Сега, след като имаме разбиране за разстоянието, можем да продължим към изчисляването на K-Means. Първо се определя крайният брой клъстери K. Броят на клъстерите трябва да се дефинира, като се има предвид контекстът на приложението за всеки случай. След това е необходимо да се дефинират центроидите за клъстерите. Центърътсе определя като средната стойност на всички точки от данни в даден клъстер. Така че, ако точка от данни Pе по-близо до центроид K1 вместо K2, можем да кажем, че тази точка принадлежи към групата K1. Има много начини за инициализиране на центроидите. Ето някои от тогава:

  • RPслучайно избрани точки. k отделни случая на данните се избират на случаен принцип, за да бъдат първоначалните центрове.
  • KMPPслучайни най-отдалечени точки или k-средно++. Първият център е избран като произволен случай от набора от данни. Вторият център също е избран на случаен принцип, но вероятността за избор на случай е пропорционална на разстоянието (квадратно евклидово) от него до този (1-ви) център. Третият център също се избира на случаен принцип, като вероятността за избор е пропорционална на разстоянието на случай до най-близкия от тези два центъра — и т.н.
  • SIMFPнай-отдалечените точки (прост избор). Първият център се избира като произволен случай от набора от данни. Вторият център е избран като случай, който е максимално отдалечен от този център. Третият център се избира като случай, който е максимално отдалечен от тези два (от най-близкия от двата), — и т.н.
  • RUNFPнай-отдалечените точки (текуща селекция). Първите k случая се вземат като центрове и след това по време на преминаването през останалите случаи от набора от данни има прогресивни замени между центровете са готови; целта на замените е да се получат в крайна сметка k точки, най-отдалечени една от друга в пространството на променливите. Тези точки (случаи), заемащи периферни позиции в облака от данни, са произведените първоначални центрове.
  • GREPпредставителни точки на групата. Идеята на метода е да се съберат като центрове k най-представителните, „депутатски“ случаи. Първият център се приема като случай, който е най-близък до центроида с общи данни. След това останалите центрове се избират от точките с данни по такъв начин, че всяка точка се разглежда дали е по-близо (и колко, по отношение на квадрат на евклидово разстояние) до набор от точки, отколкото всяка една от последните е към някой от вече съществуващите центрове. т.е. всяка точка се разглежда като кандидат за представяне на група от точки, които все още не са достатъчно добре представени от вече събраните центрове. Най-представителната точка в това отношение е избрана като следващ център.

И така, сега, след като консолидирахме разстоянието и инициализацията на центроидите, нека да видим как работят алгоритмите K-Means:

Ако все още имате някакви съмнения в която и да е стъпка, аз напълно препоръчвам това изчисление стъпка по стъпка, написано от Sunaina, на datasciencecentral. Освен това това видео показва визуализация на всяка стъпка.

Внедряване на сегментиране на изображение с K-Mean на Spark

Добре, време е да започнем да решаваме проблема със сегментирането на изображението с алгоритъма за клъстериране на k-средства на apache spark със scala. Чакай, но защо scala? В момента Python е най-предпочитаният език сред учените за данни, не само че е лесен за научаване и внедряване, но и заради обширните си библиотеки и рамки. В проектите за наука за данни и машинно обучение той включва широк набор от полезни библиотеки SciPy, NumPy, Matplolib, Pandas, наред с други, докато за по-сложни проекти в дълбокото обучение Python предлага библиотеки като Keras, Pytorch и TensorFlow. С всички тези предимства, отново, защо scala? Е, Spark е написан на Scala, тъй като може да бъде доста бърз, защото е статично въведен и се компилира по познат начин към JVM. Дойдох от света на Java и едно от предимствата е да използвам цялото наследство от Java, като библиотеки и рамки.

Няма да задълбавам в разликите между езиците, защото това не е целта на тази публикация (има стотици налични предимства и недостатъци, google е ваш приятел), но както казах преди, факт е, че общността на Python е огромна и предоставя много библиотеки и рамки за използване заедно със Spark, много налични уроци. Поради това основната ми мотивация с тази публикация е да допринеса с общността на scala.

Зареждане на изображения в Spark Data Frames

От версия 2.4, Spark има вграден нов източник на данни за четене на компресирани формати (jpg, png и т.н.). Изображението ще бъде прочетено с ImageIO Java Library и има специална схема на рамка с данни. Четенето на файл с изображение (или няколко, просто посочване на директория, която съдържа само изображения) може да се извърши чрез:

Схемата съдържа StructType колона „Изображение“, която съдържа цялата информация за прочетените данни. Вътре в StructType можете да намерите следните колони:

  • произход: StringType (представлява пътя на файла на изображението)
  • височина: IntegerType (височина на изображението)
  • ширина: IntegerType (ширина на изображението)
  • nКанали: IntegerType (брой канали за изображения)
  • режим: IntegerType (съвместим с OpenCV тип)
  • данни: BinaryType (Байтове на изображението в ред, съвместим с OpenCV: BGR по ред в повечето случаи)

Важните данни за нас са масивът от байтове, които Spark генерира за нас, представляващи необработените данни за изображението. Масивът съдържа всички функции, които можем да използваме за клъстериране.

Цифровите изображения са съставени от пиксели, а всеки пиксел е съставен от комбинации от цветове, представени от код. Едно изображение в сива скала например има само един цветен канал. За компютърни дисплеи най-използваният стандарт еRGB. Едно RGB изображение има три канала, всеки от които означава яркостта на интензитета на съответните цветове: червен, зелен и син. И така, основно RGB изображение е комбинация от три изображения (по едно за всеки канал), всяко от които съдържа информация за съответния цвят.

Опитвали ли сте някога да погледнете по-отблизо в екраните на стари CRT телевизори? Ако е така, вероятно си спомняте, че можете да видите трите цветни канала. Идеята тук е същата, както илюстрира изображението.

Как Spark декодира изображението

За да изградим нашата карта на характеристиките, трябва да разберем как искра чете и декодира изображението. Гмуркайки се в искровия код, имаме обектаImageSchema.scalaи по-конкретно метода decode.

Този метод основно проверява броя на каналите, които има изображението, и декодира въз основа на този брой. Да вземем пример: разгледайте изображение с размери 640 x 480, което ще има 307 200 пиксела. Ако изображението е в сива скала (само 1 канал), крайният размер на масива ще бъде 307200. Ако обаче е RGB изображение, крайният масив ще има 307200 * 3 = 921600. Редът на каналите се определя като BGR (и също там е пространство за алфа канала, който определя прозрачността). Със същия пример за изображение 640 x 480 RGB, първите три позиции на масива ще съдържат информация за цвета (синьо, зелено и червено) от пиксела при височина = 0 и ширина = 0. Следващите три позиции ще съдържат информация от пиксела при при височина = 0 и ширина = 1 и т.н.

Създаване на рамка с данни за функции

Сега, след като знаем как spark съхранява информация за изображението, можем да прочетем масива от данни, за да изградим нашата рамка с данни за характеристиките, която ще се използва в алгоритъма K-Means. За всеки пиксел трябва да имаме w позиция, h позиция и всеки един от цветните канали.

За да започнем тази задача, ще използваме две изображения, както е дадено по-долу, един пейзаж и човек (замъглих лицето на жената, само за да предотвратя всякакви проблеми).

Логиката за извличане на информацията е да се прочете масивът, следвайки логиката на метода за декодиране, и те да го транспонират в колони с рамка на данни. Нека следваме стъпка по стъпка:

Започваме с четене на изображението и извличане на масива от байтове и създаване на нов RDD за него, прилагане на трансформацията flatMap, връщане на всяка позиция на масива като запис в RDD. Можем да работим директно върху масива от изображения, но бихме загубили едно от предимствата на използването на spark: разпределени структури от данни. Работейки винаги с RDD структури, ние гарантираме, че нашият код винаги ще бъде разпространяван.

Следващата стъпка е извличане на информация за канали, височина, ширина и стартиране на акумулаторите. Акумулаторите са променливи за агрегиране на стойностите на всички изпълнители на искра. Тъй като използваме RDD, не можем да използваме обикновени променливи за агрегиране, защото когато Spark изпрати този код до всеки изпълнител, променливите стават локални за този изпълнител и актуализираната му стойност не се предава обратно на драйвера. Така че, ако искаме нашите броячи да работят правилно (за h и w позиции и отместване на цветовете), трябва да използваме акумулатори.

Окончателната обработка започва с трансформацията zipWithIndex, която ще върне нов RDD от двойки, кортеж с елемента и индекс, който ще бъде полезен за отместването на байтовете. zipWithIndex ще бъде полезен за присвояване на индекс на всеки запис в RDD, който ще се използва за отместване. В крайна сметка RDD ще бъде преобразуван в рамка с данни с три колони, представящи информацията за цвета и позициите x и y. Важно е да се отбележи, че всеки пиксел ще има броя канали, описани в схемата. Така че ще бъде важно да свиете цялата цветова информация на определен пиксел само в един запис от набора от данни. За това се използва агрегиране на рамката с данни, групиране по позициите X и Y на пиксела. След това се прилага функцията org.apache.spark.sql.functions.collect_list, която ще вземе всички агрегирани стойности от цветната колона и ще върне списък, който я съдържа. Накрая се връща DataFrame с позициите X и Y, информация за син, зелен и червен цвят.

Изпълнението по-горе разглежда само изображения с три канала (RGB), тъй като това е най-често срещаният в изображенията. Изображенията с 4 канала (RGBa) използват четвъртия канал за информация за прозрачност и ще изискват малка корекция в изместената позиция на пикселите.

Тръбопроводът за машинно обучение на Spark

Като цяло повечето реализации на модели за машинно обучение могат да бъдат проектирани като подредена последователност от някои алгоритми, като следното:

  • Извличане на функции, трансформация и селекция.
  • Обучете предсказуем модел въз основа на тези вектори и етикет.
  • Направете прогнози, като използвате генерирания модел.
  • Оценете модела (производителност и точност).

Spark MLib предоставя две абстракции от най-високо ниво, за да улесни разработването на този конвейер: трансформатори и оценители. Трансформатор имплементира метод transform(), който ще преобразува една DataFrame в друга, като обикновено добавя една или повече нови колони. Например, трансформаторът ще вземе всички характеристики на колоните на всеки запис в рамката с данни и ще ги картографира в нова колона (вектори на характеристики). Оценителят ще отговаря за прилагането на алгоритъма за обучение, който се вписва или обучава на данни. Той имплементира метода fit(), който приема DataFrame и създава модел, който е трансформатор. Като пример, алгоритъм за обучение като DecisionTree е оценител и извикването на fit() обучава DecisionTreeClassificationModel,който е модел и следователно трансформатор.

С нашата DataFrame с готови колони за характеристики (b,g,r,w,h), можем да започнем нашия конвейер чрез прилагане на трансформатора VectorAssembler. Този трансформатор комбинира даден списък от колони в една векторна колона. Използваме метода setInputCols за предаване на масив, посочващ колоните, които искаме да комбинираме. Методът setOutputCol се използва за посочване на колоната, която ще съдържа комбинирания вектор.

Първоначално само информацията за цвета ще се използва като характеристики на нашия модел. Можем да тестваме и обсъдим включването на x и y позиции по-късно. Това ще бъде полученият DataFrame:

Сега можем да създадем оценителя на Kmeans и да обучим нашия модел. Променливата K ще определи броя на клъстерите, които ще бъдат използвани. Методът setSeed е за произволна инициализация на клъстерите. Ако не предоставите семената, spark ще генерира такава вътрешно, което ще компрометира сравненията на тестове, тъй като производителността ще бъде различна за всяко изпълнение, тъй като началните точки на клъстерите ще бъдат различни за всяко изпълнение. Методът setFeaturesColполучава колоната, която съдържа вектора от функции.

Моделът се създава чрез извикване на метода fit() и предаване на DataFrame, който е трансформиран от Vector Assembler. Моделът ще даде резултатите чрез извикване на метода transform(), връщащ DataFrame, съдържащ нова колона, прогноза.

И накрая, полученият DataFrame ще бъде така:

Колоната за прогнозиране ще информира към коя група принадлежи този запис. Обърнете внимание, че номерата на клъстерите винаги ще започват с нула.

Писане на сегментирано изображение.

Spark не предоставя начин за писане на изображения, така че трябва да внедрим такъв. Освен това е важно да решите как да представите цветовете на клъстерите върху новото изобразено изображение. В този случай реших да използвам първия намерен цвят от всеки клъстер, за да го представя.

Идеята е доста проста. За всеки клъстер филтрираме набора от данни, като използваме номера на клъстера и получаваме първия намерен запис, добавяме номера на клъстера като ключ в карта и цветните канали като стойност в Scala Tuple (https://docs.scala -lang.org/tour/tuples.html).

Сега е възможно най-накрая да напишете окончателното изображение. За целта ще използваме класа javax.imageio.ImageIO. Първо избираме колоните w, h и предсказване и трансформираме DataFrame в масив, като използваме функцията collect(). След това създаваме буферирано изображение, като предаваме ширината, височината и типа на изображението. И накрая, просто повторете масива, записвайки в буферираното изображение позицията на пиксела и съответното цветно изображение. Забележете, че класът BufferedImage има само метода setRGB, така че трябва да променим реда на цветовете, когато използваме класа java.awt.Color.

Тестване на внедряването

Добре, сега можем да тестваме решението с изображенията, представени преди. Но първо трябва да решим колко клъстера искаме да имаме за сегментирането? Гледайки изображението по-долу, ще използвам K =5 въз основа на броя на отделните части, които мога да наблюдавам с просто око (червената рокля, кожата, океана, небето и земята).

Това е резултатът за K = 5:

Виждаме, че роклята и кожата са сегментирани почти перфектно. Някои части са поставени в същия цвят на скалите, защото спектърът им е по-близък. Небето също е почти уникален сегмент. След следващото изображение ще разгледам и K = 5.

Това е резултатът:

Трите лица на плажа бяха класифицирани в един и същи клъстер на планината и част от плажа, тъй като поради сенките цветовият им спектър е подобен.

Ето други писти с различни K. Обърнете внимание, че с увеличаване на K се доближаваме до оригиналното изображение. Това може да бъде полезно за компресиране на изображения. Опитайте се да бягате с K = 128 и K = 256.

Намиране на най-добрата стойност на K

Как можем да определим най-добрата стойност на K за определено изображение? По-рано казах, че използвах собственото си възприятие, за да използвам число, но не можем да разчитаме на човешкото възприятие, това е Data Science!

Ето как ще продължим: ще дефинираме диапазон за K, например от 2 до 20. След това за всяка стойност на K ще изчислим цената на функцията K-Means и ще начертаем цената върху графика. Стойността, която е сведена до минимум с малка
възвръщаемост, получена след това, ще бъде най-добрият избор. За чертане ще използвам vegas-viz plot lib за scala (https://github.com/vegas-viz/Vegas). Нека включим следните зависимости в pom.xml:

<dependency>
   <groupId>org.vegas-viz</groupId>
   <artifactId>vegas_2.11</artifactId>
   <version>0.3.11</version>
</dependency>
<dependency>
   <groupId>org.vegas-viz</groupId>
   <artifactId>vegas-spark_2.11</artifactId>
   <version>0.3.11</version>
</dependency>

Ще използваме оценителя KMeans() на eMLlib в Spark DataFrame, съдържащ нашите вектори на характеристиките, итерирайки стойностите на k в диапазона (2, 20). След това изобразяваме резултатите на html страница (можете да начертаете в изскачащ прозорец, да проверите документацията на vegas-viz, но ще ви трябва пакетът javafx, който не е включен в openjdk). Обърнете внимание, че не е необходимо да изпълняваме оценителя в целия DataFrame, като вземаме извадка само от 10%, за да спестим изпълнение по време на изпълнение.

И така, както виждаме в резултатите, след K=14 за изображението на плажа цената не се променя твърде много и K=12 за изображението на жената. Новите изобразени изображения са по-долу

Можем да видим, че женската кожа и рокля все още са в един клъстер за всяка, което може да бъде много полезно за откриване на човека на снимката. Небето обаче не е просто уникален клъстер. В изображението на плажа можем да видим, че тримата лица могат да бъдат идентифицирани ясно от отражението върху пясъка.

Заключение

Надявам се, че допринесох за вашето обучение относно алгоритъма K-Means, Apache Spark и ML. Spark е рамка, предназначена за групова обработка. Той разширява модела MapReduce, за да го използва ефективно за повече видове изчисления, което включва интерактивни заявки и обработка на потоци.

Въпреки че Spark има много изпълнения на ML алгоритми, готови за използване, той няма нищо свързано с Computer Vision, освен ImageSchema, което направи възможно зареждането на изображения в DataFrames. Въпреки че разработчиците на Python могат да намерят широка гама от библиотеки и документация за различни подходи към различни проблеми и Mix frameworks за него, усещам, че общността на Scala/Java се нуждае от повече съдържание по отношение на науката за данни. Това беше моята мотивация за това развитие.

Можете да проверите пълния код тук: https://github.com/gsjunior86/SIS. Ако имате някакви въпроси, съмнения относно това, моля, пишете ми.