AI & Computer Vision, lesson 22 of 52

Model to the Browser: TensorFlow.js

intermediate24 min read

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?

BenefitWhy it matters
No server to runHost the page on any site. No Python needed by the user.
PrivacyFrames never leave the device. This helps with lesson 14.
InstantNo upload and download delay
ReachWorks on phones and school laptops without installing anything
Cost
Model sizeThe user downloads it. Keep it small (MobileNet-sized, a few MB).
SpeedDepends on the user's device. TensorFlow.js uses the GPU through WebGL when it can.
Security rulesCameras only work on https:// pages or localhost

Two ways in

Route A: teach it in the browserRoute B: convert your Keras model
HowMobileNet 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?NoYes
Training timeInstant: it just stores examplesAs long as lesson 18
ReliabilityHighFragile: depends on versions (see the warning below)
Best forQuick demos, a live "teach it" momentA 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 8000

and 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:

  1. Open a terminal in the folder and run python -m http.server 8000.
  2. Open http://localhost:8000 in Chrome or Edge.
  3. Allow the camera.
  4. 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.
  5. 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.

  1. Create a separate Python environment (the converter can clash with other packages) and install: pip install tensorflow tensorflowjs.
  2. 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/")
  1. A folder model/ appears, containing model.json and some .bin files.
  2. In index.html, change const MODE = "knn"; to const MODE = "keras";.
  3. 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

ErrorCauseFix
Camera never starts, "Cannot read properties of undefined (reading 'getUserMedia')"Not a secure pageOpen via http://localhost:8000, not a file path or a LAN address
"Permission denied"Camera blockedClick the camera icon in the address bar and allow it
Page stays on "Loading..."The scripts did not loadOpen the developer console (F12). Check the network tab. Use local copies of the scripts.
mobilenet is not definedScript tag failed or the order is wrongKeep the TensorFlow.js script first
Everything is "ball"Too few "no ball" examples, or only a similar sceneAdd varied "no ball" examples
Very slowCPU backendCheck tf.getBackend() in the console. webgl is faster. Close other tabs.
Unknown layer: Rescaling (route B)The converter does not know that layerUse the export script that removes it
The save_format argument is deprecated in Keras 3 (route B)The converter is calling an old Keras APISave 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.5Missing /255 or wrong input sizeMatch 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

  1. Where does the video go when the model runs in the browser?

  2. The camera didn't work when you opened the HTML file directly. Why?

  3. In route A, what does MobileNet do?

  4. Why must browser models be small?

  5. 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.