Nhận dạng khuôn mặt CV không chính xác


13

Trong ứng dụng của tôi, tôi đang cố gắng nhận dạng khuôn mặt trên một hình ảnh cụ thể bằng Open CV, trước tiên tôi sẽ đào tạo một hình ảnh và sau đó đào tạo hình ảnh đó nếu tôi chạy nhận dạng khuôn mặt trên hình ảnh đó, nó sẽ nhận diện thành công khuôn mặt được đào tạo đó. Tuy nhiên, khi tôi chuyển sang một hình ảnh khác của cùng một người nhận ra không hoạt động. Nó chỉ hoạt động trên hình ảnh được đào tạo, vì vậy câu hỏi của tôi là làm thế nào để tôi khắc phục nó?

Cập nhật: Điều tôi muốn làm là người dùng nên chọn hình ảnh của một người từ bộ lưu trữ và sau đó sau khi đào tạo hình ảnh đã chọn đó, tôi muốn lấy tất cả hình ảnh từ bộ lưu trữ phù hợp với khuôn mặt của hình ảnh được đào tạo của tôi

Đây là lớp hoạt động của tôi:

public class MainActivity extends AppCompatActivity {
    private Mat rgba,gray;
    private CascadeClassifier classifier;
    private MatOfRect faces;
    private ArrayList<Mat> images;
    private ArrayList<String> imagesLabels;
    private Storage local;
    ImageView mimage;
    Button prev,next;
    ArrayList<Integer> imgs;
    private int label[] = new int[1];
    private double predict[] = new double[1];
    Integer pos = 0;
    private String[] uniqueLabels;
    FaceRecognizer recognize;
    private boolean trainfaces() {
        if(images.isEmpty())
            return false;
        List<Mat> imagesMatrix = new ArrayList<>();
        for (int i = 0; i < images.size(); i++)
            imagesMatrix.add(images.get(i));
        Set<String> uniqueLabelsSet = new HashSet<>(imagesLabels); // Get all unique labels
        uniqueLabels = uniqueLabelsSet.toArray(new String[uniqueLabelsSet.size()]); // Convert to String array, so we can read the values from the indices

        int[] classesNumbers = new int[uniqueLabels.length];
        for (int i = 0; i < classesNumbers.length; i++)
            classesNumbers[i] = i + 1; // Create incrementing list for each unique label starting at 1
        int[] classes = new int[imagesLabels.size()];
        for (int i = 0; i < imagesLabels.size(); i++) {
            String label = imagesLabels.get(i);
            for (int j = 0; j < uniqueLabels.length; j++) {
                if (label.equals(uniqueLabels[j])) {
                    classes[i] = classesNumbers[j]; // Insert corresponding number
                    break;
                }
            }
        }
        Mat vectorClasses = new Mat(classes.length, 1, CvType.CV_32SC1); // CV_32S == int
        vectorClasses.put(0, 0, classes); // Copy int array into a vector

        recognize = LBPHFaceRecognizer.create(3,8,8,8,200);
        recognize.train(imagesMatrix, vectorClasses);
        if(SaveImage())
            return true;

        return false;
    }
    public void cropedImages(Mat mat) {
        Rect rect_Crop=null;
        for(Rect face: faces.toArray()) {
            rect_Crop = new Rect(face.x, face.y, face.width, face.height);
        }
        Mat croped = new Mat(mat, rect_Crop);
        images.add(croped);
    }
    public boolean SaveImage() {
        File path = new File(Environment.getExternalStorageDirectory(), "TrainedData");
        path.mkdirs();
        String filename = "lbph_trained_data.xml";
        File file = new File(path, filename);
        recognize.save(file.toString());
        if(file.exists())
            return true;
        return false;
    }

    private BaseLoaderCallback callbackLoader = new BaseLoaderCallback(this) {
        @Override
        public void onManagerConnected(int status) {
            switch(status) {
                case BaseLoaderCallback.SUCCESS:
                    faces = new MatOfRect();

                    //reset
                    images = new ArrayList<Mat>();
                    imagesLabels = new ArrayList<String>();
                    local.putListMat("images", images);
                    local.putListString("imagesLabels", imagesLabels);

                    images = local.getListMat("images");
                    imagesLabels = local.getListString("imagesLabels");

                    break;
                default:
                    super.onManagerConnected(status);
                    break;
            }
        }
    };

    @Override
    protected void onResume() {
        super.onResume();
        if(OpenCVLoader.initDebug()) {
            Log.i("hmm", "System Library Loaded Successfully");
            callbackLoader.onManagerConnected(BaseLoaderCallback.SUCCESS);
        } else {
            Log.i("hmm", "Unable To Load System Library");
            OpenCVLoader.initAsync(OpenCVLoader.OPENCV_VERSION, this, callbackLoader);
        }
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        prev = findViewById(R.id.btprev);
        next = findViewById(R.id.btnext);
        mimage = findViewById(R.id.mimage);
       local = new Storage(this);
       imgs = new ArrayList();
       imgs.add(R.drawable.jonc);
       imgs.add(R.drawable.jonc2);
       imgs.add(R.drawable.randy1);
       imgs.add(R.drawable.randy2);
       imgs.add(R.drawable.imgone);
       imgs.add(R.drawable.imagetwo);
       mimage.setBackgroundResource(imgs.get(pos));
        prev.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if(pos!=0){
                  pos--;
                  mimage.setBackgroundResource(imgs.get(pos));
                }
            }
        });
        next.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if(pos<5){
                    pos++;
                    mimage.setBackgroundResource(imgs.get(pos));
                }
            }
        });
        Button train = (Button)findViewById(R.id.btn_train);
        train.setOnClickListener(new View.OnClickListener() {
            @RequiresApi(api = Build.VERSION_CODES.KITKAT)
            @Override
            public void onClick(View view) {
                rgba = new Mat();
                gray = new Mat();
                Mat mGrayTmp = new Mat();
                Mat mRgbaTmp = new Mat();
                classifier = FileUtils.loadXMLS(MainActivity.this);
                Bitmap icon = BitmapFactory.decodeResource(getResources(),
                        imgs.get(pos));
                Bitmap bmp32 = icon.copy(Bitmap.Config.ARGB_8888, true);
                Utils.bitmapToMat(bmp32, mGrayTmp);
                Utils.bitmapToMat(bmp32, mRgbaTmp);
                Imgproc.cvtColor(mGrayTmp, mGrayTmp, Imgproc.COLOR_BGR2GRAY);
                Imgproc.cvtColor(mRgbaTmp, mRgbaTmp, Imgproc.COLOR_BGRA2RGBA);
                /*Core.transpose(mGrayTmp, mGrayTmp); // Rotate image
                Core.flip(mGrayTmp, mGrayTmp, -1); // Flip along both*/
                gray = mGrayTmp;
                rgba = mRgbaTmp;
                Imgproc.resize(gray, gray, new Size(200,200.0f/ ((float)gray.width()/ (float)gray.height())));
                if(gray.total() == 0)
                    Toast.makeText(getApplicationContext(), "Can't Detect Faces", Toast.LENGTH_SHORT).show();
                classifier.detectMultiScale(gray,faces,1.1,3,0|CASCADE_SCALE_IMAGE, new Size(30,30));
                if(!faces.empty()) {
                    if(faces.toArray().length > 1)
                        Toast.makeText(getApplicationContext(), "Mutliple Faces Are not allowed", Toast.LENGTH_SHORT).show();
                    else {
                        if(gray.total() == 0) {
                            Log.i("hmm", "Empty gray image");
                            return;
                        }
                        cropedImages(gray);
                        imagesLabels.add("Baby");
                        Toast.makeText(getApplicationContext(), "Picture Set As Baby", Toast.LENGTH_LONG).show();
                        if (images != null && imagesLabels != null) {
                            local.putListMat("images", images);
                            local.putListString("imagesLabels", imagesLabels);
                            Log.i("hmm", "Images have been saved");
                            if(trainfaces()) {
                                images.clear();
                                imagesLabels.clear();
                            }
                        }
                    }
                }else {
                   /* Bitmap bmp = null;
                    Mat tmp = new Mat(250, 250, CvType.CV_8U, new Scalar(4));
                    try {
                        //Imgproc.cvtColor(seedsImage, tmp, Imgproc.COLOR_RGB2BGRA);
                        Imgproc.cvtColor(gray, tmp, Imgproc.COLOR_GRAY2RGBA, 4);
                        bmp = Bitmap.createBitmap(tmp.cols(), tmp.rows(), Bitmap.Config.ARGB_8888);
                        Utils.matToBitmap(tmp, bmp);
                    } catch (CvException e) {
                        Log.d("Exception", e.getMessage());
                    }*/
                    /*    mimage.setImageBitmap(bmp);*/
                    Toast.makeText(getApplicationContext(), "Unknown Face", Toast.LENGTH_SHORT).show();
                }
            }
        });
        Button recognize = (Button)findViewById(R.id.btn_recognize);
        recognize.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if(loadData())
                    Log.i("hmm", "Trained data loaded successfully");
                rgba = new Mat();
                gray = new Mat();
                faces = new MatOfRect();
                Mat mGrayTmp = new Mat();
                Mat mRgbaTmp = new Mat();
                classifier = FileUtils.loadXMLS(MainActivity.this);
                Bitmap icon = BitmapFactory.decodeResource(getResources(),
                        imgs.get(pos));
                Bitmap bmp32 = icon.copy(Bitmap.Config.ARGB_8888, true);
                Utils.bitmapToMat(bmp32, mGrayTmp);
                Utils.bitmapToMat(bmp32, mRgbaTmp);
                Imgproc.cvtColor(mGrayTmp, mGrayTmp, Imgproc.COLOR_BGR2GRAY);
                Imgproc.cvtColor(mRgbaTmp, mRgbaTmp, Imgproc.COLOR_BGRA2RGBA);
                /*Core.transpose(mGrayTmp, mGrayTmp); // Rotate image
                Core.flip(mGrayTmp, mGrayTmp, -1); // Flip along both*/
                gray = mGrayTmp;
                rgba = mRgbaTmp;
                Imgproc.resize(gray, gray, new Size(200,200.0f/ ((float)gray.width()/ (float)gray.height())));
                if(gray.total() == 0)
                    Toast.makeText(getApplicationContext(), "Can't Detect Faces", Toast.LENGTH_SHORT).show();
                classifier.detectMultiScale(gray,faces,1.1,3,0|CASCADE_SCALE_IMAGE, new Size(30,30));
                if(!faces.empty()) {
                    if(faces.toArray().length > 1)
                        Toast.makeText(getApplicationContext(), "Mutliple Faces Are not allowed", Toast.LENGTH_SHORT).show();
                    else {
                        if(gray.total() == 0) {
                            Log.i("hmm", "Empty gray image");
                            return;
                        }
                        recognizeImage(gray);
                    }
                }else {
                    Toast.makeText(getApplicationContext(), "Unknown Face", Toast.LENGTH_SHORT).show();
                }
            }
        });


    }
    private void recognizeImage(Mat mat) {
        Rect rect_Crop=null;
        for(Rect face: faces.toArray()) {
            rect_Crop = new Rect(face.x, face.y, face.width, face.height);
        }
        Mat croped = new Mat(mat, rect_Crop);
        recognize.predict(croped, label, predict);
        int indice = (int)predict[0];
        Log.i("hmmcheck:",String.valueOf(label[0])+" : "+String.valueOf(indice));
        if(label[0] != -1 && indice < 125)
            Toast.makeText(getApplicationContext(), "Welcome "+uniqueLabels[label[0]-1]+"", Toast.LENGTH_SHORT).show();
        else
            Toast.makeText(getApplicationContext(), "You're not the right person", Toast.LENGTH_SHORT).show();
    }
    private boolean loadData() {
        String filename = FileUtils.loadTrained();
        if(filename.isEmpty())
            return false;
        else
        {
            recognize.read(filename);
            return true;
        }
    }
}

Lớp sử dụng tệp của tôi:

   public class FileUtils {
        private static String TAG = FileUtils.class.getSimpleName();
        private static boolean loadFile(Context context, String cascadeName) {
            InputStream inp = null;
            OutputStream out = null;
            boolean completed = false;
            try {
                inp = context.getResources().getAssets().open(cascadeName);
                File outFile = new File(context.getCacheDir(), cascadeName);
                out = new FileOutputStream(outFile);

                byte[] buffer = new byte[4096];
                int bytesread;
                while((bytesread = inp.read(buffer)) != -1) {
                    out.write(buffer, 0, bytesread);
                }

                completed = true;
                inp.close();
                out.flush();
                out.close();
            } catch (IOException e) {
                Log.i(TAG, "Unable to load cascade file" + e);
            }
            return completed;
        }
        public static CascadeClassifier loadXMLS(Activity activity) {


            InputStream is = activity.getResources().openRawResource(R.raw.lbpcascade_frontalface);
            File cascadeDir = activity.getDir("cascade", Context.MODE_PRIVATE);
            File mCascadeFile = new File(cascadeDir, "lbpcascade_frontalface_improved.xml");
            FileOutputStream os = null;
            try {
                os = new FileOutputStream(mCascadeFile);
                byte[] buffer = new byte[4096];
                int bytesRead;
                while ((bytesRead = is.read(buffer)) != -1) {
                    os.write(buffer, 0, bytesRead);
                }
                is.close();
                os.close();

            } catch (FileNotFoundException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }


            return new CascadeClassifier(mCascadeFile.getAbsolutePath());
        }
        public static String loadTrained() {
            File file = new File(Environment.getExternalStorageDirectory(), "TrainedData/lbph_trained_data.xml");

            return file.toString();
        }
    }

Đây là những hình ảnh tôi đang cố gắng so sánh ở đây khuôn mặt của người vẫn giống nhau trong sự công nhận nó không phù hợp! Hình 1 Hình 2


Khi tôi xây dựng bài tập năm cuối cho Hệ thống chấm công tự động, tôi đã sử dụng 8-10 hình ảnh của mình với các tư thế và điều kiện ánh sáng hơi khác nhau để huấn luyện bộ phân loại.
ZdaR

Bạn có thể lật tấm thảm đào tạo của bạn theo chiều ngang để xử lý yêu cầu đó.
nfl-x

@ nfl-x lật hình ảnh sẽ không giải quyết được vấn đề về độ chính xác, chúng tôi cần một câu trả lời tốt hơn gần đây trên tenorflow có vẻ ổn nhưng không có đủ thông tin hoặc hướng dẫn về việc triển khai cho Android vì vậy dự đoán tốt nhất của chúng tôi là tiếp tục bình chọn bài đăng này để một chuyên gia có thể can thiệp và cung cấp một giải pháp thích hợp cho Android
Ông Patel

Câu trả lời:


5

Cập nhật

Theo chỉnh sửa mới trong câu hỏi, bạn cần một cách để xác định người mới đang di chuyển mà những bức ảnh của họ có thể không có sẵn trong giai đoạn đào tạo của người mẫu. Những nhiệm vụ này được gọi là vài shot shot . Điều này tương tự với yêu cầu của các cơ quan tình báo / cảnh sát để tìm mục tiêu của họ bằng cách sử dụng cảnh quay camera CCTV. Vì thông thường không có đủ hình ảnh của một mục tiêu cụ thể, trong quá trình đào tạo, họ sử dụng các mô hình như FaceNet . Tôi thực sự khuyên bạn nên đọc bài báo, tuy nhiên, tôi giải thích một vài điểm nổi bật của nó ở đây:

  • Nói chung, lớp cuối cùng của bộ phân loại là một vectơ * 1 với n-1 của các phần tử gần như bằng 0 và một phần tử gần bằng 1. Phần tử gần với 1, xác định dự đoán của bộ phân loại về nhãn của đầu vào. Kiến trúc CNN điển hình
  • Các tác giả đã tìm ra rằng nếu họ huấn luyện một mạng phân loại có chức năng mất cụ thể trên một tập dữ liệu khổng lồ về khuôn mặt, bạn có thể sử dụng đầu ra của lớp bán kết làm đại diện cho bất kỳ khuôn mặt nào, bất kể nó có nằm trong tập huấn luyện hay không, các tác giả gọi đây là nhúng mặt vector .
  • Kết quả trước đó có nghĩa là với một mô hình FaceNet được đào tạo rất tốt, bạn có thể tóm tắt bất kỳ khuôn mặt nào thành một vectơ. Thuộc tính rất thú vị của phương pháp này là các vectơ của một người cụ thể ở các góc / vị trí / trạng thái khác nhau có vị trí gần nhau trong không gian eidianidian (tính chất này được thi hành bởi hàm mất mát mà các tác giả đã chọn).nhập mô tả hình ảnh ở đây
  • Tóm lại, bạn có một mô hình lấy các mặt làm đầu vào và trả về các vectơ. Các vectơ gần nhau rất có thể thuộc về cùng một người (Để kiểm tra xem bạn có thể sử dụng KNN hay chỉ khoảng cách eidianidian đơn giản).

Một triển khai của FaceNet có thể được tìm thấy ở đây . Tôi đề nghị bạn thử chạy nó trên máy tính của bạn để biết bạn đang thực sự làm gì. Sau đó, tốt nhất nên làm như sau:

  1. Chuyển đổi mô hình FaceNet được đề cập trong kho lưu trữ sang phiên bản tflite của nó ( blogpost này có thể giúp đỡ)
  2. Đối với mỗi ảnh được gửi bởi người dùng, hãy sử dụng Face API để trích xuất (các) khuôn mặt
  3. Sử dụng mô hình rút gọn trong ứng dụng của bạn để có được các phần nhúng của khuôn mặt được trích xuất.
  4. Xử lý tất cả các hình ảnh trong bộ sưu tập của người dùng, lấy các vectơ cho các khuôn mặt trong ảnh.
  5. Sau đó so sánh từng vectơ được tìm thấy trong bước 4 với từng vectơ được tìm thấy trong bước 3 để có được các kết quả khớp.

Câu trả lời gốc

Bạn đã gặp một trong những thách thức phổ biến nhất của học máy: Quá mức. Phát hiện và nhận diện khuôn mặt là một lĩnh vực nghiên cứu khổng lồ và gần như tất cả các mô hình chính xác hợp lý đang sử dụng một số loại học tập sâu. Lưu ý rằng ngay cả việc phát hiện khuôn mặt chính xác cũng không dễ dàng như có vẻ, tuy nhiên, khi bạn đang thực hiện trên Android, bạn có thể sử dụng Face API cho tác vụ này. (Các kỹ thuật nâng cao khác như MTCNN quá chậm / khó triển khai trên thiết bị cầm tay). Nó đã được chỉ ra rằng chỉ cho ăn mô hình với một bức ảnh khuôn mặt có nhiều nhiễu nền hoặc nhiều người bên trong không hoạt động. Vì vậy, bạn thực sự không thể bỏ qua bước này.

Sau khi có được khuôn mặt đẹp của các mục tiêu ứng cử viên từ nền, bạn cần vượt qua thử thách nhận diện khuôn mặt được phát hiện. Một lần nữa, tất cả các mô hình có thẩm quyền theo hiểu biết tốt nhất của tôi, đang sử dụng một số loại mạng lưới thần kinh học tập / tích chập sâu. Sử dụng chúng trên điện thoại di động là một thách thức, nhưng nhờ có Tensorflow Lite, bạn có thể thu nhỏ chúng và chạy chúng trong ứng dụng của mình. Một dự án về nhận dạng khuôn mặt trên điện thoại Android mà tôi đã làm việc ở đây mà bạn có thể kiểm tra. Hãy nhớ rằng bất kỳ mô hình tốt nào cũng cần được đào tạo trên nhiều trường hợp dữ liệu được gắn nhãn, tuy nhiên, có rất nhiều mô hình đã được đào tạo trên bộ dữ liệu lớn của khuôn mặt hoặc các tác vụ nhận dạng hình ảnh khác, để điều chỉnh chúng và sử dụng kiến ​​thức hiện có của chúng, chúng ta có thể sử dụnghọc chuyển , để bắt đầu nhanh về phát hiện đối tượng và học chuyển có liên quan mật thiết đến trường hợp của bạn, hãy kiểm tra bài đăng trên blog này .

Nhìn chung, bạn phải nhận được nhiều trường hợp khuôn mặt mà bạn muốn phát hiện cộng với nhiều bức ảnh khuôn mặt của những người mà bạn không quan tâm, sau đó bạn cần đào tạo một mô hình dựa trên các tài nguyên được đề cập ở trên, và sau đó bạn cần phải sử dụng TensorFlow lite để giảm kích thước của nó và nhúng nó vào ứng dụng của bạn. Đối với mỗi khung hình sau đó, bạn gọi API Android Face và nguồn cấp dữ liệu (khuôn mặt có thể được phát hiện) vào mô hình và xác định người.

Tùy thuộc vào mức độ chịu đựng của bạn đối với độ trễ và số lượng kích thước tập huấn luyện và số lượng mục tiêu, bạn có thể nhận được nhiều kết quả khác nhau, tuy nhiên, độ chính xác% 90 + có thể dễ dàng đạt được nếu bạn chỉ có một vài người mục tiêu.


Tôi không muốn sử dụng kết nối mạng trong ứng dụng của mình vì vậy tầm nhìn của đám mây google không có vấn đề gì nhưng dòng chảy tenor có vẻ khá thú vị phải không? và nếu bạn có thể cung cấp một ví dụ hoạt động về nó, tôi sẽ đánh giá cao nó! Cảm ơn
R.Coder

Bằng cách này, câu trả lời tuyệt vời!
R.Coder

Nó miễn phí. Kiểm tra điều này cho một ví dụ làm việc. Chúng tôi có thể xác định khuôn mặt của 225 người mà không cần sử dụng kết nối mạng với độ chính xác rất cao mặc dù có một số trục trặc ở phía trải nghiệm người dùng. Nhưng đó nên là một khởi đầu tốt.
Farzad Vertigo

Được rồi tôi sẽ dùng thử
R.Coder

1
Nó đã làm việc!!!! Cuối cùng tôi đã trích xuất mô hình mạng mặt đó tflite và có độ chính xác trên 80% trên một hình ảnh được đào tạo. nhưng độ phức tạp thời gian thực sự rất lớn !!, để so sánh hai hình ảnh, phải mất tối thiểu 5 đến 6 giây bất kỳ ý tưởng nào về cách làm giảm điều đó?
R.Coder

2

Nếu tôi hiểu chính xác, bạn đang đào tạo trình phân loại với một hình ảnh duy nhất. Trong trường hợp đó, một hình ảnh cụ thể này là mọi thứ mà bộ phân loại sẽ có thể nhận ra. Bạn sẽ cần một bộ ảnh đào tạo lớn hơn đáng chú ý hiển thị cùng một người, ít nhất là 5 hoặc 10 hình ảnh khác nhau.


Bạn có ví dụ nào về cách làm điều đó không?
R.Coder

Có, tôi đang thực hiện nhận dạng khuôn mặt trên một hình ảnh tĩnh duy nhất
R.Coder

Xem ở đây để biết ví dụ về cách sử dụng train(): docs.opencv.org/3.4/dd/d65/iêu
Florian Echtler

Câu trả lời này không giúp ích gì nếu bạn có thể cung cấp một số ví dụ được mã hóa liên quan đến Android thì sẽ tốt hơn!
R.Coder

0

1) Thay đổi giá trị ngưỡng trong khi khởi tạo LBPHrecognizer thành -> LBPHFaceRecognizer (1, 8, 8, 8, 100)

2) huấn luyện mỗi khuôn mặt với ít nhất 2-3 hình ảnh vì các nhận dạng này chủ yếu làm việc so sánh

3) Đặt ngưỡng chính xác trong khi nhận dạng. Làm một cái gì đó như thế này:

//predicting result
// LoadData is a static class that contains trained recognizer
// _result is the gray frame image captured by the camera
LBPHFaceRecognizer.PredictionResult ER = LoadData.recog.Predict(_result);
int temp_result = ER.Label;

imageBox1.SizeMode = PictureBoxSizeMode.StretchImage;
imageBox1.Image = _result.Mat;

//Displaying predicted result on screen
// LBPH returns -1 if face is recognized
if ((temp_result != -1) && (ER.Distance < 55)){  
     //I get best accuracy at 55, you should try different values to determine best results
     // Do something with detected image
}

Bạn có thể chỉnh sửa mã hiện tại của tôi và cung cấp một ví dụ hoạt động để làm điều đó trong java không?
R.Coder
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.