2017. április 26.

Képfeldolgozás Androidon 1. – Bevezetés

10 perc olvasási idő

Képfeldolgozás Androidon 1. – Bevezetés

Ebben a néhány részes cikksorozatban az Androidos telefonokkal kapcsolatban fogom körüljárni a digitális képfeldolgozás témakörét. Ez az írás leginkább „mit hogyan” típusú lesz, erősen technikai jellegű. Ennek ellenére nem csak fejlesztőknek lehet érdekes, hiszen megrendelőként sem árt tisztában lenni a lehetőségekkel vagyis, hogy mit is bír el a „vas”. Lesznek tehát teljesítmény mérések is. Ez az egyik oka annak, hogy amennyire lehet, próbálom kerülni a különböző külső függvénykönyvtárakat. A másik ok, hogy igazából ez a legérdekesebb része az egésznek, hülye lennék pont ezt kihagyni. Amúgy sem túl hasznos mondjuk 3–4 szűrőért behúzni egy 50 megás gépi látásra írt függvénykönyvtárat, viszont szeretném szemléltetni, hogy milyen apróságok mennyit számítanak egy-egy szűrő implementálásánál.

Amit nem árt, ha tudsz

A képfeldolgozás egy relatíve nehéz és nagy terület, leginkább a sok matematikai formula miatt, amire szükség van hozzá. Viszont látványos, és emiatt hamar nyújt sikerélményt a nehézségek ellenére. Megmondom őszintén, hogy középiskolában és az egyetem első éveiben kimondottan utáltam mindenféle matekot. Így utólag persze rájöttem, hogy csak azért, mert elfelejtették közölni azt az apróságot, hogy egy-egy képlet mi a fenére használható. Van néhány terület, ahol azonban elég hamar értelmet nyernek a tanultak. Ilyenek a fizikai szimulációk, a hang-, kép- és egyéb jelfeldolgozási problémák, valamint a háromdimenziós grafika. Szóval igazából minden érdekesebb dologhoz kell a matek. Szerencsére diszkrét esetekben – amikor csak a különálló pontokat ismerjük, a függvényt magát nem – azért nagyban leegyszerűsödnek a dolgok. A digitális jelek, mint a képek, hangok, mért gyorsulás stb. ilyenek, így szerencsére viszonylag egyszerű dolgunk lesz. Tehát ha hadilábon állsz az egyetemi matekkal, akkor néhány komolyabb probléma okozhat fejtörést, de általában elboldogulhatsz nélküle.

Hardveres követelmények

Rengeteg lehetőséget rejt a mobilok beépített kamerája. Gondolj csak az augmented reality appokra, vagy az arccserélő hülyeségekre, de hogy valami hasznosat is mondjak, érdemes ránézni a google realtime fordítójára. A cikksorozatban tehát a kamera előnézeti képéből fogok kiindulni.

Azt viszont tartsd észben, hogy a képfeldolgozás egy meglehetősen teljesítményigényes téma. Alapvetően sok memóriát és minél erősebb processzort, ebből kifolyólag rengeteg energiát igényel. Na a mobiltelefonokban pont ezekből van kevés, de legalább jó nagy felbontású kijelzőkkel és még nagyobb felbontású kamerákkal szerelik őket, így a feldolgozandó képek mérete elég nagy. Ilyen helyzetben előtérbe kerül a megfelelő optimalizálás. A méréseket egy Huawei p9 lite-tal végeztem. Egyrészt ez a készülék egy tömegek számára is elérhető középkategóriás mobil, másrészt lehet rajta küzdeni a gyártóspecifikus érdekességekkel, de erről majd később. Ennél a készüléknél rendelkezésre áll egy 8 magos processzor és 2GB memória, valamint támogatja az Android camera2 API-ját (ezt fogom használni a későbbiekben a kamera képének kinyerésére). Tud FullHD felbontású előnézeti képet adni, ami tök hasznos, de most nem fogom használni, ugyanis annak a valós idejű feldolgozása egy asztali rendszert is megránt, nemhogy egy középkategóriás mobilt.

Android

Az Androidos alkalmazások alapvetően Java nyelven íródnak. Biztonságos, jól olvasható, viszonylag könnyen tanulható nyelv. Nem lehetetlen benne memory leaket csinálni, de meg kell érte dolgozni. Ha képfeldolgozással szeretnék foglalkozni, akkor viszont sajnos el is felejthetem. Egyrészt a managelt kód általában lassabb valamelyest, másrészt a Java nem kezel előjel nélküli típusokat. Ez azt jelenti, hogy a 8 bit/csatornás képek esetén jellemzően 0-255 közötti értéket felvevő intenzitás értékeket nem nagyon lehet a Java-s byte típusban tárolni, mivel annak értékkészlete -128 és 127 között van. Pontosítok, tárolni lehet, hiszen 8bit az 8bit, de észnél kell lenni, amikor felhasználod a kapott értékeket. Nem megoldhatatlan a probléma, némi bitmaszkolással és az éppen használt érték egyéb típusban (jellemzően int-ben) tárolásával megoldható. Igen, több memória és processzor idő árán.
A számításintenzív, vagy alacsony késleltetést igénylő kódokat bizony C, vagy C++ nyelven kell megírni. Szerencsére keverhető a két megoldás, így az app alapvető logikája írható Java-ban, a számításigényesebb képfeldolgozó algoritmusok pedig C, vagy C++ nyelven. Ha nem vagy otthon ezen nyelvek valamelyikében, akkor a továbbiakban nehézségekbe ütközhetsz, de mivel a Java alapvetően ezekből a nyelvekből származik, nem annyira vészes megtanulni. A memóriakezelésnél viszont észnél kell lenni, mivel a Java-val ellentétben elég könnyű memory leak-et csinálni, vagy rossz memória címre írni, amitől azonnal összeomlik az alkalmazás.

Másik alternatíva a RenderScript, aminek egyik nagy előnye, hogy a GPU-t is bevonja a munkába, így a jól párhuzamosítható számításokat – mint amilyenek a képfeldolgozó algoritmusok – eléggé meggyorsíthatja. Hátránya viszont, hogy a C, vagy C++ kódokkal ellentétben sajnos csak Androidon működik – ahogy az Apple féle Metal API csak iOS-en –, így a több platformot érintő fejlesztéseknél nem nagyon jöhet számításba.

A nyílt OpenCL szabványt, amelynek hasonló lenne a célja, sajnos sem az Android, sem az iOS nem támogatja hivatalosan, tehát ha a GPU-t is be szeretnéd vonni a számításokba, kénytelen vagy a platformspecifikus API-kat használni.

Kezdjünk hát neki

A kamera képét viszonylag könnyű kirajzolni. A Google-nek is van példakódja rá. Én is ebből a példából fogok kiindulni. Módosítanom kell majd, hogy a képet ne rajzoljam ki közvetlenül a TextureView-ba, hanem azt feldolgozható formában kapjam meg. A tovább haladáshoz érdemes a példát átnézni, ugyanis annak tartalmát nem fogom tárgyalni, csak a szükséges módosításokat. A C nyelvű kódrészletek hozzáadásához is van tutorial az Android oldalán.

A kamera képének kinyerése a camera2 API-val

Első lépésként létrehozok egy ImageReader.OnImageAvailableListener-t, mely a kapott kép feldolgozásáért lesz felelős.


private final ImageReader.OnImageAvailableListener mOnImageAvailableForProcessingListener
            = new ImageReader.OnImageAvailableListener() {

        @Override
        public void onImageAvailable(ImageReader reader) {
            Image img = null;
            img = reader.acquireLatestImage();

            if (img != null) {
                ByteBuffer buffer = img.getPlanes()[0].getBuffer();//Y channel buffer of YUV color image
                byte[] data = new byte[buffer.remaining()];
                buffer.get(data);
                final int width = img.getWidth();
                final int height = img.getHeight();

                img.close();
            }

        }
    };

Az utolsó sorban található img.close() hívás meglehetősen fontos. Nélküle pár kép után összecsuklik az alkalmazás java.lang.IllegalStateException-el.

A setUpCameraOutputs(lásd sample) metódusban, hozok létre egy ImageReader-t a következőképpen:


mImageReaderProcessing = ImageReader.newInstance(mPreviewSize.getWidth(), mPreviewSize.getHeight(), ImageFormat.YUV_420_888,2);

mImageReaderProcessing.setOnImageAvailableListener(mOnImageAvailableForProcessingListener, mBackgroundHandler);

Feltűnhet, hogy képformátumnak a YUV_420_888-at adtam meg. Ennek egyszerű oka van. A camera2 API ezt támogatja előnézet esetén (bővebben). Ennek egyik előnye, hogy az Y csatorna maga a fekete-fehér kép, így ha a feldolgozás során ebből szeretnél kiindulni, akkor egy lépéssel előrébb vagy. Ha a színes előnézeti képre lenne szükséged, akkor át kell konvertálni a kapott képet. Egyelőre megelégszem a fekete-fehér képpel.

Ezután a createCameraPreviewSession metódushoz adom hozzá a következőket:


Surface imageSurface = mImageReaderProcessing.getSurface();
mPreviewRequestBuilder.addTarget(imageSurface);

Majd ugyanitt átírom a következő sort erről:


mCameraDevice.createCaptureSession(Arrays.asList(surface, mImageReader.getSurface()),
                    new CameraCaptureSession.StateCallback() {...

erre:


mCameraDevice.createCaptureSession(Arrays.asList(mSurface, imageSurface),
                    new CameraCaptureSession.StateCallback() { ...

És nagyjából ennyi módosítással megkaptam az előnézeti képet fekete-fehérben egy byte tömbben, párhuzamosan a kirajzolással. Ez már alkalmas arra, hogy feldogozd és a kinyert információt megjelenítsd valamilyen formában. A fejlesztéshez azonban jó lenne látni, hogy mit csinálok, így a feldolgozott kimentet is meg kell tudni jeleníteni.

Feldolgozott kép kirajzolása TextureView segítségével

Először is kiveszem a TextureView-unkból csinált Surface-t a Surface listából, ugyanis ha erről elfelejtkezel, akkor amikor ki akarod rajzolni a képet, a következő hibát kapod majd:

E/BufferQueueProducer: [SurfaceTexture-0-30254-1] connect(P): already connected (cur=4 req=2)

Tehát a createCaptureSession hívásod így fog kinézni:


mCameraDevice.createCaptureSession(Arrays.asList(imageSurface),
                    new CameraCaptureSession.StateCallback() { ...

Első körben teljesen Java-ban fogom megoldani a dolgot, de csak azért hogy lásd mennyivel gyorsabb a natív kód, mert bizony gyorsabb.


private final ImageReader.OnImageAvailableListener mOnImageAvailableForProcessingBitmapListener
            = new ImageReader.OnImageAvailableListener() {

        Bitmap bm;

        Bitmap byteArrToBitmap(byte[] src, int width, int height) {
            byte[] bits = new byte[src.length * 4];
            int i;
            int j=0;
            for (i = 0; i < src.length; i++) {
                j=i*4;
                bits[j] =  src[i];
                bits[j + 1] =  src[i];
                bits[j + 2] = src[i];
                bits[j + 3] = (byte)0xff; // the alpha.
            }
            if(bm == null || bm.getWidth() != width || bm.getHeight() != height) {
                if(bm != null)bm.recycle();
                bm = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
            }
            bm.copyPixelsFromBuffer(ByteBuffer.wrap(bits));
            return bm;
        }

        @Override
        public void onImageAvailable(ImageReader reader) {
            Image img = null;
            img = reader.acquireLatestImage();

            if (img != null) {
                ByteBuffer buffer = img.getPlanes()[0].getBuffer();
                byte[] data = new byte[buffer.remaining()];
                buffer.get(data);
                final int width = img.getWidth();
                final int height = img.getHeight();
                img.close();
             
                SurfaceTexture texture = mTextureView.getSurfaceTexture();
                texture.setDefaultBufferSize( width,height);
                Canvas s = mSurface.lockCanvas(new Rect(0,0,mPreviewSize.getWidth(),mPreviewSize.getHeight()));
                Bitmap b = byteArrToBitmap(data,width,height);
                s.drawBitmap(b,0,0,null);
                mSurface.unlockCanvasAndPost(s);
            }
        }
    };

Ez a megoldás az, amit igazából el kell felejteni, mert ugyan megoldható, de mint a későbbiekben látni fogod, jóval lassabb, mint a natív implementáció.

A natív kódot használó megoldás Java-s fele a következőképpen néz ki:


private final ImageReader.OnImageAvailableListener mOnImageAvailableForProcessingListener
            = new ImageReader.OnImageAvailableListener() {

        @Override
        public void onImageAvailable(ImageReader reader) {
            Image img = null;
            img = reader.acquireLatestImage();

            if (img != null) {
               ByteBuffer buffer = img.getPlanes()[0].getBuffer();
               byte[] data = new byte[buffer.remaining()];
               buffer.get(data);
               final int width = img.getWidth();
               final int height = img.getHeight();
               img.close();

               PreviewHelper.drawBytesToSurface(mSurface, data, width, height, 1, 1);
            }
        }
    };

Nagy varázslat nincs benne, gyakorlatilag egy wrappert hívok megfelelően paraméterezve.

A wrapper osztály a natív kódhoz igazából egy törzs nélküli metódus, meg a natív lib betöltése:


public class PreviewHelper {
    // Used to load the 'native-lib' library on application startup.
    static {
        System.loadLibrary("native-lib");
    }
    public native static void drawBytesToSurface(Surface surface, byte[] pixelData,int w, int h , int colors, int threadNumber);
}

A lényeg, vagyis a célbufferbe másolás alapvetően nem egy bonyolult dolog, így kicsit megbonyolítottam, hogy több szálra szét lehessen bontani a műveletet. Mint később látni fogod a mérési eredményekből, nem teljesen hülyeség.

A native-lib.cpp file tartalma a következőképpen néz ki:


#include <jni.h>
#include <android/native_window.h>
#include <android/native_window_jni.h>
#include <pthread.h>

#define MAX_THREAD_NUMBER 16

/**
 * DataStructures
 */
struct imgProcArgs_struct {
    unsigned char *destImg;
    int destWidth;
    int fromdestHeight;
    int todestHeight;
    int fulldestHeight;
    unsigned char *srcImg;
    int srcWidth;
    int srcHeight;
    int destColors;
    int srcColors;
};

extern "C"
void
*copyImgGrayToImgRGBA(void *arg) {
    struct imgProcArgs_struct *args = (struct imgProcArgs_struct *) arg;
    int destIndex = args->destWidth * args->fromdestHeight * 4;
    int len = args->destWidth * args->todestHeight;
    for (int i = args->destWidth * args->fromdestHeight; i < len; i++) {

        *(args->destImg + destIndex++) = *(args->srcImg + i);
        *(args->destImg + destIndex++) = *(args->srcImg + i);
        *(args->destImg + destIndex++) = *(args->srcImg + i);
        *(args->destImg + destIndex++) = 0xFF;

    }
    return NULL;
}

extern "C"
void
createCpyThread(pthread_t *thread, imgProcArgs_struct *cpyArgs, unsigned char *destImg,
                int destWidth, int fromdestHeight, int todestHeight, unsigned char *srcImg,
                int destColors, int srcColors) {

    cpyArgs->destImg = destImg;
    cpyArgs->destWidth = destWidth;
    cpyArgs->fromdestHeight = fromdestHeight;
    cpyArgs->todestHeight = todestHeight;
    cpyArgs->srcImg = srcImg;
    cpyArgs->destColors = destColors;
    cpyArgs->srcColors = srcColors;

    pthread_create(thread, NULL, copyImgGrayToImgRGBA, (void *) cpyArgs);
}

/**
 * JNI functions
 */
extern "C"
JNIEXPORT void JNICALL
Java_hu_fps_rtcamimgproc_ui_PreviewHelper_drawBytesToSurface(JNIEnv *env, jclass type,
                                                                  jobject surface,
                                                                  jbyteArray pixelData_, jint w,
                                                                  jint h, jint colors,
                                                                  jint threadNumber) {
    jbyte *pixelData = env->GetByteArrayElements(pixelData_, NULL);
    ANativeWindow *window = ANativeWindow_fromSurface(env, surface);

    ANativeWindow_setBuffersGeometry(window, w, h, WINDOW_FORMAT_RGBA_8888);

    ANativeWindow_Buffer buffer;
    if (ANativeWindow_lock(window, &buffer, NULL) == 0) {
        int width = buffer.width;
        int height = buffer.height;

        pthread_t threadArr[MAX_THREAD_NUMBER];
        struct imgProcArgs_struct cpyArgs[MAX_THREAD_NUMBER];

        int tNum = threadNumber;
        if (tNum > MAX_THREAD_NUMBER) {
            tNum = MAX_THREAD_NUMBER;
        }

        for (int i = 0; i < tNum; i++) {
            createCpyThread(&threadArr[i], &cpyArgs[i], (unsigned char *) buffer.bits, width,
                            height * i / tNum, height * (i + 1) / tNum,
                            (unsigned char *) pixelData, 4, colors);
        }

        for (int i = 0; i < tNum; i++) {
            pthread_join(threadArr[i], NULL);
        }

        ANativeWindow_unlockAndPost(window);
    }

    ANativeWindow_release(window);

    env->ReleaseByteArrayElements(pixelData_, pixelData, 0);
}

A lényeg az ANativeWindow használata, ami önmagában ennyi:


ANativeWindow* window = ANativeWindow_fromSurface(env, javaSurface);

ANativeWindow_setBuffersGeometry(window, w, h, WINDOW_FORMAT_RGBA_8888);

ANativeWindow_Buffer buffer;
if (ANativeWindow_lock(window, &buffer, NULL) == 0) {
  memcpy(buffer.bits, pixels,  w * h * 4);
  ANativeWindow_unlockAndPost(window);
}

ANativeWindow_release(window);

Ennyi elég is lenne, ha RGBA módban kapnám a képet és egy szálon szeretném másolni. Mivel csak szürke árnyalatos képem van, így kénytelen vagyok egy ciklussal végigiterálni a forrás buffer minden pixelén és gyakorlatilag háromszor belemásolni a célbufferbe, valamint az alpha csatornát kitölteni fixen a maximális értékkel.

Amire érdemes odafigyelned – ugyanis én belefutottam ebbe a hibába –, hogy nem minden készülék tud bármilyen bufferméretet kezelni az ANativeWindow esetén. A Huawei p9 és p9 lite készülékeken az 1440*1080-as felbontáshoz nem sikerült beállítanom megfelelően a buffert, aminek az lett az eredménye, hogy ezen a felbontáson teljesen használhatatlan képet kaptam. Egyelőre úgy tűnik, hogy a szélesség és magasság szorzatának oszthatónak kell lennie 512-vel. Nexus 5 és Samsung Galaxy S7 készülékeken ezzel a problémával nem találkoztam. De hát ezért szép Androidra fejleszteni, hiszen ahány gyártó annyi féle móka…

Szóval már kirajzoltam a képet, de látható, hogy valami nem stimmel.

Kép: Hibás forgatás, torz képarány

Az eredeti kódon még faragni kell egy kicsit, ugyanis máshogy rajzolom a TextureView-ra, mint a kamera eredeti előnézeti implementációja. A configureTransform metódust a következőképpen kell módosítani:


private void configureTransform(int viewWidth, int viewHeight) {
        Activity activity = getActivity();
        if (null == mTextureView || null == mPreviewSize || null == activity) {
            return;
        }
        int rotation = activity.getWindowManager().getDefaultDisplay().getRotation();
        Matrix matrix = new Matrix();
        RectF viewRect = new RectF(0, 0, viewWidth, viewHeight);
        RectF bufferRect = new RectF(0, 0, mPreviewSize.getHeight(), mPreviewSize.getWidth());
        float centerX = viewRect.centerX();
        float centerY = viewRect.centerY();
        if (Surface.ROTATION_0 == rotation) {
            bufferRect.offset(centerX - bufferRect.centerX(), centerY - bufferRect.centerY());
            matrix.setRectToRect(viewRect, bufferRect, Matrix.ScaleToFit.FILL);
            float scaleH = (float) viewHeight / mPreviewSize.getHeight();
            float scaleW = (float) viewWidth / mPreviewSize.getWidth();
            matrix.postScale(scaleH, scaleW, centerX, centerY);
            matrix.postRotate(90, centerX, centerY);
        }
        mTextureView.setTransform(matrix);
    }

Mindjárt jobban néz ki az eredmény. Végre a telefon orientációjának megfelelően látod a képet és a képarány sem torzul.

Kép: Helyes forgatás, helyes képarány

Végeztem is. Most már minden adott ahhoz, hogy lásd is majd, mit csinálok a kamera képének feldolgozása során.

Mérjünk kicsit

Na de ennyi kód után nézzük meg, hogy mennyivel gyorsabb a natív kód, mint a Java-s Canvas-ra rajzolgatás. A több szálú feldolgozást csak a natív kódra csináltam meg, mivel jól láthatóan egy szálon is bőven veri a Java-s Canvas-ra épülő megoldást.

Tehát egy Huawei p9 lite készüléken a kép kimásolása a kimenetre milliszekundumban:

1920*1080 Canvas: 264-266ms
1920*1080 ANativeWindow thread nélkül:96-97ms
1920*1080 ANativeWindow 1 thread: 103-105ms
1920*1080 ANativeWindow 2 thread: 57-60ms
1920*1080 ANativeWindow 4 thread: 39-42ms
1920*1080 ANativeWindow 8 thread: 39-42ms

640*480 Canvas: 38-39ms
640*480 ANativeWindow thread nélkül: 16-17ms
640*480 ANativeWindow 1 thread: 21-22ms
640*480 ANativeWindow 2 thread: 13-15ms
640*480 ANativeWindow 4 thread: 12-14ms
640*480 ANativeWindow 8 thread: 12-14ms

Jól látható, hogy FullHD felbontáson a natív kódnak több mint 2,5-szeres előnye van. Ez az előny 640*480-as felbontáson 2-szeresre olvad, ami még mindig jelentős előrelépés. Az is látható, hogy a szálkezelést az okoztott overhead miatt, csak akkor érdemes használni, ha valóban szükség van rá. Jellemzően nagyobb felbontásoknál és komplexebb számításoknál ez az overhead eltörpül, így mindenképp érdemes minden magot hajtani. Mint látod ebben az esetben, amikor nem egy szimpla memcpy-vel másolom a pixeleket, már megéri több szálon végezni, bár 2-4 szálnál hiába használsz többet.

Folytatás következik

Kicsit hosszúra sikerült a poszt, de úgy gondoltam, hogy a bevezetéshez is szeretnék valami kézzel fogható, hasznos tartalmat produkálni (remélem sikerült), amivel bárki el tud indulni, akit érdekel a téma. Várhatóan a folytatások sem lesznek túl rövidek. Terveim szerint legközelebb néhány képszűrőt fogok implementálni. Tehát folyt. köv.

Tóth Róbert

Mobilalkalmazás fejlesztő. Fő érdeklődési köre a képalkotás, de érdeklődik a jelfeldolgozás egyéb területei iránt is.

Tóth Róbert

Hozzászólások