Arabic Handwritten Letter Recognition using CNNs
A personal learning project β building an OCR pipeline from scratch with PyTorch to recognize the 28 letters of the Arabic alphabet.
I wanted to understand deep learning by doing β not just reading. Arabic OCR felt right: it's a real problem, the dataset is manageable, and it forced me to think about image preprocessing, model architecture, and evaluation in a hands-on way.
ocr_7uruf/
βββ main.py # entry point β runs the full pipeline
βββ requirements.txt # dependencies
βββ src/
β βββ config.py # all hyperparameters & paths in one place
β βββ dataset.py # data loading, transforms, train/val split
β βββ model.py # CNN architecture definition
β βββ train.py # training loop + validation
β βββ evaluate.py # metrics, confusion matrix, reports
β βββ utils.py # seed, gpu check, helpers
βββ notebooks/
β βββ 01_data_exploration # understanding the dataset
β βββ 02_model_experiments # testing different approaches
β βββ 03_final_training # full training run + results
βββ 7uruf_data/ # dataset (images organized by label)
βββ outputs/ # saved plots, reports, models
The big picture β how everything connects, from raw images to predictions:
graph TB
subgraph ENTRY["π Entry Point"]
MAIN["main.py<br>orchestrates the full pipeline"]
end
subgraph CONFIG["βοΈ Configuration"]
CFG["config.py<br>single source of truth"]
CFG_PATHS["paths: data, models, outputs"]
CFG_HYPER["hyperparams: lr, batch, epochs"]
CFG_ARCH["architecture: channels, hidden size"]
CFG_DEVICE["device: auto-detect GPU/CPU"]
CFG --> CFG_PATHS
CFG --> CFG_HYPER
CFG --> CFG_ARCH
CFG --> CFG_DEVICE
end
subgraph DATA["π Data Pipeline"]
RAW["7uruf_data/<br>raw .png images"]
DS["dataset.py"]
TRANSFORM["transforms<br>grayscale β resize β normalize"]
AUG["augmentation<br>rotation, affine - train only"]
SPLIT["train/val split<br>80/20, seeded"]
LOADER["DataLoaders<br>batched, shuffled, pinned"]
RAW --> DS
DS --> TRANSFORM
TRANSFORM --> AUG
AUG --> SPLIT
SPLIT --> LOADER
end
subgraph MODEL["π§ CNN Model"]
direction TB
INPUT["Input<br>1Γ64Γ64 grayscale"]
CONV1["Conv Block 1<br>Conv2d β ReLU β MaxPool<br>1β32 channels, 64β32"]
CONV2["Conv Block 2<br>Conv2d β ReLU β MaxPool<br>32β64 channels, 32β16"]
FLAT["Flatten<br>64Γ16Γ16 β 16384"]
FC1["FC Layer 1<br>16384 β 128 + ReLU"]
FC2["FC Layer 2<br>128 β 28 classes"]
OUTPUT["Output<br>28 scores, one per letter"]
INPUT --> CONV1 --> CONV2 --> FLAT --> FC1 --> FC2 --> OUTPUT
end
subgraph TRAINING["ποΈ Training Loop"]
LOOP["train.py"]
FWD["forward pass"]
LOSS["CrossEntropyLoss"]
BWD["backward pass"]
OPT["Adam optimizer"]
VAL["validation step<br>no gradients"]
SAVE["save best model<br>by val accuracy"]
LOOP --> FWD --> LOSS --> BWD --> OPT
LOOP --> VAL
VAL --> SAVE
end
subgraph EVAL["π Evaluation"]
EV["evaluate.py"]
ACC["accuracy"]
CM["confusion matrix"]
CR["classification report"]
VIZ["prediction samples"]
EV --> ACC
EV --> CM
EV --> CR
EV --> VIZ
end
subgraph UTILS["π§ Utilities"]
UT["utils.py"]
SEED["set_seed()<br>reproducibility"]
GPU["check_gpu()<br>CUDA detection"]
UT --> SEED
UT --> GPU
end
subgraph OUTPUTS["πΎ Outputs"]
BEST["models/best_model.pth"]
PLOTS["outputs/training_history.png"]
CMPLOT["outputs/confusion_matrix.png"]
REPORT["outputs/classification_report.txt"]
end
MAIN -->|"step 0: setup"| UTILS
MAIN -->|"step 1: load data"| DATA
MAIN -->|"step 2: build model"| MODEL
MAIN -->|"step 3: train"| TRAINING
MAIN -->|"step 4: evaluate"| EVAL
CFG -.->|"imported by all modules"| DATA
CFG -.->|"imported by all modules"| MODEL
CFG -.->|"imported by all modules"| TRAINING
CFG -.->|"imported by all modules"| EVAL
LOADER -->|"feeds"| TRAINING
TRAINING -->|"trained model"| EVAL
TRAINING -->|"weights"| OUTPUTS
EVAL -->|"plots & reports"| OUTPUTS
style ENTRY fill:#1a1a2e,stroke:#e94560,color:#fff
style CONFIG fill:#16213e,stroke:#0f3460,color:#fff
style DATA fill:#1a1a2e,stroke:#533483,color:#fff
style MODEL fill:#16213e,stroke:#e94560,color:#fff
style TRAINING fill:#1a1a2e,stroke:#0f3460,color:#fff
style EVAL fill:#16213e,stroke:#533483,color:#fff
style UTILS fill:#1a1a2e,stroke:#0f3460,color:#fff
style OUTPUTS fill:#16213e,stroke:#e94560,color:#fff
A closer look at how the model processes a single image:
graph LR
subgraph INPUT
I["πΌοΈ Image<br>1 Γ 64 Γ 64"]
end
subgraph BLOCK1["Conv Block 1"]
C1["Conv2d<br>3Γ3, pad=1<br>1 β 32 ch"]
R1["ReLU"]
P1["MaxPool<br>2Γ2"]
end
subgraph BLOCK2["Conv Block 2"]
C2["Conv2d<br>3Γ3, pad=1<br>32 β 64 ch"]
R2["ReLU"]
P2["MaxPool<br>2Γ2"]
end
subgraph CLASSIFIER["Classifier"]
FL["Flatten<br>16384"]
F1["Linear<br>16384β128"]
R3["ReLU"]
F2["Linear<br>128β28"]
end
subgraph OUTPUT
O["π Prediction<br>28 Arabic letters"]
end
I --> C1 --> R1 --> P1 --> C2 --> R2 --> P2 --> FL --> F1 --> R3 --> F2 --> O
style BLOCK1 fill:#2d3436,stroke:#6c5ce7,color:#fff
style BLOCK2 fill:#2d3436,stroke:#6c5ce7,color:#fff
style CLASSIFIER fill:#2d3436,stroke:#00b894,color:#fff
What happens when you run python main.py:
sequenceDiagram
participant M as main.py
participant U as utils
participant D as dataset
participant N as model
participant T as train
participant E as evaluate
M->>U: set_seed(42)
M->>U: check_gpu()
M->>D: create_data_loaders()
D-->>M: train_loader, val_loader
M->>N: ArabicCNN()
N-->>M: model
M->>T: train_model(model, loaders)
loop each epoch
T->>T: train_one_epoch()
T->>T: validate()
T->>T: save if best
end
T-->>M: history
M->>E: evaluate_model()
M->>E: plot_confusion_matrix()
M->>E: plot_training_history()
M->>E: get_classification_report()
E-->>M: results saved to outputs/
The exploration process before the final pipeline:
graph LR
N1["π 01<br>Data Exploration<br>understand the dataset<br>class distribution<br>sample visualization"]
N2["π 02<br>Model Experiments<br>test architectures<br>tune hyperparams<br>compare results"]
N3["π 03<br>Final Training<br>full run<br>best config<br>final evaluation"]
PROD["β‘ main.py<br>production pipeline"]
N1 -->|"insights"| N2 -->|"best approach"| N3 -->|"finalized into"| PROD
style N1 fill:#2d3436,stroke:#fdcb6e,color:#fff
style N2 fill:#2d3436,stroke:#e17055,color:#fff
style N3 fill:#2d3436,stroke:#00b894,color:#fff
style PROD fill:#2d3436,stroke:#e94560,color:#fff
Structuring the project was harder than the model itself. Deciding what goes where β config vs. utils, dataset vs. transforms, what the notebooks should contain vs. what becomes production code β took more thought than expected.
What I struggled with:
- Getting the tensor dimensions right through conv β pool β flatten β linear (a single miscalculation breaks everything)
- Finding the right balance between notebook exploration and clean modular code
- Data augmentation choices β too much hurts, too little doesn't help
What was interesting:
- Seeing how a simple 2-layer CNN can already distinguish 28 classes reasonably well
- The confusion matrix reveals which letters the model confuses β often the ones that look similar to humans too
- How much
config.pyas a single source of truth simplifies everything
| Tool | Purpose |
|---|---|
| PyTorch | model, training, tensors |
| torchvision | transforms, augmentation |
| matplotlib / seaborn | visualization |
| scikit-learn | metrics, confusion matrix |
| Pillow | image loading |
| tqdm | progress bars |
keep coding greatness awaits