Android相機開發(fā)(六): 高效實時處理預覽幀數(shù)據(jù)

轉載自:Penguin

Android Camera Develop: process preview frames in real time efficiently

概述

本篇我們暫時不介紹像相機APP增加新功能,而是介紹如何處理相機預覽幀數(shù)據(jù)。想必大多數(shù)人都對處理預覽幀沒有需求,因為相機只需要拿來拍照和錄像就好了,實際上本篇和一般的相機開發(fā)也沒有太大聯(lián)系,但因為仍然是在操作Camera類,所以還是歸為相機開發(fā)。處理預覽幀簡單來說就是對相機預覽時的每一幀的數(shù)據(jù)進行處理,一般來說如果相機的采樣速率是30fps的話,一秒鐘就會有30個幀數(shù)據(jù)需要處理。幀數(shù)據(jù)具體是什么?如果你就是奔著處理幀數(shù)據(jù)來的話,想必你早已知道答案,其實就是一個byte類型的數(shù)組,包含的是YUV格式的幀數(shù)據(jù)。本篇僅介紹幾種高效地處理預覽幀數(shù)據(jù)的方法,而不介紹具體的用處,因為拿來進行人臉識別、圖像美化等又是長篇大論了。

本篇在Android相機開發(fā)(二): 給相機加上偏好設置的基礎上介紹。預覽幀數(shù)據(jù)的處理通常會包含大量的計算,從而導致因為幀數(shù)據(jù)太多而處理效率低下,以及衍生出的預覽畫面卡頓等問題。本篇主要介紹分離線程優(yōu)化畫面顯示,以及通過利用HandlerThread、Queue、ThreadPool和AsyncTask來提升幀數(shù)據(jù)處理效率的方法。

準備

為了簡單起見,我們在相機開始預覽的時候就開始獲取預覽幀并進行處理,為了能更清晰地分析這個過程,我們在UI中“設置”按鈕之下增加“開始”和“停止”按鈕以控制相機預覽的開始與停止。

修改UI

修改activity_main.xml,將

Java

<Button
    android:id="@+id/button_settings"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="設置" />

替換為

Java

<LinearLayout
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="right"
    android:orientation="vertical">

    <Button
        android:id="@+id/button_settings"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="設置" />

    <Button
        android:id="@+id/button_start_preview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="開始" />

    <Button
        android:id="@+id/button_stop_preview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="停止" />
</LinearLayout>

這樣增加了“開始”和“停止”兩個按鈕。

綁定事件

修改mainActivity,將原onCreate()中初始化相機預覽的代碼轉移到新建的方法startPreview()

Java

public void startPreview() {
    final CameraPreview mPreview = new CameraPreview(this);
    FrameLayout preview = (FrameLayout) findViewById(R.id.camera_preview);
    preview.addView(mPreview);

    SettingsFragment.passCamera(mPreview.getCameraInstance());
    PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
    SettingsFragment.setDefault(PreferenceManager.getDefaultSharedPreferences(this));
    SettingsFragment.init(PreferenceManager.getDefaultSharedPreferences(this));

    Button buttonSettings = (Button) findViewById(R.id.button_settings);
    buttonSettings.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            getFragmentManager().beginTransaction().replace(R.id.camera_preview, new SettingsFragment()).addToBackStack(null).commit();
        }
    });
}

同時再增加一個stopPreview()方法,用來停止相機預覽

Java

public void stopPreview() {
    FrameLayout preview = (FrameLayout) findViewById(R.id.camera_preview);
    preview.removeAllViews();
}

stopPreview()獲取相機預覽所在的FrameLayout,然后通過removeAllViews()將相機預覽移除,此時會觸發(fā)CameraPreview類中的相關結束方法,關閉相機預覽。

現(xiàn)在onCreate()的工作就很簡單了,只需要將兩個按鈕綁定上對應的方法就好了

Java

protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    Button buttonStartPreview = (Button) findViewById(R.id.button_start_preview);
    buttonStartPreview.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            startPreview();
        }
    });
    Button buttonStopPreview = (Button) findViewById(R.id.button_stop_preview);
    buttonStopPreview.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            stopPreview();
        }
    });
}

運行試試

現(xiàn)在運行APP不會立即開始相機預覽了,點擊“開始”按鈕屏幕上才會出現(xiàn)相機預覽畫面,點擊“停止”則畫面消失,預覽停止。

基本的幀數(shù)據(jù)獲取和處理

這里我們首先實現(xiàn)最基礎,也是最常用的幀數(shù)據(jù)獲取和處理的方法;然后看看改進提升性能的方法。

基礎

獲取幀數(shù)據(jù)的接口是Camera.PreviewCallback,實現(xiàn)此接口下的onPreviewFrame(byte[] data, Camera camera)方法即可獲取到每個幀數(shù)據(jù)data。所以現(xiàn)在要做的就是給CameraPreview類增加Camera.PreviewCallback接口聲明,再在CameraPreview中實現(xiàn)onPreviewFrame()方法,最后給Camera綁定此接口。這樣相機預覽時每產生一個預覽幀,就會調用onPreviewFrame()方法,處理預覽幀數(shù)據(jù)data。

在CameraPreview中,修改

Java

public class CameraPreview extends SurfaceView implements SurfaceHolder.Callback

Java

public class CameraPreview extends SurfaceView implements SurfaceHolder.Callback, Camera.PreviewCallback

加入Camera.PreviewCallback接口聲明。

加入onPreviewFrame()的實現(xiàn)

Java

public void onPreviewFrame(byte[] data, Camera camera) {
    Log.i(TAG, "processing frame");
    try {
        Thread.sleep(500);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

這里并沒有處理幀數(shù)據(jù)data,而是暫停0.5秒模擬處理幀數(shù)據(jù)。

surfaceCreated()getCameraInstance()這句的下面加入

Java

mCamera.setPreviewCallback(this);

將此接口綁定到mCamera,使得每當有預覽幀生成,就會調用onPreviewFrame()。

運行試試

現(xiàn)在運行APP,點擊“開始”,一般在屏幕上觀察不到明顯區(qū)別,但這里其實有兩個潛在的問題。其一,如果你這時點擊“設置”,會發(fā)現(xiàn)設置界面并不是馬上出現(xiàn),而是會延遲幾秒出現(xiàn);而再點擊返回鍵,設置界面也會過幾秒才消失。其二,在logcat中可以看到輸出的"processing frame",大約0.5秒輸出一條,因為線程睡眠設置的是0.5秒,所以一秒鐘的30個幀數(shù)據(jù)只處理了2幀,剩下的28幀都被丟棄了(這里沒有非常直觀的方法顯示剩下的28幀被丟棄了,但事實就是這樣,不嚴格的來說,當新的幀數(shù)據(jù)到達時,如果onPreviewFrame()正在執(zhí)行還沒有返回,這個幀數(shù)據(jù)就會被丟棄)。

與UI線程分離

問題分析

現(xiàn)在我們來解決第一個問題。第一個問題的原因很簡單,也是Android開發(fā)中經(jīng)常碰到的:UI線程被占用,導致UI操作卡頓。在這里就是onPreviewFrame()會阻塞線程,而阻塞的線程就是UI線程。

onPreviewFrame()在哪個線程執(zhí)行?官方文檔里有相關描述:

Called as preview frames are displayed. This callback is invoked on the event thread open(int) was called from.

意思就是onPreviewFrame()在執(zhí)行Camera.open()時所在的線程運行。而目前Camera.open()就是在UI線程中執(zhí)行的(因為沒有創(chuàng)建新進程),對應的解決方法也很簡單了:讓Camera.open()在非UI線程執(zhí)行。

解決方法

這里使用HandlerThread來實現(xiàn)。HandlerThread會創(chuàng)建一個新的線程,并且有自己的loop,這樣通過Handler.post()就可以確保在這個新的線程執(zhí)行指定的語句。雖然說起來容易,但還是有些細節(jié)問題要處理。

先從HandlerThread下手,在CameraPreview中加入

Java

private class CameraHandlerThread extends HandlerThread {
    Handler mHandler;

    public CameraHandlerThread(String name) {
        super(name);
        start();
        mHandler = new Handler(getLooper());
    }

    synchronized void notifyCameraOpened() {
        notify();
    }

    void openCamera() {
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                openCameraOriginal();
                notifyCameraOpened();
            }
        });
        try {
            wait();
        } catch (InterruptedException e) {
            Log.w(TAG, "wait was interrupted");
        }
    }
}

CameraHandlerThread繼承自HandlerThread,在構造函數(shù)中就tart()啟動這個Thread,并創(chuàng)建一個handler。openCamera()要達到的效果是在此線程中執(zhí)行mCamera = Camera.open();,因此通過handler.post()Runnable()中執(zhí)行,我們將要執(zhí)行的語句封裝在openCameraOriginal()中。使用notify-wait是為安全起見,因為post()執(zhí)行會立即返回,而Runnable()會異步執(zhí)行,可能在執(zhí)行post()后立即使用mCamera時仍為null;因此在這里加上notify-wait控制,確認打開相機后,openCamera()才返回。

接下來是openCameraOriginal(),在CameraPreview中加入

Java

private void openCameraOriginal() {
    try {
        mCamera = Camera.open();
    } catch (Exception e) {
        Log.d(TAG, "camera is not available");
    }
}

這個不用解釋,就是封裝成了方法。

最后將getCameraInstance()修改為

Java

public Camera getCameraInstance() {
    if (mCamera == null) {
        CameraHandlerThread mThread = new CameraHandlerThread("camera thread");
        synchronized (mThread) {
            mThread.openCamera();
        }
    }
    return mCamera;
}

這個也很容易理解,就是交給CameraHandlerThread來處理。

運行試試

現(xiàn)在運行APP,會發(fā)現(xiàn)第一個問題已經(jīng)解決了。

處理幀數(shù)據(jù)

接下來解決第二個問題,如何確保不會有幀數(shù)據(jù)被丟棄,即保證每個幀數(shù)據(jù)都被處理。解決方法的中心思想很明確:讓onPreviewFrame()盡可能快地返回,不至于丟棄幀數(shù)據(jù)。

下面介紹4種比較常用的處理方法:HandlerThread、Queue、AsyncTask和ThreadPool,針對每一種方法簡單分析其優(yōu)缺點。

HandlerThread

簡介

采用HandlerThread就是利用Android的Message Queue來異步處理幀數(shù)據(jù)。流程簡單來說就是onPreviewFrame()調用時將幀數(shù)據(jù)封裝為Message,發(fā)送給HandlerThread,HandlerThread在新的線程獲取Message,對幀數(shù)據(jù)進行處理。因為發(fā)送Message所需時間很短,所以不會造成幀數(shù)據(jù)丟失。

實現(xiàn)

新建ProcessWithHandlerThread類,內容為

Java

public class ProcessWithHandlerThread extends HandlerThread implements Handler.Callback {
    private static final String TAG = "HandlerThread";
    public static final int WHAT_PROCESS_FRAME = 1;

    public ProcessWithHandlerThread(String name) {
        super(name);
        start();

    }

    @Override
    public boolean handleMessage(Message msg) {
        switch (msg.what) {
            case WHAT_PROCESS_FRAME:
                byte[] frameData = (byte[]) msg.obj;
                processFrame(frameData);
                return true;
            default:
                return false;
        }
    }

    private void processFrame(byte[] frameData) {
        Log.i(TAG, "test");
    }
}

ProcessWithHandlerThread繼承HandlerThreadHandler.Callback接口,此接口實現(xiàn)handleMessage()方法,用來處理獲得的Message。幀數(shù)據(jù)被封裝到Message的obj屬性中,用what進行標記。processFrame()即處理幀數(shù)據(jù),這里僅作示例。

下面要在CameraPreview中實例化ProcessWithHandlerThread,綁定接口,封裝幀數(shù)據(jù),以及發(fā)送Message。

CameraPreview中添加新的成員變量

Java

private static final int PROCESS_WITH_HANDLER_THREAD = 1;

private int processType = PROCESS_WITH_HANDLER_THREAD;

private ProcessWithHandlerThread processFrameHandlerThread;
private Handler processFrameHandler;

在構造函數(shù)末尾增加

Java

switch (processType) {
    case PROCESS_WITH_HANDLER_THREAD:
        processFrameHandlerThread = new ProcessWithHandlerThread("process frame");
        processFrameHandler = new Handler(processFrameHandlerThread.getLooper(), processFrameHandlerThread);
        break;
}

注意這里的new Handler()同時也在綁定接口,讓ProcessWithHandlerThread處理接收到的Message。

修改onPreviewFrame()

Java

public void onPreviewFrame(byte[] data, Camera camera) {
    switch (processType) {
        case PROCESS_WITH_HANDLER_THREAD:
            processFrameHandler.obtainMessage(ProcessWithHandlerThread.WHAT_PROCESS_FRAME, data).sendToTarget();
            break;
    }
}

這里將幀數(shù)據(jù)data封裝為Message,并發(fā)送出去。

運行試試

現(xiàn)在運行APP,在logcat中會出現(xiàn)大量的"test",你也可以自己修改processFrame()進行測試。

分析

這種方法就是靈活套用了Android的Handler機制,借助其消息隊列模型Message Queue解決問題。存在的問題就是幀數(shù)據(jù)都封裝為Message一股腦丟給Message Queue會不會超出限度,不過目前還沒遇到。另一問題就是Handler機制可能過于龐大,相對于拿來處理這個問題不太“輕量級”。

Queue

簡介

Queue方法就是利用Queue建立幀數(shù)據(jù)隊列,onPreviewFrame()負責向隊尾添加幀數(shù)據(jù),而由處理方法在隊頭取出幀數(shù)據(jù)并進行處理,Queue就是緩沖和提供接口的角色。

實現(xiàn)

新建ProcessWithQueue類,內容為

Java

public class ProcessWithQueue extends Thread {
    private static final String TAG = "Queue";
    private LinkedBlockingQueue<byte[]> mQueue;

    public ProcessWithQueue(LinkedBlockingQueue<byte[]> frameQueue) {
        mQueue = frameQueue;
        start();
    }

    @Override
    public void run() {
        while (true) {
            byte[] frameData = null;
            try {
                frameData = mQueue.take();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            processFrame(frameData);
        }
    }

    private void processFrame(byte[] frameData) {
        Log.i(TAG, "test");
    }
}

ProcessWithQueue實例化時由外部提供Queue。為能夠獨立處理幀數(shù)據(jù)以及隨時處理幀數(shù)據(jù),ProcessWithQueue繼承Thread,并重載了run()方法。run()方法中的死循環(huán)用來隨時處理Queue中的幀數(shù)據(jù),mQueue.take()在隊列空時阻塞,因此不會造成循環(huán)導致的CPU占用。processFrame()即處理幀數(shù)據(jù),這里僅作示例。

下面要在CameraPreview中創(chuàng)建隊列并實例化ProcessWithQueue,將幀數(shù)據(jù)加入到隊列中。

CameraPreview中添加新的成員變量

Java

private static final int PROCESS_WITH_QUEUE = 2;

private ProcessWithQueue processFrameQueue;
private LinkedBlockingQueue<byte[]> frameQueue;

Java

private int processType = PROCESS_WITH_THREAD_POOL;

修改為

Java

private int processType = PROCESS_WITH_QUEUE;

在構造函數(shù)的switch中加入

Java

case PROCESS_WITH_QUEUE:
    frameQueue = new LinkedBlockingQueue<>();
    processFrameQueue = new ProcessWithQueue(frameQueue);
    break;

這里使用LinkedBlockingQueue滿足并發(fā)性要求,由于只操作隊頭和隊尾,采用鏈表結構。

onPreviewFrame()的switch中加入

Java

case PROCESS_WITH_QUEUE:
    try {
        frameQueue.put(data);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    break;

將幀數(shù)據(jù)加入到隊尾。

運行試試

現(xiàn)在運行APP,在logcat中會出現(xiàn)大量的"test",你也可以自己修改processFrame()進行測試。

分析

這種方法可以簡單理解為對之前的HandlerThread方法的簡化,僅用LinkedBlockingQueue來實現(xiàn)緩沖,并且自己寫出隊列處理方法。這種方法同樣也沒有避開之前說的缺點,如果隊列中的幀數(shù)據(jù)不能及時處理,就會造成隊列過長,占用大量內存。但優(yōu)點就是實現(xiàn)簡單方便。

AsyncTask

簡介

AsyncTask方法就是用到了Android的AsyncTask類,這里就不詳細介紹了。簡單來說每次調用AsyncTask都會創(chuàng)建一個異步處理事件來異步執(zhí)行指定的方法,在這里就是將普通的幀數(shù)據(jù)處理方法交給AsyncTask去執(zhí)行。

實現(xiàn)

新建ProcessWithAsyncTask類,內容為

Java

public class ProcessWithAsyncTask extends AsyncTask<byte[], Void, String> {
    private static final String TAG = "AsyncTask";

    @Override
    protected String doInBackground(byte[]... params) {
        processFrame(params[0]);
        return "test";
    }

    private void processFrame(byte[] frameData) {
        Log.i(TAG, "test");
    }
}

ProcessWithAsyncTask繼承AsyncTask,重載doInBackground()方法,輸入為byte[],返回String。doInBackground()內的代碼就是在異步執(zhí)行,這里就是processFrame(),處理幀數(shù)據(jù),這里僅作示例。

下面要在CameraPreview中實例化ProcessWithAsyncTask,將幀數(shù)據(jù)交給AsyncTask。與之前介紹的方法不一樣,每次處理新的幀數(shù)據(jù)都要實例化一個新的ProcessWithAsyncTask并執(zhí)行。

CameraPreview中添加新的成員變量

Java

private static final int PROCESS_WITH_ASYNC_TASK = 3;

Java

private int processType = PROCESS_WITH_QUEUE;

修改為

Java

private int processType = PROCESS_WITH_ASYNC_TASK;

onPreviewFrame()的switch中加入

Java

case PROCESS_WITH_ASYNC_TASK:
    new ProcessWithAsyncTask().execute(data);
    break;

實例化一個新的ProcessWithAsyncTask,向其傳遞幀數(shù)據(jù)data并執(zhí)行。

運行試試

現(xiàn)在運行APP,在logcat中會出現(xiàn)大量的"test",你也可以自己修改processFrame()進行測試。

分析

這種方法代碼簡單,但理解其底層實現(xiàn)有難度。AsyncTask實際是利用到了線程池技術,可以實現(xiàn)異步和并發(fā)。其相對之前的方法的優(yōu)點就在于并發(fā)性高,但也不能無窮并發(fā)下去,還是會受到幀處理時間的制約。另外根據(jù)官方文檔中的介紹,AsyncTask的出現(xiàn)主要是為解決UI線程通信的問題,所以在這里算旁門左道了。AsyncTask相比前面的方法少了“主控”的部分,可能滿足不了某些要求。

ThreadPool

簡介

ThreadPool方法主要用到的是Java的ThreadPoolExecutor類,想必之前的AsyncTask就顯得更底層一些。通過手動建立線程池,來實現(xiàn)幀數(shù)據(jù)的并發(fā)處理。

實現(xiàn)

新建ProcessWithThreadPool類,內容為

Java

public class ProcessWithThreadPool {
    private static final String TAG = "ThreadPool";
    private static final int KEEP_ALIVE_TIME = 10;
    private static final TimeUnit TIME_UNIT = TimeUnit.SECONDS;
    private BlockingQueue<Runnable> workQueue;
    private ThreadPoolExecutor mThreadPool;

    public ProcessWithThreadPool() {
        int corePoolSize = Runtime.getRuntime().availableProcessors();
        int maximumPoolSize = corePoolSize * 2;
        workQueue = new LinkedBlockingQueue<>();
        mThreadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, KEEP_ALIVE_TIME, TIME_UNIT, workQueue);
    }

    public synchronized void post(final byte[] frameData) {
        mThreadPool.execute(new Runnable() {
            @Override
            public void run() {
                processFrame(frameData);
            }
        });
    }

    private void processFrame(byte[] frameData) {
        Log.i(TAG, "test");
    }
}

ProcessWithThreadPool構造函數(shù)建立線程池,corePoolSize為并發(fā)度,這里就是處理器核心個數(shù),線程池大小maximumPoolSize則被設置為并發(fā)度的兩倍。post()則用來通過線程池執(zhí)行幀數(shù)據(jù)處理方法。processFrame()即處理幀數(shù)據(jù),這里僅作示例。

下面要在CameraPreview中實例化ProcessWithThreadPool,將幀數(shù)據(jù)交給ThreadPool。

CameraPreview中添加新的成員變量

Java

private static final int PROCESS_WITH_THREAD_POOL = 4;

private ProcessWithThreadPool processFrameThreadPool;

Java

private int processType = PROCESS_WITH_ASYNC_TASK;

修改為

Java

private int processType = PROCESS_WITH_THREAD_POOL;

在構造函數(shù)的switch中加入

Java

case PROCESS_WITH_THREAD_POOL:
    processFrameThreadPool = new ProcessWithThreadPool();
    break;

onPreviewFrame()的switch中加入

Java

case PROCESS_WITH_THREAD_POOL:
    processFrameThreadPool.post(data);
    break;

將幀數(shù)據(jù)交給ThreadPool。

運行試試

現(xiàn)在運行APP,在logcat中會出現(xiàn)大量的"test",你也可以自己修改processFrame()進行測試。

分析

ThreadPool方法相比AsyncTask代碼更清晰,顯得不太“玄乎”,但兩者的思想是一致的。ThreadPool方法在建立線程池時有了更多定制化的空間,但同樣沒能避免AsyncTask方法的缺點。

一點嘮叨

上面介紹的諸多方法都只是大概描述了處理的思想,在實際使用時還要根據(jù)需求去修改,但大體是這樣的流程。因為實時處理缺乏完善的測試方法,所以bug也會經(jīng)常存在,還需要非常小心地去排查;比如處理的幀中丟失了兩三幀就很難發(fā)現(xiàn),即使發(fā)現(xiàn)了也不太容易找出出錯的方法,還需要大量的測試。

上面介紹的這些方法都是根據(jù)我踩的無數(shù)坑總結出來的,因為一直沒找到高質量的介紹實時預覽幀處理的文章,所以把自己知道的一些知識貢獻出來,能夠幫到有需要的人就算達到目的了。

關于幀數(shù)據(jù)和YUV格式等的實際處理問題,可以參考我之前寫的一些Android視頻解碼和YUV格式解析的文章,也希望能夠幫到你。

DEMO

本文實現(xiàn)的相機APP源碼都放在GitHub上,如果需要請點擊zhantong/AndroidCamera-ProcessFrames

參考

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容