19 กุมภาพันธ์ 2556

[Android Code] การดึงภาพจากหน้า Preview Frame มาใช้งาน



        และคราวนี้ก็ยังคงจมปลักกับเรื่องกล้องอยู่เหมือนเคย บทความนี้จะเหมาะกับผู้ที่จะทำ Image Processing จากกล้อง โดยจะเป็นเรื่องของการดึงข้อมูลภาพจาก Preview Frame หรือที่เรียกว่า Surface Holder หรือ ภาพตอนเปิดหน้ากล้อง

        ซึ่งจะเป็นการดึงภาพจากตอนเปิดหน้ากล้องมาใช้งาน แต่จะเอาไปใช้งานอะไรก็แล้วแต่ผู้ที่หลงเข้ามาอ่านนะ เจ้าของบล็อกทำบทความเพื่อให้เอาไปประยุกต์ต่อเท่านั้น

        สำหรับการดึงภาพจาก Preview Frame ก็จะใช้ Callback ดังนี้

Camera mCamera = Camera.open();
mCamera.setPreviewCallback(PreviewCallback cb);

        สำหรับ PreviewCallback ก็เหมือนกับ Callback ตัวอื่นๆ โดยที่ PreviewCallback จะมีการ Callback ตลอดเวลา เมื่อมีการ Refresh ภาพที่แสดงบนหน้าจอทุกครั้งเลย ซึ่งในบทความนี้ก็จะใช้วิธีประกาศ Implement ไว้เหมือนเดิม

public void onPreviewFrame(byte[] arg0, Camera arg1) {
    ...        
}

        โดยที่ฟังก์ชัน onPreviewFrame จะมีการส่งข้อมูลมาสองตัว คือ byte[] ที่เป็นข้อมูลภาพดิบ เดี๋ยวจะอธิบายเพิ่มทีหลังและ Camera เป็นกล้องที่ทำการเรียกใช้งานอยู่ในตอนนั้น



การแปลงข้อมูล Byte Array หรือ byte[] ให้เป็น Bitmap

       ขอพูดถึงเรื่องการแปลงข้อมูลเป็นภาพ Bitmap กันหน่อย ซึ่งเป็นเรื่องที่มีรายละเอียดค่อนข้างซับซ้อนพอสมควร เพราะข้อมูลดิบที่ได้ ใช้ว่าจับแปลงข้อมูลได้เลย เช่น

Bitmap bitmap = BitmapFactory.decodeByteArray(data , 0, data.length);<

        คำสั่งนี้เป็นคำสั่งที่แปลงข้อมูล data (ที่เป็น byte[]) เป็น Bitmap ถ้ามองเผินๆก็คือ เอาคำสั่งนี้ไปไว้ใน onPreviewFrame ก็ได้แล้ว

        แต่มันไม่ได้ง่ายแบบนั้นน่ะสิ !?

        เพราะว่า byte[] ที่จะแปลงเป็น Bitmap ต้องเป็น JPEG หรือ PNG แต่ข้อมูลดิบที่ได้นั้นกลับไม่ใช่ JPEG หรือ PNG เลยน่ะสิ โดยข้อมูลดิบที่ได้จากกล้องจะอยู่ใน Format ที่เรียกว่า YCbCr ซึ่งเป็นอีกรูปแบบหนึ่งของภาพที่นอกเหนือจาก RGB หรือ CMYK โดยจะมีการแบ่งค่าข้อมูลเป็นสามส่วนด้วยกันคือ Y, Cb และ Cr

        • Cb คือค่าความต่างของสีน้ำเงิน (Blue-Difference)

        • Cr คือค่าความต่างของสีแดง (Red-Difference)

        • Y ค่าความสว่างของสี (Luminance)

        เพิ่มเติม - YCbCr บางทีก็ใช้เป็น YUV ซึ่งก็คืออันเดียวกัน


        ซึ่งจริงๆแล้วถึงแม้ว่าจะบอกว่ามันแตกต่างไปจาก RGB แท้จริงแล้วก็ต่างกันแค่รูปแบบในการเก็บข้อมูลอย่างเดียว แต่หลักการพื้นฐานก็มาจากแม่สี RGB เหมือนกันนั่นแหละ โดยที่ค่า Cb ยิ่งเยอะสีก็จะออกเป็นสีน้ำเงินมากขึ้น ถ้าค่า Cb น้อย สีก็จะออกมาเป็นสีตรงข้ามคือสีเหลือง และถ้าค่า Cr มีค่าเยอะ ก็จะออกเป็นสีแดงมากขึ้น ถ้าค่า Cr มีค่าน้อย สีก็จะออกมาเป็นสีตรงข้ามคือสีเขียว และเมื่อค่า Cb และ Cr มีค่าเป็น 0 ทั้งคู่ ผลก็คือสีเทา


        ถึงจุดนี้ผู้ที่หลงเข้ามาอ่านก็คงสงสัย แล้วสีขาว สีดำล่ะ? อย่าลืมว่ายังมีค่าจาก Y อยู่ ซึ่งเป็นค่าความสว่างของสี แต่ค่าของ Y จะมีแค่ 0 ถึง 1 เท่านั้น ต่างจาก Cb และ Cr เมื่อค่า Y ยิ่งมาก ก็จะทำให้สีออกไปโทนสว่างมากขึ้น จากสีเทาก็จะกลายเป็นสีขาว จากสีม่วงก็เป็นสีชมพู และถ้าค่า Y ต่ำสีก็จะไม่สว่าง สีแดงก็จะเป็นสีแดงเข้ม สีเทาก็จะกลายเป็นสีดำ และสีน้ำเงินจะเป็นสีน้ำเงินเข้ม


        เวลาเก็บภาพในรูปของ YCbCr ก็จะเป็นตามรูปนี้เลย โดยที่รูปแรกคือภาพต้นฉบับ รูปที่สองคือค่า Y รูปที่สามคือค่า Cb และรูปที่สี่คือค่า Cr นั่นเอง

ที่มาของภาพ Wikipedia

        เกริ่นแค่นี้พอล่ะ เดี๋ยวจะนอกเรื่องไปมากกว่านี้

        สำหรับผู้ที่หลงเข้ามาอ่านคนใดสนใจจะอ่านเพิ่มเติม สามารถเข้าไปอ่านกันเองได้ที่ YCbCr [Wikipedia] และ YUV [Wikipedia]

        ซึ่งฟอแมตภาพ YCbCr ยังมีการเก็บค่าในรูปแบบที่ต่างกันด้วย หรือ Encode Format โดยจะมีหลักๆที่ใช้ในแอนดรอยด์อยู่สี่แบบ มีดังนี้ คือ NV16, NV21, YUY2 หรือ YUYV และ YU12 เพื่อให้เข้าใจได้ง่ายๆ NV16 ใช้สำหรับไฟล์วีดีโอ, NV21 ใช้สำหรับไฟล์ภาพ, YUY2 ใช้สำหรับไฟล์ภาพ และ YU12 เป็น Encode Format สำหรับในแอนดรอยด์

        ปกติเวลาใช้งานกล้อง ก็จะกำหนดเป็น NV21 เป็นมาตรฐาน โดยที่แต่ละเครื่องรองรับ Encode Format ไม่เหมือนกันนะ บ้างก็รองรับแต่ NV16 กับ NV21 เท่านั้น แล้วแต่เครื่อง แต่หลักๆก็จะมี NV16 กับ NV21 นี่แหละที่เป็นมาตรฐาน

        ในการนำข้อมูลภาพดังกล่าวมาใช้งานเป็น Bitmap นั้น จะต้องมีการแปลงข้อมูลให้อยู่ในรูปของ RGB เสียก่อน ซึ่งก็จะมีสูตรคำนวณที่ใช้กันทั่วไปอยู่แล้ว ไม่ต้องคิดเอง

        เพิ่มเติม - RGB จะมี Encoder Format เป็น RGB_565

        นอกจากนี้ยังมีบางเครื่องที่รองรับ RGB ให้เลยนะ ข้อดีคือ ไม่ต้องทำการแปลงจาก YCbCr ให้เป็น RGB แต่ก็มีเฉพาะบางเครื่องเท่านั้น ดังนั้นถ้าจะใช้เป็นแบบ RGB เลยก็ควรเช็คก่อนว่าเครื่องรองรับหรือไม่ โดยใช้คำสั่ง

Camera mCamera = Camera.open();
Camera.Parameters params = mCamera.getParameters();
List<Integer> format = params.getSupportPreviewFormat();

        โดยที่ในตัวแปร format ก็จะเก็บค่า Integer แบบ Array ไว้ ก็ให้ลองแสดงค่า Int ออกมาดู โดยที่ค่าต่างๆจะมีดังนี้

        • NV16 จะมีค่า Integer เท่ากับ 16

        • NV21 จะมีค่า Integer เท่ากับ 17

        • YUY2 จะมีค่า Integer เท่ากับ 20

        • YU12 จะมีค่า Integer เท่ากับ 842094169

        • RGB_565 จะมีค่า Integer เท่ากับ 4


        ถ้าอยากจะรู้ว่าตอนนี้กล้องใช้ Encode Format แบบไหน

Camera mCamera = Camera.open();
Camera.Parameters params = mCamera.getParameters();
int format = params.getPreviewFormat();

        ถ้าต้องการกำหนด Encode Format ที่ต้องการใช้งาน (อย่าลืมเช็คก่อนว่าเรื่องรองรับ Encode Format นั้นๆหรือไม่เด็ดขาด)

Camera mCamera = Camera.open();
Camera.Parameters params = mCamera.getParameters();
params.setPreviewFormat(ImageFormat.NV21);

        ซึ่งในบทความนี้เจ้าของบล็อกจะเน้น NV21 เป็นหลัก เพราะว่าเป็นมาตรฐานหลักของกล้องในแอนดรอยด์ ดังนั้นค่าที่ได้จาก byte[] ใน onPreviewFrame ก็เป็น NV21 เวลาจะใช้งานก็ต้องทำการแปลงเป็น RGB ก่อน โดยที่ค่า RGB จะเป็นการเก็บข้อมูลอยู่ในแบบ int[] ที่จะมีค่าตั้งแต่ 0x000000 ถึง 0xFFFFFF คุ้นๆแล้วล่ะสิ

        โดย int[] ในแต่ละช่องของ Array ก็คือสีในพิกเซลนั้นๆนั่นเอง เช่น 0xFF0000 คือสีแดง อ่านค่าเป็นฐานสิบจะได้ 16711680 0xFAAE0A คือสีส้ม อ่านค่าเป็นฐานสิบจะได้เป็น 16428554 0x0AB9FA คือสีฟ้า อ่านค่าเป็นฐานสิบจะได้เป็น 702970
        สำหรับการแปลง NV21 หรือเรียกอีกชื่อว่า Y'UV420P เป็น RGB ก็จะมีคำสั่งที่เตรียมไว้ให้ใช้ได้ทันทีอยู่แล้ว ไม่ต้องเขียนเองให้ยุ่งยาก

public void decodeYUV420(int[] rgb, byte[] yuv420, int width, int height) {
    final int frameSize = width * height;
        
    for (int j = 0, yp = 0; j < height; j++) {
        int uvp = frameSize + (j >> 1) * width, u = 0, v = 0;
        for (int i = 0; i < width; i++, yp++) {
            int y = (0xff & ((int) yuv420[yp])) - 16;
            if (y < 0) y = 0;
            if ((i & 1) == 0) {
                v = (0xff & yuv420[uvp++]) - 128;
                u = (0xff & yuv420[uvp++]) - 128;
            }
                
            int y1192 = 1192 * y;
            int r = (y1192 + 1634 * v);
            int g = (y1192 - 833 * v - 400 * u);
            int b = (y1192 + 2066 * u);
                
            if (r < 0) r = 0; else if (r > 262143) r = 262143;
            if (g < 0) g = 0; else if (g > 262143) g = 262143;
            if (b < 0) b = 0; else if (b > 262143) b = 262143;
              
            rgb[yp] = 0xff000000 | ((r << 6) & 0xff0000) | ((g >> 2)
                    & 0xff00) | ((b >> 10) & 0xff);
        }
    }
}

        โดยที่ตัวแปร yuv420 คือข้อมูลดิบที่ Encode Format เป็น NV21 และ rgb คือตัวแปร Integer Array ที่จะเก็บค่าที่ได้จากการแปลง ซึ่งจะต้องมีการกำหนดขนาด Array ให้กับตัวแปร rgb ก่อน จากที่บอกไว้ว่า int[] ที่จะใช้นี้จะเก็บข้อมูลเป็นแบบ RGB โดยค่าในแต่ละช่องของ Array ก็คือค่าสี RGB ของช่องนั้นๆ ดังนั้นขนาดของ Array ตัวนี้คือ ความกว้างภาพ * ความสูงภาพ 

        แล้วขนาดภาพจะเอามาจากไหนล่ะ? 

        ก็เอามาจาก Preview Frame นั่นเอง โดยผู้ที่หลงเข้ามาอ่านสามารถเช็คได้จาก Parameter ของ Camera 

Camera mCamera = Camera.open();
Camera.Parameters params = mCamera.getParameters();
int width = params.getPreviewSize().width;
int height = params.getPreviewSize().height;
int[] rgb = new int[width * height];

        ส่วน width กับ height ในฟังก์ชัน decodeYUV420 ก็ไม่ต้องบอกละมั้ง ในการใช้งานก็เพียงแค่ใช้คำสั่งดังกล่าวใน onPreviewFrame

int[] rgb;
int width, height;

public void onCreate(Bundle savedInstanceState) {
    Camera mCamera = Camera.open();
    Camera.Parameters params = mCamera.getParameters();
    width = params.getPreviewSize().width;
    height = params.getPreviewSize().height;
    rgb = new int[width * height];
}

public void onPreviewFrame(byte[] arg0, Camera arg1) {
    decodeYUV420(rgb, arg0, width, height);
    // นำตัวแปร rgb ไปใช้งานได้ตามใจชอบ  
}

        ทีนี้มาดูตัวอย่างการดึงภาพจาก Preview Frame ทั้งหมดเลยดีกว่า

Main.java
package app.akexorcist.camerarawdata;

import java.util.List;

import android.graphics.Bitmap;
import android.graphics.Bitmap.Config;
import android.hardware.Camera;
import android.hardware.Camera.PreviewCallback;
import android.os.Bundle;
import android.app.Activity;
import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.Window;
import android.view.WindowManager;

public class Main extends Activity implements SurfaceHolder.Callback
        , PreviewCallback {
    Camera mCamera;
    SurfaceView mPreview;
    
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN 
                | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
        setContentView(R.layout.main);
        
        mPreview = (SurfaceView)findViewById(R.id.preview);
        mPreview.getHolder().addCallback(this);
        mPreview.getHolder().setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
    }
    
    public void onResume() {
        Log.d("System","onResume");
        super.onResume();
        mCamera = Camera.open();
    }
    
    public void onPause() {
        Log.d("System","onPause");
        super.onPause();
        mCamera.setPreviewCallback(null);
        mCamera.release();
    }
    
    public void surfaceChanged(SurfaceHolder arg0, int arg1, int arg2, int arg3) {
        Log.d("CameraSystem","surfaceChanged");
        Camera.Parameters params = mCamera.getParameters();        
        List<Camera.Size> previewSize = params.getSupportedPreviewSizes();
        List<Camera.Size> pictureSize = params.getSupportedPictureSizes();
        params.setPictureSize(pictureSize.get(0).width, pictureSize.get(0).height);
        params.setPreviewSize(previewSize.get(0).width, previewSize.get(0).height);
        params.setJpegQuality(100);
        mCamera.setParameters(params);
        mCamera.setPreviewCallback(this);
        try {
            mCamera.setPreviewDisplay(mPreview.getHolder());
            mCamera.startPreview();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public void surfaceCreated(SurfaceHolder arg0) { }

    public void surfaceDestroyed(SurfaceHolder arg0) { }
    
    public void onPreviewFrame(final byte[] arg0, Camera arg1) {
        Log.d("Camera", "onPreviewFrame");
        if(arg0 != null) {
            runOnUiThread(new Runnable() {
                Bitmap bitmap;
                int w = mCamera.getParameters().getPreviewSize().width;
                int h = mCamera.getParameters().getPreviewSize().height;
                int[] rgbs = new int[w * h];
                public void run() {
                    decodeYUV420(rgbs, arg0, w, h);
                    bitmap = Bitmap.createBitmap(rgbs, w, h, Config.ARGB_8888);
                    
                    // นำตัวแปร rgb หรือ bitmap ไปใช้งานได้ตามต้องการ
                }
            });
        }
    }
    
    public void decodeYUV420(int[] rgb, byte[] yuv420, int width, int height) {
        final int frameSize = width * height;
        
        for (int j = 0, yp = 0; j < height; j++) {
            int uvp = frameSize + (j >> 1) * width, u = 0, v = 0;
            for (int i = 0; i < width; i++, yp++) {
                int y = (0xff & ((int) yuv420[yp])) - 16;
                if (y < 0) y = 0;
                if ((i & 1) == 0) {
                    v = (0xff & yuv420[uvp++]) - 128;
                    u = (0xff & yuv420[uvp++]) - 128;
                }
                
                int y1192 = 1192 * y;
                int r = (y1192 + 1634 * v);
                int g = (y1192 - 833 * v - 400 * u);
                int b = (y1192 + 2066 * u);
                
                if (r < 0) r = 0; else if (r > 262143) r = 262143;
                if (g < 0) g = 0; else if (g > 262143) g = 262143;
                if (b < 0) b = 0; else if (b > 262143) b = 262143;
                
                rgb[yp] = 0xff000000 | ((r << 6) & 0xff0000) | ((g >> 2) 
                        & 0xff00) | ((b >> 10) & 0xff);
            }
        }
    }
}

        1. กำหนด PreviewCallback ให้กับ mCamera ที่เป็นคลาส Camera โดยให้ทำการ Callback ตามที่ได้ประกาศ Implement ไว้ข้างบน

        2. ฟังก์ชันที่จะ Callback เมื่อ Preview Frame หรือ Surface Holder มีการ Refresh ภาพ เพื่อแสดงภาพจากหน้ากล้องตลอดเวลา

        3. ใช้ If เช็คตัวแปร byte[] arg0 เพื่อป้องกันตัวแปรมีค่าเป็น null จริงๆไม่จำเป็นต้องเช็คก็ได้ เพราะโอกาสที่เป็น null แทบไม่มี

        4. เจ้าของบล็อกขอสร้าง Runnable ขึ้นมาซักหน่อยนะ เพื่อใช้เป็น Thread ที่ทำงานสำหรับแปลงข้อมูลดิบ เพราะถ้าไม่ใช้ Runnable ช่วย ในขณะที่แปลงข้อมูลอยู่ อาจจะทำให้เกิดการค้างเล็กน้อยได้ (จริงๆมีผลแค่นิดเดียว)

        5. ประกาศตัวแปรและกำหนดค่าให้กับบางตัวที่กำหนดค่าครั้งเดียว จะได้ไม่ต้องกำหนดค่าใหม่ทุกครั้งให้เปลืองทรัพยากรเล่นๆ สำหรับการประกาศตัวแปรจุดนี้คล้ายๆกับการประกาศตัวแปรไว้ที่ข้างบนฟังก์ชัน onCreate แต่ว่าการประกาศตัวแปรตรงนี้มีผลกับแค่ฟังก์ชันที่อยู่ใน Runnable เท่านั้น ข้างนอกไม่เกี่ยว

        6. ฟังก์ชันที่ Runnable ทำงาน จะให้ทำการแปลงข้อมูลเป็น RGB แล้วนำข้อมูล RGB ที่ได้แปลงเป็น Bitmap อีกที เพื่อนำไปใช้ได้ ก็แล้วแต่ผู้ที่หลงเข้ามาอ่านว่าจะเอาข้อมูลไปใช้ทำอะไร

        ในกรณีที่ต้องการข้อมูล Bitmap ที่เป็นแบบ Byte Array ก็ให้เพิ่ม

public void onPreviewFrame(final byte[] arg0, Camera arg1) {
    Log.d("Camera", "onPreviewFrame");
    if(arg0 != null) {
        runOnUiThread(new Runnable() {
            byte[] data;
            Bitmap bitmap;
            ByteArrayOutputStream bos;
            int w = mCamera.getParameters().getPreviewSize().width;
            int h = mCamera.getParameters().getPreviewSize().height;
            int[] rgbs = new int[w * h];
            public void run() {
                decodeYUV420(rgbs, arg0, w, h);
                bitmap = Bitmap.createBitmap(rgbs
                        , w, h, Config.ARGB_8888);
                bos = new ByteArrayOutputStream();
                bitmap.compress(CompressFormat.JPEG, 100, bos);
                data = bos.toByteArray();
            }
        });
    }
}

        เท่านี้ก็จะได้ตัวแปร data ที่เป็น Bitmap แบบ byte[] ไปใช้แล้ว

        7. ฟังก์ชันแปลง NV21 หรือ YUV420 ให้เป็นแบบ RGB

main.xml
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >

    <SurfaceView
        android:id="@+id/preview"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent" />

</RelativeLayout>

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="app.akexorcist.camerarawdata"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk
        android:minSdkVersion="10"
        android:targetSdkVersion="10" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-feature android:name="android.hardware.camera" />
    <uses-feature android:name="android.hardware.camera.autofocus" />

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher" >
        <activity
            android:name="Main"
            android:label="@string/app_name" 
            android:screenOrientation="landscape">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
    
</manifest>

        ทีนี้จะเอาข้อมูลไปใช้ทำอะไรก็แล้วแต่นะ เจ้าของบล็อกทำให้เท่านี้ก็เหลือเฟือแล้วล่ะ สำหรับผู้ที่หลงเข้ามาอ่านคนใดต้องการไฟล์ตัวอย่างสามารถดาวน์โหลดได้จาก Camera Raw Data [Google Drive]

        สำหรับไฟล์ตัวอย่าง เจ้าของบล็อกกำหนด Preview Size เป็น 8 เพราะว่าถ้ากำหนดเป็น 0 ภาพจะมีขนาดใหญ่ที่ให้ดึงภาพมาช้า ก็ขอให้ผู้ที่หลงเข้ามาอ่านแก้ตามความเหมาะสมละกันนะ เพราะ Preview Size ของอุปกรณ์แอนดรอยด์แต่ละเครื่องไม่เท่ากัน ให้เช็คดูว่าเครื่องของผู้ที่หลงเข้ามาอ่านรองรับขนาดใดบ้าง


เรื่องราวเก่าๆสำหรับกล้อง กล้อง และกล้อง

        [Android Code] การติดต่อใช้งานกล้อง
        [Android Code] การถ่ายภาพด้วยกล้อง
        [Android Code] เรียกใช้งาน Auto Focus ของกล้อง
        [Android Code] การทำให้กล้อง Auto Focus แบบอัตโนมัติ
        [Android Code] การดึงภาพจากหน้า Preview Frame มาใช้งาน


        เรื่องนี้ใช้เวลาทำซะนาน แต่ทำแล้วเพลินแปลกๆแฮะ




เหล่าพันธมิตรแอนดรอยด์

Devahoy Layer Net NuuNeoI The Cheese Factory Somkiat CC Mart Routine Artit-K Arnondora Kamonway Try to be android developer Oatrice Benz Nest Studios Kotchaphan@Medium Jirawatee@Medium Travispea@Medium