Triển khai nhận diện đối tượng trên thiết bị Android với YOLO

1. Giới thiệu

YOLO là một thuật toán deep learning nổi tiếng, giúp phát hiện và phân loại đối tượng nhanh chóng chỉ qua một lần quét hình ảnh. Khi tích hợp YOLO vào ứng dụng Android, người dùng có thể nhận diện các đối tượng từ camera hoặc ảnh tĩnh với độ chính xác cao, mở ra nhiều cơ hội phát triển cho các ứng dụng liên quan đến an ninh, tự động hóa và nhận diện thông minh.

Bài viết này sẽ hướng dẫn bạn cách triển khai một ứng dụng Android có khả năng nhận diện đối tượng, xác định tên đối tượng, vị trí của đối tượng trong ảnh. Chúng ta sẽ tìm hiểu cách tích hợp mô hình đã huấn luyện YOLOv5 vào ứng dụng, đồng thời tối ưu hiệu suất cho thiết bị di động.

Ứng dụng sẽ tải model đã được huấn luyện, hiển thị ảnh đã có trong ứng dụng, nhấn nút trên màn hình để bắt đầu nhận diện, sau khi nhận diện xong thì hiển thị tên đối tượng, vẽ khung bao đối tượng và hiển thị ảnh lên màn hình. Mỗi lần nhấn nút tiếp theo sẽ thay ảnh trên màn hình và lặp lại quá trình nhận diện và hiển thị kết quả.

aicandy.vn

2. Cấu trúc dự án

Sử dụng phần mềm lập trình Android studio, dùng ngôn ngữ Java, các tệp và thư mục chính được tổ chức như sau:

root@vm066:/aicandy/projects/app/src# tree
.
└── main
    ├── AndroidManifest.xml
    ├── assets
    │       ├── image_test
    │       │       ├── ...
    │       │       └── ...
    │       ├── model_classes.txt
    │       └── yolov5s.ptl
    ├── java
    │       └── com
    │           └── aicandy
    │               └── objectdetection
    │                   └── yolo
    │                       ├── DetectionOverlay.java
    │                       ├── ImageProcessor.java
    │                       └── MainActivity.java
    └── res
        ├── values
        |       ├── colors.xml
        │       ├── strings.xml
        │       └── styles.xml
        ├── layout
        │       └── activity_main.xml
        └── ....
            ├── ...
            └── ...

Trong đó:

  • Thư mục image_test chứa các cảnh cần kiểm tra.
  • Tệp yolov5s.ptl là model đã được huấn luyện
  • Tệp DetectionOverlay.java chứa mã nguồn để xử lý phần hiển thị ảnh lên màn hình.
  • Tệp ImageProcessor.java chứa mã nguồn để xử lý ảnh đầu vào, phân tích và tính toán IOU.
  • Tệp MainActivity.java chứa chương trình chính của ứng dụng.
  • Thư mục layout chứa các tệp cấu hình giao diện hiển thị.

3. Cấu hình AndroidManifest

Tệp AndroidManifest là nơi cấu hình thông tin quan trọng về ứng dụng, bao gồm các activity (màn hình), biểu tượng, tên ứng dụng, và các tính năng hỗ trợ khác.

Một số cấu hình chính trong tệp:

<uses-permission android:name=”android.permission.READ_EXTERNAL_STORAGE” />:

Khai báo quyền truy cập. Ở đây, ứng dụng yêu cầu quyền đọc dữ liệu từ bộ nhớ ngoài (READ_EXTERNAL_STORAGE). Điều này cần thiết nếu ứng dụng muốn truy cập hình ảnh, video hoặc các tệp khác từ bộ nhớ ngoài của thiết bị.

android:allowBackup=”true”

Cho phép hệ thống sao lưu và phục hồi dữ liệu ứng dụng khi cần (như khi cài lại app).

android:icon=”@mipmap/ic_launcher”

Chỉ định biểu tượng chính của ứng dụng, thường được hiển thị trên màn hình chính.

android:label=”@string/app_name”

Đặt tên của ứng dụng, được định nghĩa trong tệp string resources (res/values/strings.xml).

android:theme=”@style/AppTheme”

Chỉ định theme (giao diện) chung cho ứng dụng, được định nghĩa trong tệp styles (res/values/styles.xml).

android:name=”.Result”

Đây là tên class của activity, là màn hình được điều hướng đến khi cần hiển thị kết quả (nằm trong gói chính của ứng dụng).

android:name=”.MainActivity”

Đây là tên class của MainActivity, là màn hình khởi động chính của ứng dụng.

<action android:name=”android.intent.action.MAIN” />

Định nghĩa đây là “main entry point”, tức là điểm vào chính của ứng dụng.

<category android:name=”android.intent.category.LAUNCHER” />

Chỉ định activity này xuất hiện trong danh sách ứng dụng (launcher) của thiết bị, là nơi người dùng có thể khởi động ứng dụng từ màn hình chính.

<activity
    android:name=".MainActivity"
    android:configChanges="orientation"
    android:screenOrientation="portrait">
<intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

4. Giao diện hiển thị

Ứng dụng có giao diện với các thành phần chính để hiển thị thông tin trạng thái chương trình, hiển thị hình ảnh và hiển thị kết quả.

aicandy.vn

Khai báo app_name trong AndroidManifest.xml

android:label="@string/app_name"

Tạo ImageView để hiển thị hình ảnh trên màn hình.

<ImageView
        android:id="@+id/imageView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="60dp"
        android:background="#FFFFFF"
        android:contentDescription="@string/image_view"
        app:layout_constraintDimensionRatio="1:1"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

Tiếp theo tạo Button để mỗi khi “click” vào đây thì chươn trình sẽ tải ảnh tiếp theo và nhận diện ảnh này.

<Button
        android:id="@+id/detectButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:text="@string/run"
        android:textAllCaps="false"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/imageView"/>

5. Model và xử lý ảnh

Chú ý rằng các đoạn code trong bài là các key chính, code đầy đủ cần xem phần source code ở mục 7.

5.1. Model

Để tải model đã huấn luyện, sử dụng phương thức LiteModuleLoader trong pytorch và load model với tham số đầu vào là đường dẫn của model. Đồng thời tải tên các đối tượng tương ứng trong model từ file model_classes.txt vào list.

modelModule = LiteModuleLoader.load(MainActivity.getAssetPath(getApplicationContext(), "yolov5s.ptl"));
BufferedReader br = new BufferedReader(new InputStreamReader(getAssets().open("model_classes.txt")));
String line;
List classes = new ArrayList<>();
while ((line = br.readLine()) != null) {
      classes.add(line);
}
ImageProcessor.CLASSES = new String[classes.size()];
classes.toArray(ImageProcessor.CLASSES);

5.2. Tính toán NMS, IOU

Tính toán NMS

Thuật toán Non-Maximum Suppression (NMS), một kỹ thuật thường được sử dụng trong các bài toán phát hiện đối tượng (object detection) để loại bỏ các hộp bao (bounding boxes) trùng lặp hoặc không cần thiết.

Để thực hiện NMS, cần thực hiện các công việc sau:

Sắp xếp các phát hiện theo độ tự tin: Các kết quả phát hiện (detections) được sắp xếp theo độ tự tin (confidence). Điều này giúp xử lý từ hộp có độ tự tin thấp nhất đến cao nhất.

Collections.sort(detections, new Comparator() {
    @Override
    public int compare(DetectionResult o1, DetectionResult o2) {
        return o1.confidence.compareTo(o2.confidence);
    }
});

Tiếp theo, duyệt qua từng hộp phát hiện (detections). Nếu hộp đó còn hoạt động (active[i] == true), nó được chọn và thêm vào danh sách selected. Nếu danh sách selected đã đạt đến giới hạn limit, vòng lặp dừng lại. Sau khi chọn một hộp (boxA), thuật toán so sánh nó với các hộp tiếp theo (boxB). Nếu chỉ số IoU (Intersection over Union) giữa boxA và boxB lớn hơn threshold, hộp boxB được đánh dấu là không còn hợp lệ (active[j] = false), vì nó trùng lặp quá nhiều với boxA. Nếu số lượng hộp còn hoạt động (numActive) giảm về 0, vòng lặp kết thúc.

for (int i=0; i<detections.size() && !done; i++) { 
   if (active[i]) { DetectionResult boxA = detections.get(i); selected.add(boxA); if (selected.size() >= limit) break;
        for (int j=i+1; j<detections.size(); j++) { if (active[j]) { DetectionResult boxB = detections.get(j); if (calculateIOU(boxA.boundingBox, boxB.boundingBox) > threshold) {
                    active[j] = false;
                    numActive -= 1;
                    if (numActive <= 0) {
                        done = true;
                        break;
                    }
                }
            }
        }
    }
}

Tính toán IOU

IoU (Intersection over Union) là một chỉ số dùng để đo lường mức độ chồng chéo giữa hai hình chữ nhật, thường được sử dụng trong các bài toán phát hiện đối tượng (object detection). Cụ thể, IoU so sánh giữa hộp bao dự đoán (predicted bounding box) và hộp bao thực tế (ground truth bounding box).

Công thức tính IoU như sau:

$$IoU = \frac{\text{Diện tích phần giao}}{\text{Diện tích phần hợp}}$$

Trong đó, phần giao là diện tích vùng mà hai hộp bao chồng lấp lên nhau, và phần hợp là tổng diện tích của hai hộp bao trừ đi phần giao.

IoU có giá trị từ 0 đến 1:

  • IoU = 1: Hai hộp bao trùng khớp hoàn toàn.
  • IoU = 0: Hai hộp bao không chồng lên nhau.

IoU thường được dùng làm tiêu chí đánh giá mức độ chính xác của việc dự đoán đối tượng, và là cơ sở để áp dụng thuật toán Non-Maximum Suppression (NMS) để loại bỏ các hộp bao trùng lặp.

Áp dụng thực hiện, tính toán diện tích của hai hình chữ nhật

float areaA = (a.right - a.left) * (a.bottom - a.top);
if (areaA <= 0.0) return 0.0f;

float areaB = (b.right - b.left) * (b.bottom - b.top);
if (areaB <= 0.0) return 0.0f;

Tiếp theo là xác định phần giao của 2 hình chữ nhật

float intersectionMinX = Math.max(a.left, b.left);
float intersectionMinY = Math.max(a.top, b.top);
float intersectionMaxX = Math.min(a.right, b.right);
float intersectionMaxY = Math.min(a.bottom, b.bottom);

Và cuối cùng là tính IOU:

return intersectionArea / (areaA + areaB - intersectionArea);

5.3. Xử lý kích thước hộp bao theo kích thước ảnh 

Do ảnh đầu vào có kích thước thường khác với kích thước ảnh mà model yêu cầu, nên cần phải resize lại ảnh. Tương tự, kích thước ảnh đầu vào cũng khác với kích thước ảnh hiển thị trên màn hình, nên cũng cần resize lại ảnh. Sau khi resize ảnh, cần tính toán lại kích thước của các hộp bounding box cho phù hợp với ảnh.

imgScaleX, imgScaleY: Tỷ lệ co dãn của ảnh gốc theo trục X và Y (do ảnh có thể được thu phóng hoặc cắt cúp).

ivScaleX, ivScaleY: Tỷ lệ co dãn của ImageView (hoặc vùng hiển thị ảnh) theo trục X và Y.

Tính toán vị trí hộp bao (bounding box):
float x = outputs[i * OUTPUT_COL];
float y = outputs[i * OUTPUT_COL + 1];
float w = outputs[i * OUTPUT_COL + 2];
float h = outputs[i * OUTPUT_COL + 3];

float left = imgScaleX * (x - w / 2);
float top = imgScaleY * (y - h / 2);
float right = imgScaleX * (x + w / 2);
float bottom = imgScaleY * (y + h / 2);

Tiếp theo là chuyển đổi vị trí hộp bao sang tọa độ hiển thị.

Rect rect = new Rect((int)(startX + ivScaleX * left),
        (int)(startY + top * ivScaleY),
        (int)(startX + ivScaleX * right),
        (int)(startY + ivScaleY * bottom));

5.4. Hiển thị ảnh và thông tin lên màn hình

Sau khi nhận diện được đối tượng xong, chúng ta sử dụng canvas để hiển thị và vẽ bounding box lên hình ảnh sau đó là hiển thị chúng lên màn hình thiết bị.

for (DetectionResult detection : detectionResults)

Vòng lặp này duyệt qua tất cả các kết quả nhận diện (mỗi kết quả là một đối tượng DetectionResult).

canvas.drawRect(detection.boundingBox, boxPaint)

Lệnh này vẽ một khung chữ nhật (bounding box) quanh đối tượng được phát hiện, dựa trên tọa độ trong detection.boundingBox và sử dụng boxPaint để định dạng (màu sắc, độ dày…).

String labelText = ImageProcessor.CLASSES[detection.classId]

Lấy nhãn của đối tượng được phát hiện từ một mảng CLASSES (lưu trữ tên của các lớp đối tượng), dựa trên classId của đối tượng được phát hiện.

float textWidth = textBounds.width(); float textHeight = textBounds.height()

Lưu lại chiều rộng và chiều cao của nhãn để sử dụng khi vẽ khung chứa nhãn.

RectF labelRect = new RectF(…)

Tạo một đối tượng RectF (tọa độ khung hình chữ nhật) để đại diện cho vị trí và kích thước của khung chứa nhãn, ngay trên góc của bounding box.

canvas.drawRect(labelRect, labelPaint)

Vẽ khung chữ nhật chứa nhãn lên Canvas với màu và kiểu từ labelPaint.

Những cấu hình trên sẽ giúp cho khung bao tên của đối tượng có kích thước linh hoạt tùy thuộc vào độ dài tên của đối tượng, điều này giúp cho dễ quan sát đối tượng và nhìn đẹp hơn.

aicandy.vn

6. Chương trình chính

Trong chương trình này sẽ thực hiện các công việc như duyệt và lấy tên các file ảnh trong thư mục, chuyển đổi sang bitmap (là cấu trúc dữ liệu được sử dụng phổ biến để lưu trữ và quản lý các hình ảnh. Nó đại diện cho một ma trận các pixel với mỗi pixel chứa thông tin về màu sắc. Bitmap cho phép bạn dễ dàng truy xuất giá trị pixel để thực hiện chuyển ảnh sang dạng tensor), dự đoán đối tượng từ ảnh, hiển thị ảnh và hiển thị tên đối tượng lên màn hình.

Bước 1: Duyệt ảnh

Để duyệt ảnh được lưu trong thư mục “asset” cần sử dụng AssetManager

List imageFiles = new ArrayList<>();
AssetManager assetManager = this.getAssets();
String[] files = assetManager.list(folderPath);

Bước 2: Tạo ảnh đầu vào cho mô hình

Sử dụng Bitmap.createScaledBitmap để resize ảnh cho phù hợp với kích thước yêu cầu của mô hình (INPUT_WIDTH INPUT_HEIGHT). Tiếp theo, ảnh sau khi được thay đổi kích thước được chuyển đổi thành một tensor (định dạng ma trận số học) bằng cách sử dụng TensorImageUtils.bitmapToFloat32Tensor. Quá trình này cũng chuẩn hóa giá trị RGB của ảnh bằng các giá trị NO_MEAN_RGB NO_STD_RGB. (Đối với model yolo5s thì không yêu cầu chuẩn hóa, một số mô hình khác thì yêu cầu chuẩn hóa).

Bitmap resizedImage = Bitmap.createScaledBitmap(inputImage, ImageProcessor.INPUT_WIDTH, ImageProcessor.INPUT_HEIGHT, true);
final Tensor inputTensor = TensorImageUtils.bitmapToFloat32Tensor(resizedImage, ImageProcessor.NO_MEAN_RGB, ImageProcessor.NO_STD_RGB);

Bước 3: Chạy mô hình và lấy đầu ra

Sử dụng phương thức forward để truyền tensor đầu vào và lấy ra mảng giá trị kiểu IValue ở đầu ra. Sau đó, lấy đầu ra chính (có kiểu là tensor) được lấy từ phần tử đầu tiên của mảng outputTuple.

Cuối cùng, để dễ dàng thao tác và xử lý, chúng ta chuyển tensor đầu ra thành mảng float.

IValue[] outputTuple = modelModule.forward(IValue.from(inputTensor)).toTuple();
final Tensor outputTensor = outputTuple[0].toTensor();
final float[] outputs = outputTensor.getDataAsFloatArray();

Bước 4: Tính toán tỷ lệ co giãn của ảnh

imageScaleX imageScaleY: Tính toán tỷ lệ co dãn của ảnh đầu vào ban đầu so với kích thước ảnh được đưa vào mô hình. Điều này giúp đảm bảo rằng tọa độ các hộp bao phát hiện được sẽ được chuyển đổi trở lại đúng kích thước ban đầu của ảnh.

imageScaleX = (float)inputImage.getWidth() / ImageProcessor.INPUT_WIDTH;
imageScaleY = (float)inputImage.getHeight() / ImageProcessor.INPUT_HEIGHT;

viewScaleX viewScaleY: Tính toán tỷ lệ co dãn giữa kích thước của ảnh đầu vào và vùng hiển thị (ImageView). Điều này cần thiết để chuyển đổi tọa độ các hộp bao từ kích thước của ảnh đầu vào sang kích thước thực tế của vùng hiển thị.

viewScaleX = (inputImage.getWidth() > inputImage.getHeight() ? (float)imageDisplay.getWidth() / inputImage.getWidth() : (float)imageDisplay.getHeight() / inputImage.getHeight());
viewScaleY = (inputImage.getHeight() > inputImage.getWidth() ? (float)imageDisplay.getHeight() / inputImage.getHeight() : (float)imageDisplay.getWidth() / inputImage.getWidth());

Bước 5: Cập nhật giao diện người dùng

Chúng ta xử lý phân tích hình ảnh chạy dự đoán trên luồng phụ sau khi có kết quả mới chuyển sang luồng chính để hiển thị bởi vì:
Luồng chính phải xử lý UI liên tục: Nếu các tác vụ dài như xử lý hình ảnh hoặc tính toán phức tạp được thực hiện trên luồng chính, nó sẽ gây treo giao diện (UI freeze) vì giao diện sẽ không được cập nhật thường xuyên. Người dùng sẽ cảm thấy ứng dụng bị đơ, phản hồi chậm.

Tách biệt xử lý nặng khỏi luồng chính: Các tác vụ nặng (ví dụ: xử lý ảnh, tính toán mô hình AI) nên được thực hiện trên các luồng khác để giữ cho luồng chính luôn “nhẹ nhàng”, đảm bảo trải nghiệm người dùng mượt mà và ứng dụng có thể phản hồi nhanh chóng.

Sử dụng runOnUiThread để cập nhật UI: Sau khi hoàn thành xử lý trên luồng phụ, chúng ta cần quay lại luồng chính để cập nhật UI (hiển thị kết quả phát hiện đối tượng, v.v.). Do đó, phương pháp runOnUiThread được sử dụng để đảm bảo rằng các thay đổi UI sẽ chạy an toàn trên luồng chính mà không gây ra lỗi hoặc xung đột luồng.

runOnUiThread(() -> {
            imageDisplay.setImageBitmap(inputImage);
            detectButton.setEnabled(true);
            detectButton.setText(getString(R.string.run));
            loadingBar.setVisibility(ProgressBar.INVISIBLE);
            detectionOverlay.setDetections(results);
            detectionOverlay.invalidate();
            detectionOverlay.setVisibility(View.VISIBLE);

            // Load the next image for the next detection
            loadNextImage();
        });

7. Demo và source code

Video demo có tại đây 

Source code miễn phí có tại đây

Chúc bạn thành công trong hành trình khám phá và ứng dụng trí tuệ nhân tạo vào học tập và công việc. Đừng quên truy cập thường xuyên để cập nhật thêm kiến thức mới tại AIcandy