Questa narrazione, tratta in parte da fatti realmente accaduti, mi ha dato la motivazione per provare a buttare giù un piccolo progetto basato sulla nostra libreria preferita, OpenCV, che vi presenterò in due articoli pubblicati a breve distanza fra loro (si spera). Il programma che andrò a presentarvi, date una serie di pagine di fumetti, mostra a video le singole vignette ordinate dal vertice alto a sinistra della pagina a quello in basso a destra (o dal vertice in alto a destra a quello in basso a sinistra per quanto riguarda i manga).
Oltre all'evitare spoiler dati dai rapidi sguardi alle pagine successive, il codice potrebbe essere inglobato in una applicazione mobile/tablet per leggere i fumetti senza stare ogni 5 secondi a pinchare sullo schermo per spostarsi nella pagina o zoomare su certe vignette.
La base del programma è un esempio di codice OpenCV già presente all'interno della libreria: squares.cpp, semplice applicazione per la detection di rettangoli all'interno di una serie di immagini. Partendo da questo, ho aggiunto alcune funzioni appositamente create per raggiungere lo scopo prefissato. Qui il main del codice:
#include "opencv2/core/core.hpp" #include "opencv2/imgproc/imgproc.hpp" #include "opencv2/highgui/highgui.hpp" #include <limits.h> #include <list> #include <iostream> #include <math.h> #include <string.h> using namespace cv; using namespace std; // inserire qui le funzioni descritte in seguito negli articoli int main(int /*argc*/, char** /*argv*/) { static const char* names[] = { "ratman.png", 0 }; help(); namedWindow(wndname, 1); bool isManga = false; bool draw = true; for (unsigned int i = 0; names[i] != 0; i++) { Mat image = imread(names[i], 1); vector<vector<Point> > squares; if (image.empty()) { cout << "Couldn't load " << names[i] << endl; continue; } findSquares(image, squares); squares = eraseMaxSquare(squares); squares = eraseNestedSquares(squares, image); squares = addNotReveleadSquares(squares, image); squares = reorderSquares(squares, image, isManga); if(draw){ drawSquares(image, squares); imshow(wndname, image); int c = waitKey(); if ((char) c == 'q') continue; } for (unsigned int i = 0; i < squares.size(); i++) { Rect roi = Rect(squares[i][0], squares[i][2]); Mat img = image(roi); imshow(wndname, img); int c = waitKey(); if ((char) c == 'q') continue; } squares.clear(); } return 0; }
Dopo aver specificato l'elenco delle immagini, il programma le carica una a una nella variabile Mat image. La funzione findSquares era già implementata all'interno dell'esempio. Ho dovuto solo modificare alcuni dettagli per adeguarmi al task. Vi riporto qui il codice, anche se le differenze con l'originale sono minime:
// returns sequence of squares detected on the image. // the sequence is stored in the specified memory storage static void findSquares(const Mat& image, vector<vector<Point> >& squares) { squares.clear(); Mat imgThreshold = Mat(image.cols, image.rows, IPL_DEPTH_8U, 1); cvtColor(image, imgThreshold, CV_RGB2GRAY); adaptiveThreshold(imgThreshold, imgThreshold, 255, CV_ADAPTIVE_THRESH_GAUSSIAN_C, CV_THRESH_BINARY, 75, 25); Mat pyr, timg, gray0(imgThreshold.size(), CV_8U), gray; // down-scale and upscale the image to filter out the noise pyrDown(imgThreshold, pyr, Size(imgThreshold.cols / 2, imgThreshold.rows / 2)); pyrUp(pyr, timg, imgThreshold.size()); vector<vector<Point> > contours; // find squares in every color plane of the image for (unsigned int c = 0; c < 1; c++) { int ch[] = { c, 0 }; mixChannels(&timg, 1, &gray0, 1, ch, 1); // try several threshold levels for (unsigned int l = 0; l < N; l++) { // hack: use Canny instead of zero threshold level. // Canny helps to catch squares with gradient shading if (l == 0) { // apply Canny. Take the upper threshold from slider // and set the lower to 0 (which forces edges merging) Canny(gray0, gray, 0, thresh, 5); // dilate canny output to remove potential // holes between edge segments dilate(gray, gray, Mat(), Point(-1, -1)); } // find contours and store them all as a list findContours(gray, contours, CV_RETR_LIST, CV_CHAIN_APPROX_SIMPLE); vector<Point> approx; // test each contour for (size_t i = 0; i < contours.size(); i++) { // approximate contour with accuracy proportional // to the contour perimeter approxPolyDP(Mat(contours[i]), approx, arcLength(Mat(contours[i]), true) * 0.02, true); // square contours should have 4 vertices after approximation // relatively large area (to filter out noisy contours) // and be convex. // Note: absolute value of an area is used because // area may be positive or negative - in accordance with the // contour orientation if (approx.size() == 4 && fabs(contourArea(Mat(approx))) > 1000 && isContourConvex(Mat(approx))) { double maxCosine = 0; for (unsigned int j = 2; j < 5; j++) { // find the maximum cosine of the angle between joint edges double cosine = fabs( angle(approx[j % 4], approx[j - 2], approx[j - 1])); maxCosine = MAX(maxCosine, cosine); } // if cosines of all angles are small // (all angles are ~90 degree) then write quandrange // vertices to resultant sequence if (maxCosine < 0.3) squares.push_back(approx); } } } } }
La funzione contiene diversi argomenti già trattati in precedenti tutorial come l'algoritmo di Canny e l'adaptive thresholding.
Questa funzione però non basta da sola per trovare solo le vignette: nel vettore squares saranno presenti tanti, troppi, falsi positivi. A questo scopo sono state implementate le quattro funzioni che seguono e che ad ogni chiamata modificano appropriatamente il vettore di rettangoli, fino ad arrivare alle sole vignette contenute nelle pagine.
Le funzioni (dai nomi piuttosto evocativi) sono:
- eraseMaxSquare
- eraseNestedSquares
- addNotReveleadSquares
- reorderSquares
La prima semplicemente si occupa di ricercare l'elemento di squares con l'area più grande e ad eliminarlo dal vettore. Questo perchè findSquares aggiunge nel vettore anche la pagina completa, essendo anch'essa un rettangolo. Ma dopotutto mostrare l'intera pagina è esattamente quello che vogliamo evitare con questo codice.
La seconda ricerca i rettangoli che sono totalmente inclusi dentro altri e li elimina. Questo perchè per l'elaborato la funzione findSquares genera molti falsi positivi tra i quadrilateri contenuti nelle scene. Esempi pratici sono le narrazioni esterne, racchiuse spesso in rettangoli fluttuanti nella scena.
Può accadere però che alcune vignette non siano inserite nel vettore squares. Questo perchè per esempio non hanno tutti i contorni ben definiti. Utilizzando una griglia più o meno densa, questa funzione aggiunge le vignette non trovate al vettore.
Infine, la funzione reorderSquares riarrangia squares (che arrivati a questo punto conterrà tutte e sole le vignette) per mostrarle a video in ordine a seconda che il fumetto sia occidentale o manga.
Siccome a noi ingegneri piacciono tanto i diagrammi a blocchi, ricapitolo tutto quanto nello schema seguente in cui ho utilizzato come esempio una pagina fotografata da un mio albo di RatMan. Cliccate sull'immagine per ingrandire.
Nel prossimo articolo vedremo nel dettaglio il codice delle quattro funzioni. Come sempre, per domande e dubbi, scrivete un commento all'articolo!
