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);
    }
}

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

Monday, February 27, 2017

มาลองใช้ OpenCV กับ Android กัน

มีโปรเจคที่ต้องทำ Image Processing บน Android ซึ่งต้องการการประมวลผลที่ซับซ้อนขึ้น คงจะใช้แค่ Bitmap pixel processing ไม่ไหว เลยต้องหันมาใช้ OpenCV แทน

ก่อนที่จะเริ่มกระบวนการติดตั้งต่างๆ คงต้องบอกข้อจำกัดของการใช้ OpenCV บน Android เท่าที่ผมเจอมาไว้ก่อนครับ
  • ความแตกต่างระหว่างภาษา คือเนื่องจาก OpenCV ถูกพัฒนาหลักด้วย C++ เวลาเอามาใช้งานบน Android ซึ่งภาษาเขียนเป็น Java จึงมีทางออกหลักๆ 2 ทางคือ เขียนโค้ดบน Android ด้วย Java (ใช้ได้เลย ใช้ Instant run ได้ แต่ต้องไปหาเอกสาร OpenCV for Java มาอ่านเพิ่ม ซึ่งหายาก) หรือ เขียนโค้ดบน Android ด้วย C++ (ต้องลง LLDC, CMake หรือ NDK เพิ่ม และใช้ Instant run ไม่ได้ แต่หาเอกสารต่างๆได้ง่ายกว่า) ในที่นี้ผมจะลองแบบแรกคือเขียนทุกอย่างด้วย Java เลย
  • กระบวนการติดตั้งและใช้งานค่อนข้างไม่นิ่ง ยังมีการเปลี่ยนตามเวอร์ชันของ OpenCV และ Android Studio ทำให้เวลาไปดูในเว็บ อ่านในหนังสือ หรือดูคลิปสอน อาจจะใช้ไม่ได้กับเครื่องของเรา
  • ไฟล์ app ค่อนข้างใหญ่ (ประมาณ 20 MB หรือมากกว่า) คือมันต้องผนวก library ของ OpenCV เข้าไปด้วย จริงๆมีอีกทางเลือกนึงคือให้ผู้ใช้ไปติดตั้ง OpenCV library (OpenCV Manager) ก่อนบน Play store แล้วแอพของเราก็จะทำให้ขนาดเล็กลงได้ (แต่มันไม่สะดวกต่อผู้ใช้มั้ง)
เอาล่ะ ถ้าไม่ติดข้อจำกัดข้างต้น มาลองติดตั้งกันดีกว่า

สมมติว่าเราจะใช้ซอฟต์แวร์ทุกอย่างเวอร์ชันล่าสุด ณ ปัจจุบัน (ก.พ. 2560) ได้แก่ Android Studio 2.2.3  และ OpenCV for Android 3.2.0

ขั้นตอน
1. ดาวน์โหลด OpenCV for Android 3.2.0 ที่เว็บ OpenCV หรือตรงๆเลยที่นี่ แล้วแตกไฟล์ไว้ซักที่ เช่น D:\ จะได้โฟลเดอร์ชื่อ D:\OpenCV-android-sdk

2. เปิด Android Studio สร้างโปรเจคใหม่ (ถ้าต้องการเขียนแบบ C++ ตอนสร้างโปรเจคต้องเลือก C++ support ด้วย แต่ไม่ใช่วิธีของเราตอนนี้)

3. เลือกเมนู File/New/Import Module... แล้วเลือกโฟลเดอร์ D:\OpenCV-android-sdk\sdk\java จากนั้นเลือก Next แล้วไม่เลือก (เอาเครื่องหมายติ้กออก) ทั้งสามรายการ กด Finish

4. สังเกตว่ามีข้อแจ้งเตือนอย่างไรบ้าง ซึ่งมักจะเจอว่า SDK เวอร์ชันในไฟล์ build.gradle ของ project เรา กับของ OpenCV ไม่ตรงกัน

5.   ให้เลือกโหมด Project แล้วเทียบไฟล์สองไฟล์ตามรูป


6. แก้ไขไฟล์ build.gradle ของ OpenCV (อันล่าง) ให้เวอร์ชัน SDK เป็นไปตามอันบน ซึ่งจะแก้ไขเวอร์ชันของค่าต่างๆต่อไปนี้
compileSdkVersion
buildToolsVersion
minSdkVersion
targetSdkVersion

แล้ว Sync project อีกครั้ง ปัญหานี้ก็จะหายไป


7. สร้าง JNI folder โดยการเลือกเมนู File/New Folder/ JNI Folder แล้วเลือกเปลี่ยนชื่อโฟลเดอร์ตามนี้

 จะได้

8. ทำการคัดลอกโฟลเดอร์ทั้งหมดจาก D:\OpenCV-android-sdk\sdk\native\libs มาไว้ที่โฟลเดอร์นี้ โดยการ copy และ paste (ความจริงจะเลือกเฉพาะที่สอดคล้องกับ CPU ของมือถือที่เป็นเป้าหมายของเราก็ได้) จะได้

ถ้าไม่ได้เขียนโค้ด OpenCV แบบ C++ ที่ต้องการ link library ด้วย ในแต่ละโฟลเดอร์ข้างต้น สามารถลบไฟล์นามสกุล .a ได้หมด ให้เหลือไว้แค่ .so

9. เลือกแก้ไขไฟล์ข้อใดข้อหนึ่งเท่านั้นต่อไปนี้
9.1 ถ้าไม่ได้เขียนโค้ด OpenCV แบบ C++ เลย ให้แก้ไขไฟล์ build.gradle ของ Module app จาก
sourceSets { main { jni.srcDirs = ['src/main/jni', 'src/main/jniLibs/'] } }เป็น
sourceSets { main { jni.srcDirs = [] } }
หรือ
9.2 แก้ไขไฟล์ gradle.properties เพิ่มคำสั่งต่อไปนี้ที่บรรทัดล่างสุด
android.useDeprecatedNdk=true
กรณีนี้ตอน build จะถูกเตือนว่าคำสั่งดังกล่าวอาจจะถูกยกเลิกใน gradle รุ่นถัดไป

10. เพิ่ม Dependencies สำหรับการ build ให้โปรเจค โดยเลือก File/Project Structure แล้วเลือกแท็บ Dependencies กดเครื่องหมายบวกเลือก Module dependency แล้วเพิ่มตามนี้



11. ติดตั้ง NDK เพิ่มเติม โดยเลือกจากเมนูดังรูป (จากการไปดูของคนอื่นมา ถ้าเป็น Android Studio ต่ำกว่า 2.3 ไม่ต้องก็ได้)

12. รีสตาร์ท Android Studio (หรือจะไปเพิ่ม path ของ NDK ที่ไฟล์ local.properties ก็ได้ โดยเพิ่มเช่น

ndk.dir=C\:\\Users\\Surapong\\Downloads\\android-sdk-windows\\ndk-bundle)

13. เพิ่มโค้ดทดสอบเพื่อโหลด OpenCV ใน MainAcitivity.java ดังนี้

public class MainActivity extends AppCompatActivity {

    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);
    }
}

14. ลอง Build หรือรันโปรเจค แอพน่าจะติดตั้งลงเครื่องหรือ emulator ได้แล้ว แต่ว่าจะใช้เวลานานเพราะไฟล์ apk จะใหญ่มาก เนื่องจากมีการสร้าง apk สำหรับทุกรุ่น CPU ของมือถือ

15. เพื่อที่จะลดขนาด เราสามารถเพิ่มคำสั่งใน build.gradle (Module: app) ดังนี้


android {
    compileSdkVersion 23
    buildToolsVersion "23.0.3"
    defaultConfig {
        applicationId "com.example.mobile.opencv102"
        minSdkVersion 15
        targetSdkVersion 23
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
    sourceSets { main { jni.srcDirs = [] } }
    productFlavors {
        x86 {
            ndk {
                abiFilter "x86"
            }
        }
        armv7a {
            ndk {
                abiFilter "armeabi-v7a"
            }
        }
    }
}

16. จากนั้นลอง run ดูอีกครั้ง น่าจะเร็วขึ้นเพราะไฟล์ apk ขนาดเล็กลงแล้วครับ

17. นอกจากนี้ เมื่อเราตรวจสอบที่  Android Monitor เราควรจะเห็น log message ดังนี้

อ้างอิง
https://www.youtube.com/watch?v=nv4MEliij14&t=922s
https://www.learn2crack.com/2016/03/setup-opencv-sdk-android-studio.html
http://stackoverflow.com/questions/8283206/huge-apk-filesize-when-using-opencv
http://stackoverflow.com/questions/40065871/error-your-project-contains-c-files-but-it-is-not-using-a-supported-native-bu