Quante volte abbiamo avviato l’applicazione della nostra banca e cliccato sul bottone “Scannerizza documento”? Probabilmente tante volte visto che ogni mese abbiamo bollettini da pagare e documenti da inviare. Quando inquadriamo quel rettangolo di carta appoggiato sulla scrivania ecco che, come per magia, lo smartphone lo riconosce ed effettua un ritaglio quasi perfetto elevando il documento dallo sfondo che lo circonda.
Ma è magia o è scienza?
In Mobimentum abbiamo avuto la necessità di sviluppare un plugin nativo per le nostre app Ibride che permettesse di acquisire e riconoscere documenti cartacei tramite fotocamera, un’operazione che all'utente finale deve risultare semplice, ma che sotto al cofano dell'interfaccia richiede di trovare una soluzione a sfide insidiose e stimolanti allo stesso tempo.
Dopo la primissima domanda di rito “E ora, come faccio?!” sono iniziate le ricerche e l’analisi del problema, dopo un paio d’ore avevamo un candidato molto forte: OpenCV, libreria Open Source, largamente utilizzata nel mondo dell’Edge Detection e del riconoscimento di immagini, sfruttata da aziende del calibro di Tesla, BMW e Audi.
Implementata la prima versione del software avevamo un prototipo funzionante ma nello stesso tempo si presentavano le prime problematiche: il plugin riusciva a identificare documenti di colore chiaro su sfondo nero (ad alto contrasto) ma aveva difficoltà a riconoscere documenti di colore simile allo sfondo (a basso contrasto) soprattutto in condizioni di luce variabile.
Ecco una porzione di sorgente della prima versione:
Mat mGrayMat = new Mat(mat.rows(), mat.cols(), CV_8UC1);
Imgproc.cvtColor(mat, mGrayMat, Imgproc.COLOR_BGR2GRAY, 4);
Imgproc.threshold(mGrayMat, mGrayMat, 150, 255, THRESH_BINARY + THRESH_OTSU);
Questi passaggi vengono eseguiti ad ogni acquisizione del frame da parte della fotocamera. Come prima cosa l’immagine viene convertita in bianco e nero e poi viene applicato un Threshold così da aumentare il contrasto. Fatto ciò si hanno delle rette continue che evidenziano il perimetro del documento da scannerizzare, a questo punto vengono acquisite le coordinate e costruito il poligono grafico.
Di seguito l’immagine processata e l’individuazione del documento:
L’immagine viene trasformata in un’immagine binaria così da eliminare ogni tipo di sfumatura ottenendo vari blob ben marcati che utilizzeremo per ricavare una lista di rettangoli di dimensioni diverse tra loro. Si presume che il documento sia il rettangolo più grande presente in lista e quindi quello con un’area poco più piccola dell’area totale dello schermo. Una volta scartati i rettangoli più piccoli il risultato finale sarà:
Bellissimo ma ora rimaneva un problema da risolvere, il più complesso: il basso contrasto. Dopo vari giorni di studio siamo arrivati ad ottenere un codice più completo e soprattutto più adattabile a varie condizioni di luminosità:
Mat mGrayMat = new Mat(mat.rows(), mat.cols(), CV_8UC1);
Mat dst = new Mat(mat.rows(), mat.cols(), CV_8UC1);
Imgproc.cvtColor(mat, mGrayMat, Imgproc.COLOR_BGR2GRAY, 4);
Imgproc.bilateralFilter(mGrayMat, dst, 11, 11, 11);
Imgproc.adaptiveThreshold(dst, dst, 255, ADAPTIVE_THRESH_MEAN_C, THRESH_BINARY_INV, 115, 4);
int border = 3;
Core.copyMakeBorder(dst, dst, border, border, border, border, Core.BORDER_REFLECT_101);
Imgproc.Canny(dst, dst, 50, 150);
Imgproc.dilate(dst, dst, new Mat(), new Point(-1, -1), 11);
L’immagine viene sempre convertita in bianco e nero ma, rispetto alla versione precedente, non viene più applicata solo la soglia di contrasto, vengono invece eseguiti 5 differenti passaggi:
- bilateralFilter() → viene applicata una prima pulizia del “rumore”, cioè i pixel non desiderati, attorno all’ancora presunto documento da scannerizzare.
2. adaptiveThreshold() → In questo caso, per questioni prestazionali, è stato applicato un ADAPTIVE_THRESH_MEAN_C ma il più adatto sarebbe ADAPTIVE_THRESH_GAUSSIAN_C che trasforma l’immagine in un’immagine binaria applicando la distribuzione di Gauss.
3. copyMakeBorder() → La funzione copia l'immagine sorgente al centro dell'immagine di destinazione. Le aree a sinistra, a destra, sopra e sotto l'immagine sorgente copiata verranno riempite con pixel estrapolati.
4. Canny → La funzione trova i bordi nell'immagine di input e li contrassegna nei bordi della mappa di output usando l'algoritmo Canny. Su questo algoritmo vale la pena di aprire una piccola digressione.
Essendo un algoritmo nativo (C++) il nostro progetto avrà bisogno dell’implementazione dell’NDK di Android. In più ci servirà un file .mk che si occuperà di puntare alle risorse native, in questo caso di OpenCV, con cui il nostro progetto verrà buildato:
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := app
LOCAL_LDFLAGS := -Wl,--build-id
APP_ABI := arm64-v8a armeabi armeabi-v7a mips mips64 x86 x86_64
LOCAL_SRC_FILES := \
/jniLibs/mips/libopencv_java3.so \
/jniLibs/armeabi/libopencv_java3.so \
/jniLibs/x86/libopencv_java3.so \
/jniLibs/x86_64/libopencv_java3.so \
/jniLibs/arm64-v8a/libopencv_java3.so \
/jniLibs/mips64/libopencv_java3.so \
/jniLibs/armeabi-v7a/libopencv_java3.so \
include $(BUILD_SHARED_LIBRARY)
Come possiamo notare abbiamo dei contorni evidenti ma con delle interruzioni.
5. dilate() → Quest’ultimo passaggio servirà a dilatare eventuali pixel isolati, facenti parte del perimetro del documento, e quindi della retta, fino ad unirli così da eliminare le interruzioni. Come vediamo da questa immagine ci sono ancora dei pixel, totalmente isolati, che vanno a creare del rumore e quindi del disturbo. Noi scartiamo questi pixel controllando la loro dimensione.
Il risultato di questi passaggi sarà il riconoscimento del documento a basso contrasto:
Quello che abbiamo ottenuto alla fine di questo procedimento è un sistema in grado di riconoscere dei documenti cartacei in diverse condizioni di luce e con livelli di contrasto molto bassi: Scanbot, non ti temiamo!