AI & Computer Vision, lesson 34 of 52

Flask API: The Python to JavaScript Bridge

advanced26 min read

Introduction

Your detector lives in Python; your dashboard lives in the browser. They can't call each other directly, so today you'll build the waiter between the kitchen and the dining room: a small Flask API. The page asks a question with a web address, Python answers with JSON, and both sides can be built and tested separately.

By the end of this lesson you'll be able to:

  • Explain why Python and the browser need an API between them
  • Write Flask routes that answer with JSON
  • Choose GET or POST correctly, and return meaningful status codes
  • Reuse the lesson 31 detector behind the API without changing it
  • Test an API with Flask's test client, including the error cases
  • Name three limits of the design (single user, polling, open CORS)

Working principle

Two programs, two languages

The CV code is in Python. The dashboard is in the browser (JavaScript). They cannot call each other directly, so they talk over HTTP, the same way a browser talks to any website. Python runs a small server; the browser sends requests; Python answers with JSON.

Browser (JavaScript)  --- request --->  Flask (Python)
                      <-- JSON reply --

Routes are questions the page can ask

RouteMethodMeaning
/api/statusGETSize of the video, the score, is it playing
/api/frame/<n>GETPlayers and ball in frame n
/api/nowGETThe current frame (moves the match on if it is playing)
/api/goalsGETThe goal log
/api/play, /api/pause, /api/resetPOSTChange the state
/api/seekPOSTJump forward, sending {"frame": 300}

GET reads and never changes anything. POST changes something. Keeping that split makes the API easy to reason about.

The same detector, behind a door

Notice what is not in app.py: no new goal logic. It reuses GoalDetector and EventBus from lesson 31 unchanged. The server just feeds frames in and listens for the goal event. That is the payoff of separating seeing from doing.

Status codes are part of the answer

CodeMeaningExample here
200OKNormal answers
400You asked wronglySeek without a frame number
404No such thingFrame 9999
409Conflict with the current stateSeeking backwards

A page that gets an error should tell the user in words. Our page shows "Cannot reach the Python server" if Flask is stopped.

Why can't we seek backwards?

The goal detector remembers things (how many frames inside, when the last goal was). Jumping back would leave that memory wrong and could count goals twice. So we refuse, with a 409 and a message, and offer reset. A cleaner design would replay from the start on a seek. This is a good extension.

Honest limits of this design

  • One match, one user. The state is a single Python dictionary, shared by everyone who opens the page.
  • Polling again. The page asks every 100 ms. lesson 31 said events are better. For live push, use Server-Sent Events or WebSockets (an extension). Polling is simple and fine for a classroom.
  • CORS. We allow any page to read the API (Access-Control-Allow-Origin: *) so you can experiment. That is fine on your own laptop. Do not leave it open on a public server.
  • Debug mode. debug=True reloads on every save, which is great for learning. Never use it on a server other people can reach, because the debugger lets a visitor run code.

Where this goes

In lesson 35 the same events will drive hardware. In lesson 36 the API gets stress tested. In lesson 40 to lesson 42 the dashboard shows live data and highlights.

What you'll need

  • Your cv_course environment with Flask: pip install flask
  • Your detector and a match clip
  • VS Code and a browser

Build it

Install Flask: pip install flask. Put these in one folder (with events.py and goal_detector.py from lesson 31 and detections.json from lesson 30):

project/
  app.py
  events.py
  goal_detector.py
  detections.json
  static/
    index.html

Cell 1: app.py (the server)

"""A tiny web API: Python holds the match, JavaScript asks for it.
Run:  python app.py      then open  http://localhost:5000
"""
import json
import time
 
from flask import Flask, jsonify, request, send_from_directory
 
from events import EventBus
from goal_detector import GoalDetector
 
 
def create_app(detections_path="detections.json"):
    app = Flask(__name__, static_folder="static")
    data = json.load(open(detections_path))
    frames = data["frames"]
    state = {"frame": 0, "playing": False, "started": None, "goals": [], "score": 0}
 
    bus = EventBus()
    detector = GoalDetector(data["goal_zone"], bus, name="north goal")
 
    def on_goal(zone, t):
        state["goals"].append({"zone": zone, "t": round(t, 2)})
        state["score"] += 1
 
    bus.on("goal", on_goal)
 
    def advance_to(index):
        """Feed every frame between the current one and `index` to the detector, in order."""
        index = max(0, min(index, len(frames) - 1))
        while state["frame"] < index:
            state["frame"] += 1
            fr = frames[state["frame"]]
            detector.update(fr["ball"], fr["t"])
        return index
 
    # Let a page on another address (for example a file or another port) read the answers.
    @app.after_request
    def allow_other_pages(resp):
        resp.headers["Access-Control-Allow-Origin"] = "*"
        return resp
 
    @app.get("/")
    def home():
        return send_from_directory("static", "index.html")
 
    @app.get("/api/status")
    def status():
        fr = frames[state["frame"]]
        return jsonify(frame=state["frame"], t=fr["t"], total_frames=len(frames), fps=data["fps"],
                       width=data["width"], height=data["height"],
                       score=state["score"], playing=state["playing"])
 
    @app.get("/api/frame/<int:n>")
    def frame(n):
        if n < 0 or n >= len(frames):
            return jsonify(error="no such frame", total_frames=len(frames)), 404
        return jsonify(frames[n])
 
    @app.get("/api/now")
    def now():
        """The current frame. Moves the match forward if it is playing, in real time."""
        if state["playing"]:
            elapsed = time.time() - state["started"]
            advance_to(int(elapsed * data["fps"]))
            if state["frame"] >= len(frames) - 1:
                state["playing"] = False
        return jsonify(frames[state["frame"]])
 
    @app.get("/api/goals")
    def goals():
        return jsonify(goals=state["goals"], score=state["score"])
 
    @app.post("/api/play")
    def play():
        state["playing"] = True
        state["started"] = time.time() - frames[state["frame"]]["t"]
        return jsonify(playing=True)
 
    @app.post("/api/pause")
    def pause():
        state["playing"] = False
        return jsonify(playing=False)
 
    @app.post("/api/seek")
    def seek():
        body = request.get_json(silent=True) or {}
        if "frame" not in body or not isinstance(body["frame"], int):
            return jsonify(error="send JSON like {\"frame\": 120}"), 400
        n = max(0, min(body["frame"], len(frames) - 1))
        if n < state["frame"]:
            return jsonify(error="can only move forward (the goal log would be wrong). Use /api/reset first."), 409
        advance_to(n)
        return jsonify(frame=state["frame"], score=state["score"])
 
    @app.post("/api/reset")
    def reset():
        state.update(frame=0, playing=False, started=None, goals=[], score=0)
        detector.inside_count = 0
        detector.seen_outside = False
        detector.last_goal = -1e9
        return jsonify(ok=True)
 
    return app
 
 
if __name__ == "__main__":
    create_app().run(debug=True, port=5000)

Start it with python app.py and open http://localhost:5000. Press Play. The players move and the goals count up, because the browser keeps asking Python "what is happening now?" ten times a second.

Try the endpoints by hand in the address bar: /api/status, /api/frame/10, /api/goals. You are reading the same JSON the page reads.

Cell 2: static/index.html (the page)

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Match dashboard (live from Python)</title>
  <style>
    body { font-family: system-ui, sans-serif; margin: 16px; max-width: 760px; }
    canvas { width: 100%; background: #2e7d32; border-radius: 8px; }
    button { padding: 8px 14px; margin-right: 6px; }
    #score { font-size: 2rem; font-weight: 600; }
  </style>
</head>
<body>
  <h1>Match dashboard</h1>
  <p id="score">Goals: 0</p>
  <canvas id="pitch" width="640" height="360"></canvas>
  <p>
    <button id="play">Play</button><button id="pause">Pause</button><button id="reset">Reset</button>
    <span id="clock">t = 0.0 s</span>
  </p>
  <ul id="goals"></ul>
  <p id="error" style="color:#b00020"></p>
  <script>
    const canvas = document.getElementById("pitch");
    const ctx = canvas.getContext("2d");
    let meta = null;
 
    async function getJSON(url, options) {
      const res = await fetch(url, options);
      if (!res.ok) throw new Error(url + " answered " + res.status);
      return res.json();
    }
 
    function draw(frame) {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      const sx = canvas.width / meta.width, sy = canvas.height / meta.height;
      for (const p of frame.players) {
        ctx.fillStyle = "#fff";
        ctx.beginPath();
        ctx.arc(p.x * sx, p.y * sy, 6, 0, Math.PI * 2);
        ctx.fill();
        ctx.fillStyle = "#000";
        ctx.font = "10px sans-serif";
        ctx.fillText(p.id, p.x * sx - 3, p.y * sy + 3);
      }
      if (frame.ball) {
        ctx.fillStyle = "#ffeb3b";
        ctx.beginPath();
        ctx.arc(frame.ball.x * sx, frame.ball.y * sy, 5, 0, Math.PI * 2);
        ctx.fill();
      }
    }
 
    async function tick() {
      try {
        const frame = await getJSON("/api/now");
        draw(frame);
        document.getElementById("clock").textContent = "t = " + frame.t.toFixed(1) + " s";
        const g = await getJSON("/api/goals");
        document.getElementById("score").textContent = "Goals: " + g.score;
        document.getElementById("goals").innerHTML = g.goals.map(x => "<li>GOAL at " + x.t + " s</li>").join("");
        document.getElementById("error").textContent = "";
      } catch (e) {
        document.getElementById("error").textContent = "Cannot reach the Python server: " + e.message;
      }
    }
 
    document.getElementById("play").onclick = () => getJSON("/api/play", { method: "POST" });
    document.getElementById("pause").onclick = () => getJSON("/api/pause", { method: "POST" });
    document.getElementById("reset").onclick = async () => { await getJSON("/api/reset", { method: "POST" }); tick(); };
 
    getJSON("/api/status").then(m => { meta = m; setInterval(tick, 100); });
  </script>
</body>
</html>

Cell 3: test the server without a browser

Flask has a test client, so you can check every route in a script.

from app import create_app
 
app = create_app()
c = app.test_client()
bad = 0
 
 
def check(name, ok, extra=""):
    global bad
    bad += not ok
    print("PASS" if ok else "FAIL", name, extra)
 
 
r = c.get("/api/status"); j = r.get_json()
check("status 200 + fields", r.status_code == 200 and j["total_frames"] == 600 and j["score"] == 0, j)
check("CORS header", r.headers.get("Access-Control-Allow-Origin") == "*")
 
r = c.get("/api/frame/10"); check("frame 10", r.status_code == 200 and "players" in r.get_json())
r = c.get("/api/frame/9999"); check("missing frame is 404", r.status_code == 404, r.get_json())
r = c.get("/api/frame/abc"); check("non-number frame is 404", r.status_code == 404)
 
r = c.post("/api/seek", json={}); check("seek without frame is 400", r.status_code == 400)
r = c.post("/api/seek", json={"frame": "x"}); check("seek with text is 400", r.status_code == 400)
r = c.post("/api/seek", json={"frame": 300}); j = r.get_json()
check("seek to frame 300 (10 s): the first goal (5.7 s) only", r.status_code == 200 and j["score"] == 1, j)
r = c.post("/api/seek", json={"frame": 400}); j = r.get_json()
check("seek to frame 400 (13.3 s): two goals", j["score"] == 2, j)
r = c.post("/api/seek", json={"frame": 100}); check("seek backwards is 409", r.status_code == 409)
r = c.get("/api/goals"); j = r.get_json()
check("goal log times", [round(g["t"], 1) for g in j["goals"]] == [5.7, 12.4], j)
r = c.post("/api/seek", json={"frame": 599}); check("seek to end: 3 goals", r.get_json()["score"] == 3, r.get_json())
 
r = c.post("/api/reset"); check("reset", r.status_code == 200)
check("score back to 0", c.get("/api/goals").get_json()["score"] == 0)
r = c.post("/api/seek", json={"frame": 599}); check("same result after reset (3 goals)", r.get_json()["score"] == 3)
 
c.post("/api/reset")
c.post("/api/play"); check("play sets playing", c.get("/api/status").get_json()["playing"] is True)
c.post("/api/pause"); check("pause", c.get("/api/status").get_json()["playing"] is False)
r = c.get("/"); check("home page served", r.status_code == 200 and b"Match dashboard" in r.data)
r = c.get("/api/now"); check("now returns a frame", "players" in r.get_json())
print("failures:", bad)

All of them should print PASS. The 404, 400 and 409 tests matter as much as the success ones: a good API answers bad requests with a clear error.

Cell 4: your turn

Add one route. For example GET /api/player/<int:pid> that returns the positions of one player over all frames, or GET /api/stats that returns the time-in-zone numbers from lesson 32. Write the test first.

Questions: why do we use POST for play and reset and GET for reading? What happens if two people open the page? (Hint: look at state.) Why did we forbid seeking backwards?

If it goes wrong

ErrorCauseFix
Page says 'Cannot reach the Python server'Flask is not running, or the wrong portStart python app.py. Check the port in the address.
ModuleNotFoundError: flaskNot installed in this Pythonpip install flask. Check you use the same Python.
FileNotFoundError: detections.jsonRun from another folderRun from the project folder, or pass the path
Port already in useAn old server is still running, or AirPlay on MacStop it, or use another port
Browser shows the old pageCached copyHard refresh
Score doesn't resetReset only resets the server statePress Reset, then Play again
CORS error from a file opened directlyThe page is loaded from file://Open the page through the server at localhost:5000
Seek returns 409Seeking backwardsReset first

Try it

  • Add a route that returns time-in-zone from lesson 32
  • Replay from the start when seeking backwards, so it works both ways
  • Try Server-Sent Events instead of polling

Check what you learnt

  1. Why do we need an API?

  2. GET or POST: which one changes something?

  3. What does a 404 status mean?

  4. Why does the API refuse to seek backwards (409)?

  5. Why never leave debug mode on for a public server?

Conclusion

An API is the waiter between Python and the browser. Flask routes are the menu, GET reads and POST changes things, answers come back as JSON with a status code (200, 400, 404, 409), and the page simply fetches what it needs. Keep debug mode off anywhere public.

What next?

Next: CV that moves things: Arduino/ESP32 over serial. You'll make your computer vision move something real: an Arduino or ESP32.