A few months ago me and my friend decided to work on a project that breaks annoying captchas in course registration website. It was a fun project and a nice opportunity to put our knowledge into a real-world example. Throughout the process we have learned a lot, so I decided to write a tutorial that breaks down the steps we took. I hope this helps someone out there with a similar goal and if you have any suggestions feel free to contact.
Repo for this project is HERE.
To accomplish our goal we used python(3) and following libraries;
- opencv and numpy are used in image processing i.e. denoising the images and separating the digits.
- pytorch used for training the model that will autonomously breaks captchas.
Creating the Dataset
The very first step of the project is to create a dataset to work on with. Since we wanted to break a specific website’s captchas, we downloaded thousands of sample images via a click bot. Click bot basically refreshed the page and downloaded the captcha to a folder every time. We could have as many samples we want but decided that around five thousand would be enough in our purpose.
You can download the dataset HERE.
After constructing the dataset, here comes the crucial part. Annotating the dataset:). In every supervised learning problem, we have to annotate(label) our dataset, and I should admit it was the most boring part of the whole thing. Thankfully we had a saint friend who helped us with this task. Our labels for the dataset is a plain text file and every line has the solution for associated captcha image. For example line 15 in the text contains solution for “15.jpeg” image in the dataset.
You can download the labels HERE.
Denoising and Separating the Digits
After creating our dataset we can experiment on it. At the first glimpse, you can see there are two different types of images. One seems to have a Gaussian noise and another one with periodical noise. We call them type1 and type2 images.
So the first image processing task should be determining the type of the given image, and then we can apply denoising on them with an intention to separate digits clearly. Separating the digits is a necessity because we can only train our model based on the digits and not the whole images. The gif below is a summary of denoising and separation steps.
To determine the type of image we need a general method that distinguishes between types. In this step, one could find multiple solutions that would work fine. Our approach was based on image histograms. We saw that type 1 images contain lots of pure black(0 intensity value) in it, so a threshold value for black pixel count actually worked perfectly.
def whichPattern(image): hist = cv2.calcHist([image], , None, , [0, 256]) # pattern1 images has almost more than 600 pixels # for 0(pure black) intensity, lets set threshold to 500 thresh = 500 if(hist[0, 0] > thresh): return 1 else: return 2
Above function takes an image as input and determines its type based on its histogram. The solution seems to be naive but works perfectly in this case. This link provides more information on calcHist function.
The whole point of determining the type was applying an appropriate filter to the image. It has seen that different filters work better on different types. After investigating couple image processing filters we decided on using median filter for type 1 and gaussian filter for type 2. Both of these filters smoothen images for further processing.
# use median blur for pattern1 and gaussian blur for patter2 images as pre-processing srcImage = cv2.medianBlur(srcImage, 3) if pattern == 1 else cv2.GaussianBlur(srcImage, (3,3), 0)
You can find more about medianBlur and GaussionBlur here.
As we can see in Figure 3, smoothing filters make a great job at reducing the noise (especially for type1), therefore, now we can try binary threshold to separate content(digits) from the background.
#obtained through experiment pattern1_thresh = 170 pattern2_thresh = 155 thresh = pattern1_thresh if pattern == 1 else pattern2_thresh ret, threshImage = cv2.threshold(srcImage, thresh, 255, cv2.THRESH_BINARY_INV)
Above code uses OpenCV’s threshold function to separate background and foreground of the images. Binary thresholding is one of the simplest method for image segmentation and very straight forward. It compares every pixel with a certain threshold value, then if the value of the pixel is less then the threshold it sets pixel’s intensity value to 0. In our case (see Fig 3.) digits actually have less intensity values than background so using inverse binary thresholding was appropriate. In this case, pixel values greater than the threshold set to 0(background) and remaining pixels set to 255(foreground). Each type has its own threshold value and these values selected based on manual inspection of sample images.
In Figure 4 we can see thresholding operation gives a binary image that only has black and white pixels. The type2 image seems be completely noise free but the type1 image still has white pixel regions that are not part of the digits. Note that these regions relatively smaller than digits. To delete this noisy parts we can use connected components.
min_connected_len = 15 # get connected components numLabel, labelImage, stats, centroids = cv2.connectedComponentsWithStats(threshImage, 8, cv2.CV_32S) # holds if component will be included to foreground foreComps = [i for i in range(1, numLabel) if stats[i, cv2.CC_STAT_AREA] >= min_connected_len] # Get binary image after erasing some connected components those areas under the threshold binaryImage = np.zeros_like(srcImage) labelImage = np.array(labelImage) for k in [np.where(labelImage == i) for i in foreComps]: binaryImage[k] = 255
connectedComponentsWithStats function we can find
threshImage’s connected components with stats associated with it.
We have used 8-connectivity in order to
find connected components of the image. Here,
labelImage is a matrix same size with the
threshImage where each pixel of the certain connected component has the value of its own label
(0 being the background) and
numLabel is the number of connected components. Knowing the labels of connected components we can choose those have areas(number of pixels) greater
min_connected_len thanks to
CC_STAT_AREA. Finally, labels in
foreComps are added to the foreground in
binaryImage and other labels discarded(added to background).
From Figure 5, we can see connected component analysis made a great job at deleting regions that are not part of the digits. Now we can clip a rectangle that only contains the digits thus removing the unnecessary background. In order to do that we need to find where do the digits lay in the image.
minCol = 30; # seen that all digits start at 30th column # no need for additional computation # find the boundaries where digits present in the image array = np.array([stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH] for i in foreComps]) maxCol = max(array[np.where(array < 125)]) # observed that digits right boundary never exceeds 125th pixel # thus this one prevents false boundaries # find boundaries in y axis minRow = min([stats[i, cv2.CC_STAT_TOP] for i in foreComps]) maxRow = max([stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT] for i in foreComps]) subImage = threshImage[minRow:maxRow, minCol:maxCol]
Examining the dataset we saw left boundary of digits starts at 30, so
minCol is fixed. Right boundary(
maxCol) is the rightmost pixel in the foreground. This is found by
CC_STAT_WIDTH which is adding leftmost pixel of the connected component to its width. There is a little hack (
np.where(array < 125) to prevent false boundaries that occurs
when some noise remained in the image. Upper and lower boundaries are found by similar manner.
So now we are close to ending of pre-processing, separating digits individually.
# Sub image divided to half in order to segment digit's more precisely subImage1 = subImage[:, :int(subImage.shape/2)] subImage2 = subImage[:, int(subImage.shape/2):] colIncrement1 = subImage1.shape / 3 colIncrement2 = subImage2.shape / 3 # get segmented digits as list digitList1 =  digitList2 =  col1 = 0 col2 = 0 for i in range(2): digitList1.append(subImage1[:, int(col1):int(col1+colIncrement1)]) digitList2.append(subImage2[:, int(col2):int(col2+colIncrement2)]) col1 += colIncrement1 col2 += colIncrement2 digitList1.append(subImage1[:, int(col1):]) digitList2.append(subImage2[:, int(col2):]) digitList = digitList1 + digitList2
We could divide resulting
subImage to 6 pieces of equal lengths in order to obtain separated digits. But it has seen that instead of dividing by six right away,
first dividing the image to half and then taking digits gives better results. Above code implements this approach. It first divides the image to half then
each half again divided to three to obtain digits. Results stored in
digitList. You might wonder why we just didn’t take remaining connected components as digits.
Well, actually we couldn’t, because as we can see from Fig7 sometimes there is a gap between a digit’s parts(more than 6 connected components), and sometimes digits are touching each other(less than 6 connected
Thus we achieved digit separation we could finish pre-processing here and start training our DL model. But when we checked some images we saw that some parts of the digits shifted to other digits.
Although this phenomenon was rare and most of the images was correctly separated, we wanted to make something about it.
# It is observed that sometimes part of the previous digit shifted to # digit that follows it. # Following prevents it most of the time for i in range(1, 6): # Appy slight closing before finding c.c, sometimes particular digit parts only apart by 1 pixels (fill them to avoid false shifting) kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3,3)) closedDigit= cv2.morphologyEx(digitList[i], cv2.MORPH_CLOSE, kernel, 1) numLabel, labelImage, stats, centroids = cv2.connectedComponentsWithStats(closedDigit, 8, cv2.CV_32S) if numLabel > 2: # consider shifting if there is more than 1(with background its 2) c.c. per digit for j in range(1,numLabel): # If it touches left border of the image # and it's width doesnt exceed 35 percent of the digit, assume it is a shifting if stats[j, cv2.CC_STAT_LEFT] == 0 and stats[j, cv2.CC_STAT_LEFT] + stats[j, cv2.CC_STAT_WIDTH] < 0.35 * digitList[i].shape: # find the part that will attach to previous digit startCol = stats[j, cv2.CC_STAT_LEFT] width_shifted = stats[j, cv2.CC_STAT_WIDTH] shiftedPart = digitList[i][:, startCol:startCol+width_shifted] # shift previous image to left while maintain its size # shift amount is width of the shifted-part fixedDigit = np.zeros_like(digitList[i - 1]) fixedDigit[:, 0:fixedDigit.shape - width_shifted] = digitList[i - 1][:, width_shifted:fixedDigit.shape] # appending shifted part back to previous digit fixedDigit[:, fixedDigit.shape-width_shifted:fixedDigit.shape] = shiftedPart[:] digitList[i - 1] = fixedDigit # replace digit with fixed digit # remove shifted part from current digit digitList[i][:, 0:width_shifted] = 0
Ok. This one seems to be complex, but the idea is very simple. For better understanding let’s take a look at Fig9.
Hmm, first 9’s end is shifted to the second nine, second 9’s end is shifted to 1… and so on. Fixing this can be achieved by re-shifting the digit parts to the left. To do that, first, we need to define rules for wrong shifting then take action. The very first thing we need to consider is if there are more than one connected components per separated digit. (
if numLabel > 2). This might be true because of shifting or there could be gaps between single digit parts(in Fig7, separated 5 has two c.c). So by only this fact we cannot conclude on wrong shifting exists.
Second thing to consider is shifted part need to always touching to left boundary of the digit image(
if stats[j, cv2.CC_STAT_LEFT] == 0). In addition to that it is assumed shifted part is
no more than %35 of the image(
stats[j, cv2.CC_STAT_LEFT] + stats[j, cv2.CC_STAT_WIDTH] < 0.35 * digitList[i].shape). If the conditions satisfied then shifting to the left is applied.
Let’s see this in action in Gif2.
We did our best for separating digits. There might still bad separations but it will be enough to train our DL model.
Training the Model
Now we can define our model which is going to be trained on our dataset. For this task CNN’s are a great choice, they are the kings of image recognition.
import torch.nn as nn import torch.nn.functional as F class Net(nn.Module): def __init__(self): super(Net, self).__init__() # convolutional layer (sees 24x14x1 image tensor) self.conv1 = nn.Conv2d(1, 3, kernel_size=3) # convolutional layer (sees 22x12x3 tensor) self.conv2 = nn.Conv2d(3, 3, kernel_size=3) # linear layer (20 * 10 * 3 -> 30) self.fc1 = nn.Linear(20*10*3, 30) # linear layer (30 -> 10) self.fc2 = nn.Linear(30, 10) def forward(self, x): # sequance of convolutional layers with relu activation x = F.relu(self.conv1(x)) x = F.relu(self.conv2(x)) # flatten the image input x = x.view(-1, 20*10*3) # 1st hidden layer with relu activation x = F.relu(self.fc1(x)) # output-layer x = self.fc2(x) return x
We are not experienced in NN architecture, but we know how to google :D. Pytorch provides a great example in image recognition so we were basically
playing with the parameters. Let’s quickly go over the model. The model contains two convolutional layers and two fully connected layers. First convolutional layer (
24x14x1 image tensor, here
14 are height and width of the image while
1 is the number of channels in the image(our images are grayscale thus 1 channel).
24x14? Pytorch’s Conv2d excepts images with a fixed shape, i.e. all input images should have the same height and width.
To fix all digits shape before feeding them to model, we calculated the mean height and width of the images, and here comes the
I believe the second important thing to mention here is figuring out correct parameters for the first fully connected layer. As you can see from the code it takes
20 * 10 * 3 as input tensor’s size.
We started with
24x14x1 but why end up with
20x10x3 tensors in convolutional layers? The reason is every time we go through a convolutional layer it shrinks the input size according to
applied kernel size in the absence of padding. Thus we are applying
3x3 kernel, it shrinks input tensor by 1 from the top, bottom, left and right. So
22x12x3 tensor as output and
20x10x3 as its output. You can find more about the topic here.
Now we are ready to train our model. Let’s look at the
# Resize the images and convert to tensors transform = transforms.Compose([transforms.Resize((24,14)), transforms.Grayscale(num_output_channels=1), transforms.ToTensor()])
Before loading the dataset we need to define the transforms that will apply to each image in the dataset. Here
Resize function changes the size of the images to
24x14 for the
reason we mentioned above.
Grayscale transforms images to grayscale and
ToTensor converts the images to tensor so we could make computations on them.
# loads dataset from folder named train # modify this path if you will load from other file dset = datasets.ImageFolder(root='train', transform=transform) dloader = torch.utils.data.DataLoader(dset, batch_size=8, shuffle=True, num_workers=12)
Pytorch’s datasets.ImageFolder lets us load datasets from folders. It expects a root path that contains
folders for each classification type (0..9 digits in this case). In the repository we have
saveDigits.py python script to handle this. It uses the digit separation algorithm and labels
to save digits in their associated folders. I will show how to use it in a second.
# specify loss function (categorical cross-entropy) criterion = nn.CrossEntropyLoss() # specify optimizer optimizer = optim.SGD(net.parameters(), lr=0.001, momentum=0.9) n_epochs = 20 for epoch in range(n_epochs): running_loss = 0.0 for i, (inp, lab) in enumerate(dloader, 0): inp = inp.to(device) lab = lab.to(device) # clear the gradients optimizer.zero_grad() # forward pass outs = net(inp) # batch loss loss = criterion(outs, lab) # backward pass loss.backward() # perform optimization(parameter update) optimizer.step() running_loss += loss.item() if i % 100 == 99: print('[%d, %d] loss: %f' % (epoch + 1, i+1, running_loss)) running_loss = 0.0 print('finished') torch.save(net.module if multi_gpu else net, 'model.pt')
Here, before running into the training loop, we define our loss function and optimizer to update model weights.
CrossEntropyLoss is very suitable for multi-classification problems and
as an optimizer we used
SDG, stochastic gradient descent. The training loop is very similar with all the pytorch training loops out there. It mainly has 5 steps,
optimizer.zero_grad(). Thus pytorch accumulates the gradients on subsequent backward passes we need to zero out the gradients in each iteration.
net(inp). This line feeds forward the batch of input through the architecture and takes output scores.
criterion(outs, lab). Using the labels and output scores, this like calculates the loss according to defined loss function.
loss.backward(). Calculates gradient for every parameter.
optimizer.step(). Updates parameters based on the gradients.
When the training is finished model is saved to
model.pt. Let’s look at how to do training in the terminal.
After cloning the repo, we can use
saveDigits to create necessary traing folder which contains 9 distinct folder for each digit. It’s usage as follows:
python saveDigits.py <dataset-path> <train-digits-path> <startIndex> <endIndex> <groundTruth-path>
Here I used first 4500 images in the training (you can change the interval) and created
train folder in the repo. If you are going to save the folder
in a different path, remember to change the root path in the
Once we have train folder, we can run
train.py to train our model. You will see
model.pt in the folder when the training finish.
Now it’s time to evaluate our model’s accuracy.
transform = transforms.Compose([transforms.Scale((24,14)), transforms.Grayscale(num_output_channels=1), transforms.ToTensor()]) dset = datasets.ImageFolder(root='test', transform=transform) dloader = torch.utils.data.DataLoader(dset, batch_size=8, shuffle=True, num_workers=12)
test.py uses the same transformations as
train.py and here only difference is, this time
ImageFolder expects a folder named
As in the training part, we will create this folder using
total = 0 correct = 0 net = torch.load('model.pt').to(device) with torch.no_grad(): for i, (inp,lab) in enumerate(dloader,0): inp = inp.to(device) lab = lab.to(device) # forward pass outs = net(inp) # convert output scores to predicted class _, pred = torch.max(outs,1) correct += (pred == lab).sum().item() total += lab.size(0) print('Accuracy: %f %%' % (100*correct/total))
In the above code, the trained model is loaded to
net variable. Because we don’t need backprop while testing
torch.no_grad is used to speed up computations.
The loop checks if the model predicts digits correctly and calculates the accuracy.
As in the training phase, we first created
test folder using remaining images in the dataset. It is crucial to test the model
with data it has not seen before. This model achieves
98.8 percent accuracy.
The important thing to note here is achieved accuracy is per digit and not whole captcha. Thus single captcha has six digits inside, we can assume
0.988^6=0.93 of the time model will predict the whole captcha correctly.
breaker.py we can break whole captchas. It’s usage as follows:
python breaker.py <image_file1> [<image_file2> ...]
Let’s give the first 5 test images and see the results.
And that’s it. If you read this far I would like to thank you and I hope it has been helpful.