This document defines the inference pipeline that is implemented today in the browser runtime.
Its job is to turn a freehand drawing into:
- a normalized
28x28input matrix, - a TensorFlow.js model prediction,
- a ranked confidence output for digits
0-9.
flowchart LR
A["User draws on 280x280 canvas"] --> B["Export ImageData"]
B --> C["toGrayscale + invert"]
C --> D["light dilation"]
D --> E["bounding box with threshold"]
E --> F["center + resize to 28x28"]
F --> G["PixelGrid preview"]
F --> H["Build tensor [1,28,28,1]"]
H --> I["TF.js model.predict"]
I --> J["Probability vector"]
J --> K["Top class + confidence bars"]
- Drawing canvas size:
280x280 - Brush color: black stroke on a white/transparent background
- Capture format: browser
ImageData
- Type:
number[28][28] - Range:
[0, 1] - Convention:
0is background-like,1is strong ink.
The implemented preprocessing flow in src/canvas/preprocess.ts is:
- Read RGBA pixels from the canvas.
- Composite transparent pixels onto white.
- Convert to grayscale intensity.
- Invert intensity so darker stroke becomes larger signal.
- Apply light dilation to reconnect thin or slightly broken strokes.
- Detect the ink bounding box using an explicit threshold.
- Place the detected content in a square workspace.
- Resize to an inner
20x20region. - Center that region inside the final
28x28matrix. - Clamp values to
[0,1].
- Output size:
28x28 - Inner content size:
20x20 - Ink threshold:
0.2
If no ink is detected, preprocessing returns an all-zero 28x28 matrix.
The current app does not yet expose a dedicated "no input" UX state; it will still allow prediction against that zero matrix.
const imageData = drawCanvas.exportImageData();
const matrix28 = preprocessTo28x28(imageData);
pixelGrid.update(matrix28);
const result = await predictDigit(model, matrix28);The current prediction path flattens the 28x28 matrix into a Float32Array and builds:
- shape:
[1, 28, 28, 1] - dtype:
float32
Reference pattern:
const input = tf.tensor4d(flatInput, [1, 28, 28, 1], 'float32');The model is loaded from:
tf.loadLayersModel('/model/model.json')- The model promise is cached after the first successful load.
- A warmup pass with
tf.zeros([1, 28, 28, 1])runs after loading. - Prediction is executed on the loaded
LayersModel.
The exported model currently ends with softmax, so output is expected to already be probabilities.
Even so, the prediction code checks whether the output looks like a probability vector and applies tf.softmax() only if necessary.
Current output shape for the UI:
type PredictionResult = {
confidences: number[];
topClass: number;
topConfidence: number;
ranking: Array<{ digit: number; confidence: number }>;
};Rules:
confidences.lengthmust be10topClassis the highest-confidence digitrankingis sorted descending by confidence
Current runtime safeguards:
- Temporary tensors are wrapped in
tf.tidy()during prediction. - Warmup tensors are disposed immediately after model load.
- The final output tensor is disposed after its values are copied to JS.
- The model itself is loaded once and reused.
The browser pipeline must stay aligned with the artifact generation flow from:
training/train-cnn.jstraining/python/train_cnn.pytraining/export-python-model.js
The most important compatibility points are:
- intensity convention,
- spatial shape
28x28x1, - centered digit placement,
- resilience to stroke thickness and small gaps.
The current browser pipeline does not yet include:
- intermediate activation extraction for the UI,
- layer-by-layer visualization payloads,
- a dedicated no-input state,
- automated browser-side validation of model asset integrity.
Those belong to future phases, not the current runtime contract.
The current inference pipeline is behaving correctly when:
- repeated identical input produces the same
28x28matrix, - the model loads from
/model/model.json, - a valid prediction returns
10class confidences, - the top-class UI updates after each predict action,
- repeated draw/predict/clear cycles do not visibly degrade the app.