A deep learning system that breaks simple 4-character CAPTCHAs using a Convolutional Neural Network built with PyTorch. The pipeline covers the full workflow — segmenting CAPTCHAs into characters, training a classifier, and decoding unseen images end to end.
Inspired by Adam Geitgey's article: How to break a CAPTCHA system in 15 minutes with Machine Learning, ported from Keras to PyTorch.
Educational project. These are synthetic CAPTCHAs from a known generator, not a real-world security bypass — real systems use adversarial distortion, overlapping glyphs and behavioural signals that defeat this approach. The value here is the decomposition: turning "read a 4-character image" into "classify one character, four times", which is what makes a 700 KB model reach ~98% where end-to-end sequence prediction would need far more data.
The approach breaks a complex image classification problem into a simpler one by splitting each CAPTCHA into individual characters:
┌─────────────────────────────────────────────────────────────┐
│ CAPTCHA Image ("8X2N") │
│ ┌──────┐ │
│ │8X2N │ │
│ └──────┘ │
│ │ │
│ ▼ │
│ 1. Threshold + Contour Detection (OpenCV) │
│ │ │
│ ▼ │
│ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │
│ │8 │ │X │ │2 │ │N │ ← Individual letter crops │
│ └──┘ └──┘ └──┘ └──┘ │
│ │ │
│ ▼ │
│ 2. Resize to 20×20 px → Normalize to [0, 1] │
│ │ │
│ ▼ │
│ 3. CNN Prediction → "8", "X", "2", "N" │
│ │ │
│ ▼ │
│ Result: "8X2N" ✓ │
└─────────────────────────────────────────────────────────────┘
Wide contours (w/h > 1.25) are assumed to be two touching characters and split down the middle — a cheap fix for the most common segmentation failure.
A compact CNN with two convolutional blocks followed by a fully connected classifier:
| Layer | Type | Output Shape |
|---|---|---|
| Input | Grayscale image | 1 × 20 × 20 |
| Conv Block 1 | Conv2d(1→20, 5×5) + ReLU + MaxPool | 20 × 10 × 10 |
| Conv Block 2 | Conv2d(20→50, 5×5) + ReLU + MaxPool | 50 × 5 × 5 |
| Flatten | — | 1250 |
| FC1 | Linear(1250→500) + ReLU | 500 |
| FC2 (Output) | Linear(500→32) | 32 classes |
32 classes: digits 2-9 + letters A-Z (excluding 0, 1, I, O to avoid ambiguity). The network outputs raw logits — CrossEntropyLoss applies log_softmax internally, so no softmax layer is defined.
Measured against the shipped captcha_model.pth, not taken from the training log. Per-character accuracy is reproduced on the same 25% validation split the training script creates (random_state=0); end-to-end figures come from running the full inference pipeline over 3,000 sampled CAPTCHAs.
| Metric | Value |
|---|---|
| Per-character validation accuracy | 99.35% (2,762 / 2,780) |
| End-to-end, letters never seen in training | 98.01% (2,068 / 2,110) |
| End-to-end, letters seen in training | 97.44% (798 / 819) |
| End-to-end including segmentation failures | 95.53% of all sampled CAPTCHAs |
| Inference segmentation failure rate | 2.4% |
A CAPTCHA is only solved if all four characters are right. At 99.35% per character, the expected full-solve rate is 0.9935⁴ ≈ 97.4% — and the measured held-out rate of 98.01% lands right there. That gap between "99.35% accurate" and "one in fifty CAPTCHAs wrong" is the number that matters for a system whose output is a string.
The held-out figure is the honest headline: those 2,110 CAPTCHAs contributed no letters to training, because segmentation skipped them (see below). They are genuinely unseen images from the same generator.
The most interesting finding in this project is that the training set uses only 27.9% of the available data — and it's caused by one line.
extract_single_letters_from_captchas.py discards contours smaller than 20 px² as noise:
if cv.contourArea(contour) < 20:
continuecontourArea measures the area enclosed by a contour outline, and thin glyph strokes enclose very little. So the filter throws away real characters, the image then fails the len(regions) != 4 check, and the whole CAPTCHA is skipped. Measured over all 9,955 images:
| Extraction setting | CAPTCHAs segmenting to exactly 4 regions |
|---|---|
| With the area filter (current) | 27.9% — 2,780 images → 11,120 letters |
| Without it | 97.4% — 9,700 images → ~38,800 letters |
An ablation over padding and the filter confirms the filter alone is responsible; padding (8 px vs 20 px) moves the rate by under half a percent.
Two consequences:
- Removing that line yields ~3.5× more training data for free.
- It skews the class distribution. Characters with thin strokes get filtered most often, so class counts range from 20 samples (
7) to 550 (M) — a 27× imbalance that is an artifact of segmentation, not of the generator, which produces characters near-uniformly.
The inference script does not apply this filter, which is why it segments 97.4% of images successfully. The two halves of the pipeline disagree about what counts as a letter.
captcha-solver-cnn/
├── extract_single_letters_from_captchas.py # Step 1: Split CAPTCHAs into letter images
├── train_model.py # Step 2: Train the CNN on extracted letters
├── solve_captchas_with_model.py # Step 3: Run inference on new CAPTCHAs
├── helper.py # Utility: aspect-ratio-preserving resize
├── captcha_model.pth # Pre-trained model weights
├── model_labels.dat # Serialized label encoder (pickle)
├── generated_captcha_images/ # 9,955 synthetic CAPTCHA images
├── extracted_letter_images/ # Training data: 11,120 letters, one folder per class
│ ├── 2/ ... Z/ # 32 character class folders
└── requirements.txt
git clone https://github.com/pop123-ux/captcha-solver-cnn.git
cd captcha-solver-cnn
pip install torch opencv-python numpy imutils scikit-learn
⚠️ Install the packages above rather than usingrequirements.txt— it is stale and lists the wrong framework. See limitations.
Option A — Use the pre-trained model (fastest):
python solve_captchas_with_model.pyLoads captcha_model.pth, picks 10 random CAPTCHAs, and opens a window per image showing predictions with bounding boxes. Press any key to advance. Requires a desktop environment — see limitations for headless use.
Option B — Train from scratch:
python extract_single_letters_from_captchas.py # Step 1: extract letters
python train_model.py # Step 2: train (10 epochs, ~1 min on CPU)
python solve_captchas_with_model.py # Step 3: inferenceSome of these are bugs worth fixing; others are deliberate choices for a learning project. Both are listed.
Known bugs
requirements.txtdescribes a different project. It liststensorflowandkeras— leftovers from the Keras original this was ported from — while the code is entirely PyTorch. It also listssklearn, a deprecated stub package that now fails deliberately on install, and omitstorch,opencv-pythonandscikit-learnentirely. Runningpip install -r requirements.txtfails, and would install the wrong framework if it didn't.- The area filter silently discards 72% of the training data. Detailed above. One line, ~3.5× more data.
CaptchaCNNis defined twice, intrain_model.pyandsolve_captchas_with_model.py. The copies have already diverged once — a comment in the inference copy reads "Re-added this missing layer". A sharedmodel.pywould make that class of bug impossible.- Extraction and inference preprocess differently — 8 px vs 20 px padding, and the area filter applied in one but not the other. Training and inference should see identical pixels.
solve_captchas_with_model.pysamples from the training folder. It draws its 10 demo CAPTCHAs fromgenerated_captcha_images/, most of which contributed training letters. There is no held-out set in the repo; the held-out figures above were obtained by separating images whose letters segmentation had skipped.- Inference silently skips unsolvable CAPTCHAs. When segmentation doesn't yield exactly 4 regions the loop
continues without printing anything, so a failed solve is indistinguishable from one that never ran. 2.4% of images hit this. cv.imshowblocks and requires a display. The script can't run on a headless server, in Colab, or over plain SSH, and needs a keypress per image. Writing annotated output to disk would make it scriptable.- The label encoder is a pickle, so loading it executes arbitrary code from the file and is tied to the scikit-learn version that wrote it — loading
model_labels.datunder a newer scikit-learn already emits anInconsistentVersionWarning. A JSON list of classes would be safer and version-proof.
Deliberate scope choices
- Synthetic CAPTCHAs only. The generator produces clean, non-overlapping glyphs on light noise. Real-world CAPTCHAs are adversarially designed against exactly this pipeline.
- Segment-then-classify rather than sequence modelling. A CRNN with CTC loss would handle touching characters without the
w/h > 1.25hack, but needs far more data and would obscure the core lesson. - The train/test split is over letters, not CAPTCHAs. Characters cropped from the same source image can land on both sides of the split, which slightly inflates per-character validation accuracy. The end-to-end held-out figure above is not affected.
- Datasets are committed (~104 MB). It makes the repo runnable immediately with no generation step, at the cost of clone size.
extracted_letter_images/is fully derivable fromgenerated_captcha_images/.
- Image preprocessing — thresholding, contour detection, and bounding box extraction with OpenCV
- Data pipeline — splitting multi-character images into single-character training samples
- CNN architecture — convolutional feature extraction + fully connected classification head
- PyTorch training loop — forward pass, loss computation, backpropagation, and validation
- Inference pipeline — end-to-end from raw image to decoded CAPTCHA text
- Evaluation design — why per-character accuracy and per-CAPTCHA accuracy differ, and how to construct a genuinely held-out set
- Drop the
contourAreafilter and retrain on ~3.5× the data. - Fix
requirements.txt, or replace it with apyproject.toml. - Move
CaptchaCNNinto a sharedmodel.pyimported by both scripts. - Hold out a proper test split of whole CAPTCHAs before extraction.
- Replace the pickled
LabelBinarizerwith a plain JSON class list. - Add a
--no-displayflag that writes annotated images to disk instead of callingcv.imshow. - Report per-class accuracy to quantify the effect of the 27× class imbalance.
This project is for educational purposes only. It demonstrates fundamental computer vision and deep learning concepts using synthetic CAPTCHAs. Do not use it to bypass security measures on real websites.
MIT
- Author: @pop123-ux
- Medium write-ups: medium.com/@Pop123