28
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

AndroidAdvent Calendar 2018

Day 18

全画面表示にも負けないカメラ

Last updated at Posted at 2018-12-18

本日二十歳になりました @nakker1218 です。1

はじめに

こんなやばいカメラ見たことないでしょうか。
Macのキーボードをとったものなのですが、横に伸びてしまっていますね:thinking:
やばい.png

ここまで過激なものはなかなかないですが、開発中やリリースされているアプリでも表示が崩れてしまっているものは見かけます。
この現象は、カメラの映像の比率と表示するSurfaceViewの比率がずれているために起こります。
端末に標準で入っているカメラアプリでは、表示するViewを16:9に保ち余白を作ることでどんな端末でも表示が崩れないようにしています。
Google純正のカメラアプリ

しかし、全画面でカメラのプレビューを表示したいときもありますよね。
この記事では全画面表示でも崩れずに表示することの出来るカメラのプレビューを実装していきます。

画像が崩れないようにする

カメラの映像を表示する部分はお好みで実装してください。
Androidにおいてカメラの映像が崩れてしまうのは、端末の画面サイズと表示する映像のアスペクト比がずれているためです。
ということは、カメラの映像をきれいに表示するためには、SurfaceViewの比率をCameraのPreviewSizeに合わせればよいのです。例えば、CameraのPreviewSizeが16:9の比率であればSurfaceViewの比率も16:9にすればきれいに表示されます。
つまり、下の図のようにImageViewのCenterCropのように比率を保ったまま、長辺に合わせてはみ出すように表示すればどんな大きさのプレビュー画面でもきれいに表示させることができるはずです。

比率.png

Androidではみ出すように表示するにするためには、FrameLayoutの中にSurfaceViewを配置し、カメラの比率に合わせて動的にSurfaceViewの大きさを変えることができればいいはずです。

順を追って見ていきましょう。

カメラの比率を取得する

まずは、カメラのPreviewSizeの比率を求めます。
比率はWidthとHeightの最大公約数(GCD)を求め、Width or Height / GCDすることで求められます。

最大公約数の求め方は、aをbでわり、あまりをrとしてbでわるということをrが0になるまで繰り返して行くことで求まります。

gcd(64, 24)
gcd(24, 16)
gcd(16, 8)
gcd(8, 0))
-> 8

といったように簡単に最大公約数を求めることが出来ます。
これをコードに起こしましょう。
ここでKotlinの tailrec という修飾子が活躍します。
tailrec は、再帰的な書き方のデメリットである実行時間の遅さや、スタックオーバーフローのリスクを、コンパイル時に最適化してくれるため、いい感じにつかうことが出来るようになります。
今回は、最大公約数の計算を再帰的に行うために使います。


private tailrec fun calculatedGcd(a: Int, b: Int): Int 
    = if (b == 0) a else calculatedGcd(b, a % b)

そして、それぞれの辺を最大公約数で割れば比が出ます。

val widthRatio = previewSize.width / gcd
val heightRatio = previewSize.height / gcd

画面の回転に対応するためにどちらの辺の比が大きいかも取得しておきましょう。


val largeRate: Int
val shortRate: Int

if (widthRatio > heightRatio) {
    largeRate = widthRatio
    shortRate = heightRatio
} else {
    largeRate = heightRatio
    shortRate = widthRatio
}

SurfaceViewの大きさを決める

カメラのサイズの比が求まったので、SurfaceViewの大きさを決めていきます。
Frameの長辺いっぱいに映像が表示されるように拡大していきます。

まず、表示する枠の大きさを取得し、どちらの辺が大きいかを計算します
ここで注意が必要なのですが、この処理はViewのサイズを使うため画面の描画が終わったあとに行う必要があります。
そこで、ViewTreeObserverを使用して描画が終了したあとにレイアウトのサイズを変数に入れます。

private sealed class LargeSide(val longLength: Int) {
    data class Width(val width: Int, val height: Int) : LargeSide(width)
    data class Height(val width: Int, val height: Int) : LargeSide(height)

    companion object {
        fun getLargeSide(width: Int, height: Int): LargeSide {
            return when {
                width > height -> Width(width, height)
                else -> Height(width, height)
            }
        }
    }
}

var largeSide: LargeSide? = null

 viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
    override fun onGlobalLayout() {
        viewTreeObserver?.removeOnGlobalLayoutListener(this)
        largeSide = LargeSide.getLargeSide(width, height)
    }
})

余談ですが、Viewの大きさをよく使う場合はこのような拡張関数を作ると便利です。


inline fun <T : View> T.addOnGlobalLayoutListener(crossinline action: T.() -> Unit) {
    viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
        override fun onGlobalLayout() {
            viewTreeObserver?.removeOnGlobalLayoutListener(this)
            action()
        }
    })
}

長辺 / 大きい方の比をして長辺の長さが、比の大きい方になるような最大公約数を求めます。

val previewGcd = Math.ceil(longSide.toDouble() / largeRate).toInt()

先ほど出した最大公約数を使ってSurfaceViewの大きさを計算します。
映像のアスペクト比 * Frameの最大公約数をすると縦横のサイズが確定します。

val widthLength: Int
val heightLength: Int
when (largeSide) {
    is LargeSide.Width -> {
        widthLength = largeRate * previewGcd
        heightLength = shortRate * previewGcd
    }
    is LargeSide.Height -> {
        widthLength = shortRate * previewGcd
        heightLength = largeRate * previewGcd
    }
}

SurfaceViewのLayoutParamsに今計算したWidthとHeightを設定します。


val layoutParams = surface_view.layoutParams
layoutParams.width = size.width
layoutParams.height = size.height
surface_view.layoutParams = layoutParams

さいごに

このようにSurfaceView自体の大きさを調整することによって、縦画面でも横画面でも表示の崩れないカメラを作ることが出来るようになります。
正常.png
正常横.png

class CameraSurfaceView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0)
    : FrameLayout(context, attrs, defStyleAttr), SurfaceHolder.Callback {

    private var facing: CameraFacing = CameraFacing.BACK

    private var surfaceHolder: SurfaceHolder? = null
    private var camera: Camera? = null

    private var largeSide: LargeSide? = null

    companion object {
        private const val ASPECT_TOLERANCE = 0.1

        private fun getIdForRequestedCamera(cameraFacing: CameraFacing): Int {
            val cameraInfo = Camera.CameraInfo()
            for (i in 0 until Camera.getNumberOfCameras()) {
                Camera.getCameraInfo(i, cameraInfo)
                if (cameraInfo.facing === cameraFacing.facing) {
                    return i
                }
            }
            return -1
        }
    }

    init {
        LayoutInflater.from(context).inflate(R.layout.view_camera, this)

        clipToPadding = true
        clipChildren = true

        viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
            override fun onGlobalLayout() {
                viewTreeObserver?.removeOnGlobalLayoutListener(this)

                largeSide = LargeSide.get(width, height)
                surface_view.visibility = View.VISIBLE
            }
        })
    }

    override fun onFinishInflate() {
        super.onFinishInflate()

        surfaceHolder = surface_view.holder
        surfaceHolder?.addCallback(this)

        surface_view.setOnTouchListener { _, motionEvent ->
            when (motionEvent.action) {
                MotionEvent.ACTION_DOWN -> camera?.let { startAutoFocus(it, motionEvent) }
            }
            return@setOnTouchListener false
        }
    }

    override fun surfaceCreated(holder: SurfaceHolder?) {
        camera = createCamera().apply {
            startPreview()
        }
    }

    override fun surfaceChanged(holder: SurfaceHolder?, format: Int, width: Int, height: Int) {
        camera?.startPreview()
    }

    override fun surfaceDestroyed(holder: SurfaceHolder?) {
        camera?.apply {
            stopPreview()
            setPreviewCallbackWithBuffer(null)
            try {
                setPreviewTexture(null)
            } catch (e: Exception) {
                e.printStackTrace()
            }
            release()
        }
    }

    private tailrec fun calculatedGcd(a: Int, b: Int): Int = if (b == 0) a else calculatedGcd(b, a % b)

    private fun initSurfaceView(previewSize: Camera.Size) {
        val largeSide = largeSide ?: return
        val gcd = calculatedGcd(previewSize.width, previewSize.height)

        val widthRatio = previewSize.width / gcd
        val heightRatio = previewSize.height / gcd

        val largeRate: Int
        val shortRate: Int

        if (widthRatio > heightRatio) {
            largeRate = widthRatio
            shortRate = heightRatio
        } else {
            largeRate = heightRatio
            shortRate = widthRatio
        }

        val previewGcd = Math.ceil(largeSide.longLength.toDouble() / largeRate).toInt()

        val widthLength: Int
        val heightLength: Int
        when (largeSide) {
            is LargeSide.Width -> {
                widthLength = largeRate * previewGcd
                heightLength = shortRate * previewGcd
            }
            is LargeSide.Height -> {
                widthLength = shortRate * previewGcd
                heightLength = largeRate * previewGcd
            }
        }

        val size = Size(widthLength, heightLength)

        val layoutParams = surface_view.layoutParams
        layoutParams.width = size.width
        layoutParams.height = size.height
        surface_view.layoutParams = layoutParams
    }

    private fun createCamera(): Camera {
        val requestedCameraId = getIdForRequestedCamera(facing)
        if (requestedCameraId == -1) throw IllegalArgumentException("Could not find requested camera.")

        val camera = Camera.open(requestedCameraId)
        val previewSize = largeSide ?: throw IllegalStateException()

        val parameters = camera.parameters

        parameters.apply {
            setPreviewSize(parameters.previewSize.width, parameters.previewSize.height)
            initSurfaceView(parameters.previewSize)

            previewFormat = ImageFormat.NV21
            setRotation(camera, this, requestedCameraId)
        }

        camera.apply {
            this.parameters = parameters
            setPreviewDisplay(surfaceHolder)
        }
        
        return camera
    }

    enum class CameraFacing(val facing: Int) {
        BACK(Camera.CameraInfo.CAMERA_FACING_BACK),
        FRONT(Camera.CameraInfo.CAMERA_FACING_FRONT)
    }
    
    private sealed class LargeSide(val longLength: Int) {
        data class Width(val width: Int, val height: Int) : LargeSide(width)
        data class Height(val width: Int, val height: Int) : LargeSide(height)

        companion object {
            fun get(width: Int, height: Int): LargeSide {
                return when {
                    width > height -> Width(width, height)
                    else -> Height(width, height)
                }
            }
        }
    }
}
  1. ウイッシュリストはこちらになります。
    この記事はAndroid Advent Calendar 2018の18日目の記事です。

28
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?