Tuesday, February 28, 2017

ลองเล่นกับ Pixel - เปลี่ยนรูปสีให้เป็นเฉดสีเทา

หลังจากที่เราสามารถ import OpenCV เพื่อมาใช้งานกับ Android Studio และทดสอบว่าใช้งานได้แล้ว เราจะลองมาเริ่มโค้ดประมวลผลภาพกันในตอนนี้

งานประมวลผลภาพขั้นพื้นฐานก็มักจะเป็นการอ่านค่า Pixel ของรูปนั่นเอง ในที่นี้เราจะทดลองอ่านค่าจากรูปที่เป็น resource ซึ่งอยู่ในเครื่องโดยตรง แล้วลองมาทำ Linear pixel processing ให้เปลี่ยนจากรูปสีกลายเป็นรูปเฉดสีเทา (grayscale image)

ต้นฉบับ

ผลลัพธ์

อันดับแรกเราก็จะสร้างหน้าตาของแอพก่อน ในที่นี้ก็จะใช้ ImageView เป็นตัวแสดงผลรูป และใช้ ToggleButton เป็นการเปิดและปิดการประมวลผล

ตัว ImageView ก็จะแสดงผลรูป lenna ซึ่งเราคัดลอกไปวางในโฟลเดอร์ drawable ของ Android  ส่วน ToggleButton ก็จะผูกเข้ากับ event onClick ที่จะไปเรียกฟังก์ชัน process ตาม xml ต่อไปนี้


xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="com.example.mobile.opencv102.MainActivity">

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true"
        android:layout_centerHorizontal="true"
        android:id="@+id/ivImage"
        app:srcCompat="@drawable/lenna256" />

    <ToggleButton
        android:text="ToggleButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:id="@+id/tbProcess"
        android:layout_below="@+id/ivImage"
        android:layout_centerHorizontal="true"
        android:onClick="process" />

</RelativeLayout>

ลองมาดูขั้นตอนในส่วนของโค้ดการประมวลผลบ้าง

1. หลักการสำคัญคือ OpenCV จะมองภาพอยู่ในรูปแบบของเมตริกซ์ ผ่านคลาส Mat ดังนั้นขั้นตอนแรกเราจำเป็นต้องเปลี่ยน image resource ใน Android ให้เป็น Mat ให้ได้ ซึ่งต้องทำทางอ้อม โดย
1.1) เปลี่ยน image resource ให้เป็น bitmap โดยใช้คำสั่ง

Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.lenna256);

การใช้คำสั่งข้างต้นต้องมีความระมัดระวังพอสมควร เนื่องจาก Android จะทำการแปลงแล้วปรับขนาดของรูปไปด้วยโดยอัตโนมัติ เพื่อให้รูปมีขนาดเหมาะสมกับความละเอียดของหน้าจอ ทำให้ขนาดของ bitmap ที่ได้ มักจะมีขนาดไม่เท่ากับขนาดของรูปจริง เช่น ถ้ารูปจริงขนาด 256x256 เมื่อใช้คำสั่งนี้แล้ว ขนาดของ bitmap อาจจะกลายเป็น 384x384 จากเหตุผลข้างต้น

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

BitmapFactory.Options option = new BitmapFactory.Options();
option.inScaled = false;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.lenna256, option);

สำหรับโจทย์ข้อนี้ไม่มีปัญหาอะไร เราจะประมวลผลตามรูปที่ Android ปรับขนาดให้ จึงจะใช้วิธีแรก

1.2) เปลี่ยน bitmap ให้เป็น Mat
ในขั้นตอนนี้จำเป็นต้องมีการสร้าง matrix จากคลาส Mat ก่อน ซึ่งต้องกำหนดด้วยว่าเป็นชนิดไหน จากนั้นจึงค่อยแปลง bitmap ให้เป็น Mat ด้วยชุดคำสั่งดังนี้

Mat mat1 = new Mat(bitmap.getWidth(), bitmap.getHeight(), CvType.CV_8UC4);

Utils.bitmapToMat(bitmap, mat1);

คำสั่งบรรทัดแรกจะเป็นการสร้าง matrix ตามความกว้างและสูงของ bitmap รวมถึงกำหนดชนิดของข้อมูลใน matrix ให้เป็นแบบ CV_8UC4 ซึ่งก็คือ unsigned 8-bit 4 channels ซึ่งก็เทียบได้กับรูป RGBA นั่นเอง ความจริงสามารถสร้างให้เป็นชนิดอื่นได้ แต่เนื่องจากคำสั่งบรรทัดถัดมาจะแปลง bitmap ให้อยู่ในฟอร์แมต CV_8UC4 เสมอ จึงจำเป็นต้องสร้าง matrix ให้เป็นประเภทนี้

2. สร้าง matrix เพื่อเก็บผลลัพธ์
ในที่นี้เราทราบว่าผลลัพธ์จะเป็นภาพสีเทา ซึ่งมีแค่ 1 channel เราก็จะใช้คำสั่งในการสร้าง matrix ดังนี้

Mat mat2 = new Mat(bitmap.getWidth(), bitmap.getHeight(), CvType.CV_8UC1);

สังเกตว่าชนิดของ matrix เป็น  CV_8UC1 หรือ unsigned 8-bit 1 channel นั่นเอง

3. เรามีทางเลือกอย่างน้อยสามทางในการประมวลผลภาพ จากภาพสีเป็นสีเทา คือ
3.1 ใช้คำสั่งสำเร็จรูป

Imgproc.cvtColor(mat1, mat2, Imgproc.COLOR_RGB2GRAY);

3.2 วนลูปแต่ละแถว แต่ละหลักเพื่อเปลี่ยนค่าแต่ละพิกเซล
จะมีการอ่านค่าแต่ละพิกเซลขึ้นมาเก็บใน array ด้วยคำสั่ง
double[] pixelSet = mat1.get(r, c);
ซึ่ง array นี้ก็จะเก็บสี่ค่าคือ RGBA ของหนึ่งพิกเซล

แล้วเราก็เอาค่าแต่ละพิกเซลมาหาค่าเฉลี่ยง่ายๆ ด้วยคำสั่ง
double pixel = (pixelSet[0]+pixelSet[1]+pixelSet[2])/3;

และเขียนทับลงใน matrix ผลลัพธ์ด้วย
mat2.put(r, c, pixel);

โค้ดโดยรวมก็จะเป็น


int row = mat2.rows();
int col = mat2.cols();
for(int r=0;r<row;r++) {
 for(int c=0;c<col;c++) {
  //read pixel
  double[] pixelSet = mat1.get(r, c);
  //find average of RGB
  double pixel = (pixelSet[0]+pixelSet[1]+pixelSet[2])/3;
  //put average value of pixel to matrix
  mat2.put(r, c, pixel);
 }
}

วิธีที่สองนี้จะค่อนข้างช้าที่สุดเมื่อเทียบกับวิธีอื่น แต่เข้าใจได้ง่ายครับ

3.3 อ่านค่าพิกเซลนำมาเก็บใน array แก้ไข array และแปลงกับให้เป็น matrix
เพื่อให้การประมวลผลเร็วขึ้น เราจะลองลดลงมาเหลือแค่ลูปเดียว โดยการ dump ค่าจาก matrix ไปเก็บใน array ก่อน

ในวิธีนี้ ขั้นตอนแรกต้องแปลงชนิดของ matrix จาก byte ให้เป็น double ก่อน ไม่งั้นตอน dump ค่าออกไปก็จะได้ byte array ซึ่งเอาไปประมวลผลในการคูณหารไม่ได้
mat1.convertTo(mat1, CvType.CV_64FC4);

ต่อมาก็สร้าง array สองตัว เอาไว้เก็บค่าจาก matrix และเก็บผลลัพธ์
int total = (int)(mat1.total()*mat1.channels());
double[] source = new double[total];
byte[] dest = new byte[(int)mat1.total()];

จากนั้นก็ทำการ dump ค่าจาก matrix ไปไว้ใน array
mat1.get(0, 0, source);

และทำการประมวลผลพิกเซล เอาค่าผลลัพธ์ใส่ใน array อีกตัวนึง
int j=0;
//RGBA, move every four bytes
for(int i=0;i<source.length;i+=4) {
 //find average of RGB
 dest[j] = (byte)((source[i]+source[i+1]+source[i+2])/3);
 j++;
}

สุดท้ายก็ทำการแปลง array ผลลัพธ์กลับให้เป็น matrix
mat2.put(0, 0, dest);

4. เปลี่ยน matrix กลับให้เป็น bitmap

Bitmap result = Bitmap.createBitmap(mat2.cols(), mat2.rows(), Bitmap.Config.RGB_565);

โค้ดนี้มีข้อสังเกตว่า bitmap ที่สร้างขึ้นจะมีสองรูปแบบเท่านั้น คือ Bitmap.Config.RGB_565 (RGB) หรือ Bitmap.Config.ARGB_8888 (RGBA) ซึ่งในที่นี้เราเลือกใช้ค่าแรกเพราะไม่สนใจ alpha channel

5. แสดงผล bitmap ใน ImageView

โค้ดแบบเต็มครับ

java

import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.ImageView;
import android.widget.ToggleButton;

import org.opencv.android.OpenCVLoader;
import org.opencv.android.Utils;
import org.opencv.core.Core;
import org.opencv.core.CvType;
import org.opencv.core.Mat;
import org.opencv.imgproc.Imgproc;

public class MainActivity extends AppCompatActivity {
    private Bitmap bitmap;
    private ToggleButton tbProcess;
    private ImageView ivImage;

    static {
        if(OpenCVLoader.initDebug()) {
            Log.i("OpenCV", "Success");
        }
        else {
            Log.i("OpenCV", "Fail");
        }
    }

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

        ivImage = (ImageView) findViewById(R.id.ivImage);
        tbProcess = (ToggleButton) findViewById(R.id.tbProcess);

        //decode resource file to bitmap with no scale
        bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.lenna256);
        //if you don't want Android to auto resize the bitmap according to device resolution
//        BitmapFactory.Options option = new BitmapFactory.Options();
//        option.inScaled = false;
//        bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.lenna256, option);
    }

    public void process(View view) {
        if(!tbProcess.isChecked()) {
            ivImage.setImageResource(R.drawable.lenna256);
            return;
        }
        //source matrix, unsigned 8-bit 4 channels (RGBA)
        Mat mat1 = new Mat(bitmap.getWidth(), bitmap.getHeight(), CvType.CV_8UC4);
        //convert bitmap to matrix
        //Be careful that the output Mat have the same size as the input Bitmap
        //and of the 'CV_8UC4' type, RGBA format.
        Utils.bitmapToMat(bitmap, mat1);

        //output matrix, grayscale 8-bit 1 channel
        Mat mat2 = new Mat(bitmap.getWidth(), bitmap.getHeight(), CvType.CV_8UC1);
        //convert RGB to gray
        //Method 1: use built-in class, fastest
//        Imgproc.cvtColor(mat1, mat2, Imgproc.COLOR_RGB2GRAY);

        //Method 2: 2D array, simple but slow
//        int row = mat2.rows();
//        int col = mat2.cols();
//        for(int r=0;r<row;r++) {
//            for(int c=0;c<col;c++) {
//                //read pixel
//                double[] pixelSet = mat1.get(r, c);
//                //find average of RGB
//                double pixel = (pixelSet[0]+pixelSet[1]+pixelSet[2])/3;
//                //put average value of pixel to matrix
//                mat2.put(r, c, pixel);
//            }
//        }

        //Method 3: single loop, more complex but faster
        //it requires converting mat data type from byte to double
        //otherwise the mat.get() gives the wrong pixel value
        mat1.convertTo(mat1, CvType.CV_64FC4);
        //create temp arrays to keep pixels for both original and output
        //total array size = total pixels * image channels
        int total = (int)(mat1.total()*mat1.channels());
        double[] source = new double[total];
        byte[] dest = new byte[(int)mat1.total()];

        //dump matrix to array
        mat1.get(0, 0, source);
        int j=0;
        //RGBA, move every four bytes
        for(int i=0;i<source.length;i+=4) {
            //find average of RGB
            dest[j] = (byte)((source[i]+source[i+1]+source[i+2])/3);
            j++;
        }
        //put the modified temp array to matrix
        mat2.put(0, 0, dest);

        //create bitmap having width (columns) and height(rows) as matrix and in the format of 8 bit/pixel
        //ARGB_8888 and RGB565 are normal bitmap formats
        Bitmap result = Bitmap.createBitmap(mat2.cols(), mat2.rows(), Bitmap.Config.RGB_565);
        //convert result matrix to bitmap
        Utils.matToBitmap(mat2, result);
        //show bitmap in ImageView
        ivImage.setImageBitmap(result);
    }
}

จบจ้า พิมพ์จนเหนื่อยเลย

No comments:

Post a Comment