Biblioteka OpenCV
Biblioteka OpenCV
jest bardzo popularną biblioteką do pisania programów, które operują na obrazach (i plikach wideo również). Dzięki tej bibliotece napiszemy dziś prosty program, który będzie zmniejszał obrazek i dodawał do niego znak wodny. W sam raz – aby umieścić pierwszą fotografię na instagram.
Oczywiście należy wspomnieć o tym, że biblioteka OpenCV
jest używana profesjonalnie przez naukowców zajmujących się widzeniem komputerowym, a przez to również sztuczną inteligencją. Biblioteka OpenCV
jest również z powodzeniem używana w świecie komercyjnego wytwarzania oprogramowania. Na stronie Baeldung
możecie znaleźć bardzo fajny tutorial jak napisać program, który będzie rozpoznawał twarze w czasie rzeczywistym.
Biblioteka zyskała swoją popularność dzięki temu, że można jej używać w programach tworzonych w wielu językach programowania. Sama biblioteka została napisana w języku C++ (czy kogoś to dziwi?), ale są dostępne porty w Pythonie, Javie a nawet JavaScripcie.
My dziś zajmiemy się portem biblioteki OpenCV
do Javy. Jak zwykle kod zamieszczony w artykule można znaleźć na naszym githubie doktor-ziel/pictures-manipulation-1
.
Pierwszy projekt
Zaczniemy od krótkiego tutoriala o tym, jak stworzyć projekt w programie IntelliJ, w którym będziemy mogli korzystać z biblioteki OpenCV
. Twórcy biblioteki stworzyli podobny tutorial dla Eclipse (Ale sam powiedz, czy ktokolwiek jeszcze używa Eclipse w dzisiejszych czasach;-) ) Jeżeli czujesz się w tej materii w miarę pewnie, możesz opuścić tę sekcję i kontynuować czytanie wpisu od następnego rozdziału korzystając z commitu bf09a86
.
Uruchamiamy IntelliJ. Jeżeli uruchamiasz go po raz pierwszy (to przede wszystkim szacun – że nie zaczynasz od zwykłego hello world
) to będziesz miał w okienku opcję do stworzenia nowego projektu. Jeżeli to jest Twoja kolejna przygoda z IntelliJ to sam dobrze wiesz File -> New -> Project...
. I zobaczysz okienko:
Możemy w nim po prostu kliknąć Next
. Zobaczymy potem takie okienko
Ja nigdy nie korzystam z szablonu, ale jak ktoś lubi. Po kliknięciu Next
dostajemy okienko do podania danych projektu
Tutaj każdy może wpisać co chce (ale odradzam zostawianie wartości domyślnych – ja potem nigdy nie wiem, co jest w projekcie o nazwie untitled
). Po kliknięciu Finish
pojawia się nam główne okienko IntelliJ.
Zanim przejdziemy dalej, będziemy potrzebowali dwóch plików: skompilowanej biblioteki dll
(jestem pewien, że linuksiarze jakoś to ogarną bez mojej pomocy) oraz paczki jar
. Pliki pobieramy ze strony twórców biblioteki OpenCV
. W tym tutorialu korzystam z wersji 4.4.0
. Gdy wybierzesz opcję Windows
to pobierze Ci się samorozpakowujące się archiwum. Po rozpakowaniu tego archiwum przekopiuj zawartość katalogu opencv/build/java
do katalogu lib
w swoim projekcie. Powinieneś dostać coś podobnego do tego:
Teraz musimy poinstruować, żeby IntelliJ skorzystał z pobranej paczki jar
. W tym celu otwieramy okienko File -> Project Structure...
Wybieramy zakładkę Libraries
I korzystając z przycisku z plusem dodajemy paczkę jar
z naszego katalogu lib
Możemy śmiało zamknąć to okienko klikając w przycisk OK
. Teraz dodajemy klasę do naszego katalogu src
:
public class ScaleInputImage { public static void main(String[] args) { System.loadLibrary(Core.NATIVE_LIBRARY_NAME); Mat inputImage = Imgcodecs.imread("resources/inputImage.jpg"); Size inSize = inputImage.size(); Mat outputImage = new Mat(); Size outSize = new Size(inSize.width/3, inSize.height/3); Imgproc.resize(inputImage, outputImage, outSize); Imgcodecs.imwrite("resources/outputImage.jpg", outputImage); } }
Kod przeanalizujemy w dalszej części artykułu.
Program ma zmniejszać zadany obrazek. Więc musimy dodać jakiś plik graficzny do projektu. W projekcie na githubie zdecydowałem się na obrazek z domeny publicznej. Ale możesz wybrać dowolny inny plik graficzny.
Teraz pozostaje nam tylko uruchomić program. W IntelliJ, przy nazwie klasy i przy nagłówku funkcji main
rzucają się w oczy przyciski z zielonymi trójkątami, które pozwalają uruchomić program. Jeżeli po prostu naciśniesz na któryś z tych przycisków, to program nie zakończy się powodzeniem. Kompilator Javy potrzebuje wiedzieć, gdzie jest skompilowany kod C++, który będziemy wykonywać przez funkcje native
. Zgodnie z sugestią twórców biblioteki powinniśmy poinstruować Javę, gdzie ma szukać bibliotek do zlinkowania.
Najprościej to zrobić przez kliknięcie przycisku Add Configurations...
w górnej części głównego okienka IntelliJ.
Dodajemy nową aplikację (przcisk z plusem w lewym górnym rogu). Wybieramy klasę zawierającą metodę main
i przekazujemy dodatkowy argument dla wirtualnej maszyny Javy: -Djava.library.path=lib/x64/
. Gdy potrzebujesz 32-bitowej wersji biblioteki, podaj -Djava.library.path=lib/x86/
.
Po uruchomieniu programu nie zobaczysz żadnego komunikatu wypisanego na standardowe wyjście.
Jeżeli nie stało się nic nieoczekiwanego, to powinieneś zobaczyć dodatkowy plik resources/outputImage.jpg
, który rzeczywiście ma mniejszy rozmiar niż plik resources/inputImage.jpg
.
Gratulacje, właśnie uruchomiłeś swój pierwszy program wykorzystujący bibliotekę OpenCV
.
Dygresja
Tak, wiem, że takie tworzenie projektów jest nieżyciowe i niestosowane w praktyce. Dużo wygodniej korzysta się z narzędzi maven
lub gradle
. Są dużo wygodniejsze – nie trzeba tak dużo klikać. Planuję przedstawienie tego samego zagadnienia przy wykorzystaniu któregoś z wymienionych narzędzi. Ale najpierw chciałbym napisać wpis o tym narzędziu (mam przeczucie, że to jednak będzie maven
), żeby początkujący programiści nie zniechęcili się przez nadmiar nowych informacji.
Analiza pierwszego programu
Korzystanie z funkcji native
i kompilowanych bibliotek wymaga od nas załadowania tej biblioteki. W tym celu na samym początku funkcji main
znajduje się następująca linijka:
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
Następnie wczytujemy do pamięci programu obrazek. W tym celu korzystamy ze statycznej funkcji imread
(od image read) zdefiniowanej w klasie org.opencv.imgcodecs.Imgcodecs
. Obrazki w pamięci naszego programu będą reprezentowały obiekty klasy org.opencv.core.Mat
. Dokładnie tak, nasze obrazki będą macierzami – czyli dwuwymiarowymi tablicami. Każda komórka takiej tablicy będzie reprezentowała jeden piksel naszego obrazka. W zależności od obrazka może to być jedna liczba, lub tablica 3 (albo nawet 4) liczb. W tym momencie nie jest to dla nas aż tak bardzo istotne. Tak więc kolejna linijka naszego programu:
Mat inputImage = Imgcodecs.imread("resources/inputImage.jpg");
tworzy obiekt macierzy, który przechowuje informacje o wszystkich pikselach naszego obrazka. Program ma zmniejszyć wymiary wejściowego obrazka trzykrotnie. Więc potrebujemy informacji o rozmiarze wczytanego obrazka. Do tego celu wykorzystujemy metodę size()
obiektu inputImage
. Metoda ta zwraca obiekt klasy org.opencv.core.Size
, który zawiera informację o wymiarach obrazka. To chyba dość dokładnie tłumaczy kolejną linijkę naszego programu:
Size inSize = inputImage.size();
Do zmniejszenia obrazka wykorzystamy metodę statyczną resize
zdefiniowaną w klasie org.opencv.imgproc.Imgproc
. Metoda ta przyjmuje 3 argumenty:
Mat src
– obiekt źródłowy – czyli obrazek, który chcemy zmniejszyćMat dst
– obiekt docelowy – czyli tak naprawdę referencja na obiekt, w którym zostanie zapisany wynik zmiany rozmiaruSize dsize
– obiekt, który mówi jakie wymiary powinien mieć obrazek wynikowy
Zwróć uwagę, że metoda resize
nie zwraca żadnej wartości. Nie jest to typowe dla programów napisanych w Javie – ale ja widzę w tym podobieństwo do niskopoziomowych programów pisanych w języku C.
Aby wykonać linijkę
Imgproc.resize(inputImage, outputImage, outSize);
potrzebujemy najpierw stworzyć obiekt outSize
. Możemy to zrobić wykorzystując konstruktor klasy org.opencv.core.Size
:
Size outSize = new Size(inSize.width/3, inSize.height/3);
Przy tej linijce powinniśmy się zadumać nad faktem, jak bardzo niezgodna z programowaniem obiektowym jest biblioteka OpenCV
– czy kojarzysz słowo enkapsulacja (w niektórych podręcznikach jest używane słowo hermetyzacja)? Ale to jest temat na inną okazję. Żeby wywołać metodę resize
potrzebujemy jeszcze stworzyć pusty obiekt, w którym będziemy przechowywali zmniejszony obrazek. W tym celu możemy wykorzystać bezargumentowy konstruktor klasy org.opencv.core.Mat
:
Mat outputImage = new Mat();
Na samym końcu programu zapisujemy obrazek do pliku. W tym celu wywołujemy metodę statyczną imwrite
(od image write) z klasy org.opencv.imgcodecs.Imgcodecs
:
Imgcodecs.imwrite("resources/outputImage.jpg", outputImage);
I to wszystko. To jest cały program, który zmniejsza zadany obrazek.
Generowanie znaku wodnego
Teraz przejdźmy do kwestii znaku wodnego. Zgodnie z polskojęzyczną Wikipedią
Cyfrowy znak wodny – technologia służąca do oznaczania plików dźwiękowych oraz graficznych.
Oczywiście znaki wodne stanowią bardzo ciekawy temat sam w sobie. Jeżeli chodzi o znaki wodne na plikach graficznych, to na pewno nie raz widziałeś obrazek w Internecie, który miał na sobie nałożoną grafikę, która z jednej strony nie zamazuje istoty grafiki, ale z drugiej strony uniemożliwia jej użycie w dowolnym innym kontekście.
Najpierw wygenerujemy obrazek z przezroczystym tłem, który będzie zawierał fioletowy napis.
Program będzie się kończył tak samo, jak poprzedni. Czyli będzie zapisywał obiekt klasy org.opencv.core.Mat
do pliku:
Imgcodecs.imwrite("resources/watermark.png", image);
Tym razem obiekt image
stworzymy korzystając z konstruktora:
Mat image = new Mat(200, 200, CvType.CV_8UC4);
w którym dwa pierwsze argumenty oznaczają wymiary (szerokość i wysokość) tworzonego obrazu. Trzeci typ oznacza tryb zapisu piksela. W związku z tym, że chcemy mieć przezroczystość, potrzebujemy czterech kanałów.
Na obrazku chcemy umieścić tekst, możemy do tego wykorzystać metodę statyczną putText
zdefiniowaną w klasie org.opencv.imgproc.Imgproc
:
Imgproc.putText(image,text, position, font, scale, color, thickness);
Gdzie image
jest macierzą przechowującą piksele obrazu (na początku wszystkie piksele są przezroczyste). Referencja text
wskazuje na napis, który chcemy wydrykować. Obiekt position
to obiekt klasy org.opencv.core.Point
, ktróry definiuje współrzędne położenia tekstu na obrazie. Zmienna font
to wartość całkowita, która definiuje czcionkę tekstu. W klasie org.opencv.imgproc.Imgproc
są stałe reprezentujące tę wartość. Obiekt scale
to liczba zmienno przecinkowa, która definiuje skalę tekstu. Jak sama nazwa wskazuje, zmienna color
to obiekt klasy org.opencv.core.Scalar
. A zmienna thickness
to grubość czcionki.
Zatrzymajmy się przez chwilę na kolorze. Obiekt Scalar
zawiera w sobie tablicę liczb double
. W praktyce używa się tylu wartości, ile kanałów posiada obrazek na którym operujemy. Więc konstruktor new Scalar(90, 60, 90, 255)
definiuje kolor fioletowy (skąd wiem? Kliknij tutaj). Jest jedno ale
. Kolejność kolorów to nie standardowe RGB… Kolejność to: BGR (niebieski, zielony, czerwony). Na pozycji cztery znajduje parametr przezroczystości. Każda z tych liczb przyjmuje wartości od 0 do 255. Przezroczystość 0 oznacza zupełną przezroczystość.
Nałożenie znaku wodnego
Teraz możemy przejść do rozwiązania problemu połączenia dwóch obrazków w jeden.
Podejście pierwsze
Użyjemy funkcji addWeighted
, którą wywołamy w następujący sposób:
addWeighted(image, alpha, watermark, beta, gamma, result);
W powyższym wywołaniu image
, watermark
i result to są obiekty klasy org.opencv.core.Mat
. Bardzo ważna rzecz – image
i watermark
muszą mieć takie same rozmiary i muszą mieć tyle samo kanałów. Inaczej OpenCV
zaprotestuje. Oczywiście result
jest pustym obiektem, do którego program wstawi nałożony obrazek. Parametry α (alpha
), β (beta
) i γ (gamma
) są parametrami w równaniu matematycznym. Zaraz przejdę do tego równania. Powiedziałem już wcześniej, a przynajmniej mam taką nadzieję, że obrazek to dwuwymiarowa tablica przechowująca informację o pikselu. Powiedzmy sobie, że result[x, y]
to jest referencja na obiekt, który opisuje zawartość piksela w komórce o współrzędnych x
, y
. W naszym programie wykorzystując bibliotekę OpenCV
możemy stworzyć obiekt:
double pixel[] = image.get(x,y);
Tak więc piksel jest opisywany za pomocą jednowymiarowej tablicy liczb zmiennoprzecinkowych. Przypominam, że OpenCV
jest dość specyficzny pod względem przechowywania informacji o kolorach. I tak też
image.get(x,y)[0]
to wartość liczbowa opisująca składową niebieską piksela na pozycjix
,y
image.get(x,y)[1]
– składowa zielonaimage.get(x,y)[2]
– składowa czerwonaimage.get(x,y)[3]
– składowa przezroczystości.
Każda ze składowych może przyjąć wartość od 0
do 255
. Teraz możemy wrócić do równania o którym mówiłem.
Równanie opisuje wartość każdego kanału każdego piksela, który zależy od wartości poszczególnych kanałów w odpowiadających pikselach obrazka wejściowego i znaku wodnego. α i β możemy traktować współczynnik istotności koloru z obrazka image
i watermark
. Współczynnik γ może po prostu podnieść wartość współrzędnych koloru – czyli rozjaśnić wynikowy obrazek. Teoretycznie powinniśmy zachować Równanie
W przeciwnym przypadku możemy zaobserwować pewne nieoczekiwane zachowanie. Gdy uruchomisz program:
System.loadLibrary(NATIVE_LIBRARY_NAME); Mat image = imread("resources/inputImage.jpg", IMREAD_COLOR); cvtColor(image, image, COLOR_BGR2BGRA); Mat watermark = imread("resources/watermark.png", IMREAD_UNCHANGED); resize(watermark, watermark, image.size()); Mat result = new Mat(); addWeighted(image, 1, watermark, 1, 0, result); imwrite("resources/finalImage.png", result);
to na wynikowym obrazku zobaczysz znak wodny – tylko on nie będzie fioletowy. Bliżej mu będzie do białego. Zastanów się, czy rozumiesz dlaczego.
Odpowiedź jest prosta: gdy dodamy do siebie dwa piksele [200, 180, 220]
i [90, 60, 90]
to dostaniemy po prostu biały piksel. Stąd w teorii powinno się pamiętać o równaniu
Wtedy piksele nie będą tak bardzo jasne.
Wspomniałem o tym, że obiekty image
i watermark
muszę mieć tyle samo kanałów i takie same wymiary. Inaczej nie da się prawidłowo obliczyć kolorów poszczególnych pikseli w obrazie wynikowym. Stąd wywołanie statycznej funkcji cvtColor
zdefiniowanej w klasie org.opencv.imgproc.Imgproc
. Stała COLOR_BGR2BGRA
(zdefiniowana w klasie org.opencv.imgproc.Imgproc
) oznacza dodanie do obrazka czwartego kanału opisującego przezroczystość. Podobnie funkcja resize
z klasy org.opencv.imgproc.Imgproc
zmienia rozmiar obrazka. Warto jeszcze wspomnieć, że w linii
Mat watermark = imread("resources/watermark.png", IMREAD_UNCHANGED);
używamy stałej IMREAD_UNCHANGED
, aby wczytać plik png ze wszystkimi czterema kanałami.
Uzyskaliśmy efekt, który nie jest zbyt imponujący:
Po pierwsze – widać, że znak wodny jest rozciągnięty i nie wygląda przez to zbyt estetycznie. Po drugie – w zdjęciach, którymi chcemy się pochwalić, znak wodny powinien być bardziej dyskretny. Powinien na przykład znajdować się w prawym dolnym rogu.
Podejście drugie
W tym paragrafie spróbujemy właśnie wygenerować obrazek z bardziej dyskretnym znakiem wodnym.
Tak naprawdę w kolejnym programie zastąpimy tylko jedną linijkę:
resize(watermark, watermark, image.size());
ciągiem innych operacji. Po pierwsze – stwórzmy zupełnie przezroczysty obrazek o wymiarach identycznych do wymiarów obrazka, który chcemy oznaczyć:
Mat transparentLayer = new Mat(image.rows(), image.cols(), CvType.CV_8UC4);
Wyznaczmy roi
czyli region of interests:
Rect roi = new Rect( image.cols() - watermark.cols(), image.rows() - watermark.rows(), watermark.cols(), watermark.rows());
Czyli kwadrat w prawym dolnym rogu obrazka, do którego następnie wkleimy piksel po pikselu nasz znak wodny:
watermark.copyTo(transparentLayer.submat(roi));
Teraz pozostaje tylko połączyć ze sobą dwa obrazki:
addWeighted(image, 1, transparentLayer, 1, 0, result);
żeby w wyniku dostać następujący obrazek:
Rzeczywisty przypadek użycia
Gdy do poniższego programu
public static void main(String[] args) { System.loadLibrary(NATIVE_LIBRARY_NAME); Mat image = imread("resources/inputImage.jpg", IMREAD_COLOR); cvtColor(image, image, COLOR_BGR2BGRA); Mat watermark = imread("resources/watermark.png", IMREAD_UNCHANGED); Mat transparentLayer = new Mat(image.rows(), image.cols(), CvType.CV_8UC4); Rect roi = new Rect( image.cols() - watermark.cols(), image.rows() - watermark.rows(), watermark.cols(), watermark.rows()); watermark.copyTo(transparentLayer.submat(roi)); Mat result = new Mat(); addWeighted(image, 1, transparentLayer, 1, 0, result); imwrite("resources/finalImage.png", result); }
przekażę ścieżkę do mojego zdjęcia (które na początku miesiąca zrobiłem na krakowskim Rynku) oraz do mojego znaku wodnego (który również sam wykonałem w programie Inkscape), to w wyniku dostanę obrazek, który umieściłem na moim koncie na instagramie.
Wyświetl ten post na Instagramie.