AI & Computer Vision, lesson 22 of 52
Model to the Browser: TensorFlow.js
Introduction
Your models so far live in Python on your laptop. Today you'll put one inside a web page, so anyone can open it on their own phone or laptop, with nothing to install. You'll teach a classifier live by showing it examples through the webcam, then load the model you trained yourself, and the video never leaves the device.
By the end of this lesson you'll be able to:
- Explain what "running a model in the browser" means and why it is useful
- Serve a web page locally and give it camera permission
- Teach a classifier in the browser using MobileNet features and nearest neighbours
- Export a Keras model to TensorFlow.js format and load it in a page
- Name the limits: model size, speed, camera security rules, browser support
- Explain the privacy advantage: the video never leaves the device
Working principle
Why the browser?
| Benefit | Why it matters |
|---|---|
| No server to run | Host the page on any site. No Python needed by the user. |
| Privacy | Frames never leave the device. This helps with lesson 14. |
| Instant | No upload and download delay |
| Reach | Works on phones and school laptops without installing anything |
| Cost | |
|---|---|
| Model size | The user downloads it. Keep it small (MobileNet-sized, a few MB). |
| Speed | Depends on the user's device. TensorFlow.js uses the GPU through WebGL when it can. |
| Security rules | Cameras only work on https:// pages or localhost |
Two ways in
| Route A: teach it in the browser | Route B: convert your Keras model | |
|---|---|---|
| How | MobileNet turns each frame into numbers (an "embedding"). A k-nearest-neighbours classifier remembers examples you add with buttons and answers by finding the closest ones. | You train in Python (lesson 18) and convert the files for the browser. |
| Needs Python? | No | Yes |
| Training time | Instant: it just stores examples | As long as lesson 18 |
| Reliability | High | Fragile: depends on versions (see the warning below) |
| Best for | Quick demos, a live "teach it" moment | A model you trained on your football crops |
We do route A first. If time allows, route B.
How nearest neighbours learns (route A)
Imagine MobileNet gives every picture a "position" on a big map where similar pictures sit close together. You press Add ball with a ball on camera: you drop a pin marked "ball" at that spot. You press Add no ball for other scenes: pins marked "no ball". To judge a new frame, the classifier finds the nearest pins and votes. It is a very simple form of transfer learning (lesson 21): the heavy lifting is done by the pretrained network.
The camera security rule
Browsers only allow getUserMedia (the camera) on secure pages: https://... or http://localhost. If you open the file straight from your folder (file://) or from an http://192.168... address, the camera may not work. Serve it with:
python -m http.server 8000and open http://localhost:8000.
What you'll need
- Chrome or Edge, a webcam and VS Code
- Python, to run
python -m http.server - Your saved model from lesson 18 or 21 (for route B)
Build it
The page: index.html
Save as index.html in a new folder. Check the three script lines at the top: they load TensorFlow.js, MobileNet and the KNN classifier from a CDN. The version numbers were current when this was written; check them at npmjs.com if a script fails to load.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ball detector in the browser</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 1rem auto; padding: 0 1rem; }
video { width: 100%; border-radius: 8px; background: #111; transform: scaleX(-1); }
.bar { height: 26px; background: #ddd; border-radius: 13px; overflow: hidden; margin: .5rem 0; }
.fill { height: 100%; width: 0; background: #0084ff; transition: width .15s; }
button { font-size: 1rem; padding: .5rem .9rem; margin: .2rem .2rem .2rem 0; }
#verdict { font-size: 1.6rem; font-weight: 600; margin: .3rem 0; }
#status { color: #555; min-height: 1.4em; }
</style>
</head>
<body>
<h1>Ball detector</h1>
<p id="status">Loading...</p>
<video id="cam" autoplay playsinline muted></video>
<div id="verdict">-</div>
<div class="bar"><div class="fill" id="fill"></div></div>
<div id="knnButtons" hidden>
<button id="addBall">Add "ball" example</button>
<button id="addNone">Add "no ball" example</button>
<button id="clear">Clear</button>
<p id="counts"></p>
</div>
<!-- Version numbers were current when written: check them on npmjs.com before class. -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.22.0/dist/tf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/mobilenet@2.1.1"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/knn-classifier@1.2.4"></script>
<script>
// "knn" : teach it in the browser with MobileNet + a nearest-neighbour classifier (no Python, no converting)
// "keras" : run the model you trained in Python, after converting it (see the lesson)
const MODE = "knn";
const KERAS_MODEL_URL = "model/model.json";
const KERAS_INPUT_SIZE = 64; // the lesson 18 model was trained on 64 x 64 crops
const video = document.getElementById("cam");
const statusEl = document.getElementById("status");
const verdictEl = document.getElementById("verdict");
const fillEl = document.getElementById("fill");
const countsEl = document.getElementById("counts");
let net = null, classifier = null, kerasModel = null;
async function startCamera() {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" }, audio: false });
video.srcObject = stream;
await new Promise(resolve => { video.onloadeddata = resolve; });
}
function show(isBall, probability) {
verdictEl.textContent = (isBall ? "BALL " : "no ball ") + Math.round(probability * 100) + "%";
fillEl.style.width = Math.round(probability * 100) + "%";
}
// ---------- Mode 1: teach it in the browser ----------
function setupKnn() {
document.getElementById("knnButtons").hidden = false;
const add = label => {
const activation = net.infer(video, true); // MobileNet's "embedding" of the current frame
classifier.addExample(activation, label);
activation.dispose(); // free the memory
updateCounts();
};
document.getElementById("addBall").onclick = () => add("ball");
document.getElementById("addNone").onclick = () => add("none");
document.getElementById("clear").onclick = () => { classifier.clearAllClasses(); updateCounts(); };
}
function updateCounts() {
const c = classifier.getClassExampleCount();
countsEl.textContent = "Examples - ball: " + (c.ball || 0) + ", no ball: " + (c.none || 0);
}
async function predictKnn() {
if (classifier.getNumClasses() < 2) { verdictEl.textContent = "Add examples of both classes"; return; }
const activation = net.infer(video, true);
const result = await classifier.predictClass(activation);
activation.dispose();
const p = result.confidences.ball || 0;
show(result.label === "ball", result.label === "ball" ? p : 1 - p);
}
// ---------- Mode 2: your converted Keras model ----------
async function predictKeras() {
const p = tf.tidy(() => {
const img = tf.browser.fromPixels(video) // RGB, 0..255
.resizeBilinear([KERAS_INPUT_SIZE, KERAS_INPUT_SIZE])
.toFloat()
.div(255) // the exported model has no Rescaling layer, so we scale here
.expandDims(0);
return kerasModel.predict(img);
});
const value = (await p.data())[0]; // probability of "ball"
p.dispose();
show(value >= 0.5, value >= 0.5 ? value : 1 - value);
}
async function loop() {
try {
if (MODE === "knn") await predictKnn(); else await predictKeras();
} catch (err) {
statusEl.textContent = "Error: " + err.message;
}
requestAnimationFrame(loop);
}
async function main() {
try {
await tf.ready();
statusEl.textContent = "Starting the camera (allow it when asked)...";
await startCamera();
statusEl.textContent = "Loading the model...";
if (MODE === "knn") {
net = await mobilenet.load({ version: 2, alpha: 1.0 });
classifier = knnClassifier.create();
setupKnn();
statusEl.textContent = "Hold a ball in view and press 'Add ball'. Then show other things and press 'Add no ball'. Add 20 or more of each.";
} else {
kerasModel = await tf.loadLayersModel(KERAS_MODEL_URL);
statusEl.textContent = "Model loaded. Backend: " + tf.getBackend();
}
loop();
} catch (err) {
statusEl.textContent = "Could not start: " + err.message;
}
}
main();
</script>
</body>
</html>Run it:
- Open a terminal in the folder and run
python -m http.server 8000. - Open
http://localhost:8000in Chrome or Edge. - Allow the camera.
- Hold the ball in view and press Add ball about 20 times, moving it a little. Then show the scene without a ball (your face, a desk, the wall) and press Add no ball about 20 times.
- Watch the bar and the verdict.
Questions: what happens if you teach it only 3 examples of each? If the background changes? If you teach "ball" with a red ball and then show a blue one? What is the difference between this and the model you trained in lesson 18?
Route B: use your lesson 18 model
Warning. The TensorFlow.js converter was written for older Keras. It does not accept the new .keras file that Keras 3 saves (see the issue on the TensorFlow.js GitHub, number 8229), and the preprocessing layers can cause errors. The recipe below works around this by saving an .h5 file and removing the Rescaling layer (the browser divides by 255 instead). If it fails, use route A, and see the common errors below.
- Create a separate Python environment (the converter can clash with other packages) and install:
pip install tensorflow tensorflowjs. - Put
ball_classifier.keras(from lesson 18) next to this script and run it:
import os
from tensorflow import keras
model = keras.models.load_model("ball_classifier.keras")
# The browser converter does not know every preprocessing layer, so rebuild the model without Rescaling.
# We divide by 255 in JavaScript instead. The other layers (and their trained weights) are shared.
keep = [layer for layer in model.layers if layer.__class__.__name__ != "Rescaling"]
export = keras.Sequential([keras.Input(shape=(64, 64, 3))] + keep)
export.save("ball_classifier.h5") # the converter reads .h5, not .keras
print("saved ball_classifier.h5")
export.summary()
os.system("tensorflowjs_converter --input_format=keras ball_classifier.h5 model/")- A folder
model/appears, containingmodel.jsonand some.binfiles. - In
index.html, changeconst MODE = "knn";toconst MODE = "keras";. - Restart the local server and reload the page.
If the converter complains, set the environment variable TF_USE_LEGACY_KERAS=1 (and pip install tf_keras) before running the script, or run the conversion in a hosted notebook (Google Colab) where you can pin older versions.
Questions: the model gives a probability for "ball" for the whole frame. Why will it work best when the ball is large and close? What would you need for it to say where the ball is?
If it goes wrong
| Error | Cause | Fix |
|---|---|---|
| Camera never starts, "Cannot read properties of undefined (reading 'getUserMedia')" | Not a secure page | Open via http://localhost:8000, not a file path or a LAN address |
| "Permission denied" | Camera blocked | Click the camera icon in the address bar and allow it |
| Page stays on "Loading..." | The scripts did not load | Open the developer console (F12). Check the network tab. Use local copies of the scripts. |
mobilenet is not defined | Script tag failed or the order is wrong | Keep the TensorFlow.js script first |
| Everything is "ball" | Too few "no ball" examples, or only a similar scene | Add varied "no ball" examples |
| Very slow | CPU backend | Check tf.getBackend() in the console. webgl is faster. Close other tabs. |
Unknown layer: Rescaling (route B) | The converter does not know that layer | Use the export script that removes it |
The save_format argument is deprecated in Keras 3 (route B) | The converter is calling an old Keras API | Save as .h5, or set TF_USE_LEGACY_KERAS=1, or convert in a notebook with older versions |
| Model JSON loads but predictions are all 0.5 | Missing /255 or wrong input size | Match KERAS_INPUT_SIZE to the training size and keep .div(255) |
Try it
- Add a button to save the examples to a file and load them back
- Make the page show a coloured border (green for ball, grey for none)
- Add a third class (for example "goal")
Check what you learnt
Where does the video go when the model runs in the browser?
The camera didn't work when you opened the HTML file directly. Why?
In route A, what does MobileNet do?
Why must browser models be small?
What does this ball/no-ball model NOT tell you?
Conclusion
TensorFlow.js runs models in the web page itself: no install, no server, and the video stays on the device. You can teach a classifier live with MobileNet embeddings and nearest neighbours, or convert your own Keras model. Keep models small, and serve pages over localhost or https.
What next?
Next: Detection vs classification & how YOLO works. Unit 5 starts: finding where things are, with object detection.

