Building A Tiny Digit Morphology Classifier With Deep Learning
Written by
Nova Neural
The weird problem I tried to solve
I wanted to understand whether a deep learning model could learn morphology—specifically, whether it could recognize digit shapes under binary morphological closing (a dilation followed by erosion) without ever being explicitly told what “closing” is.
Why I picked this: I’ve seen plenty of “deep learning learns features” claims, but I wanted something more concrete than generic feature talk. So I built a tiny experiment where the training labels depend on a morphological operation applied to the input.
In short: I generated binary digit images, applied binary closing with a chosen structuring element, and asked a neural network to classify which closing strength was used.
That forced the model to key into shape changes caused by morphology rather than raw pixel patterns.
What binary morphological closing means (practically)
Binary morphological closing is:
- Dilation: expand foreground pixels (usually “1” pixels) outward.
- Erosion: shrink them back inward.
- The combination “fills small holes” and “bridges small gaps” in shapes.
If I have a digit “0” with a tiny break in the loop, closing tends to reconnect that break. The size of the structuring element controls how aggressive the filling/bridging is.
Dataset generation: digits with controlled closing strength
I used MNIST as a base source of digits, then binarized them and applied closing with different structuring element sizes.
Key idea
- Input: a binary digit image after closing
- Label: which closing strength (radius) was applied
This makes the classification task deterministic and tests whether the network can learn morphology-driven cues.
Step-by-step code
This script:
- loads MNIST
- binarizes images
- applies closing using a circular structuring element with radius
r - builds a dataset with labels
r
import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from torchvision import datasets from torch.utils.data import DataLoader, TensorDataset from scipy.ndimage import binary_closing, generate_binary_structure # Reproducibility torch.manual_seed(0) np.random.seed(0) device = "cuda" if torch.cuda.is_available() else "cpu" # --- Morphology: binary closing with radius control --- def circular_structuring_element(radius: int): # generate_binary_structure('ndim', connectivity) gives a square-like kernel. # I build a circular kernel manually for more "digit shape" realism. # kernel size is (2r+1) x (2r+1) size = 2 * radius + 1 yy, xx = np.mgrid[:size, :size] cy = cx = radius dist2 = (yy - cy) ** 2 + (xx - cx) ** 2 kernel = dist2 <= radius ** 2 return kernel def apply_closing(binary_img: np.ndarray, radius: int): kernel = circular_structuring_element(radius) closed = binary_closing(binary_img, structure=kernel) return closed.astype(np.float32) # --- Load MNIST --- mnist = datasets.MNIST(root="./data", train=True, download=True) X = mnist.data.numpy().astype(np.float32) / 255.0 # shape: (N, 28, 28) y_digit = mnist.targets.numpy() # not used as labels; just for diversity # --- Binarize digits --- # Adaptive-looking thresholding usually works better than a fixed threshold, # but for a deterministic benchmark I used a fixed threshold. threshold = 0.5 X_bin = (X > threshold).astype(np.float32) # --- Choose closing radii and build classification dataset --- # radius=0 means "no-op": I treat it as identity by skipping closing. radii = [0, 1, 2, 3] # niche + controllable set label_for_radius = {r: i for i, r in enumerate(radii)} num_classes = len(radii) # Build closed images and labels images = [] labels = [] # I limit samples so the blog post run is quick. max_samples = 20000 idxs = np.random.choice(len(X_bin), size=min(max_samples, len(X_bin)), replace=False) for idx in idxs: base = X_bin[idx] for r in radii: if r == 0: closed = base else: closed = apply_closing(base.astype(bool), r) images.append(closed[None, ...]) # add channel dim labels.append(label_for_radius[r]) images = np.stack(images, axis=0) # (N*radii, 1, 28, 28) labels = np.array(labels, dtype=np.int64) # Shuffle perm = np.random.permutation(len(images)) images = images[perm] labels = labels[perm] # Train/val split split = int(0.9 * len(images)) X_train, X_val = images[:split], images[split:] y_train, y_val = labels[:split], labels[split:] # Convert to torch tensors train_ds = TensorDataset(torch.from_numpy(X_train), torch.from_numpy(y_train)) val_ds = TensorDataset(torch.from_numpy(X_val), torch.from_numpy(y_val)) train_loader = DataLoader(train_ds, batch_size=256, shuffle=True, num_workers=0) val_loader = DataLoader(val_ds, batch_size=512, shuffle=False, num_workers=0)
Model: a tiny CNN that must detect morphology
I used a small convolutional network. The CNN learns spatial patterns (edges, curves, holes) that change under closing.
class MorphologyCNN(nn.Module): def __init__(self, num_classes: int): super().__init__() self.conv1 = nn.Conv2d(1, 16, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1) self.conv3 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.fc1 = nn.Linear(64 * 3 * 3, 128) self.fc2 = nn.Linear(128, num_classes) def forward(self, x): # x: (B,1,28,28) x = F.relu(self.conv1(x)) # (B,16,28,28) x = F.max_pool2d(x, 2) # (B,16,14,14) x = F.relu(self.conv2(x)) # (B,32,14,14) x = F.max_pool2d(x, 2) # (B,32,7,7) x = F.relu(self.conv3(x)) # (B,64,7,7) x = F.max_pool2d(x, 2) # (B,64,3,3) x = x.view(x.size(0), -1) # (B,64*3*3) x = F.relu(self.fc1(x)) x = self.fc2(x) # logits return x model = MorphologyCNN(num_classes=num_classes).to(device)
Training loop: cross-entropy on closing strength
I trained for a handful of epochs. The labels correspond to the closing radius, so the accuracy tells me whether morphology-specific artifacts are learnable.
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() def evaluate(loader): model.eval() total = 0 correct = 0 val_loss = 0.0 with torch.no_grad(): for xb, yb in loader: xb = xb.to(device) yb = yb.to(device) logits = model(xb) loss = criterion(logits, yb) val_loss += loss.item() * xb.size(0) pred = logits.argmax(dim=1) correct += (pred == yb).sum().item() total += xb.size(0) return val_loss / total, correct / total epochs = 8 for epoch in range(1, epochs + 1): model.train() train_loss = 0.0 train_correct = 0 train_total = 0 for xb, yb in train_loader: xb = xb.to(device) yb = yb.to(device) optimizer.zero_grad() logits = model(xb) loss = criterion(logits, yb) loss.backward() optimizer.step() train_loss += loss.item() * xb.size(0) pred = logits.argmax(dim=1) train_correct += (pred == yb).sum().item() train_total += xb.size(0) train_loss /= train_total train_acc = train_correct / train_total val_loss, val_acc = evaluate(val_loader) print(f"Epoch {epoch:02d} | train loss {train_loss:.4f} acc {train_acc:.3f} | val loss {val_loss:.4f} acc {val_acc:.3f}")
What I observed (and why it’s interesting)
When I ran this, the model consistently learned to distinguish:
- radius=0 (no closing) vs any closing
- small vs large closing more reliably after a couple epochs
The clearest signal came from hole filling and gap bridging:
- closing with larger radii tends to thicken strokes and reconnect broken curves
- that changes the distribution of local edge orientations and “enclosed” regions in the binary mask
- CNN feature maps quickly become sensitive to those changes
Importantly, the digits themselves (0–9) weren’t the training labels. So the model wasn’t just learning “MNIST digit recognition.” It learned morphology strength as a proxy for how the shape was transformed.
That’s the fun part: deep learning isn’t only learning raw appearance—it can learn to infer transform parameters when the transformation leaves systematic traces.
Sanity-check: visualize one sample group
To see the transformation effect directly, I plotted one base digit through radii [0,1,2,3] and confirmed the expected closing behavior.
import matplotlib.pyplot as plt def show_closing_series(): # pick one random digit sample idx = np.random.randint(0, len(X_bin)) base = X_bin[idx] fig, axes = plt.subplots(1, len(radii), figsize=(12, 3)) for j, r in enumerate(radii): if r == 0: closed = base else: closed = apply_closing(base.astype(bool), r) axes[j].imshow(closed, cmap="gray") axes[j].set_title(f"radius={r}") axes[j].axis("off") plt.tight_layout() plt.show() show_closing_series()
Takeaway
I built a tiny deep learning classifier whose job wasn’t to recognize digits—it was to infer the strength of binary morphological closing applied to them. The experiment showed that a small CNN can reliably learn morphology-driven shape changes (hole filling, gap bridging, stroke thickening) even when labels come from a deterministic image transformation rather than semantic classes.