Bardzo się cieszę, ponieważ dziś artykuł numer 14, w którym omówię coś praktycznego. Biblioteka Emgu CV w połączeniu z odczytywaniem tekstu napisanego alfabetem Braille’a. Temat wydaje się ciężki, ale w rzeczywistości sprawia sporo frajdy. Kod poniżej jest w 100% mojego autorstwa i tak naprawdę to tylko ułamek problemu.
Postaram się przedstawić następujące zagadnienia:
- W jaki sposób odczytać kropki z obrazu?
- Jak mapować znaki alfabetu Braille’a na litery alfabetu łacińskiego?
- Jakie problemy możemy napotkać podczas czytania znaków alfabetu?
Skupiam się w tym problemie na ściśle określonym zadaniu, czyli deszyfracji pojedynczego znaku alfabetu, który występuje na obrazie z białym tłem. Bardziej zaawansowane rozwiązanie pozostawiam Ci do przygotowania na bazie poniższej implementacji.
Miłego czytania i zabawy z kodem!
Zatrzymaj się!
Książki to obowiązkowa pozycja dla każdego zainteresowanego programowaniem!
Jest to zdecydowanie jedno z najlepszych źródeł do nauki programowania! Zyskasz przewagę w branży IT i osiągniesz dużo jako deweloper.
Wprowadzenie
Skąd pomysł na tego typu zadanie? Po pierwsze, z problemem wyszedł mój znajomy, który potrzebował czegoś podobnego w ramach jednego dużego projektu. Po drugie, podczas podsumowania pierwszego miesiąca robiłem rozeznanie na temat kierunku bloga. Bardzo chcieliście, by artykuły były techniczne, ale również praktyczne, w których nie będzie tylko przykładów teoretycznych. Postanowiłem trochę kodu „produkcyjnego” prezentować w artykułach.
Problem z odczytem tekstu z plików graficznych jest dość ciekawym zagadnieniem i często poruszanym podczas nauki na studiach i zajęć z przetwarzania grafiki. Bardzo popularną biblioteką dla języka C++ jest OpenCV. W niniejszym artykule jednak stworzę fragment rozwiązania z użyciem języka C#, przy wykorzystaniu nakładki na OpenCV, czyli Emgu CV.
Na temat Emgu CV pisałem w jednym z czasopism, więc nie chcę teraz omawiać tego zbyt szczegółowo. Skupię się na praktycznym podejściu do tematu w sposób analityczny oraz pokaże przykład pracy na tablicach i listach w efektywny sposób.
Po pierwsze analiza
Architekt stoi na brzegu rzeki i ma w myślach projekt, który za parę miesięcy odmieni jego karierę zawodową. Wizualizuje sobie nowy most wraz z szeroką drogą i siebie mknącego po nowym szlaku. Tak samo Ty jako programista musisz na tym brzegu przystanąć i zwizualizować sobie wygląd oraz działanie produktu, nad którym zaczynasz pracę. Musisz być jak ten architekt myślący o karierze, którą każdy nowy projekt odmienia i wprowadza na jeszcze wyższy poziom. Bez względu na to, czy jest to mała budowla, czy największy na świecie most i wyzwanie architektoniczne godne najlepszych specjalistów w branży. Każda praca oznacza progres.
Projekt dotyczący alfabetu Braille’a nie będzie wyjątkiem. Przed rozpoczęciem prac zastanowiłem się nad następującymi aspektami:
- W jaki sposób powinna wyglądać aplikacja?
- W jakim charakterze wyświetlać napis?
- Czy będą wyświetlane też obrazy liter alfabetu Braille’a obok liter alfabetu łacińskiego?
- Czy Emgu CV będzie odpowiednia do tego typu zadania?
- Jak mogą wyglądać obrazy? Zamazane? Obrócone? Rozmyte? Rozciągnięte?
- Czy będą widoczne białe kropki? Kropki czarne będą miały intensywny kolor?
- Tło będzie białe czy może czerwone, zielone albo niebieskie?
- Ile plików jednoczenie będzie można wgrać?
- Czy będzie potrzebna autoryzacja?
- Litery wyświetlamy tylko na ekranie czy może zapisujemy do pliku? Bazy danych? Na zasób sieciowy?
Tak jak widać, pytań postawionych jest bardzo wiele, a może ich być jeszcze więcej. Wszystko to kwestia doświadczenia zebranego w bojach produkcyjnych. Odpowiedzi na te pytania nigdy nie mogą ominąć klienta, ponieważ musi on dostać te pytania wraz z ewentualnymi sugestiami oraz zarysem wizualnym aplikacji. Gdy już etap analizy i ustalenia szczegółów mamy za sobą pozostaje przygotować specyfikacje, uzyskać podpis kontrahenta i wrócić do prac programistycznych. Jeśli jesteś studentem, warto takie rzeczy symulować, na przykład z kolegą z koła programistycznego, który będzie zleceniodawca.
Praktyka jest najważniejsza!
Po drugie — opis kodu
Gdy już nastąpił moment, że trzymasz w dłoni podpisany dokument ze specyfikacją, musisz mieć w myślach produkt końcowy, który zostanie dostarczony klientowi. Siadasz przed komputerem, uruchamiasz IDE i zaczyna się właściwa część pracy, czyli programowanie. Projekt masz już zaczęty, ponieważ w momencie analizy musiałeś przygotować wizualizację. Prace wykonane zostanę w starym, dobrym Windows Forms.
Wygląd aplikacji
Ustalenia pierwszego etapu aplikacji stanęły na tym, że aplikacja ma zezwolić na wczytanie jednego pliku z dysku. Pozwoli na to przycisk Wybierz plik o nazwie btnChoose. Ścieżka do pliku zostanie zaprezentowana w polu tekstowym o nazwie txtFilePath. Po wczytaniu pliku z zasobu będzie można go przetworzyć, klikając w przycisk Odczyt o nazwie btnReadLetter.
Przetwarzany obraz będzie zawierał jedną literę alfabetu Braille’a, która będzie zaprezentowana z zaznaczonymi na czerwono okręgami w prawym polu typu PictureBox o nazwie pb. Odczytana litera alfabetu łacińskiego widoczna będzie w lewej części w RichTextArea o nazwie rtb.


Metody inicjalizujące
Podczas tworzenia nowego projektu Visual Studio pomaga, tworząc kilka metod i ten akapit będzie poświęcony konstrukcji głównej klasy i inicjalizacji obiektów.
Konstruktor FrmMain() (linie 22-27) zawiera metodę InitializeComponent() (linia 24) wygenerowaną automatycznie przez IDE. Zadaniem metody jest inicjalizacja kontrolek podczas startu aplikacji. Dodatkowo dodaję metodę InitializeObjects() (linia 26), w której następuje inicjalizacja zmiennych. Zawartość metody jest bardzo prosta, gdzie znajdują się trzy obiekty:
- Image<Bgr, Byte> img, który ustawiany jest na wartość NULL (linia 48). Będzie on przechowywał wczytany obraz.
- List<Dot> allDots, którego obiekt jest tworzony od nowa i będzie przechowywał położenie X i Y każdej z odczytanych kropek. Dodatkowo przechowana zostanie informacja o tym, czy kropka jest ciemna, czy jasna. Pomocna w tym przypadku będzie klasa Dot (linie 218-223).
- Zmienna tekstowa dots, która będzie ustawiana jako pusty String. Przechowana w niej zostanie informacja o wszystkich 6 kropkach litery alfabet Braille’a. Następnie tekst ten zostanie przekazany do metody Letter (linie 155-214). Tam nastąpi przypisanie i zmatchowanie wartości tekstowej z odpowiadającą jej literą. Więcej poniżej.
Przyciski
Po uruchomieniu projektu i rozłożeniu kontrolek następuje czas oprogramowania przycisków. Przycisk do wczytania pliku graficznego wywołuje metodę buttonLoadFile_Click (linie 29-44). Metoda ta po pierwsze wywołuję metodę z inicjalizacją obiektów, dlatego by podczas kolejnego wczytania pliku nie pozostały w obiektach jakieś poprzednie wartości.
Następnie właściwa część metody, której zadaniem jest otworzenie okna dialogowego z filtrem "Image Files (*.bmp;*.jpg;*.jpeg,*.png)|*.BMP;*.JPG;*.JPEG;*.PNG"
. Dzięki temu pokażą nam się pliki graficzne. Gdy plik uda się poprawnie wczytać, następuje wczytanie ścieżki do tekstowego label oraz przypisanie obrazu do obiektu Image, który to obiekt udostępnia biblioteka Emgu CV (linie 40-41).
Pod przyciskiem Odczyt dzieje się cała magia, ponieważ event buttonReading_Click ma w sobie kilka podrzędnych metod, które realizują wszystkie operacje potrzebne do odczytu tekstu z obrazu. Na wstępie sprawdzam, czy obiekt Image został poprawnie załadowany (linia 55). Następnie obudowuję wszystko blokiem try…catch, aby złapać ewentualne problemy (linie 57-71).
W odpowiedniej kolejności wywoływane są metody:
- FindCircles
- DrawRectangles
- CreateDotList
- SumDots
Omówienie każdej z nich następuje poniżej.
Metoda FindCircles
Jak sama nazwa metody sugeruje, następuje tam znalezienie okręgów. W naszym przypadku będzie to sprawdzenie wszystkich okrągłych kształtów na wczytanym obrazie. Metoda FindCircles jest typu VectorOfVectorOfPoint, który jest jedną z klas dostępnych w bibliotekach od Emgu CV. Nie będę nad tym się tu rozwodził, ale miej w świadomości, że zwrócone zostaną informacje o położeniu każdego ze znalezionych okrągłych kształtów.
W pierwszej kolejności następuje odwrócenie kolorów i zapisanie tego obrazu do zmiennej temp (linia 82). Później tworzę dwa obiekty — VectorOfVectorOfPoint oraz Mat (linie 84-85), które będą pomocne podczas wywołania funkcji FindContours z klasy CvInvoke (linia 87). Po jej wywołaniu w zmiennej contours pojawią się informacje na temat każdego ze znalezionych obiektów. Następuje zwrócenie wartości z metody.
Metoda DrawRectangles
Metoda ma kilka zadań (nie jest to zbyt dobre, ale przed refaktoryzacją rozwiązanie jest wystarczające). Na początku tworzę listę obiektów Rectangle (linia 94) i w kolejnym kroku następuje iteracja po wszystkich znalezionych okręgach i zostają one „otoczone” (linie 96-99). Dzięki temu w obiekcie List<Rectangle> mamy wszystkie okręgi wpisane w kwadraty, które możemy teraz przesortować, zaczynając od lewego, górnego narożnika (linia 101). Druga z pętli (linia 103-110) powinna być wydzielona do osobnej metody, ponieważ jej zadaniem jest narysowanie na obrazie czerwonych obwodów. Będzie to widoczne w boxie po prawej stronie programu (Rysunek 2), gdzie, aby zaprezentować obraz na ekranie, należy z obiektu Image pobrać bitmapę i ustawić ją w obiekcie pb (linia 112). Ostatecznie zwrócony będzie obiekt listy czerwonych okręgów (linia 114).
Metoda CreateDotList
Metoda jest typu void i jej zadaniem jest uzupełnienie listy z informacjami o każdej z kropek. Całe działanie opiera się na pętli foreach (linie 119-129), która iteruje po liście wyznaczonych okręgów, następnie z pomocą metody IsBlack typu bool sprawdzane jest, czy centralny punkt okręgu jest jasnego, czy ciemnego koloru (linie 123-126). Po ocenie koloru pozostaje utworzenie obiektu typu Dot i dodanie go do listy (linia 128).
Wrócę jeszcze na moment do metody IsBlack, która przyjmuje wszystkie podstawowe parametry okręgu oraz bitmapę i tam na podstawie palety kolorów RGB weryfikuje barwę najbardziej scentralizowanego pixela. Metoda celowo została tak napisana, by dać Ci pole do popisu podczas refaktoryzacji. Spróbuj inaczej przekazać parametry wejściowe i możesz optymalnie uzyskać informację na temat koloru kropki/ W razie problemów skontaktuj się ze mną.
Metoda SumDots
Ostatnia z czterech głównych metod programu, gdzie też zostawiam pole do optymalizacji. Zadaniem jest przypisanie do zmiennej tekstowej wartości wszystkich kropek, które znajdują się na liście (linie 134-137). Pobranie informacji o poszczególnych kropkach wspomagane jest za pomocą Linq. Na koniec, gdy już wszystkie kropki zostaną „złożone” w obiekt tekstowy pozostaje wywołać metodę Letter (linia 139) i zapisać literę alfabetu łacińskiego do pola tekstowego po lewej stronie programu (Rysunek 2).
Po trzecie — ostateczna implementacja
using Emgu.CV;
using Emgu.CV.CvEnum;
using Emgu.CV.Structure;
using Emgu.CV.Util;
using System;
using System.Collections.Generic;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
namespace Alfabet_Braille
{
public partial class FrmMain : Form
{
Image<Bgr, Byte> img;
List<Dot> allDots;
string dots;
public FrmMain()
{
InitializeComponent();
InitializeObjects();
}
private void buttonLoadFile_Click(object sender, EventArgs e)
{
InitializeObjects();
using (OpenFileDialog dlg = new OpenFileDialog())
{
dlg.Title = "Open Image";
dlg.Filter = "Image Files (*.bmp;*.jpg;*.jpeg,*.png)|*.BMP;*.JPG;*.JPEG;*.PNG";
if (dlg.ShowDialog() == DialogResult.OK)
{
txtFilePath.Text = dlg.FileName;
img = new Image<Bgr, Byte>(txtFilePath.Text);
}
}
}
private void InitializeObjects()
{
img = null;
allDots = new List<Dot>();
dots = "";
}
private void buttonReading_Click(object sender, EventArgs e)
{
if (img != null)
{
try
{
VectorOfVectorOfPoint contours = FindCircles();
List<Rectangle> rectangles = DrawRectangles(contours);
CreateDotList(rectangles);
SumDots();
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
else
{
MessageBox.Show("Nie załadowano pliku graficznego!", "Błąd",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private VectorOfVectorOfPoint FindCircles()
{
var temp = img.SmoothGaussian(5).Convert<Gray, byte>().ThresholdBinaryInv(new Gray(230), new Gray(255));
VectorOfVectorOfPoint contours = new VectorOfVectorOfPoint();
Mat m = new Mat();
CvInvoke.FindContours(temp, contours, m, RetrType.External, ChainApproxMethod.ChainApproxSimple);
return contours;
}
private List<Rectangle> DrawRectangles(VectorOfVectorOfPoint contours)
{
List<Rectangle> rectangles = new List<Rectangle>();
for (int i = 0; i < contours.Size; i++)
{
rectangles.Add(CvInvoke.BoundingRectangle(contours[i]));
}
rectangles = rectangles.OrderBy(r => ((int)Math.Round(r.X / 10.0)) * 10).OrderBy(r => ((int)Math.Round(r.Y / 10.0)) * 10).ToList();
for (int i = 0; i < rectangles.Count; i++)
{
double perimeter = CvInvoke.ArcLength(contours[i], true);
VectorOfPoint approx = new VectorOfPoint();
CvInvoke.ApproxPolyDP(contours[i], approx, 0.04 * perimeter, true);
CvInvoke.DrawContours(img, contours, i, new MCvScalar(0, 0, 255), 2);
}
pb.Image = img.Bitmap;
return rectangles;
}
private void CreateDotList(List<Rectangle> rectangles)
{
foreach (var item in rectangles)
{
int dotIsBlack = 0;
if (!isBlack(img.Bitmap, item.X, item.Y, item.Right - item.Left, item.Bottom - item.Top))
{
dotIsBlack = 1;
}
allDots.Add(new Dot() { blackDot = dotIsBlack, X = item.X, Y = item.Y });
}
}
private void SumDots()
{
for (int i = 0; i < allDots.Count; i++)
{
dots += allDots.ElementAt(i).blackDot.ToString();
}
rtb.Text = Letter(dots);
}
private bool IsBlack(Bitmap bm, int cropX, int cropY, int cropWidth, int cropHeight)
{
var rect = new Rectangle(cropX, cropY, cropWidth, cropHeight);
Bitmap newBm = bm.Clone(rect, bm.PixelFormat);
Color obj = newBm.GetPixel(cropWidth / 2, cropHeight / 2);
if (obj.G > 20 && obj.B > 20 && obj.A > 20)
return true;
else
return false;
}
private string Letter(string alphabet)
{
switch (alphabet)
{
case "100000":
return "A";
case "101000":
return "B";
case "110000":
return "C";
case "110100":
return "D";
case "100100":
return "E";
case "111000":
return "F";
case "111100":
return "G";
case "101100":
return "H";
case "011000":
return "I";
case "011100":
return "J";
case "100010":
return "K";
case "101010":
return "L";
case "110010":
return "M";
case "110110":
return "N";
case "100110":
return "O";
case "111010":
return "P";
case "111110":
return "Q";
case "101110":
return "R";
case "011010":
return "S";
case "011110":
return "T";
case "100011":
return "U";
case "101011":
return "V";
case "011101":
return "W";
case "110011":
return "X";
case "110111":
return "Y";
case "100111":
return "Z";
default:
return " ";
}
}
}
}
public class Dot
{
public int blackDot { get; set; }
public int X { get; set; }
public int Y { get; set; }
}
Podsumowanie
Jak już wcześniej wspomniałem, jest to projekt produkcyjny z użyciem Emgu CV, lecz to może 10% finalnego produktu. Będzie dobrą bazą dla Ciebie, by rozpocząć pracę nad bardziej zaawansowanym programem. Warto zastanowić się, w jaki sposób czytać linie tekstu, a nie tylko pojedyncze znaki. Jak pracować z mniej wyrazistymi obrazami, w jaki sposób czytać litery z różnokolorowego tła. Można dodać autoryzację i wczytanie wielu plików jednocześnie, jednak co zrobi program w przypadku, gdy tekst będzie widoczny pod pewnym kątem?
Jak widzisz, możliwości rozwoju aplikacji jest wiele i w tym przypadku tylko od Ciebie będzie zależało to, w jaki sposób oprogramujesz nowe funkcjonalności. Gorąco zachęcam do tego, by użyć tego kodu i modyfikować go wedle własnych potrzeb. W razie pytań i problemów można mnie złapać na mailu lub w Social Media.
Wrócę jeszcze chwilę do Emgu CV, ponieważ celowo nie zawarłem w tym artykule opisu biblioteki. Jest tak, ponieważ będę na ten temat pisał osobny artykuł, który swoją premierę miał na początku 2020 roku.
W następnym artykule napiszę dość kontrowersyjnie na temat zmiany branży.
Źródła
http://www.emgu.com/wiki/index.php/Main_Page
Newsletter
Nie przegap i dołącz już dziś do 838 osób będących w tym Newsletter! Otrzymuj co niedzielę o godzinie 20 listę kilku ciekawych tematów, które miałem okazję obserwować w mijającym tygodniu.
Tematy będą głównie techniczne, ale czasami pojawi się coś, co może wprowadzi Cię w stan zadumy i zmusi do dyskusji w szerszym gronie. Zero spamu!