Android相機開發: 高效實時處理預覽幀數據

原文鏈接:https://blog.csdn.net/qq_25439417/article/details/89207462

Android相機開發: 高效實時處理預覽幀數據

 

本文鏈接:https://blog.csdn.net/qq_25439417/article/details/89207462

 

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

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

準備
爲了簡單起見,我們在相機開始預覽的時候就開始獲取預覽幀並進行處理,爲了能更清晰地分析這個過程,我們在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()將相機預覽移除,此時會觸發CameraPreview類中的相關結束方法,關閉相機預覽。

現在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();
        }
    });
}
運行試試
現在運行APP不會立即開始相機預覽了,點擊“開始”按鈕屏幕上纔會出現相機預覽畫面,點擊“停止”則畫面消失,預覽停止。

基本的幀數據獲取和處理
這裏我們首先實現最基礎,也是最常用的幀數據獲取和處理的方法;然後看看改進提升性能的方法。

基礎
獲取幀數據的接口是Camera.PreviewCallback,實現此接口下的onPreviewFrame(byte[] data, Camera camera)方法即可獲取到每個幀數據data。所以現在要做的就是給CameraPreview類增加Camera.PreviewCallback接口聲明,再在CameraPreview中實現onPreviewFrame()方法,最後給Camera綁定此接口。這樣相機預覽時每產生一個預覽幀,就會調用onPreviewFrame()方法,處理預覽幀數據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()的實現

Java

public void onPreviewFrame(byte[] data, Camera camera) {
    Log.i(TAG, "processing frame");
    try {
        Thread.sleep(500);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
這裏並沒有處理幀數據data,而是暫停0.5秒模擬處理幀數據。

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

Java

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

運行試試

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

與UI線程分離
問題分析

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

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

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

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

解決方法

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

先從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,在構造函數中就tart()啓動這個Thread,並創建一個handler。openCamera()要達到的效果是在此線程中執行mCamera = Camera.open();,因此通過handler.post()在Runnable()中執行,我們將要執行的語句封裝在openCameraOriginal()中。使用notify-wait是爲安全起見,因爲post()執行會立即返回,而Runnable()會異步執行,可能在執行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來處理。

運行試試

現在運行APP,會發現第一個問題已經解決了。

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

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

HandlerThread
簡介

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

實現

新建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繼承HandlerThread和Handler.Callback接口,此接口實現handleMessage()方法,用來處理獲得的Message。幀數據被封裝到Message的obj屬性中,用what進行標記。processFrame()即處理幀數據,這裏僅作示例。

下面要在CameraPreview中實例化ProcessWithHandlerThread,綁定接口,封裝幀數據,以及發送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;
在構造函數末尾增加

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;
    }
}
這裏將幀數據data封裝爲Message,併發送出去。

運行試試

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

分析

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

Queue
簡介

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

實現

新建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。爲能夠獨立處理幀數據以及隨時處理幀數據,ProcessWithQueue繼承Thread,並重載了run()方法。run()方法中的死循環用來隨時處理Queue中的幀數據,mQueue.take()在隊列空時阻塞,因此不會造成循環導致的CPU佔用。processFrame()即處理幀數據,這裏僅作示例。

下面要在CameraPreview中創建隊列並實例化ProcessWithQueue,將幀數據加入到隊列中。

在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;
在構造函數的switch中加入

Java

case PROCESS_WITH_QUEUE:
    frameQueue = new LinkedBlockingQueue<>();
    processFrameQueue = new ProcessWithQueue(frameQueue);
    break;
這裏使用LinkedBlockingQueue滿足併發性要求,由於只操作隊頭和隊尾,採用鏈表結構。

在onPreviewFrame()的switch中加入

Java

case PROCESS_WITH_QUEUE:
    try {
        frameQueue.put(data);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    break;
將幀數據加入到隊尾。

運行試試

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

分析

這種方法可以簡單理解爲對之前的HandlerThread方法的簡化,僅用LinkedBlockingQueue來實現緩衝,並且自己寫出隊列處理方法。這種方法同樣也沒有避開之前說的缺點,如果隊列中的幀數據不能及時處理,就會造成隊列過長,佔用大量內存。但優點就是實現簡單方便。

AsyncTask
簡介

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

實現

新建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()內的代碼就是在異步執行,這裏就是processFrame(),處理幀數據,這裏僅作示例。

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

在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,向其傳遞幀數據data並執行。

運行試試

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

分析

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

ThreadPool
簡介

ThreadPool方法主要用到的是Java的ThreadPoolExecutor類,想必之前的AsyncTask就顯得更底層一些。通過手動建立線程池,來實現幀數據的併發處理。

實現

新建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構造函數建立線程池,corePoolSize爲併發度,這裏就是處理器核心個數,線程池大小maximumPoolSize則被設置爲併發度的兩倍。post()則用來通過線程池執行幀數據處理方法。processFrame()即處理幀數據,這裏僅作示例。

下面要在CameraPreview中實例化ProcessWithThreadPool,將幀數據交給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;
在構造函數的switch中加入

Java

case PROCESS_WITH_THREAD_POOL:
    processFrameThreadPool = new ProcessWithThreadPool();
    break;
在onPreviewFrame()的switch中加入

Java

case PROCESS_WITH_THREAD_POOL:
    processFrameThreadPool.post(data);
    break;
將幀數據交給ThreadPool。

運行試試

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

分析

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

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

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

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

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

參考
Camera | Android Developers
Camera.PreviewCallback | Android Developers
android - Best use of HandlerThread over other similar classes - Stack Overflow
Using concurrency to improve speed and performance in Android – Medium
HandlerThread | Android Developers
LinkedBlockingQueue | Android Developers
AsyncTask | Android Developers
ThreadPoolExecutor (Java Platform SE 7 )

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章