Malmatching for mynter med OpenCV

 C Programming >> C C# Program >  >> C++
Malmatching for mynter med OpenCV

En måte å gjøre mønstertilpasning på er å bruke cv::matchTemplate.

Dette tar et inngangsbilde og et mindre bilde som fungerer som mal. Den sammenligner malen med overlappede bildeområder og beregner likheten mellom malen og den overlappede regionen. Flere metoder for å beregne sammenligningen er tilgjengelige.
Denne metoden støtter ikke direkte skala eller orienteringsinvarians. Men det er mulig å overvinne det ved å skalere kandidater til en referansestørrelse og ved å teste mot flere roterte maler.

Et detaljert eksempel på denne teknikken er vist for å oppdage trykk og plassering av 50c-mynter. Samme prosedyre kan brukes på de andre myntene.
Det skal bygges to programmer. En for å lage maler fra den store bildemalen for 50c-mynten. Og en annen som vil ta som input disse malene samt bildet med mynter og vil sende ut et bilde der 50c-mynten(e) er merket.

Malmaker

#define TEMPLATE_IMG "50c.jpg"
#define ANGLE_STEP 30
int main()
{
    cv::Mat image = loadImage(TEMPLATE_IMG);
    cv::Mat mask = createMask( image );
    cv::Mat loc = locate( mask );
    cv::Mat imageCS;
    cv::Mat maskCS;
    centerAndScale( image, mask, loc, imageCS, maskCS);
    saveRotatedTemplates( imageCS, maskCS, ANGLE_STEP );
    return 0;
}

Her laster vi inn bildet som skal brukes til å lage malene våre.
Segmenter den for å lage en maske.
Finn midten av massene til masken.
Og vi skalerer om og kopierer den masken og mynten slik at de opptar en firkant med fast størrelse der kantene på firkanten berører omkretsen av masken og mynten. Det vil si at siden av firkanten har samme lengde i piksler som diameteren på den skalerte masken eller myntbildet.
Til slutt lagrer vi det skalerte og sentrerte bildet av mynten. Og vi lagrer ytterligere kopier av den rotert i trinn med faste vinkel.

cv::Mat loadImage(const char* name)
{
    cv::Mat image;
    image = cv::imread(name);
    if ( image.data==NULL || image.channels()!=3 )
    {
        std::cout << name << " could not be read or is not correct." << std::endl;
        exit(1);
    }
    return image;
}

loadImage bruker cv::imread for å lese bildet. Verifiserer at data er lest og at bildet har tre kanaler og returnerer det leste bildet.

#define THRESHOLD_BLUE  130
#define THRESHOLD_TYPE_BLUE  cv::THRESH_BINARY_INV
#define THRESHOLD_GREEN 230
#define THRESHOLD_TYPE_GREEN cv::THRESH_BINARY_INV
#define THRESHOLD_RED   140
#define THRESHOLD_TYPE_RED   cv::THRESH_BINARY
#define CLOSE_ITERATIONS 5
cv::Mat createMask(const cv::Mat& image)
{
    cv::Mat channels[3];
    cv::split( image, channels);
    cv::Mat mask[3];
    cv::threshold( channels[0], mask[0], THRESHOLD_BLUE , 255, THRESHOLD_TYPE_BLUE );
    cv::threshold( channels[1], mask[1], THRESHOLD_GREEN, 255, THRESHOLD_TYPE_GREEN );
    cv::threshold( channels[2], mask[2], THRESHOLD_RED  , 255, THRESHOLD_TYPE_RED );
    cv::Mat compositeMask;
    cv::bitwise_and( mask[0], mask[1], compositeMask);
    cv::bitwise_and( compositeMask, mask[2], compositeMask);
    cv::morphologyEx(compositeMask, compositeMask, cv::MORPH_CLOSE,
            cv::Mat(), cv::Point(-1, -1), CLOSE_ITERATIONS );

    /// Next three lines only for debugging, may be removed
    cv::Mat filtered;
    image.copyTo( filtered, compositeMask );
    cv::imwrite( "filtered.jpg", filtered);

    return compositeMask;
}

createMask gjør segmenteringen av malen. Den binariserer hver av BGR-kanalene, gjør OG av de tre binariserte bildene og utfører en CLOSE morfologisk operasjon for å produsere masken.
De tre feilsøkingslinjene kopierer originalbildet til et svart ved å bruke den beregnede masken som en maske for kopieringsoperasjonen. Dette hjalp til med å velge de riktige verdiene for terskelen.

Her kan vi se 50c-bildet filtrert av masken opprettet i createMask

cv::Mat locate( const cv::Mat& mask )
{
  // Compute center and radius.
  cv::Moments moments = cv::moments( mask, true);
  float area = moments.m00;
  float radius = sqrt( area/M_PI );
  float xCentroid = moments.m10/moments.m00;
  float yCentroid = moments.m01/moments.m00;
  float m[1][3] = {{ xCentroid, yCentroid, radius}};
  return cv::Mat(1, 3, CV_32F, m);
}

locate beregner massesenteret til masken og dens radius. Returnerer de 3 verdiene i en enkelt radmatte i formen { x, y, radius }.
Den bruker cv::moments som beregner alle momentene opp til tredje orden av en polygon eller rasterisert form. En rasterisert form i vårt tilfelle. Vi er ikke interessert i alle disse øyeblikkene. Men tre av dem er nyttige her. M00 er området av masken. Og tyngdepunktet kan beregnes fra m00, m10 og m01.

void centerAndScale(const cv::Mat& image, const cv::Mat& mask,
        const cv::Mat& characteristics,
        cv::Mat& imageCS, cv::Mat& maskCS)
{
    float radius = characteristics.at<float>(0,2);
    float xCenter = characteristics.at<float>(0,0);
    float yCenter = characteristics.at<float>(0,1);
    int diameter = round(radius*2);
    int xOrg = round(xCenter-radius);
    int yOrg = round(yCenter-radius);
    cv::Rect roiOrg = cv::Rect( xOrg, yOrg, diameter, diameter );
    cv::Mat roiImg = image(roiOrg);
    cv::Mat roiMask = mask(roiOrg);
    cv::Mat centered = cv::Mat::zeros( diameter, diameter, CV_8UC3);
    roiImg.copyTo( centered, roiMask);
    cv::imwrite( "centered.bmp", centered); // debug
    imageCS.create( TEMPLATE_SIZE, TEMPLATE_SIZE, CV_8UC3);
    cv::resize( centered, imageCS, cv::Size(TEMPLATE_SIZE,TEMPLATE_SIZE), 0, 0 );
    cv::imwrite( "scaled.bmp", imageCS); // debug

    roiMask.copyTo(centered);
    cv::resize( centered, maskCS, cv::Size(TEMPLATE_SIZE,TEMPLATE_SIZE), 0, 0 );
}

centerAndScale bruker tyngdepunkt og radius beregnet av locate for å få et område av interesse for inngangsbildet og et område av interesse for masken slik at sentrum av slike områder også er midten av mynten og masken og sidelengden til områdene er lik diameteren til mynten /maske.
Disse områdene skaleres senere til en fast TEMPLATE_SIZE. Denne skalerte regionen vil være vår referansemal. Når vi senere i matchingsprogrammet ønsker å sjekke om en oppdaget kandidatmynt er denne mynten, vil vi også ta en region av kandidatmynten, midtstille og skalere den kandidatmynten på samme måte før vi utfører malmatching. På denne måten oppnår vi skalainvarians.

void saveRotatedTemplates( const cv::Mat& image, const cv::Mat& mask, int stepAngle )
{
    char name[1000];
    cv::Mat rotated( TEMPLATE_SIZE, TEMPLATE_SIZE, CV_8UC3 );
    for ( int angle=0; angle<360; angle+=stepAngle )
    {
        cv::Point2f center( TEMPLATE_SIZE/2, TEMPLATE_SIZE/2);
        cv::Mat r = cv::getRotationMatrix2D(center, angle, 1.0);

        cv::warpAffine(image, rotated, r, cv::Size(TEMPLATE_SIZE, TEMPLATE_SIZE));
        sprintf( name, "template-%03d.bmp", angle);
        cv::imwrite( name, rotated );

        cv::warpAffine(mask, rotated, r, cv::Size(TEMPLATE_SIZE, TEMPLATE_SIZE));
        sprintf( name, "templateMask-%03d.bmp", angle);
        cv::imwrite( name, rotated );
    }
}

saveRotatedTemplates lagrer den forrige beregnede malen.
Men den lagrer flere kopier av den, hver av dem rotert med en vinkel, definert i ANGLE_STEP . Målet med dette er å gi orienteringsinvarians. Jo lavere vi definerer stepAngle, jo bedre orienteringsinvarians får vi, men det innebærer også en høyere beregningskostnad.

Du kan laste ned hele malmakerprogrammet her.
Når jeg kjører med ANGLE_STEP som 30 får jeg følgende 12 maler:

Maltilpasning.

#define INPUT_IMAGE "coins.jpg"
#define LABELED_IMAGE "coins_with50cLabeled.bmp"
#define LABEL "50c"
#define MATCH_THRESHOLD 0.065
#define ANGLE_STEP 30
int main()
{
    vector<cv::Mat> templates;
    loadTemplates( templates, ANGLE_STEP );
    cv::Mat image = loadImage( INPUT_IMAGE );
    cv::Mat mask = createMask( image );
    vector<Candidate> candidates;
    getCandidates( image, mask, candidates );
    saveCandidates( candidates ); // debug
    matchCandidates( templates, candidates );
    for (int n = 0; n < candidates.size( ); ++n)
        std::cout << candidates[n].score << std::endl;
    cv::Mat labeledImg = labelCoins( image, candidates, MATCH_THRESHOLD, false, LABEL );
    cv::imwrite( LABELED_IMAGE, labeledImg );
    return 0;
}

Målet her er å lese malene og bildet som skal undersøkes og finne plasseringen av mynter som samsvarer med malen vår.

Først leste vi inn i en vektor av bilder alle malbildene vi produserte i forrige program.
Så leser vi bildet som skal undersøkes.
Deretter binariserer vi bildet som skal undersøkes ved å bruke nøyaktig samme funksjon som i malmakeren.
getCandidates lokaliserer gruppene av punkter som sammen danner en polygon. Hver av disse polygonene er en kandidat for mynt. Og alle er reskalert og sentrert i en kvadrat med størrelse som tilsvarer malene våre, slik at vi kan utføre matching på en måte som er uforanderlig i forhold til skala.
Vi lagrer kandidatbildene som er oppnådd for feilsøking og tuning.
matchCandidates matcher hver kandidat med alle malene som lagrer resultatet av den beste kampen for hver. Siden vi har maler for flere orienteringer gir dette invarians til orientering.
Poeng for hver kandidat skrives ut slik at vi kan bestemme en terskel for å skille 50c-mynter fra ikke-50c-mynter.
labelCoins kopierer originalbildet og tegner en etikett over de som har en poengsum som er større enn (eller lavere enn for noen metoder) terskelen definert i MATCH_THRESHOLD .
Og til slutt lagrer vi resultatet i en .BMP

void loadTemplates(vector<cv::Mat>& templates, int angleStep)
{
    templates.clear( );
    for (int angle = 0; angle < 360; angle += angleStep)
    {
        char name[1000];
        sprintf( name, "template-%03d.bmp", angle );
        cv::Mat templateImg = cv::imread( name );
        if (templateImg.data == NULL)
        {
            std::cout << "Could not read " << name << std::endl;
            exit( 1 );
        }
        templates.push_back( templateImg );
    }
}

loadTemplates ligner loadImage . Men den laster inn flere bilder i stedet for bare ett og lagrer dem i en std::vector .

loadImage er nøyaktig det samme som i malmakeren.

createMask er også nøyaktig det samme som i tempatemakeren. Denne gangen bruker vi det på bildet med flere mynter. Det skal bemerkes at binariseringsterskler ble valgt for å binarisere 50c, og de vil ikke fungere ordentlig for å binarisere alle myntene i bildet. Men det har ingen betydning siden programmets mål bare er å identifisere 50c-mynter. Så lenge de er riktig segmentert har vi det bra. Det fungerer faktisk i vår favør hvis noen mynter går tapt i denne segmenteringen siden vi vil spare tid på å evaluere dem (så lenge vi bare mister mynter som ikke er 50c).

typedef struct Candidate
{
    cv::Mat image;
    float x;
    float y;
    float radius;
    float score;
} Candidate;

void getCandidates(const cv::Mat& image, const cv::Mat& mask,
        vector<Candidate>& candidates)
{
    vector<vector<cv::Point> > contours;
    vector<cv::Vec4i> hierarchy;
    /// Find contours
    cv::Mat maskCopy;
    mask.copyTo( maskCopy );
    cv::findContours( maskCopy, contours, hierarchy, CV_RETR_TREE, CV_CHAIN_APPROX_SIMPLE, cv::Point( 0, 0 ) );
    cv::Mat maskCS;
    cv::Mat imageCS;
    cv::Scalar white = cv::Scalar( 255 );
    for (int nContour = 0; nContour < contours.size( ); ++nContour)
    {
        /// Draw contour
        cv::Mat drawing = cv::Mat::zeros( mask.size( ), CV_8UC1 );
        cv::drawContours( drawing, contours, nContour, white, -1, 8, hierarchy, 0, cv::Point( ) );

        // Compute center and radius and area.
        // Discard small areas.
        cv::Moments moments = cv::moments( drawing, true );
        float area = moments.m00;
        if (area < CANDIDATES_MIN_AREA)
            continue;
        Candidate candidate;
        candidate.radius = sqrt( area / M_PI );
        candidate.x = moments.m10 / moments.m00;
        candidate.y = moments.m01 / moments.m00;
        float m[1][3] = {
            { candidate.x, candidate.y, candidate.radius}
        };
        cv::Mat characteristics( 1, 3, CV_32F, m );
        centerAndScale( image, drawing, characteristics, imageCS, maskCS );
        imageCS.copyTo( candidate.image );
        candidates.push_back( candidate );
    }
}

Hjertet til getCandidates er cv::findContours som finner konturene av områder som er tilstede i inndatabildet. Som her er masken beregnet tidligere.
findContours returnerer en vektor av konturer. Hver kontur i seg selv er en vektor av punkter som danner den ytre linjen til det detekterte polygonet.
Hver polygon avgrenser regionen til hver kandidatmynt.
For hver kontur bruker vi cv::drawContours å tegne den fylte polygonen over et svart bilde.
Med dette tegnede bildet bruker vi den samme prosedyren som er forklart tidligere for å beregne tyngdepunkt og radius til polygonet.
Og vi bruker centerAndScale , den samme funksjonen som brukes i malmakeren, for å sentrere og skalere bildet i den poligonen i et bilde som vil ha samme størrelse som malene våre. På denne måten vil vi senere kunne utføre en riktig matching selv for mynter fra bilder i forskjellige skalaer.
Hver av disse kandidatmyntene er kopiert i en kandidatstruktur som inneholder:

  • Kandidatbilde
  • x og y for tyngdepunkt
  • radius
  • poengsum

getCandidates beregner alle disse verdiene bortsett fra poengsum.
Etter å ha komponert kandidaten settes den i en vektor av kandidater som er resultatet vi får fra getCandidates .

Dette er de 4 kandidatene som er oppnådd:

void saveCandidates(const vector<Candidate>& candidates)
{
    for (int n = 0; n < candidates.size( ); ++n)
    {
        char name[1000];
        sprintf( name, "Candidate-%03d.bmp", n );
        cv::imwrite( name, candidates[n].image );
    }
}

saveCandidates lagrer de beregnede kandidatene for feilsøkingsformål. Og også slik at jeg kan legge ut disse bildene her.

void matchCandidates(const vector<cv::Mat>& templates,
        vector<Candidate>& candidates)
{
    for (auto it = candidates.begin( ); it != candidates.end( ); ++it)
        matchCandidate( templates, *it );
}

matchCandidates bare ringer matchCandidate for hver kandidat. Etter gjennomføringen vil vi få beregnet poengsummen for alle kandidatene.

void matchCandidate(const vector<cv::Mat>& templates, Candidate& candidate)
{
    /// For SQDIFF and SQDIFF_NORMED, the best matches are lower values. For all the other methods, the higher the better
    candidate.score;
    if (MATCH_METHOD == CV_TM_SQDIFF || MATCH_METHOD == CV_TM_SQDIFF_NORMED)
        candidate.score = FLT_MAX;
    else
        candidate.score = 0;
    for (auto it = templates.begin( ); it != templates.end( ); ++it)
    {
        float score = singleTemplateMatch( *it, candidate.image );
        if (MATCH_METHOD == CV_TM_SQDIFF || MATCH_METHOD == CV_TM_SQDIFF_NORMED)
        {
            if (score < candidate.score)
                candidate.score = score;
        }
        else
        {
            if (score > candidate.score)
                candidate.score = score;
        }
    }
}

matchCandidate har som input en enkelt kandidat og alle malene. Målet er å matche hver mal mot kandidaten. Dette arbeidet er delegert til singleTemplateMatch .
Vi lagrer den beste oppnådde poengsummen, som for CV_TM_SQDIFF og CV_TM_SQDIFF_NORMED er den minste, og for de andre samsvarsmetodene er den største.

float singleTemplateMatch(const cv::Mat& templateImg, const cv::Mat& candidateImg)
{
    cv::Mat result( 1, 1, CV_8UC1 );
    cv::matchTemplate( candidateImg, templateImg, result, MATCH_METHOD );
    return result.at<float>( 0, 0 );
}

singleTemplateMatch utfører matchingen.
cv::matchTemplate bruker to imput-bilder, det andre mindre eller like stort som det første.
Vanlig bruk er at en liten mal (2. parameter) skal matches mot et større bilde (1. parameter), og resultatet er en todimensjonal matte av flyter med matching av malen langs bildet. Ved å finne maximun (eller minimun avhengig av metoden) til denne matten av flyter får vi den beste kandidatposisjonen for malen vår i bildet av den første parameteren.
Men vi er ikke interessert i å finne malen vår i bildet, vi har allerede koordinatene til våre kandidater.
Det vi ønsker er å få et mål på likhet mellom vår kandidat og mal. Det er derfor vi bruker cv::matchTemplate på en måte som er mindre vanlig; vi gjør det med et 1. parameterbilde med størrelse lik 2. parametermalen. I denne situasjonen er resultatet en matte i størrelsen 1x1. Og den eneste verdien i den Maten er vår poengsum for likhet (eller ulikhet).

for (int n = 0; n < candidates.size( ); ++n)
    std::cout << candidates[n].score << std::endl;

Vi skriver ut poengsummene som er oppnådd for hver av våre kandidater.
I denne tabellen kan vi se poengsummene for hver av metodene som er tilgjengelige for cv::matchTemplate. Den beste poengsummen er i grønt.

CCORR og CCOEFF gir feil resultat, så de to blir forkastet. Av de resterende 4 metodene er de to SQDIFF-metodene de med høyere relativ forskjell mellom den beste matchen (som er en 50c) og den 2. beste (som ikke er en 50c). Derfor har jeg valgt dem.
Jeg har valgt SQDIFF_NORMED, men det er ingen sterk grunn til det. For å virkelig velge en metode bør vi teste med en høyere mengde prøver, ikke bare én.
For denne metoden kan en arbeidsterskel være 0,065. Valg av en riktig terskel krever også mange prøver.

bool selected(const Candidate& candidate, float threshold)
{
    /// For SQDIFF and SQDIFF_NORMED, the best matches are lower values. For all the other methods, the higher the better
    if (MATCH_METHOD == CV_TM_SQDIFF || MATCH_METHOD == CV_TM_SQDIFF_NORMED)
        return candidate.score <= threshold;
    else
        return candidate.score>threshold;
}

void drawLabel(const Candidate& candidate, const char* label, cv::Mat image)
{
    int x = candidate.x - candidate.radius;
    int y = candidate.y;
    cv::Point point( x, y );
    cv::Scalar blue( 255, 128, 128 );
    cv::putText( image, label, point, CV_FONT_HERSHEY_SIMPLEX, 1.5f, blue, 2 );
}

cv::Mat labelCoins(const cv::Mat& image, const vector<Candidate>& candidates,
        float threshold, bool inverseThreshold, const char* label)
{
    cv::Mat imageLabeled;
    image.copyTo( imageLabeled );

    for (auto it = candidates.begin( ); it != candidates.end( ); ++it)
    {
        if (selected( *it, threshold ))
            drawLabel( *it, label, imageLabeled );
    }

    return imageLabeled;
}

labelCoins trekker en etikettstreng ved plasseringen av kandidater med en poengsum som er større enn (eller mindre enn avhengig av metoden) terskelen. Og til slutt lagres resultatet av labelCoins med

cv::imwrite( LABELED_IMAGE, labeledImg );

Resultatet er:

Hele koden for myntmatcheren kan lastes ned her.

Er dette en god metode?

Det er vanskelig å si.
Metoden er konsistent. Den oppdager 50c-mynten riktig for prøven og inndatabildet som er gitt.
Men vi aner ikke om metoden er robust fordi den ikke er testet med riktig utvalgsstørrelse. Og enda viktigere er det å teste det mot prøver som ikke var tilgjengelige da programmet ble kodet, det er det sanne målet på robusthet når det gjøres med en stor nok prøvestørrelse.
Jeg er ganske sikker på at metoden ikke har falske positiver fra sølvmynter. Men jeg er ikke så sikker på andre kobbermynter som 20c. Som vi kan se fra poengsummen får 20c-mynten en poengsum som er veldig lik 50c.
Det er også fullt mulig at falske negativer vil skje under varierende lysforhold. Noe som kan og bør unngås hvis vi har kontroll over lysforholdene som når vi designer en maskin for å ta bilder av mynter og telle dem.

Hvis metoden fungerer, kan den samme metoden gjentas for hver type mynt, noe som fører til full gjenkjenning av alle mynter.

Koden i dette svaret er også tilgjengelig under vilkårene i GNU General Public License som publisert av Free Software Foundation, enten versjon 3 av lisensen, eller (etter eget valg) en hvilken som helst senere versjon.