AI & Computer Vision, lesson 34 of 52
Flask API: The Python to JavaScript Bridge
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
| Route | Method | Meaning |
|---|---|---|
/api/status | GET | Size of the video, the score, is it playing |
/api/frame/<n> | GET | Players and ball in frame n |
/api/now | GET | The current frame (moves the match on if it is playing) |
/api/goals | GET | The goal log |
/api/play, /api/pause, /api/reset | POST | Change the state |
/api/seek | POST | Jump 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
| Code | Meaning | Example here |
|---|---|---|
| 200 | OK | Normal answers |
| 400 | You asked wrongly | Seek without a frame number |
| 404 | No such thing | Frame 9999 |
| 409 | Conflict with the current state | Seeking 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=Truereloads 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_courseenvironment 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.htmlCell 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
| Error | Cause | Fix |
|---|---|---|
| Page says 'Cannot reach the Python server' | Flask is not running, or the wrong port | Start python app.py. Check the port in the address. |
ModuleNotFoundError: flask | Not installed in this Python | pip install flask. Check you use the same Python. |
FileNotFoundError: detections.json | Run from another folder | Run from the project folder, or pass the path |
| Port already in use | An old server is still running, or AirPlay on Mac | Stop it, or use another port |
| Browser shows the old page | Cached copy | Hard refresh |
| Score doesn't reset | Reset only resets the server state | Press Reset, then Play again |
| CORS error from a file opened directly | The page is loaded from file:// | Open the page through the server at localhost:5000 |
| Seek returns 409 | Seeking backwards | Reset 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
Why do we need an API?
GET or POST: which one changes something?
What does a 404 status mean?
Why does the API refuse to seek backwards (409)?
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.

