Programistyczne manipulowanie obrazkami

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 rozmiaru
  • Size 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 pozycji x, y
  • image.get(x,y)[1] – składowa zielona
  • image.get(x,y)[2] – składowa czerwona
  • image.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.

    \[\textrm{result}[x,y][i] = \alpha \cdot \textrm{image}[x,y][i] + \beta \cdot \textrm{watermark}[x,y][i] + \gamma\]

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

    \[\alpha + \beta = 1\]

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 \alpha + \beta = 1
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:

samolot-ze-znakiem-wodnym

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:
samolot-2

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.

 

#nofilter #cracow #kraków #sukiennice #sky #skyphotography #cityphotography #cityphoto #instaphoto #instapic #instaphotography

Post udostępniony przez Doktor Ziel (@doktor_ziel)

Aspirujący twórca internetowy, który zna się na programowaniu i chce się dzielić wiedzą

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Scroll to top