|
1 | 1 | --- |
2 | 2 | # User change |
3 | | -title: "Summary" |
| 3 | +title: "Inference and Model Evaluation" |
4 | 4 |
|
5 | 5 | weight: 6 |
6 | 6 |
|
7 | 7 | layout: "learningpathall" |
8 | 8 | --- |
9 | | -## Summary ## |
10 | 9 |
|
11 | | -In this Learning Path, you have created an Android application to capture and process camera images using OpenCV. |
| 10 | +## Objective ## |
| 11 | +In this section, we validate the digit recognizer by running inference on the validation dataset using both the PyTorch checkpoint and the exported ONNX model. We verify that PyTorch and ONNX Runtime produce consistent results, analyze class-level behavior using a confusion matrix, and generate visual diagnostics for debugging and documentation. This step acts as a final verification checkpoint before integrating the model into the full OpenCV-based Sudoku processing pipeline. |
12 | 12 |
|
13 | | -You performed the following steps: |
| 13 | +Before introducing geometric processing, grid detection, and perspective correction, it is important to confirm that the digit recognizer works reliably in isolation. By validating inference and analyzing errors at the digit level, we ensure that any future issues in the end-to-end system can be attributed to image processing or geometry rather than the classifier itself. |
14 | 14 |
|
15 | | -* Integrated the OpenCV library into your Android project. |
| 15 | +## Inference and Evaluation Script |
| 16 | +Create a new file named 04_Test.py and paste the script below into it. This script evaluates the digit recognizer in a way that closely mirrors deployment conditions. It compares PyTorch and ONNX Runtime inference, measures accuracy on the validation dataset, and generates visual diagnostics that reveal both strengths and remaining failure modes of the model. |
16 | 17 |
|
17 | | -* Enabled camera permissions to ensure the application can access the device's camera. |
| 18 | +```python |
| 19 | +import os, numpy as np, torch |
| 20 | +from torchvision import datasets, transforms |
| 21 | +from torch.utils.data import DataLoader |
| 22 | +from tqdm import tqdm |
| 23 | +import matplotlib.pyplot as plt |
18 | 24 |
|
19 | | -* Set up `JavaCameraView` to capture real-time frames from the camera. You declared and initialized Mat objects to store and process camera frames. |
| 25 | +from digitnet_model import DigitNet |
20 | 26 |
|
21 | | -* Implemented adaptive thresholding using OpenCV's `Imgproc.adaptiveThreshold` to process the camera frames when a checkbox is checked. |
| 27 | +DATA_DIR = "data" |
| 28 | +ARTI_DIR = "artifacts" |
| 29 | +os.makedirs(ARTI_DIR, exist_ok=True) |
22 | 30 |
|
23 | | -By following these steps, you have successfully created an Android application that captures real-time images from the camera. |
| 31 | +ONNX_PATH = os.path.join(ARTI_DIR, "sudoku_digitnet.onnx") # fp32 |
24 | 32 |
|
| 33 | +# Same normalization as training (and force grayscale → 1 channel) |
| 34 | +tfm_val = transforms.Compose([ |
| 35 | + transforms.Grayscale(num_output_channels=1), |
| 36 | + transforms.ToTensor(), |
| 37 | + transforms.Normalize((0.5,), (0.5,)) |
| 38 | +]) |
| 39 | +val_ds = datasets.ImageFolder(os.path.join(DATA_DIR, "val"), transform=tfm_val) |
| 40 | +val_loader = DataLoader(val_ds, batch_size=512, shuffle=False, num_workers=0) |
| 41 | + |
| 42 | +DIGIT_NAMES = [str(i) for i in range(10)] # 0 = blank, 1..9 = digits |
| 43 | + |
| 44 | + |
| 45 | +def evaluate_pytorch(model, loader): |
| 46 | + model.eval() |
| 47 | + correct = total = 0 |
| 48 | + with torch.no_grad(): |
| 49 | + for x, y in loader: |
| 50 | + pred = model(x).argmax(1) |
| 51 | + correct += (pred == y).sum().item() |
| 52 | + total += y.numel() |
| 53 | + return correct / total if total else 0.0 |
| 54 | + |
| 55 | + |
| 56 | +def confusion_matrix_onnx(onnx_model_path, loader): |
| 57 | + import onnxruntime as ort |
| 58 | + sess = ort.InferenceSession(onnx_model_path, providers=["CPUExecutionProvider"]) |
| 59 | + mat = np.zeros((10, 10), dtype=np.int64) |
| 60 | + total = 0 |
| 61 | + correct = 0 |
| 62 | + for x, y in tqdm(loader, desc="ONNX eval"): |
| 63 | + # x: torch tensor [N,1,28,28] normalized to [-1,1] |
| 64 | + inp = x.numpy().astype(np.float32) |
| 65 | + logits = sess.run(["logits"], {"input": inp})[0] # [N,10] |
| 66 | + pred = logits.argmax(axis=1) |
| 67 | + y_np = y.numpy() |
| 68 | + for t, p in zip(y_np, pred): |
| 69 | + mat[t, p] += 1 |
| 70 | + correct += (pred == y_np).sum() |
| 71 | + total += y_np.size |
| 72 | + acc = float(correct) / float(total) if total else 0.0 |
| 73 | + return acc, mat |
| 74 | + |
| 75 | + |
| 76 | +def plot_confusion_matrix(cm, classes=DIGIT_NAMES, normalize=False, title="Confusion matrix", fname=None): |
| 77 | + """Plot confusion matrix. If normalize=True, rows sum to 1.""" |
| 78 | + cm_plot = cm.astype("float") |
| 79 | + if normalize: |
| 80 | + row_sums = cm_plot.sum(axis=1, keepdims=True) + 1e-12 |
| 81 | + cm_plot = cm_plot / row_sums |
| 82 | + |
| 83 | + plt.figure(figsize=(6, 5)) |
| 84 | + plt.imshow(cm_plot, interpolation="nearest") |
| 85 | + plt.title(title) |
| 86 | + plt.colorbar() |
| 87 | + tick_marks = np.arange(len(classes)) |
| 88 | + plt.xticks(tick_marks, classes) |
| 89 | + plt.yticks(tick_marks, classes) |
| 90 | + |
| 91 | + # Label each cell |
| 92 | + thresh = cm_plot.max() / 2.0 |
| 93 | + for i in range(cm_plot.shape[0]): |
| 94 | + for j in range(cm_plot.shape[1]): |
| 95 | + txt = f"{cm_plot[i, j]:.2f}" if normalize else f"{int(cm_plot[i, j])}" |
| 96 | + plt.text(j, i, txt, |
| 97 | + horizontalalignment="center", |
| 98 | + verticalalignment="center", |
| 99 | + fontsize=7, |
| 100 | + color="white" if cm_plot[i, j] > thresh else "black") |
| 101 | + |
| 102 | + plt.ylabel("True label") |
| 103 | + plt.xlabel("Predicted label") |
| 104 | + plt.tight_layout() |
| 105 | + if fname: |
| 106 | + plt.savefig(fname, dpi=150) |
| 107 | + print(f"Saved: {fname}") |
| 108 | + plt.show() |
| 109 | + |
| 110 | + |
| 111 | +def sample_predictions_onnx(onnx_path, dataset, k=24, seed=0): |
| 112 | + """Show a grid of sample predictions (mix of correct and misclassified).""" |
| 113 | + import onnxruntime as ort |
| 114 | + rng = np.random.default_rng(seed) |
| 115 | + sess = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"]) |
| 116 | + |
| 117 | + # Over-sample candidates then choose some wrong + some right |
| 118 | + idxs = rng.choice(len(dataset), size=min(k * 2, len(dataset)), replace=False) |
| 119 | + imgs, ys, preds = [], [], [] |
| 120 | + |
| 121 | + for i in idxs: |
| 122 | + x, y = dataset[i] # x: [1,28,28] after transforms; y: int |
| 123 | + x_np = x.unsqueeze(0).numpy().astype(np.float32) # [1,1,28,28] |
| 124 | + logits = sess.run(["logits"], {"input": x_np})[0] # [1,10] |
| 125 | + p = int(np.argmax(logits, axis=1)[0]) |
| 126 | + imgs.append(x.squeeze(0).numpy()) # [28,28] |
| 127 | + ys.append(int(y)) |
| 128 | + preds.append(p) |
| 129 | + |
| 130 | + mis_idx = [i for i, (t, p) in enumerate(zip(ys, preds)) if t != p] |
| 131 | + cor_idx = [i for i, (t, p) in enumerate(zip(ys, preds)) if t == p] |
| 132 | + picked = (mis_idx[:k // 2] + cor_idx[:k - len(mis_idx[:k // 2])])[:k] |
| 133 | + if not picked: # fallback |
| 134 | + picked = list(range(min(k, len(imgs)))) |
| 135 | + |
| 136 | + # Plot grid |
| 137 | + import math |
| 138 | + cols = 8 |
| 139 | + rows = math.ceil(len(picked) / cols) |
| 140 | + plt.figure(figsize=(cols * 1.6, rows * 1.8)) |
| 141 | + for j, idx in enumerate(picked): |
| 142 | + plt.subplot(rows, cols, j + 1) |
| 143 | + plt.imshow(imgs[idx], cmap="gray") |
| 144 | + t, p = ys[idx], preds[idx] |
| 145 | + title = f"T:{t} P:{p}" |
| 146 | + color = "green" if t == p else "red" |
| 147 | + plt.title(title, color=color, fontsize=9) |
| 148 | + plt.axis("off") |
| 149 | + plt.tight_layout() |
| 150 | + out = os.path.join(ARTI_DIR, "samples_grid.png") |
| 151 | + plt.savefig(out, dpi=150) |
| 152 | + print(f"Saved: {out}") |
| 153 | + plt.show() |
| 154 | + |
| 155 | +def main(): |
| 156 | + # Optional: evaluate the best PyTorch checkpoint for reference |
| 157 | + pt_ckpt = os.path.join(ARTI_DIR, "digitnet_best.pth") |
| 158 | + if os.path.exists(pt_ckpt): |
| 159 | + model = DigitNet() |
| 160 | + model.load_state_dict(torch.load(pt_ckpt, map_location="cpu")) |
| 161 | + pt_acc = evaluate_pytorch(model, val_loader) |
| 162 | + print(f"PyTorch val acc: {pt_acc:.4f}") |
| 163 | + else: |
| 164 | + print("No PyTorch checkpoint found; skipping PT eval.") |
| 165 | + |
| 166 | + # Evaluate ONNX fp32 |
| 167 | + if os.path.exists(ONNX_PATH): |
| 168 | + acc, cm = confusion_matrix_onnx(ONNX_PATH, val_loader) |
| 169 | + print(f"ONNX fp32 val acc: {acc:.4f}") |
| 170 | + print("Confusion matrix (rows=true, cols=pred):\n", cm) |
| 171 | + |
| 172 | + # Plots: counts + normalized |
| 173 | + plot_confusion_matrix(cm, normalize=False, |
| 174 | + title="ONNX fp32 – Confusion (counts)", |
| 175 | + fname=os.path.join(ARTI_DIR, "cm_fp32_counts.png")) |
| 176 | + plot_confusion_matrix(cm, normalize=True, |
| 177 | + title="ONNX fp32 – Confusion (row-normalized)", |
| 178 | + fname=os.path.join(ARTI_DIR, "cm_fp32_norm.png")) |
| 179 | + |
| 180 | + # Sample predictions grid |
| 181 | + try: |
| 182 | + sample_predictions_onnx(ONNX_PATH, val_ds, k=24) |
| 183 | + except Exception as e: |
| 184 | + print("Sample grid skipped:", e) |
| 185 | + else: |
| 186 | + print("Missing ONNX model:", ONNX_PATH) |
| 187 | + |
| 188 | +if __name__ == "__main__": |
| 189 | + main() |
| 190 | +``` |
| 191 | + |
| 192 | +The script first loads the validation dataset using the same preprocessing pipeline as training, including forced grayscale conversion to ensure a single input channel. It then optionally evaluates the best PyTorch checkpoint (digitnet_best.pth) to establish a reference accuracy. |
| 193 | + |
| 194 | +Next, the exported ONNX model (sudoku_digitnet.onnx) is loaded using ONNX Runtime and evaluated in batches. Because the model was exported with a dynamic batch dimension, inference can be performed efficiently on larger batches, which is representative of how the model will be used later in the pipeline. |
| 195 | + |
| 196 | +The script expects two things from the earlier steps: |
| 197 | +1. A validation dataset stored under data/val/0..9/… |
| 198 | +2. A trained model exported in previous step and stored under artifacts/ |
| 199 | + * artifacts/digitnet_best.pth (optional, PyTorch weights) |
| 200 | + * artifacts/sudoku_digitnet.onnx (required, ONNX model) |
| 201 | + |
| 202 | +When you run the script, it first loads the validation dataset using the same preprocessing as training, including forcing grayscale so the input has a single channel. It then optionally evaluates the PyTorch checkpoint to provide a reference accuracy. After that, it runs batched inference with ONNX Runtime, computes an overall accuracy, and builds a confusion matrix (true class vs predicted class) that reveals which digits are being confused. |
| 203 | + |
| 204 | +In addition to printing accuracy metrics, the script generates two types of diagnostic outputs: |
| 205 | +1. Confusion matrix visualizations, saved as: |
| 206 | + * artifacts/cm_fp32_counts.png (raw counts) |
| 207 | + * artifacts/cm_fp32_norm.png (row-normalized) |
| 208 | +2. A grid of example predictions, saved as: |
| 209 | + *artifacts/samples_grid.png |
| 210 | + |
| 211 | +These artifacts provide both quantitative and qualitative insight into model performance. |
| 212 | + |
| 213 | +In the sample grid, each tile shows one crop together with its True label (T:) and Predicted label (P:), with correct predictions highlighted in green and mistakes highlighted in red. This makes it easy to quickly verify that the classifier behaves sensibly and to spot remaining failure modes. |
| 214 | + |
| 215 | +## Running the script |
| 216 | +Run the evaluation script from the project root: |
| 217 | + |
| 218 | +```console |
| 219 | +python 04_Test.py |
| 220 | +``` |
| 221 | + |
| 222 | +In the example below, the PyTorch and ONNX accuracies match exactly, confirming that the export process preserved model behavior. |
| 223 | + |
| 224 | +```console |
| 225 | +python3 04_Test.py |
| 226 | +PyTorch val acc: 0.9928 |
| 227 | +ONNX eval: 100%|███████████████████████████████████████████████████████████| 32/32 [00:01<00:00, 21.06it/s] |
| 228 | +ONNX fp32 val acc: 0.9928 |
| 229 | +Confusion matrix (rows=true, cols=pred): |
| 230 | + [[12623 7 0 0 0 0 0 0 0 0] |
| 231 | + [ 0 420 0 0 0 0 0 0 0 0] |
| 232 | + [ 0 0 331 0 4 0 1 0 0 0] |
| 233 | + [ 0 1 0 332 0 1 0 0 0 0] |
| 234 | + [ 0 0 0 0 460 0 0 0 0 0] |
| 235 | + [ 0 1 0 1 0 486 2 0 0 0] |
| 236 | + [ 1 0 0 0 0 19 387 0 1 2] |
| 237 | + [ 0 1 0 0 0 0 0 375 0 0] |
| 238 | + [ 0 0 0 0 0 6 27 0 297 10] |
| 239 | + [ 0 1 0 0 0 14 10 0 7 372]] |
| 240 | +Saved: artifacts/cm_fp32_counts.png |
| 241 | +``` |
| 242 | + |
| 243 | + |
| 244 | +The confusion matrix provides more insight than a single accuracy number. Each row corresponds to the true class, and each column corresponds to the predicted class. A strong diagonal indicates correct classification. In this output, blank cells (class 0) are almost always recognized correctly, while the remaining errors occur primarily between visually similar printed digits such as 6, 8, and 9. |
| 245 | + |
| 246 | +This behavior is expected and indicates that the model has learned meaningful digit features. The remaining confusions are rare and can be addressed later through targeted augmentation or higher-resolution crops if needed. |
| 247 | + |
| 248 | +## Summary |
| 249 | +With inference validated and error modes understood, the digit recognizer is now ready to be embedded into the full Sudoku image-processing pipeline, where OpenCV will be used to detect the grid, rectify perspective, segment cells, and run batched ONNX inference to reconstruct and solve complete puzzles. |
0 commit comments