Tworzenie natywnych procesorów klatek dla Vision Camera w React Native z użyciem OpenCV
dogtronic.io 2 lat temu
Zmiana architektury w React Native na Fabric, a w jej skutek zastosowanie synchronicznej komunikacji między częścią natywną a JS, umożliwiło stworzenie bibliotek, które prowadzą do świetnej wydajności, nieosiągalnej wcześniej.
Jedną z bibliotek umożliwiającą taką komunikację jest react-native-reanimated, którą wykorzystam wspólnie z react-native-vision-camera do stworzenia aplikacji umożliwiającej wykrywanie elementów z kamery urządzenia w czasie rzeczywistym z wykorzystaniem biblioteki OpenCV.
OpenCV to wieloplatformowa i otwarta biblioteka służąca do obróbki obrazu, stworzona w języku C++, z możliwością skorzystania z nakładek dla innych języków takich jak Java, JavaScript, Python.
Obecnie brak jest jednak sensownych bibliotek dla React Native, które umożliwiły by w sposób prosty wykorzystanie funkcjonalności OpenCV bezpośrednio w kodzie JS. Z pomocą przychodzi nam jednak możliwość wykorzystania kodu natywnego oraz stworzenie komunikacji między wątkiem natywnym, a wątkiem JS.
Standardowe podejście opisywane w niektórych postach, umożliwia stworzenie mostu, z którym komunikacja będzie przebiegać w sposób asynchroniczny. Jednak jest to podejście, które często nie działa wystarczająco sprawnie w taki sposób, aby dokonywać detekcji czy przekształceń w czasie rzeczywistym.
Istotna informacja Wpis był tworzony z wykorzystaniem OpenCV 4.6.0 oraz React Native 0.68.2. W przypadku innych wersji biblioteki, niektóre kroki mogą się różnić. Zwłaszcza importowania OpenCV do projektu.
Tworzenie projektu
Pierwszym krokiem będzie stworzenie nowej aplikacji z wykorzystaniem polecenia:
npx react-native init opencvframeprocessor
Po instalacji niezbędnych podów oraz stworzeniu katalogów, przechodzimy do importowania OpenCV do naszego projektu, oddzielnie dla systemu iOS i Android.
Importowanie OpenCV
iOS
Importowanie OpenCV dla iOS
Pierwszym krokiem będzie pobranie OpenCV w wersji dla iOS. W moim przypadku jest to wersja 4.6.0.
Po pobraniu biblioteki, uruchamiamy nasz projekt w Xcode (pamiętajmy aby był to projekt z rozszerzeniem .xcworkspace. Aby zaimportować bibliotekę, przeciągamy pobrany katalog o nazwie opencv2.framework do głównego projektu (lewy panel okna).
Następnie zaznaczamy opcję >Copy items if needed< oraz klikamy >Finish<. Biblioteka powinna pojawić się w panelu po lewej stronie okna.
Kolejnym krokiem, będzie dołączenie do projektu wymaganych frameworków. Możemy to zrobić w ustawieniach projektu -> Build Phases -> Link Binary With Libraries.
Do projektu powinna zostać dodana następująca lista elementów:
QuartzCore.framework,
CoreVideo.framework,
CoreImage.framework,
AssetsLibrary.framework,
CoreFoundation.framework,
CoreGraphics.framework,
CoreMedia.framework,
Accelerate.framework.
Następnym krokiem będzie stworzenie plików z obsługą OpenCV w projekcie. Pliki tworzymy w katalogu głównym projektu (tam gdzie znajdują się pliki AppDelegate.h i AppDelegate.m).
Najpierw tworzymy nowy plik z headerem naszego pliku – nazwijmy go OpenCV.h.
Deklarujemy w nim nową klasę oraz przykładową metodę do pobierania wersji OpenCV. Aby to zrobić korzystamy z poniższego kodu.
Następnie tworzymy nowy plik Objective-C o nazwie OpenCV.m.
Jako, iż biblioteka OpenCV jest napisana z wykorzystaniem C++, konieczna będzie zmiana formatu utworzonego pliku na format .mm (czyli inaczej Objective C++). Możemy to zrobić w prawym panelu (poprzez dopisanie formatu do nazwy pliku).
Następnie w utworzonym pliku tworzymy kod implementujący klasę z pliku z nagłówkiem.
Kolejnym krokiem będzie utworzenie pliku PCH, w którym dodamy informację, iż biblioteka OpenCV będzie wymagała kompilatora dla języka Objective C++. Aby to zrobić dodajemy nowy plik PCH o nazwie PrefixHeader w lokalizacji pozostałych, wcześniej utworzonych plików.
Następnie w ustawieniach projektu musimy wskazać jego lokalizację. W tym celu w Build Settings -> Prefix Header dodajemy wpis o treści: ${PROJECT_DIR}/PrefixHeader.pch.
Po tym wszystkim sprawdzamy czy aplikacja się buduje – o ile tak, nasza biblioteka została dodana poprawnie i możemy przejść do kolejnych kroków.
Android
Importowanie OpenCV dla Androida
Aby pobrać bibliotekę OpenCV dla Androida. Wracamy do strony głównej OpenCV, z której pobieraliśmy bibliotekę dla iOS jednak tym razem wybieramy pakiet dla systemu Android.
Po pobraniu i rozpakowaniu archiwum otwieramy nasz projekt w Android Studio. Pierwszym krokiem do importu naszego modułu będzie wybranie opcji File -> Import module oraz wskazaniu lokalizacji do katalogu sdk (Uwaga! nie będzie to katalog sdk/java). Bibliotekę nazywamy, np. openCVLib, a pozostałe opcje pozostawiamy domyślnie.
Następnie musimy dodać wsparcie dla języka Kotlin. W pliku build.gradle dodajemy następujące elementy:
Przechodzimy do dodania biblioteki jako zależności dla projektu. W menu File wybieramy opcję Project Structure.
Wchodzimy w zakładkę Dependencies i klikamy ikonę + wybierając opcję Module Dependency. W kolejnym kroku wybieramy naszą bibliotekę OpenCV i dodajemy zależność.
Kolejnym krokiem będzie dodanie plików jniLibs do naszej aplikacji. W katalogu app/src/main tworzymy katalog jniLibs i kopiujemy tam zawartość katalogu sdk/native/libs z wcześniej pobranego archiwum.
W pliku dodajemy następującą linijkę, aby naprawić błąd przy budowaniu aplikacji.
Po zbudowaniu i włączeniu aplikacji w logach powinien wyświetlić się komunikat o załadowaniu OpenCV.
Instalacja wymaganych bibliotek
Jak wspomniałem wcześniej, kolejnym krokiem będzie dodanie bibliotek Vision Camera oraz Reanimated do naszego projektu. W tym celu w katalogu głównym projektu React Native wykonujemy polecenia:
Aby stworzyć nowy frame procesor dla biblioteki Vision Camera, konieczne jest stworzenie pliku w którym zaszyjemy logikę. Jednak zanim to zrobimy musimy rozbudować nasz plik OpenCV.mm o funkcje umożliwiające detekcję obiektów.
W naszym przypadku będzie to wykrywanie niebieskiego kwadratu. Frame processor domyślnie zwraca nam klatkę z aparatu w formie obiektu o typie CMSampleBufferRef i dlatego konieczne będzie przygotowanie funkcji, która umożliwi nam jego konwersję na standardowy obraz używany w iOS o typie UIImage. Możemy to zrobić dzięki funkcji (dodajmy ją w klasie OpenCV w pliku OpenCV.mm):
Bibioteka OpenCV wykonuje operacje na tzw. matrycach. Stąd konieczna będzie funkcja, która umożliwi nam konwersję UIImage na obiekt typu Mat. Możemy to zrobić na przykład w następujący sposób:
Tak dodany kod, możemy już wykorzystać w kodzie JS. Najpierw jednak dodajmy podobną funkcjonalność także dla systemu Android.
Android
Frame Processor dla Androida
Domyślnym formatem zwracanym przez bibliotekę Vision Camera w Frame procesorze dla Androida jest ImageProxy. Aby dodać wsparcie dla niego w pliku app/build.gradle w sekcji dependencies musimy dodać:
Dodajmy plik OpenCV.java, który będzie zawierał funkcję findObject, która będzie odpowiadała za wykrywanie niebieskich obiektów. Dodatkowo dodajmy pomocnicza metodę do konwersji obiektu ImageProxy na obiekt Mat.
package com.opencvframeprocessor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.ImageFormat;
import android.graphics.Matrix;
import android.graphics.YuvImage;
import com.facebook.react.bridge.WritableNativeMap;
import org.opencv.android.Utils;
import org.opencv.core.Core;
import org.opencv.core.CvType;
import org.opencv.core.Mat;
import org.opencv.core.MatOfPoint;
import org.opencv.core.Rect;
import org.opencv.core.Scalar;
import org.opencv.imgproc.Imgproc;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import androidx.camera.core.ImageProxy;
public class OpenCV {
static WritableNativeMap findObjects(Mat matRGB) {
Scalar lowerBound = new Scalar(90, 120, 120);
Scalar upperBound = new Scalar(140, 255, 255);
Mat matBGR = new Mat(), hsv = new Mat();
List<Mat> channels = new ArrayList<>();
Imgproc.cvtColor(matRGB, matBGR, Imgproc.COLOR_RGB2BGR);
Imgproc.cvtColor(matBGR, hsv, Imgproc.COLOR_BGR2HSV);
Core.inRange(hsv, lowerBound, upperBound, hsv);
Core.split(hsv, channels);
List<MatOfPoint> contours = new ArrayList<>();
Mat hierarchy = new Mat();
Imgproc.findContours(channels.get(0), contours, hierarchy, Imgproc.RETR_TREE, Imgproc.CHAIN_APPROX_SIMPLE);
for (int i = 0; i < contours.size(); i++) {
MatOfPoint contour = contours.get(i);
double area = Imgproc.contourArea(contour);
if(area > 3000) {
Rect rect = Imgproc.boundingRect(contour);
WritableNativeMap result = new WritableNativeMap();
result.putInt("x", rect.x);
result.putInt("y", rect.y);
result.putInt("width", rect.width);
result.putInt("height", rect.height);
return result;
}
}
return new WritableNativeMap();
}
static Mat imageToMat(ImageProxy imageProxy) {
ImageProxy.PlaneProxy[] plane = imageProxy.getPlanes();
ByteBuffer yBuffer = plane[0].getBuffer();
ByteBuffer uBuffer = plane[1].getBuffer();
ByteBuffer vBuffer = plane[2].getBuffer();
int ySize = yBuffer.remaining();
int uSize = uBuffer.remaining();
int vSize = vBuffer.remaining();
byte[] nv21 = new byte[ySize + uSize + vSize];
yBuffer.get(nv21, 0, ySize);
vBuffer.get(nv21, ySize, vSize);
uBuffer.get(nv21, ySize + vSize, uSize);
try {
YuvImage yuvImage = new YuvImage(nv21, ImageFormat.NV21, imageProxy.getWidth(), imageProxy.getHeight(), null);
ByteArrayOutputStream stream = new ByteArrayOutputStream(nv21.length);
yuvImage.compressToJpeg(new android.graphics.Rect(0, 0, yuvImage.getWidth(), yuvImage.getHeight()), 90, stream);
Bitmap bitmap = BitmapFactory.decodeByteArray(stream.toByteArray(), 0, stream.size());
Matrix matrix = new Matrix();
matrix.postRotate(90);
stream.close();
Bitmap rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
Mat mat = new Mat(rotatedBitmap.getWidth(), rotatedBitmap.getHeight(), CvType.CV_8UC4);
Utils.bitmapToMat(rotatedBitmap, mat);
return mat;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
Aby dodać Frame Processor, musimy stworzyć plik ObjectDetectFrameProcessorPlugin.java z następującą zawartością:
Tak przygotowany moduł jest już gotowy do użycia w kodzie JS.
Javascript
Wykorzystanie Frame Procesorów po stronie JS
Aby umożliwić korzystanie z procesora klatek po stronie aplikacji, musimy dodać możliwość wykrywania go przez plugin react-native-reanimated. Aby to zrobić musimy dodać odpowiedni wpis w pliku babel.config.js (znajduje się on w katalogu głównym aplikacji).
Dzięki zastosowaniu hooka useSharedValue możemy przekazywać wartości pozycji i wielkości kwadratu bezpośrednio do stylu wykorzystującego hook useAnimatedStyle. Oba pochodzą z biblioteki react-native-reanimated.
Ważną sprawą jest również sprawdzenie uprawnień do aparatu, bez tego nie uda nam się uruchomić aparatu.
Przejdźmy do deklaracji frame procesora. Po wykryciu obiektu, musimy przekonwertować wartość pozycji i wielkości z klatki z aparatu na wielkości rozdzielczości ekranu urządzenia (z powodu, iż mają one różne wymiary). Z powodu, iż wielkość klatki jest podawana odwrotnie na iOS niż na Android musimy dokonać zamiany wielkości.
Proces importowania biblioteki OpenCV oraz wykorzystanie jej do detekcji obiektów w czasie rzeczywistym nie jest łatwym zadaniem. Mnogość wersji oraz sposób ich wykorzystania, często dostarcza wielu problemów trudnych do rozwiązania.
Nie mniej jednak efekt jest wystarczającą nagrodą za przebytą drogę. Niestety głównym problemem przy wykorzystywaniu OpenCV w aplikacjach React Native jest konieczność tworzenia kodu Natywnego czy to w Java (lub Kotlin) w przypadku systemu Android, czy Objective C/C++ (lub Swift) w przypadku iOS.