我必须编写一个程序来检测3种类型的路标(限速,禁止 parking 和警告)。我知道如何使用HoughCircles来检测圆,但是我有几张图像,并且HoughCircles的参数对于每张图像都是不同的。有一种检测圆形的通用方法,而无需更改每个图像的参数?

此外,我需要检测三角形(警告信号),因此我正在寻找通用的形状检测器。您是否有任何建议/代码可以帮助我完成此任务?

最后,为了检测限速标志上的数字,我想使用SIFT并将图像与一些模板进行比较,以识别标志上的数字。这是一个好方法吗?

谢谢您的回答!

最佳答案

我知道这是一个非常老的问题,但是我曾经遇到过同样的问题,现在向您展示如何解决。
下图显示了opencv程序显示的一些最准确的结果。
在以下图像中,检测到的路标用三种不同的颜色圈出,以区分三种路标(警告,禁止 parking ,限速)。

  • 红色,用于警告标志
  • 禁止 parking 的蓝色
  • 紫红色用于限速标志

  • 速度限制值在速度限制标志上方以绿色写入
    [![example][1]][1]
    [![example][2]][2]
    [![example][3]][3]
    [![example][4]][4]
    

    如您所见,该程序执行得很好,它能够检测和区分三种符号,并在有速度限制标志的情况下识别速度限制值。例如,当图像中有一些不属于这三种类别之一的信号时,一切都无需计算太多的误报即可。
    为了获得该结果,软件分三个主要步骤计算检测量。
    第一步涉及一种基于颜色的方法,其中检测图像中的红色对象并提取其区域以进行分析。由于仅处理图像的一小部分,因此此步骤对于防止检测到误报特别有用。
    第二步使用机器学习算法:特别是,我们使用Cascade分类器来计算检测。该操作首先需要训练分类器,并在以后的阶段中使用它们来检测标志。
    在最后一步,读取速度限制符号内的速度限制值,在这种情况下,也通过机器学习算法但使用k最近邻居算法读取。
    现在,我们将详细了解每个步骤。

    基于颜色的步骤

    由于路牌总是被红色框圈起,因此我们只能取出并分析仅检测到红色物体的区域。
    为了选择红色对象,我们考虑了红色的所有范围:即使这可能产生一些误报,也将在接下来的步骤中将其轻松丢弃。
    inRange(image, Scalar(0, 70, 50), Scalar(10, 255, 255), mask1);
    inRange(image, Scalar(170, 70, 50), Scalar(180, 255, 255), mask2);
    

    在下图中,我们可以看到使用此方法检测到的红色物体的示例。

    c++ - OpenCV和C++-形状和路标检测-LMLPHP

    找到红色像素后,我们可以使用聚类算法将它们收集起来以找到区域,我使用了
    partition(<#_ForwardIterator __first#>, _ForwardIterator __last, <#_Predicate __pred#>)
    

    执行此方法后,我们可以将同一簇中的所有点保存在一个 vector 中(每个簇一个),并提取表示
    下一步要分析的区域。

    用于标志检测的Haar级联分类器

    这是真正的检测步骤,在其中检测路牌。为了执行级联分类器,第一步在于建立正图像和负图像的数据集。现在,我解释如何构建自己的图像数据集。
    首先要注意的是,我们需要训练三个不同的Haar级联,以便区分必须检测的三种信号,因此,对于三种信号中的每一种,我们都必须重复以下步骤。

    我们需要两个数据集:一个用于正样本(必须是包含要检测的路标的图像集),另一个用于负样本(可以是没有路标的任何图像)。
    在两个不同的文件夹中收集了一组100个阳性样本图像和一组200个阴性样本图像后,我们需要编写两个文本文件:
  • Signs.info包含文件名列表,如下所示,
    一个为阳性文件夹中的每个阳性样品。
    pos/image_name.png 1 0 0 50 45
    

    在此,名称后面的数字分别代表数字
    图片中路牌的位置,左上角的坐标
    路牌的一角,他的高度和宽度。
  • Bg.txt包含一个文件名列表,如下所示,一个
    否定文件夹中的每个符号。
    neg/street15.png
    

  • 在下面的命令行中,我们生成.vect文件,其中包含软件从阳性样本中检索到的所有信息。
    opencv_createsamples -info sign.info -num 100 -w 50 -h 50 -vec signs.vec
    

    之后,我们使用以下命令训练级联分类器:
    opencv_traincascade -data data -vec signs.vec -bg bg.txt -numPos 60 -numNeg 200 -numStages 15 -w 50 -h 50 -featureType LBP
    

    其中,阶段数表示将生成以构建级联的分类器的数量。
    在此过程结束时,我们将获得CascadeClassifier程序中使用的文件cascade.xml,以检测图像中的对象。
    现在我们已经训练了算法,可以为每种路牌声明一个CascadeClassifier,而不是通过以下方式检测图像中的路标:
    detectMultiScale(<#InputArray image#>, <#std::vector<Rect> &objects#>)
    

    此方法将在每个已检测到的对象周围创建一个Rect。
    重要的是要注意,与每种机器学习算法一样,为了表现良好,我们在数据集中需要大量样本。我建立的数据集不是很大,因此在某些情况下它无法检测所有信号。当图像中看不到路牌的一小部分时,如以下警告标志中所示,通常会发生这种情况:

    c&#43;&#43; - OpenCV和C&#43;&#43;-形状和路标检测-LMLPHP

    我将数据集扩展到可以获得相当准确的结果而没有
    错误太多。

    速度极限值检测

    就像路牌检测一样,我在这里也使用了机器学习算法,但是使用了不同的方法。经过一些工作,我意识到OCR(tesseract)解决方案不能很好地工作,因此我决定构建自己的ocr软件。

    对于机器学习算法,我将下图作为训练数据,其中包含一些速度极限值:

    c&#43;&#43; - OpenCV和C&#43;&#43;-形状和路标检测-LMLPHP

    训练数据量很小。但是,由于在限速标志中所有字母都具有相同的字体,所以这不是一个大问题。
    为了准备训练数据,我在OpenCV中编写了一个小代码。它执行以下操作:
  • 它将图像加载到左侧;
  • 它选择数字(显然是通过轮廓查找并在字母的面积和高度上施加约束来避免错误检测)。
  • 围绕一个字母绘制边界矩形,并等待手动按下键。这次用户自己按了框中对应字母的数字键。
  • 一旦按下相应的数字键,它将在一个数组中保存100个像素值,并在另一个数组中保存相应的手动输入的数字。
  • 最终,这两个数组都保存在单独的txt文件中。

  • 按照手动数字分类,火车数据(train.png)中的所有数字都被手动标记,并且图像看起来像下面的数字。
    c&#43;&#43; - OpenCV和C&#43;&#43;-形状和路标检测-LMLPHP

    现在,我们进入培训和测试部分。

    对于培训,我们执行以下操作:
  • 加载我们之前已经保存的txt文件
  • 创建将要使用的分类器的实例(KNearest)
  • 然后我们使用KNearest.train函数来训练数据

  • 现在检测:
  • 我们使用检测到的速度限制标志加载图像
  • 和以前一样处理图像并使用轮廓方法
  • 提取每个数字
  • 为其绘制边界框,然后将其大小调整为10x10,并将其像素值存储在数组中,如之前所做的那样。
  • 然后,我们使用KNearest.find_nearest()函数查找与我们给出的项目最接近的项目。
    并且它识别正确的数字。

  • 我在许多图像上测试了这个小的OCR,仅通过这个小的数据集,我就获得了大约90%的精度。



    在下面,我将所有openCv c++代码发布在一个类中,按照我的指示,您应该可以实现我的结果。
    #include "opencv2/objdetect/objdetect.hpp"
    #include "opencv2/imgproc/imgproc.hpp"
    #include <iostream>
    #include <stdio.h>
    #include <cmath>
    #include <stdlib.h>
    #include "opencv2/core/core.hpp"
    #include "opencv2/highgui.hpp"
    #include <string.h>
    #include <opencv2/ml/ml.hpp>
    
    using namespace std;
    using namespace cv;
    
    std::vector<cv::Rect> getRedObjects(cv::Mat image);
    vector<Mat> detectAndDisplaySpeedLimit( Mat frame );
    vector<Mat> detectAndDisplayNoParking( Mat frame );
    vector<Mat> detectAndDisplayWarning( Mat frame );
    void trainDigitClassifier();
    string getDigits(Mat image);
    vector<Mat> loadAllImage();
    int getSpeedLimit(string speed);
    
    //path of the haar cascade files
    String no_parking_signs_cascade = "/Users/giuliopettenuzzo/Desktop/cascade_classifiers/no_parking_cascade.xml";
    String speed_signs_cascade = "/Users/giuliopettenuzzo/Desktop/cascade_classifiers/speed_limit_cascade.xml";
    String warning_signs_cascade = "/Users/giuliopettenuzzo/Desktop/cascade_classifiers/warning_cascade.xml";
    
    CascadeClassifier speed_limit_cascade;
    CascadeClassifier no_parking_cascade;
    CascadeClassifier warning_cascade;
    
    int main(int argc, char** argv)
    {
        //train the classifier for digit recognition, this require a manually train, read the report for more details
        trainDigitClassifier();
    
        cv::Mat sceneImage;
        vector<Mat> allImages = loadAllImage();
    
        for(int i = 0;i<=allImages.size();i++){
            sceneImage = allImages[i];
    
            //load the haar cascade files
            if( !speed_limit_cascade.load( speed_signs_cascade ) ){ printf("--(!)Error loading\n"); return -1; };
            if( !no_parking_cascade.load( no_parking_signs_cascade ) ){ printf("--(!)Error loading\n"); return -1; };
            if( !warning_cascade.load( warning_signs_cascade ) ){ printf("--(!)Error loading\n"); return -1; };
    
            Mat scene = sceneImage.clone();
    
            //detect the red objects
            std::vector<cv::Rect> allObj = getRedObjects(scene);
    
            //use the three cascade classifier for each object detected by the getRedObjects() method
            for(int j = 0;j<allObj.size();j++){
                Mat img = sceneImage(Rect(allObj[j]));
                vector<Mat> warningVec = detectAndDisplayWarning(img);
                if(warningVec.size()>0){
                    Rect box = allObj[j];
                }
                vector<Mat> noParkVec = detectAndDisplayNoParking(img);
                if(noParkVec.size()>0){
                    Rect box = allObj[j];
                }
                vector<Mat> speedLitmitVec = detectAndDisplaySpeedLimit(img);
                if(speedLitmitVec.size()>0){
                    Rect box = allObj[j];
                    for(int i = 0; i<speedLitmitVec.size();i++){
                        //get speed limit and skatch it in the image
                        int digit = getSpeedLimit(getDigits(speedLitmitVec[i]));
                        if(digit > 0){
                            Point point = box.tl();
                            point.y = point.y + 30;
                            cv::putText(sceneImage,
                                        "SPEED LIMIT " + to_string(digit),
                                        point,
                                        cv::FONT_HERSHEY_COMPLEX_SMALL,
                                        0.7,
                                        cv::Scalar(0,255,0),
                                        1,
                                        cv::CV__CAP_PROP_LATEST);
                        }
                    }
                }
            }
            imshow("currentobj",sceneImage);
            waitKey(0);
        }
    }
    
    /*
     *  detect the red object in the image given in the param,
     *  return a vector containing all the Rect of the red objects
     */
    std::vector<cv::Rect> getRedObjects(cv::Mat image)
    {
        Mat3b res = image.clone();
        std::vector<cv::Rect> result;
    
        cvtColor(image, image, COLOR_BGR2HSV);
    
        Mat1b mask1, mask2;
        //ranges of red color
        inRange(image, Scalar(0, 70, 50), Scalar(10, 255, 255), mask1);
        inRange(image, Scalar(170, 70, 50), Scalar(180, 255, 255), mask2);
    
        Mat1b mask = mask1 | mask2;
        Mat nonZeroCoordinates;
        vector<Point> pts;
    
        findNonZero(mask, pts);
        for (int i = 0; i < nonZeroCoordinates.total(); i++ ) {
            cout << "Zero#" << i << ": " << nonZeroCoordinates.at<Point>(i).x << ", " << nonZeroCoordinates.at<Point>(i).y << endl;
        }
    
        int th_distance = 2; // radius tolerance
    
         // Apply partition
         // All pixels within the radius tolerance distance will belong to the same class (same label)
        vector<int> labels;
    
         // With lambda function (require C++11)
        int th2 = th_distance * th_distance;
        int n_labels = partition(pts, labels, [th2](const Point& lhs, const Point& rhs) {
            return ((lhs.x - rhs.x)*(lhs.x - rhs.x) + (lhs.y - rhs.y)*(lhs.y - rhs.y)) < th2;
        });
    
         // You can save all points in the same class in a vector (one for each class), just like findContours
        vector<vector<Point>> contours(n_labels);
        for (int i = 0; i < pts.size(); ++i){
            contours[labels[i]].push_back(pts[i]);
        }
    
         // Get bounding boxes
        vector<Rect> boxes;
        for (int i = 0; i < contours.size(); ++i)
        {
            Rect box = boundingRect(contours[i]);
            if(contours[i].size()>500){//prima era 1000
                boxes.push_back(box);
    
                Rect enlarged_box = box + Size(100,100);
                enlarged_box -= Point(30,30);
    
                if(enlarged_box.x<0){
                    enlarged_box.x = 0;
                }
                if(enlarged_box.y<0){
                    enlarged_box.y = 0;
                }
                if(enlarged_box.height + enlarged_box.y > res.rows){
                    enlarged_box.height = res.rows - enlarged_box.y;
                }
                if(enlarged_box.width + enlarged_box.x > res.cols){
                    enlarged_box.width = res.cols - enlarged_box.x;
                }
    
                Mat img = res(Rect(enlarged_box));
                result.push_back(enlarged_box);
            }
         }
         Rect largest_box = *max_element(boxes.begin(), boxes.end(), [](const Rect& lhs, const Rect& rhs) {
             return lhs.area() < rhs.area();
         });
    
        //draw the rects in case you want to see them
         for(int j=0;j<=boxes.size();j++){
             if(boxes[j].area() > largest_box.area()/3){
                 rectangle(res, boxes[j], Scalar(0, 0, 255));
    
                 Rect enlarged_box = boxes[j] + Size(20,20);
                 enlarged_box -= Point(10,10);
    
                 rectangle(res, enlarged_box, Scalar(0, 255, 0));
             }
         }
    
         rectangle(res, largest_box, Scalar(0, 0, 255));
    
         Rect enlarged_box = largest_box + Size(20,20);
         enlarged_box -= Point(10,10);
    
         rectangle(res, enlarged_box, Scalar(0, 255, 0));
    
         return result;
    }
    
    /*
     *  code for detect the speed limit sign , it draws a circle around the speed limit signs
     */
    vector<Mat> detectAndDisplaySpeedLimit( Mat frame )
    {
        std::vector<Rect> signs;
        vector<Mat> result;
        Mat frame_gray;
    
        cvtColor( frame, frame_gray, CV_BGR2GRAY );
        //normalizes the brightness and increases the contrast of the image
        equalizeHist( frame_gray, frame_gray );
    
        //-- Detect signs
        speed_limit_cascade.detectMultiScale( frame_gray, signs, 1.1, 3, 0|CV_HAAR_SCALE_IMAGE, Size(30, 30) );
        cout << speed_limit_cascade.getFeatureType();
    
        for( size_t i = 0; i < signs.size(); i++ )
        {
            Point center( signs[i].x + signs[i].width*0.5, signs[i].y + signs[i].height*0.5 );
            ellipse( frame, center, Size( signs[i].width*0.5, signs[i].height*0.5), 0, 0, 360, Scalar( 255, 0, 255 ), 4, 8, 0 );
    
    
            Mat resultImage = frame(Rect(center.x - signs[i].width*0.5,center.y - signs[i].height*0.5,signs[i].width,signs[i].height));
            result.push_back(resultImage);
        }
        return result;
    }
    
    /*
     *  code for detect the warning sign , it draws a circle around the warning signs
     */
    vector<Mat> detectAndDisplayWarning( Mat frame )
    {
        std::vector<Rect> signs;
        vector<Mat> result;
        Mat frame_gray;
    
        cvtColor( frame, frame_gray, CV_BGR2GRAY );
        equalizeHist( frame_gray, frame_gray );
    
        //-- Detect signs
        warning_cascade.detectMultiScale( frame_gray, signs, 1.1, 3, 0|CV_HAAR_SCALE_IMAGE, Size(30, 30) );
        cout << warning_cascade.getFeatureType();
        Rect previus;
    
    
        for( size_t i = 0; i < signs.size(); i++ )
        {
            Point center( signs[i].x + signs[i].width*0.5, signs[i].y + signs[i].height*0.5 );
            Rect newRect = Rect(center.x - signs[i].width*0.5,center.y - signs[i].height*0.5,signs[i].width,signs[i].height);
            if((previus & newRect).area()>0){
                previus = newRect;
            }else{
                ellipse( frame, center, Size( signs[i].width*0.5, signs[i].height*0.5), 0, 0, 360, Scalar( 0, 0, 255 ), 4, 8, 0 );
                Mat resultImage = frame(newRect);
                result.push_back(resultImage);
                previus = newRect;
            }
        }
        return result;
    }
    
    /*
     *  code for detect the no parking sign , it draws a circle around the no parking signs
     */
    vector<Mat> detectAndDisplayNoParking( Mat frame )
    {
        std::vector<Rect> signs;
        vector<Mat> result;
        Mat frame_gray;
    
        cvtColor( frame, frame_gray, CV_BGR2GRAY );
        equalizeHist( frame_gray, frame_gray );
    
        //-- Detect signs
        no_parking_cascade.detectMultiScale( frame_gray, signs, 1.1, 3, 0|CV_HAAR_SCALE_IMAGE, Size(30, 30) );
        cout << no_parking_cascade.getFeatureType();
        Rect previus;
    
        for( size_t i = 0; i < signs.size(); i++ )
        {
            Point center( signs[i].x + signs[i].width*0.5, signs[i].y + signs[i].height*0.5 );
            Rect newRect = Rect(center.x - signs[i].width*0.5,center.y - signs[i].height*0.5,signs[i].width,signs[i].height);
            if((previus & newRect).area()>0){
                previus = newRect;
            }else{
                ellipse( frame, center, Size( signs[i].width*0.5, signs[i].height*0.5), 0, 0, 360, Scalar( 255, 0, 0 ), 4, 8, 0 );
                Mat resultImage = frame(newRect);
                result.push_back(resultImage);
                previus = newRect;
            }
        }
        return result;
    }
    
    /*
     *  train the classifier for digit recognition, this could be done only one time, this method save the result in a file and
     *  it can be used in the next executions
     *  in order to train user must enter manually the corrisponding digit that the program shows, press space if the red box is just a point (false positive)
     */
    void trainDigitClassifier(){
        Mat thr,gray,con;
        Mat src=imread("/Users/giuliopettenuzzo/Desktop/all_numbers.png",1);
        cvtColor(src,gray,CV_BGR2GRAY);
        threshold(gray,thr,125,255,THRESH_BINARY_INV); //Threshold to find contour
        imshow("ci",thr);
        waitKey(0);
        thr.copyTo(con);
    
        // Create sample and label data
        vector< vector <Point> > contours; // Vector for storing contour
        vector< Vec4i > hierarchy;
        Mat sample;
        Mat response_array;
        findContours( con, contours, hierarchy,CV_RETR_CCOMP, CV_CHAIN_APPROX_SIMPLE ); //Find contour
    
        for( int i = 0; i< contours.size(); i=hierarchy[i][0] ) // iterate through first hierarchy level contours
        {
            Rect r= boundingRect(contours[i]); //Find bounding rect for each contour
            rectangle(src,Point(r.x,r.y), Point(r.x+r.width,r.y+r.height), Scalar(0,0,255),2,8,0);
            Mat ROI = thr(r); //Crop the image
            Mat tmp1, tmp2;
            resize(ROI,tmp1, Size(10,10), 0,0,INTER_LINEAR ); //resize to 10X10
            tmp1.convertTo(tmp2,CV_32FC1); //convert to float
    
            imshow("src",src);
    
            int c=waitKey(0); // Read corresponding label for contour from keyoard
            c-=0x30;     // Convert ascii to intiger value
            response_array.push_back(c); // Store label to a mat
            rectangle(src,Point(r.x,r.y), Point(r.x+r.width,r.y+r.height), Scalar(0,255,0),2,8,0);
            sample.push_back(tmp2.reshape(1,1)); // Store  sample data
        }
    
        // Store the data to file
        Mat response,tmp;
        tmp=response_array.reshape(1,1); //make continuous
        tmp.convertTo(response,CV_32FC1); // Convert  to float
    
        FileStorage Data("TrainingData.yml",FileStorage::WRITE); // Store the sample data in a file
        Data << "data" << sample;
        Data.release();
    
        FileStorage Label("LabelData.yml",FileStorage::WRITE); // Store the label data in a file
        Label << "label" << response;
        Label.release();
        cout<<"Training and Label data created successfully....!! "<<endl;
    
        imshow("src",src);
        waitKey(0);
    
    
    }
    
    /*
     *  get digit from the image given in param, using the classifier trained before
     */
    string getDigits(Mat image)
    {
        Mat thr1,gray1,con1;
        Mat src1 = image.clone();
        cvtColor(src1,gray1,CV_BGR2GRAY);
        threshold(gray1,thr1,125,255,THRESH_BINARY_INV); // Threshold to create input
        thr1.copyTo(con1);
    
    
        // Read stored sample and label for training
        Mat sample1;
        Mat response1,tmp1;
        FileStorage Data1("TrainingData.yml",FileStorage::READ); // Read traing data to a Mat
        Data1["data"] >> sample1;
        Data1.release();
    
        FileStorage Label1("LabelData.yml",FileStorage::READ); // Read label data to a Mat
        Label1["label"] >> response1;
        Label1.release();
    
    
        Ptr<ml::KNearest>  knn(ml::KNearest::create());
    
        knn->train(sample1, ml::ROW_SAMPLE,response1); // Train with sample and responses
        cout<<"Training compleated.....!!"<<endl;
    
        vector< vector <Point> > contours1; // Vector for storing contour
        vector< Vec4i > hierarchy1;
    
        //Create input sample by contour finding and cropping
        findContours( con1, contours1, hierarchy1,CV_RETR_CCOMP, CV_CHAIN_APPROX_SIMPLE );
        Mat dst1(src1.rows,src1.cols,CV_8UC3,Scalar::all(0));
        string result;
    
        for( int i = 0; i< contours1.size(); i=hierarchy1[i][0] ) // iterate through each contour for first hierarchy level .
        {
            Rect r= boundingRect(contours1[i]);
            Mat ROI = thr1(r);
            Mat tmp1, tmp2;
            resize(ROI,tmp1, Size(10,10), 0,0,INTER_LINEAR );
            tmp1.convertTo(tmp2,CV_32FC1);
            Mat bestLabels;
            float p=knn -> findNearest(tmp2.reshape(1,1),4, bestLabels);
            char name[4];
            sprintf(name,"%d",(int)p);
            cout << "num = " << (int)p;
            result = result + to_string((int)p);
    
            putText( dst1,name,Point(r.x,r.y+r.height) ,0,1, Scalar(0, 255, 0), 2, 8 );
        }
    
        imwrite("dest.jpg",dst1);
        return  result ;
    }
    /*
     *  from the digits detected, it returns a speed limit if it is detected correctly, -1 otherwise
     */
    int getSpeedLimit(string numbers){
        if ((numbers.find("30") != std::string::npos) || (numbers.find("03") != std::string::npos)) {
            return 30;
        }
        if ((numbers.find("50") != std::string::npos) || (numbers.find("05") != std::string::npos)) {
            return 50;
        }
        if ((numbers.find("80") != std::string::npos) || (numbers.find("08") != std::string::npos)) {
            return 80;
        }
        if ((numbers.find("70") != std::string::npos) || (numbers.find("07") != std::string::npos)) {
            return 70;
        }
        if ((numbers.find("90") != std::string::npos) || (numbers.find("09") != std::string::npos)) {
            return 90;
        }
        if ((numbers.find("100") != std::string::npos) || (numbers.find("001") != std::string::npos)) {
            return 100;
        }
        if ((numbers.find("130") != std::string::npos) || (numbers.find("031") != std::string::npos)) {
            return 130;
        }
        return -1;
    }
    
    /*
     *  load all the image in the file with the path hard coded below
     */
    vector<Mat> loadAllImage(){
        vector<cv::String> fn;
        glob("/Users/giuliopettenuzzo/Desktop/T1/dataset/*.jpg", fn, false);
    
        vector<Mat> images;
        size_t count = fn.size(); //number of png files in images folder
        for (size_t i=0; i<count; i++)
            images.push_back(imread(fn[i]));
        return images;
    }
    

    关于c++ - OpenCV和C++-形状和路标检测,我们在Stack Overflow上找到一个类似的问题:https://stackoverflow.com/questions/51080519/

    10-12 19:30