Sencillo Anti-Captcha

por | 13 diciembre, 2015

Hoy voy a explicar como construir un anti-captcha. La idea es que pueda resolver los captchas mas sencillos. Es decir, captchas que tienen una serie de caracteres alfanuméricos fácilmente identificables y exactamente iguales en cada aparición, sin ruido y sin transformaciones.

Para resolver captchas más complejos se requiere de la aplicación de algoritmos matemáticos complejos que hacen uso de matrices y transformadas, estadísticas, cálculos probabilísticos y redes neuronales, entre otras cosas.

Traté de hacerlo lo mas sencillo posible de manera que no utilice numpy, opencv y otros módulos que nos brindarían un montón de funciones útiles. Solo vamos a hacer uso de el modulo Image de PIL.

Este es el tipo de captcha con el que vamos a trabajar. Los saque de algún sitio en el que explican como construir un sistema de captchas simple en php.

captcha_sencillo

 

Generación de patrones

Antes de poder resolver algún captcha necesitamos generar los patrones necesarios. A continuación explico cada paso

 

 

1. Muestreo

Lo primero que deben hacer es obtener muestras suficientes del captcha que quieren resolver automáticamente. Con este tipo de captchas un muestreo de 100 es suficiente, pero pueden utilizar 150 o 200 si quieren asegurarse de que en las muestras se encuentren todos los caracteres que pueden aparecer. En mi caso descargue las muestras en el directorio ‘samples’.

Para poder iterar sobre las muestras creamos la siguiente función que nos retorna un generador que nos retorna un objeto Image por cada muestra.


def get_images(directory):

    def images():
        for f in os.listdir(directory):
            yield Image.open(os.path.join(directory, f))

    return images()

2. Binarización

Una vez que tenemos las imágenes de muestra tenemos que binarizarlas. Esto es, convertir estos captchas a blanco y negro. Esto nos permite simplificar la imagen quedándonos solo con la información que nos importa.

Así debería quedar una imagen ya binarizada:

simple_captcha_binarized

Notar que creamos una nueva imagen en blanco y negro en la que si el pixel pertenece a un carácter lo dejamos en negro, de otra manera en blanco.

 

def get_iterator(image):
   def iterator():
   for x in range(image.size[0]):
       for y in range(image.size[1]):
           yield (x, y)
 
   return iterator()

def binarize(image, fn):
    new_image = Image.new('1', image.size)
    for xy in get_iterator(image):
        new_image.putpixel(xy, fn(image.getpixel(xy)))
    return new_image

def fn(color):
    return 0 if color == 80 else 1

Bueno acá tenemos 3 funciones. get_iterator simplemente nos devuelve un generador que nos devuelve un tupla (x, y) por cada pixel de la imagen dada como parámetro. Esto simplemente es para no tener que estar poniendo 2 ciclos for anidados cada que hay que recorrer una imagen.

binarize es la función que recibe la muestra y nos devuelve la imagen binarizada. Notar que recibe una función fn como parámetro. Esta va a ser invocada por cada pixel y debe retornar un 0 o un 1.

En nuestro caso los caracteres siempre son negros. Como las muestras están en modo indexado(mode=’P’), fn va a recibir un entero como parámetro cada vez que se invoque. Cuando el valor sea 80 el pixel va a ser negro. Lo hice así porque para binarizar otro tipo de captcha solo tendríamos que cambiar la función fn. Por ejemplo para el siguiente captcha, que esta en modo RGB

captcha

tendríamos el siguiente código

def fn(color):
    return 0 if color == (255, 255, 255) else 1

3. Segmentación

Este paso consiste en separar cada carácter en una imagen distinta. Vamos a obtener 8 imágenes del mismo tamaño pero con solo un carácter en cada una. Así quedaría:

segment0 segment1 segment2 segment3 segment4 segment5 segment6 segment7

def get_segments(image):
    image = image.copy()
    segments = []

    for xy in get_iterator(image):
        if image.getpixel(xy) == 0:
            new_segment = get_segment(image, xy)
            segments.append(new_segment)
    return segments

def get_pixel(image, xy):
    try:
        return image.getpixel(xy)
    except:
        return 1

def get_segment(image, xy, segment=None):
    x, y = xy
 
    if segment is None:
        segment = Image.new('1', image.size, 'white')

    image.putpixel(xy, 1)
    segment.putpixel(xy, 0)
 

    adjacent = [(x, y-1), (x+1, y-1), (x-1, y), 
    (x+1, y), (x-1, y+1), (x, y+1), (x+1, y+1)]

    for a in adjacent:
        if get_pixel(image, a) == 0:
            get_segment(image, a, segment)

    return segment

La función get_segments recibe una imagen(binarizada) y devuelve una lista de imágenes. Cada una de las mismas va a contener un segmento. Es decir vamos a tener una imagen por cada objeto conexo que para nosotros van a ser caracteres. Por último menciono que esta función recorre la imagen de entrada de izquierda a derecha y de arriba a abajo buscando pixeles negros y por ende caracteres.

La función get_pixel es como la función getpixel de Image, pero en vez de lanzar un IndexError si el índice es invalido retorna 1. Como lo usamos sobre imágenes binarias si el pixel es negro retorna 0, si es blanco o no existe retorna 1.

Por último la función get_segment que recibe una imagen binarizada y un punto de la misma se ejecuta recursivamente buscando todos los puntos adyacentes que pertenecen al carácter.

4. Recorte

En este paso simplemente vamos a recortar los sobrantes de cada segmento, de manera que cada imágen tenga el tamaño justo para albergar el carácter. Este sería el resultado de este paso:

segment0 segment1 segment2 segment3 segment4 segment5 segment6 segment7

def get_limits(image):
    min_x, min_y = image.size
    max_x, max_y = 0, 0
 
    for x, y in get_iterator(image):
        if image.getpixel((x, y)) == 0:
            if x < min_x:
                min_x = x
            if y < min_y: min_y = y if x > max_x:
                max_x = x
            if y > max_y:
                max_y = y
 
    return (min_x, min_y, max_x+1, max_y+1)
 
def trim(image):
    cropped = image.crop(get_limits(image))
    cropped.load()
    return cropped</pre>
<pre>

get_limits recibe una imagen binarizada y retorna una tupla con cuatro valores correspondientes a las coordenadas de los pixeles negros mas extremos de la imagen.

trim toma la imagen y devuelve otra recortada.

5. Procesamiento de muestras

Haciendo uso de las funciones anteriores ahora vamos a procesar las muestras y generar los patrones.

def get_patterns(samples, fn):
    def patterns():
        for sample in samples:
            for s in get_segments(binarize(sample, fn)):
                yield trim(s)
 
    return patterns()

def find_image(image, directory):
    for f in os.listdir(directory):
        path = os.path.join(directory, f)
        i = Image.open(path)
        if i.tostring() == image.tostring():
            return f
    return None

def generate_patterns(samples_directory, patterns_directory, fn):
    samples = get_images(samples_directory)
    c = 0
    for p in get_patterns(samples, fn):
        if not find_image(p, patterns_directory):
            path = os.path.join(patterns_directory, '%s.png' % c)
            c += 1
            p.save(path, 'PNG')

 

get_patterns recibe las muestras y fn, y retorna un generador que permite iterar sobre todos los caracteres(imágenes segmentadas y recortadas) encontrados en todas las muestras

find_image recibe una imagen y comprueba si ya existe en el directorio pasado como parámetro retornando el nombre de la imagen o None.

Por último tenemos generate_patterns. Itera sobre todos los caracteres encontrados en todas las muestras y va comprobando si ya existen en el directorio de patrones. Si no existe guarda la imagen. Al finalizar la ejecución deberíamos tener en el directorio patterns un conjunto de imágenes distintas, cada una de ellas conteniendo un carácter.

6. Etiquetado

Hasta ahora solo tenemos el directorio patterns lleno de imágenes con los caracteres. Ahora tenemos que asociar cada imagen con un carácter. Podríamos usar una base de datos y una interfaz gráfica para ir haciendo las asociaciones, pero vamos a mantenerlo simple. Una vez que ejecutamos la función generate_patterns vamos al directorio patterns y renombramos todas las imágenes poniendole como nombre el carácter que le corresponde. De esta manera logramos relacionar una imagen con el carácter correspondiente.

Aquí finalizamos la parte de la generación de los patrones.

 

Resolución de Captcha


def sort_segments(segments):
 
    def position_compare(s1, s2):
        return get_limits(s1)[0] - get_limits(s2)[0]
    sorted(segments, cmp=position_compare)


def solve_captcha(image, fn, patterns_directory):
    image = binarize(image, fn)
    segments = get_segments(image)
    sort_segments(segments)
 
    segments = [trim(s) for s in segments] 

    response = ''

    for s in segments:
        path = find_image(s, patterns_directory)
        if path is None:
            return None
        else:
            response += os.path.splitext(os.path.basename(path))[0]
 
    return response

Bueno, solve_captcha recibe el captcha a resolver junto con la función fn y el directorio de patrones y nos devuelve el string correspondiente o None si no lo puede resolver. Para esto primero binariza el captcha y lo segmenta.

Ahora tenemos la lista de segmentos que pueden estar en cualquier orden, pero nosotros lo necesitamos ordenada según que carácter esta mas a la izquierda. Para esto le aplicamos la función sort_segments que nos ordena la lista.

Luego recortamos los segmentos y a continuación por cada segmento buscamos en el directorio de patrones. Si lo encontramos extraemos el carácter del nombre del archivo de la imagen. Si no encontramos el carácter asociado a algún segmento no podemos resolver el captcha y retornamos None.

 

 

Pasos

Por último pongo los pasos a seguir para generar los patrones, para cualquier captcha que cumpla las condiciones que impuse al principio:

  1. Cargar muestras en carpeta ‘samples’.
  2. Definir la función fn.
  3. Llamar a la función generate_patterns.
  4. Renombrar los patrones generados.

 

Ejemplos

from anticaptchalib import *

#Solving captcha1
captcha = Image.open('01.png')
fn = lambda color: 0 if color == 80 else 1
print solve_captcha(captcha, fn, 'patterns01')

#Solving captcha2
captcha = Image.open('02.png')
fn = lambda color: 0 if color == (255,255,255) else 1
print solve_captcha(captcha, fn, 'patterns02')

#Generating patterns
generate_patterns('samples02', 'patterns03', fn)

Conclusiones

Bueno lo primero que debo decir es que esto es solo un ejemplo básico que tiene un montón de restricciones. Algunas son sencillas de quitar, otras no tanto y otras directamente no se pueden. Por ejemplo, por el método de etiquetado empleado no podemos tener dos o más caracteres que representen la misma letra, pero se puede subsanar facilmente(mediante una base de datos o un simple diccionario). Del otro lado tenemos restricciones propias de la librería como por ejemplo que no puede resolver captchas con ruido incorporado. En futuras entradas iré explicando como lidiar con estos problemas.

Por último, esta claro que se pueden hacer optimizaciones como hashear los patrones para acelerar las búsquedas o utilizar módulos que implementen funciones a un nivel mas bajo, pero como esto tiene fines didácticos elegí claridad del código sobre performance.

Les dejo el repositorio y un chiste.Saludos

captcha_joke

 

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *