Image classification means teaching a computer to look at a picture and name what it shows. It powers photo search, quality checks on factory lines, and digit readers on cheques. In this tutorial we build a classifier with scikit-learn, train it on handwritten digits, and finish with a tool that reads an image file and names the digit it sees.
Key takeaways
A classifier turns an image into numbers first, then learns patterns in those numbers.
The digits dataset ships with scikit-learn, so we train with no download, no API key, and no GPU.
On our test run, an SVM scored 98.9 percent, ahead of KNN at 98.4 percent and logistic regression at 96.2 percent.
A confusion matrix shows exactly which digits get mixed up. Ours struggles most with the digit 8.
Saving the trained model with joblib turns an experiment into a tool that reads real image files.
What are we building?
By the end of this tutorial we have two small scripts and a saved model file. The first script, train.py, loads 1,797 handwritten digits, trains three classifiers, compares them on a held out test set, and saves the winner. The second script, predict.py, loads that saved model and reads a digit from a real PNG file, then prints the digit it sees plus how sure it is. We also print the digit as text art, so we can check with our own eyes that the model looked at the right shape. Everything runs on a normal laptop in under a minute. There is no GPU, no cloud account, and no API key involved.
What do you need?
We used Python 3.12.3 with scikit-learn 1.9.1, NumPy 2.5.4, SciPy 1.18.1, joblib 1.6.0, and Pillow 13.0.0. Install them with one command:
pip install scikit-learn joblib pillow
That pulls in NumPy and SciPy as well. The whole build takes about fifteen minutes, and every step below prints real output we can compare against our own run.
Step 1: Load the digits dataset and look at it
The digits dataset ships inside scikit-learn, so nothing downloads. Each image is 8 pixels wide and 8 pixels tall, and each pixel holds a value from 0 for white to 16 for black. Scikit-learn stores each image flattened into 64 numbers, which is exactly what a classifier wants: a row of numbers in, a label out. Our first script loads the data and prints its shape.
from sklearn import datasets
digits = datasets.load_digits()
X = digits.data # shape (1797, 64): each 8x8 image flattened
y = digits.target # the true digit, 0-9
print(f"Loaded {X.shape[0]} images, each {X.shape[1]} pixels "
f"({digits.images.shape[1]}x{digits.images.shape[2]})")
print(f"Pixel values range 0 to {X.max()}, labels 0 to {y.max()}")
Real output from our run:
Loaded 1797 images, each 64 pixels (8x8)
Pixel values range 0 to 16.0, labels 0 to 9
Step 2: Split the data into training and test sets
We keep 25 percent of the images aside as a test set, 450 images the models never see during training. The stratify argument keeps the same share of each digit in both sets, so a digit that is rare in training is not suddenly common in testing. The random_state of 42 fixes the split, so anyone who runs our code gets the same 1,347 training images and the same 450 test images. Testing on data the model trained on would flatter the scores, so this split is the honest part of the build.
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y
)
print(f"Train set: {len(X_train)} images, test set: {len(X_test)} images")
Real output:
Train set: 1347 images, test set: 450 images
Step 3: Train three classifiers and compare them
We train three different classifiers on the same split, so the comparison is fair: a support vector machine with an RBF kernel, logistic regression, and K-nearest neighbours with k set to 3. Each one gets the same training images and the same test images. One detail worth knowing: scikit-learn 1.9 deprecated the probability option on SVC, so we wrap the SVM in CalibratedClassifierCV to get honest probabilities instead. The code below ran clean, with no warnings, on scikit-learn 1.9.1.
import joblib
from sklearn.calibration import CalibratedClassifierCV
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
models = {
# probability=True on SVC is deprecated in scikit-learn 1.9, so we wrap
# the SVC in CalibratedClassifierCV to get honest probabilities instead.
"SVM (RBF kernel)": CalibratedClassifierCV(
SVC(gamma=0.001, random_state=42), ensemble=False),
"Logistic Regression": LogisticRegression(max_iter=5000,
random_state=42),
"K-Nearest Neighbours (k=3)": KNeighborsClassifier(n_neighbors=3),
}
results = {}
for name, model in models.items():
model.fit(X_train, y_train)
accuracy = model.score(X_test, y_test)
results[name] = (model, accuracy)
print(f"{name}: test accuracy {accuracy:.4f}")
best_name = max(results, key=lambda n: results[n][1])
best_model, best_accuracy = results[best_name]
print(f"\nBest model: {best_name} ({best_accuracy:.4f})")
Real output from our run:
SVM (RBF kernel): test accuracy 0.9889
Logistic Regression: test accuracy 0.9622
K-Nearest Neighbours (k=3): test accuracy 0.9844
Best model: SVM (RBF kernel) (0.9889)
Why did the SVM win, and where does it fail?
The SVM scored 98.9 percent on the test set, which means 445 correct answers out of 450. KNN came close at 98.4 percent, and logistic regression trailed at 96.2 percent. The gap is small, but it is real, and it repeats across runs because the split is fixed. The classification report shows the story per digit. Precision tells us how often a predicted digit was right, and recall tells us how often a true digit was found. Digit 8 is the weak spot, with recall of 0.953. The confusion matrix shows why: two 8s were read as 1, and one 6 was read as 8. Handwritten 8s with a narrow top loop look like 1s, which is exactly the kind of mistake a human makes too. That is the value of the matrix. A single accuracy number hides the mistakes, while the matrix names them.
Classification report for the best model:
precision recall f1-score support
0 1.000 1.000 1.000 45
1 0.958 1.000 0.979 46
2 1.000 1.000 1.000 44
3 1.000 1.000 1.000 46
4 1.000 1.000 1.000 45
5 1.000 0.978 0.989 46
6 1.000 0.978 0.989 45
7 0.978 1.000 0.989 45
8 0.976 0.953 0.965 43
9 0.978 0.978 0.978 45
accuracy 0.989 450
macro avg 0.989 0.989 0.989 450
weighted avg 0.989 0.989 0.989 450
Confusion matrix (rows = true digit, columns = predicted digit):
[[45 0 0 0 0 0 0 0 0 0]
[ 0 46 0 0 0 0 0 0 0 0]
[ 0 0 44 0 0 0 0 0 0 0]
[ 0 0 0 46 0 0 0 0 0 0]
[ 0 0 0 0 45 0 0 0 0 0]
[ 0 0 0 0 0 45 0 0 0 1]
[ 0 0 0 0 0 0 44 0 1 0]
[ 0 0 0 0 0 0 0 45 0 0]
[ 0 2 0 0 0 0 0 0 41 0]
[ 0 0 0 0 0 0 0 1 0 44]]
Step 4: Save the best model so we can reuse it
Training takes seconds, but retraining every time we want a prediction would waste that work. One line saves the fitted model to disk with joblib, and loading it back takes a fraction of a second. The file is called digit_model.joblib, and it holds everything the model learned: the support vectors, the calibration, and the class labels. From here on, predict.py never trains anything. It only loads and asks.
joblib.dump(best_model, "digit_model.joblib")
print("\nSaved best model to digit_model.joblib")
Real output:
Saved best model to digit_model.joblib
Step 5: Build a predictor that reads real image files
A model that only works on arrays from the dataset is a demo. The real test is an image file, the kind a user would actually hand us. Our predict.py takes a PNG path, reads it as greyscale, shrinks it to 8 by 8 pixels, and scales the pixel values from 0 to 255 back to the 0 to 16 range the model trained on. It prints the image as text art first, so we can see what the model saw, then prints the prediction with its top three guesses. Before we test it, we need sample images, so a tiny export script saves three test images as PNG files, upscaled to 64 by 64 pixels so they look like normal small images.
import numpy as np
from PIL import Image
from sklearn import datasets
from sklearn.model_selection import train_test_split
digits = datasets.load_digits()
_, X_test, _, y_test = train_test_split(
digits.data, digits.target, test_size=0.25, random_state=42, stratify=digits.target
)
for index in (0, 1, 2):
pixels = X_test[index].reshape(8, 8)
# Digits values are 0-16, PNG greyscale is 0-255.
grey = np.clip(np.rint(pixels / 16 * 255), 0, 255).astype(np.uint8)
image = Image.fromarray(grey, mode="L")
# Upscale so the file looks like a normal small image, not 8 pixels.
image = image.resize((64, 64), Image.NEAREST)
filename = f"sample_{index}_true_{y_test[index]}.png"
image.save(filename)
print(f"Saved {filename} (true digit {y_test[index]})")
Real output:
Saved sample_0_true_1.png (true digit 1)
Saved sample_1_true_0.png (true digit 0)
Saved sample_2_true_9.png (true digit 9)
Now the predictor itself:
import argparse
import joblib
import numpy as np
from PIL import Image
from sklearn import datasets
from sklearn.model_selection import train_test_split
SHADES = " .:-=+*#%@"
def ascii_art(pixels):
"""Render an 8x8 image as text so we can see what the model saw."""
lines = []
for row in pixels:
lines.append("".join(SHADES[min(int(v), 16) * (len(SHADES) - 1) // 16]
for v in row))
return "\n".join(lines)
def pixels_from_file(path):
image = Image.open(path).convert("L").resize((8, 8), Image.LANCZOS)
return np.asarray(image, dtype=float) / 255 * 16
def main():
parser = argparse.ArgumentParser(description="Predict a handwritten digit.")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--image", help="path to a PNG image of a digit")
group.add_argument("--sample", type=int,
help="index of a test-set image to predict")
args = parser.parse_args()
model = joblib.load("digit_model.joblib")
if args.image:
pixels = pixels_from_file(args.image)
source = args.image
else:
digits = datasets.load_digits()
_, X_test, _, _ = train_test_split(
digits.data, digits.target, test_size=0.25,
random_state=42, stratify=digits.target,
)
pixels = X_test[args.sample].reshape(8, 8)
source = f"test-set image {args.sample}"
print(f"Input: {source}\n")
print(ascii_art(pixels))
features = pixels.reshape(1, -1)
prediction = model.predict(features)[0]
print(f"\nPredicted digit: {prediction}")
if hasattr(model, "predict_proba"):
probs = model.predict_proba(features)[0]
top3 = sorted(enumerate(probs), key=lambda p: -p[1])[:3]
print("Top guesses:", ", ".join(
f"{digit} ({prob:.1%})" for digit, prob in top3))
if __name__ == "__main__":
main()
Real output from our run, all three correct:
Input: sample_0_true_1.png
=%#-
#@#.
.%@+
.%@*
:%@*
.%@+
#%+
-##
Predicted digit: 1
Top guesses: 1 (97.6%), 8 (2.4%), 5 (0.0%)
Input: sample_1_true_0.png
:*-
+#*-
.*..#
:- +:
-- :-
:+ :-
*+:*:
.+#+
Predicted digit: 0
Top guesses: 0 (97.7%), 9 (2.2%), 2 (0.2%)
Input: sample_2_true_9.png
=%*.
.#=+*
.#:*%:
+###=
:.+*
:: =*
#+.*
=#%#.
Predicted digit: 9
Top guesses: 9 (97.4%), 3 (2.5%), 0 (0.1%)
What are the limits of this approach?
Our classifier is honest about its limits. It reads small, clean, centred digits. Feed it a photo of a street sign and it will guess badly, because it has never seen that kind of image. Real photo classification needs convolutional neural networks, which learn edges and shapes in layers. That is a bigger build, and it starts exactly where this tutorial ends: data in, numbers out, honest scores.
What should you try next?
Draw a digit in any paint app on a white background, save it as a PNG, and run predict.py on it. Our scaling expects dark digits on a light background, so keep the digit bold and centred.
Swap the built-in digits for the full MNIST set through fetch_openml and see how the same three classifiers behave on 28 by 28 images with 70,000 samples.
Wrap predict.py in a tiny FastAPI endpoint that accepts an uploaded image and returns the prediction as JSON. That turns this tutorial into a small service, and the model file already has everything the endpoint needs.
Originally published on LogicStack. Run the code examples right in your browser with our free interactive Python and SQL playgrounds — no setup, no sign-up.
Top comments (0)