【Camera相機開發】實現相機預覽

認識 Parameters

【Camera相機開發】知識點中瞭解了Parameters的常用方法

除了通過 Camera.Parameters 判斷相機功能的支持情況之外,我們還通過 Camera.Parameters 設置絕大部分相機參數,並且通過 Camera.setParameters() 方法將設置好的參數傳給底層,讓這些參數生效。所以相機參數的配置流程基本就是以下三個步驟:

  1. 通過 Camera.getParameters() 獲取 Camera.Parameters 實例。
  2. 通過 Camera.Parameters.getSupportedXXX 獲取某個參數的支持情況。
  3. 通過 Camera.Parameters.set() 方法設置參數。
  4. 通過 Camera.setParameters() 方法將參數應用到底層。

注意:Camera.getParameters() 是一個比較耗時的操作,實測 20ms 到 100ms不等,所以儘可能地一次性設置所有必要的參數,然後通過 Camera.setParameters() 一次性應用到底層

設置預覽尺寸

上面我們簡單介紹了 Camera.Parameters,這一節我們就要通過它來配置相機的預覽尺寸。所謂的預覽尺寸,指的就是相機把畫面輸出到手機屏幕上供用戶預覽的尺寸,通常來說我們希望預覽尺寸在不超過手機屏幕分辨率的情況下,越大越好。另外,出於業務需求,我們的相機可能需要支持多種不同的預覽比例供用戶選擇,例如 4:3 和 16:9 的比例。由於不同廠商對相機的實現都會有差異,所以很多參數在不同的手機上支持的情況也不一樣,相機的預覽尺寸也是。所以在設置相機預覽尺寸之前,我們先通過Camera.Parameters.getSupportedPreviewSizes()獲取該設備支持的所有預覽尺寸:

Camera.Parameters parameters = mCamera.getParameters();
List<Camera.Size> supportedPreviewSizes = parameters.getSupportedPreviewSizes();

如果我們把所有的預覽尺寸都打印出來看時,會發現一個比較特別的情況,就是預覽尺寸的寬是長邊,高是短邊,例如 1920x1080,而不是 1080x1920,這一點大家需要特別注意

在獲取到預覽尺寸列表之後,我們要根據自己的實際需求過濾出其中一個最符合要求的尺寸,並且把它設置給相機,在我們的 Demo 裏,只有當預覽尺寸的比例和大小都滿足要求時才能被設置給相機,如下所示:

    /**
     * 根據指定的尺寸要求設置照片尺寸,我們會考慮指定尺寸的比例,並且去符合比例的最大尺寸作爲照片尺寸。
     *
     * @param shortSide 短邊長度
     * @param longSide  長邊長度
     */
    @WorkerThread
    private void setPictureSize(int shortSide, int longSide) {
        Camera camera = mCamera;
        if (camera != null && shortSide != 0 && longSide != 0) {
            float aspectRatio = (float) longSide / shortSide;
            Camera.Parameters parameters = camera.getParameters();
            List<Camera.Size> supportedPictureSizes = parameters.getSupportedPictureSizes();
            for (Camera.Size pictureSize : supportedPictureSizes) {
                if ((float) pictureSize.width / pictureSize.height == aspectRatio) {
                    parameters.setPictureSize(pictureSize.width, pictureSize.height);
                    camera.setParameters(parameters);
                    Log.d(TAG, "setPictureSize() called with: width = " + pictureSize.width + "; height = " + pictureSize.height);
                    break;
                }
            }
        }
    }

添加預覽 Surface

相機輸出的預覽畫面最終都是繪製到指定的 Surface 上,這個 Surface 可以來自 SurfaceHolder 或者 SurfaceTexture。以在開啓預覽之前,我們還要告訴相機把畫面輸出到哪個 Surface 上,Camera 支持兩種方式設置預覽的 Surface:

  1. 通過 Camera.setPreviewDisplay()方法設置 SurfaceHolder 給相機,通常是在你使用 SurfaceView 作爲預覽控件時會使用該方法
  2. 通過 Camera.setPreviewTexture() 方法設置SurfaceTexture 給相機,通常是在你使用 TextureView 作爲預覽控件或者自己創建 SurfaceTexture 時使用該方法

在我們的 Demo 裏,使用的 SurfaceView,所以會通過 Camera.setPreviewDisplay() 方法設置預覽的Surface

/**
     * 設置預覽 Surface。
     */
    @WorkerThread
    private void setPreviewSurface(SurfaceHolder previewSurface) {
        if (mCamera != null && previewSurface != null) {
            try {
                mCamera.setPreviewDisplay(previewSurface);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

開啓和關閉預覽

/**
 * 開始預覽。
 */
@WorkerThread
private void startPreview() {
    if (mCamera != null) {
        mCamera.startPreview();
        Log.d(TAG, "startPreview() called");
    }
}

/**
 * 停止預覽。
 */
@WorkerThread
private void stopPreview() {
    if (mCamera != null) {
        mCamera.stopPreview();
        Log.d(TAG, "stopPreview() called");
    }
}

校正預覽畫面方向

如果沒有做任何畫面方向的校正,我們看到的畫面很可能是橫向的,這是因爲手機上的攝像頭傳感器方向不一定是垂直的。在做預覽畫面方向的校正之前我們先來了解五個概念,分別是自然方向、設備方向、局部座標系、屏幕方向和攝像頭傳感器方向

自然方向

當我們談論方向的時候,實際上都是相對於某一個 0° 方向的角度,這個 0° 方向被稱作自然方向,例如人站立的時候就是自然方向

設備方向

設備方向指的是硬件設備在空間中的方向與其自然方向的順時針夾角。這裏提到的自然方向指的就是我們手持一個設備的時候最習慣的方向,比如手機我們習慣豎着拿,而平板我們則習慣橫着拿,所以通常情況下手機的自然方向就是豎着的時候,平板的自然方向就是橫着的時候

在這裏插入圖片描述
以手機爲例,我們可以有以下四個比較常見的設備方向:

  • 當我們把手機垂直放置且屏幕朝向我們的時候,設備方向爲 0°,即設備自然方向
  • 當我們把手機向右橫放且屏幕朝向我們的時候,設備方向爲 90°
  • 當我們把手機倒着放置且屏幕朝向我們的時候,設備方向爲 180°
  • 當我們把手機向左橫放且屏幕朝向我們的時候,設備方向爲 270°

瞭解了設備方向的概念之後,我們可以通過 OrientationEventListener 監聽設備的方向,進而判斷設備當前是否處於自然方向,當設備的方向發生變化的時候會回調 OrientationEventListener.onOrientationChanged(int) 方法,傳給我們一個 0° 到 359° 的方向值,其中 0° 就代表設備處於自然方向

局部座標系

所謂的局部座標系指的是當設備處於自然方向時,相對於設備屏幕的座標系,該座標系是固定不變的,不會因爲設備方向的變化而改變,下圖是基於手機的局部座標系示意圖:
在這裏插入圖片描述

  • x 軸是當手機處於自然方向時,和手機屏幕平行且指向右邊的座標軸。
  • y 軸是當手機處於自然方向時,和手機屏幕平行且指向上方的座標軸。
  • z 軸是當手機處於自然方向時,和手機屏幕垂直且指向屏幕外面的座標軸。

爲了進一步解釋【座標系是固定不變的,不會因爲設備方向的變化而改變】的概念,這裏舉個例子,當我們把手機向右橫放且屏幕朝向我們的時候,此時設備方向爲 90°,局部座標系相對於手機屏幕是保持不變的,所以 y 軸正方向指向右邊,x 軸正方向指向下方,z 軸正方向還是指向屏幕外面,如下圖所示:
在這裏插入圖片描述

屏幕方向

屏幕方向指的是屏幕上顯示畫面與局部座標系 y 軸的順時針夾角,注意這裏實際上指的是顯示的畫面,而不是物理硬件上的屏幕,只是我們習慣上稱作屏幕方向而已。

爲了更清楚的說明這個概念,我們舉一個例子,假設我們將手機向右橫放看電影,此時畫面是朝上的,如下圖所示:

在這裏插入圖片描述
從上圖來看,手機向右橫放會導致設備方向變成了 90°,但是屏幕方向卻是 270°,因爲它是相對局部座標系 y 軸的順時針夾角,所以跟設備方向沒有任何關係。如果把圖中的設備換成是平板,結果就不一樣了,因爲平板橫放的時候就是它的設備自然方向,y 軸朝上,屏幕畫面顯示的方向和 y 軸的夾角是 0°,設備方向也是 0°。

總結一下,設備方向和屏幕方向之間沒有任何關係,設備方向是相對於其現實空間中自然方向的角度,而屏幕方向是相對局部座標系的角度。

我開始沒看懂這段話,只要這樣算就可以了:顯示的人物是正着的,然後頭部順時針多少度到y軸的尾部,這個角度就是屏幕方向

攝像頭傳感器方向

攝像頭傳感器方向指的是傳感器採集到的畫面方向經過順時針旋轉多少度之後才能和局部座標系的 y 軸正方向一致,也就是之前知識點那章,我們提到的 Camera.CameraInfo.orientation 屬性

例如 orientation 爲 90° 時,意味我們將攝像頭採集到的畫面順時針旋轉 90° 之後,畫面的方向就和局部座標系的 y 軸正方向一致,換個說法就是原始畫面的方向和 y 軸的夾角是逆時針 90°。
最後我們要考慮一個特殊情況,就是前置攝像頭的畫面是做了鏡像處理的,也就是所謂的前置鏡像操作,這個情況下, orientation 的值並不是實際我們要旋轉的角度,我們需要取它的鏡像值纔是我們真正要旋轉的角度,例如 orientation 爲 270°,實際我們要旋轉的角度是 90°。

注意:攝像頭傳感器方向在不同的手機上可能不一樣,大部分手機都是 90°,也有小部分是 0° 的,所以我們要通過 Camera.CameraInfo.orientation 去判斷方向,而不是假設所有設備的攝像頭傳感器方向都是 90°。

畫面方向校正

首先我們要知道的是攝像頭傳感器方向只有 0°、90°、180°、270° 四個可選值,並且這些值是相對於局部座標系 的 y 軸定義出來的,現在假設一個相機 APP 的畫面在手機上是豎屏顯示,也就是屏幕方向是 0° ,並且假設攝像頭傳感器的方向是 90°,如果我們沒有校正畫面的話,則顯示的畫面如下圖所示(忽略畫面變形):

在這裏插入圖片描述
很明顯,上面顯示的畫面內容方向是錯誤的,裏面的人物應該是垂直向上顯示纔對,所以我們應該吧攝像頭採集到的畫面順時針旋轉 90°,才能得到正確的顯示結果,如下圖所示:
在這裏插入圖片描述

上面的例子是建立在我們的屏幕方向是 0° 的時候

如果我們要求屏幕方向是 90°,也就是手機向左橫放的時候畫面纔是正的,並且假設攝像頭傳感器的方向還是 90°,如果我們沒有校正畫面的話,則顯示的畫面如下圖所示(忽略畫面變形):
在這裏插入圖片描述
此時,我們知道傳感器的方向是 90°,如果我們將傳感器採集到的畫面順時針旋轉 90° 顯然是無法得到正確的畫面,因爲它是相對於局部座標系 y 軸的角度,而不是實際屏幕方向,所以在做畫面校正的時候我們還要把實際屏幕方向也考慮進去,這裏實際屏幕方向是 90°,所以我們應該把傳感器採集到的畫面順時針旋轉 180°(攝像頭傳感器方向 + 實際屏幕方向) 才能得到正確的畫面,顯示的畫面如下圖所示(忽略畫面變形):
在這裏插入圖片描述
總結一下,在校正畫面方向的時候要同時考慮兩個因素,即攝像頭傳感器方向和屏幕方向。接下來我們要回到我們的相機應用裏,看看通過代碼是如何實現預覽畫面方向校正的。

如果你有自己看過 Camera 的官方 API 文檔,你會發現官方已經給我們寫好了一個同時考慮屏幕方向和攝像頭傳感器方向的方法:

private int getCameraDisplayOrientation(Camera.CameraInfo cameraInfo) {
    int rotation = getWindowManager().getDefaultDisplay().getRotation();
    int degrees = 0;
    switch (rotation) {
        case Surface.ROTATION_0:
            degrees = 0;
            break;
        case Surface.ROTATION_90:
            degrees = 90;
            break;
        case Surface.ROTATION_180:
            degrees = 180;
            break;
        case Surface.ROTATION_270:
            degrees = 270;
            break;
    }
    int result;
    if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
        result = (cameraInfo.orientation + degrees) % 360;
        result = (360 - result) % 360;  // compensate the mirror
    } else {  // back-facing
        result = (cameraInfo.orientation - degrees + 360) % 360;
    }
    return result;
}

如果你已經完全理解前面介紹的那些角度的概念,那你應該很容易就能理解上面這段代碼,實際上就是通過 WindowManager 獲取當前的屏幕方向,然後再參照攝像頭傳感器方向以及是否是前後置,最後計算出我們實際要旋轉的角度。

計算出要矯正的角度之後,我們要通過 Camera.setDisplayOrientation() 方法設置畫面的矯正方向,下面是 Demo 中開啓相機之後,馬上配置畫面矯正方向的代碼:

private void openCamera(int cameraId) {
    if (mCamera != null) {
        throw new RuntimeException("You must close previous camera before open a new one.");
    }
    if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
        mCamera = Camera.open(cameraId);
        mCameraId = cameraId;
        mCameraInfo = cameraId == mFrontCameraId ? mFrontCameraInfo : mBackCameraInfo;
        Log.d(TAG, "Camera[" + cameraId + "] has been opened.");
        assert mCamera != null;
        mCamera.setDisplayOrientation(getCameraDisplayOrientation(mCameraInfo));
    }
}

適配預覽比例

實際上預覽比例的適配有兩種方式:

  1. 根據預覽比例修改 Surface 的比例,這個是我們實際業務中經常用的方式,比如用戶選擇了 4:3 的預覽比例,這個時候我們會選取 4:3 的預覽尺寸並且把 Surface 修改成 4:3 的比例,從而讓畫面不會變形。
  2. 根據 Surface 的比例修改預覽比例,這種情況適用於 Surface 的比例是固定的,然後根據 Surface 的比例去選取適合的預覽尺寸。

在我們的 Demo 中,出於簡化的目的,我們選擇了第二種方式適配比例,因爲這種方式實現起來比較簡單,所以我們會寫一個自定義的 SurfaceView,讓它的比例固定是 4:3,它的寬度固定填滿父佈局,高度根據比例動態計算:

public class SurfaceView43 extends SurfaceView {

    public SurfaceView43(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int width = MeasureSpec.getSize(widthMeasureSpec);
        int height = width / 3 * 4;
        setMeasuredDimension(width, height);
    }

}

上面的 SurfaceView43 使我們自定義的 SurfaceView,它的比例固定爲 4:3,所以在它的 surfaceChanged() 回調中拿到的寬高的比例固定是 4:3,我們根據這個寬高比去調用前面定義好的設置預覽尺寸方法就可以設置正確比例的預覽尺寸:

@WorkerThread
private void setPreviewSize(int shortSide, int longSide) {
    if (mCamera != null && shortSide != 0 && longSide != 0) {
        float aspectRatio = (float) longSide / shortSide;
        Camera.Parameters parameters = mCamera.getParameters();
        List<Camera.Size> supportedPreviewSizes = parameters.getSupportedPreviewSizes();
        for (Camera.Size previewSize : supportedPreviewSizes) {
            if ((float) previewSize.width / previewSize.height == aspectRatio && previewSize.height <= shortSide && previewSize.width <= longSide) {
                parameters.setPreviewSize(previewSize.width, previewSize.height);
                mCamera.setParameters(parameters);
                Log.d(TAG, "setPreviewSize() called with: width = " + previewSize.width + "; height = " + previewSize.height);
            }
        }
    }
}

經過上面的比例適配之後,相機的預覽畫面就應該固定是 4:3 的比例並且不會變形了

獲取預覽數據

開啓相機預覽的時候我們可以通過回調方法獲取相機的預覽數據,並且可以配置預覽數據的數據格式,拿到預覽數據之後進而做一些算法處理什麼的。首先我們要通過 Parameters.getSupportedPreviewFormats() 方法獲取相機支持哪些預覽數據格式,所以我們定義了下面的方法:

/**
 * 判斷指定的預覽格式是否支持。
 */
private boolean isPreviewFormatSupported(Camera.Parameters parameters, int format) {
    List<Integer> supportedPreviewFormats = parameters.getSupportedPreviewFormats();
    return supportedPreviewFormats != null && supportedPreviewFormats.contains(format);
}

確定了你要的數據格式是支持的之後,就可以通過 Parameters.setPreviewFormat() 放配置預覽數據的格式了,代碼片段如下所示:

private static final int PREVIEW_FORMAT = ImageFormat.NV21;

if (isPreviewFormatSupported(parameters, PREVIEW_FORMAT)) {
    parameters.setPreviewFormat(PREVIEW_FORMAT);
}

上面說到我們是通過回調的方式獲取相機預覽數據的,所以相機爲我們提供了一個回調接口叫 Camera.PreviewCallback,我們只需實現該接口並且註冊給相機就可以在預覽的時候接收到數據了,註冊回調接口的方式有兩種:

  1. setPreviewCallback():註冊預覽回調
  2. setPreviewCallbackWithBuffer():註冊預覽回調,並且使用已經配置好的緩衝池

使用 setPreviewCallback() 註冊預覽回調獲取預覽數據是最簡單的,因爲你不需要其他配置流程,直接註冊即可,但是出於性能考慮,官方推薦我們使用 setPreviewCallbackWithBuffer(),因爲它會使用我們配置好的緩衝對象回調預覽數據,避免重複創建內存佔用很大的對象。所以接下來我們重點介紹如何根據預覽尺寸配置對象池並註冊回調,整個步驟如下:

  1. 根據需求確定預覽尺寸
  2. 根據需求確定預覽數據格式
  3. 根據預覽尺寸和數據格式計算出每一幀畫面要佔用的內存大小
  4. 通過 addCallbackBuffer() 方法提前添加若干個創建好的 byte 數組對象作爲緩衝對象供回調預覽數據使用
  5. 通過 setPreviewCallbackWithBuffer() 註冊預覽回調
  6. 使用完緩衝對象之後,通過 addCallbackBuffer() 方法回收緩衝對象

根據上述步驟,我們修改原來設置預覽尺寸的方法,在配置預覽尺寸的同時根據預覽尺寸和數據格式配置緩衝對象,代碼如下:

@WorkerThread
private void setPreviewSize(int shortSide, int longSide) {
    Camera camera = mCamera;
    if (camera != null && shortSide != 0 && longSide != 0) {
        float aspectRatio = (float) longSide / shortSide;
        Camera.Parameters parameters = camera.getParameters();
        List<Camera.Size> supportedPreviewSizes = parameters.getSupportedPreviewSizes();
        for (Camera.Size previewSize : supportedPreviewSizes) {
            if ((float) previewSize.width / previewSize.height == aspectRatio && previewSize.height <= shortSide && previewSize.width <= longSide) {
                parameters.setPreviewSize(previewSize.width, previewSize.height);
                Log.d(TAG, "setPreviewSize() called with: width = " + previewSize.width + "; height = " + previewSize.height);

                if (isPreviewFormatSupported(parameters, PREVIEW_FORMAT)) {
                    parameters.setPreviewFormat(PREVIEW_FORMAT);
                    int frameWidth = previewSize.width;
                    int frameHeight = previewSize.height;
                    int previewFormat = parameters.getPreviewFormat();
                    PixelFormat pixelFormat = new PixelFormat();
                    PixelFormat.getPixelFormatInfo(previewFormat, pixelFormat);
                    int bufferSize = (frameWidth * frameHeight * pixelFormat.bitsPerPixel) / 8;
                    camera.addCallbackBuffer(new byte[bufferSize]);
                    camera.addCallbackBuffer(new byte[bufferSize]);
                    camera.addCallbackBuffer(new byte[bufferSize]);
                    Log.d(TAG, "Add three callback buffers with size: " + bufferSize);
                }

                camera.setParameters(parameters);
                break;
            }
        }
    }
}

上面代碼中,我們使用 PixelFormat 工具類根據當前的預覽尺寸和格式計算出每一個像素佔用多少 Bit,進而算出一幀畫面需要佔用的內存大小,最後創建三個 Buffer 通過 addCallbackBuffer() 添加給相機供相機循環使用。

當面我們開啓預覽的時候,相機就會通過 Camera.PreviewCallback 將每一幀畫面的數據填充到 Buffer 裏傳遞給我們,我們在使用完 Buffer 之後,必須通過 addCallbackBuffer() 將用完的 Buffer 重新設置回去一遍相機繼續重複利用該緩衝,代碼如下:

private class PreviewCallback implements Camera.PreviewCallback {
    @Override
    public void onPreviewFrame(byte[] data, Camera camera) {
        // 在使用完 Buffer 之後記得回收複用。
        camera.addCallbackBuffer(data);
    }
}

注意:在預覽回調方法裏使用完 Buffer 之後,記得一定要調用 addCallbackBuffer() 將 Buffer 重新添加到緩衝池裏供相機使用。

切換前後置攝像頭

大部分情況下我們在切換前後置攝像頭的時候,都會直接複用同一個 Surface,所以我們會在 surfaceChanged() 的時候把 Surface 保存下來,如下所示:

private class PreviewSurfaceCallback implements SurfaceHolder.Callback {
    @Override
    public void surfaceCreated(SurfaceHolder holder) {

    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        mPreviewSurface = holder;
        mPreviewSurfaceWidth = width;
        mPreviewSurfaceHeight = height;
        setupPreview(holder, width, height);
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        mPreviewSurface = null;
        mPreviewSurfaceWidth = 0;
        mPreviewSurfaceHeight = 0;
    }
}

然後就是添加一個切換前後置的按鈕,當點擊按鈕的時候回去獲取和當前攝像頭 ID 相反方向的 ID,所以我們定義了一個 switchCameraId() 方法,如下所示:

/**
 * 切換前後置時切換ID
 */
private int switchCameraId() {
    if (mCameraId == mFrontCameraId && hasBackCamera()) {
        return mBackCameraId;
    } else if (mCameraId == mBackCameraId && hasFrontCamera()) {
        return mFrontCameraId;
    } else {
        throw new RuntimeException("No available camera id to switch.");
    }
}

最後就是走一個標準的切換前後置攝像頭流程了:

  1. 停止預覽
  2. 關閉當前攝像頭
  3. 開啓新的攝像頭
  4. 配置預覽尺寸
  5. 配置預覽 Surface
  6. 開啓預覽

因爲我們的 Demo 中使用 HandlerThread 控制了相機的操作流程,所以你可以看到如下代碼,具體的實現請看 Demo:

private class OnSwitchCameraButtonClickListener implements View.OnClickListener {
    @Override
    public void onClick(View v) {
        Handler cameraHandler = mCameraHandler;
        SurfaceHolder previewSurface = mPreviewSurface;
        int previewSurfaceWidth = mPreviewSurfaceWidth;
        int previewSurfaceHeight = mPreviewSurfaceHeight;
        if (cameraHandler != null && previewSurface != null) {
            int cameraId = switchCameraId();// 切換攝像頭 ID
            cameraHandler.sendEmptyMessage(MSG_STOP_PREVIEW);// 停止預覽
            cameraHandler.sendEmptyMessage(MSG_CLOSE_CAMERA);// 關閉當前的攝像頭
            cameraHandler.obtainMessage(MSG_OPEN_CAMERA, cameraId, 0).sendToTarget();// 開啓新的攝像頭
            cameraHandler.obtainMessage(MSG_SET_PREVIEW_SIZE, previewSurfaceWidth, previewSurfaceHeight).sendToTarget();// 配置預覽尺寸
            cameraHandler.obtainMessage(MSG_SET_PREVIEW_SURFACE, previewSurface).sendToTarget();// 配置預覽 Surface
            cameraHandler.sendEmptyMessage(MSG_START_PREVIEW);// 開啓預覽
        }
    }
}

轉自

https://www.jianshu.com/p/705d4792e836

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