AI & Computer Vision, lesson 50 of 52
Field Trip 3: The Final Deployment Test
Introduction
This is the dress rehearsal for your product. At a real venue, in real light, with real people, you'll test it against the success criteria you wrote in lesson 47, while a scribe records what really happened. Afterwards an acceptance tool turns the day into numbers, and tells you honestly whether the product passed.
By the end of this lesson you'll be able to:
- Prepare a venue visit with permission, consent, roles and a run sheet
- Run the full product for a timed test while a scribe writes down what really happens
- Record the raw video first and keep a log
- Turn the log into the numbers named in the brief
- Judge the result with an error bar and not a single flattering number
- Write an acceptance report whose decision follows from the evidence
Working principle
This is a test, not a demo
A demo is where everything is arranged to go well. A test is arranged to find out. You will write down what really happened, including the bad parts, and you will not retune the code halfway to get a nicer number. If you must change something, note the time, and test again after.
Everything from lesson 39 still applies
Permission in writing before you go, consent for everyone who may be filmed (lesson 14), a safety check of the rig and the place, preflight on the laptop, raw video first, an event log, and a backup before you pack. This lesson adds one thing: a verdict against the brief.
If you did lesson 39 well, re-use its checklist unchanged. The table below only lists what is different.
| Item | lesson 39 | This lesson |
|---|---|---|
| Purpose | Learn to run live | Judge the finished product |
| Brief | Not needed | The success criteria from lesson 47 are printed and on site |
| Scribe | Writes events | Writes events and does not look at the screen |
| Changes during the test | Allowed | Not allowed. Note them and test again |
| Duration | As long as the event | A fixed time, written in the plan (for example 20 to 30 minutes) |
| After | Debrief | Acceptance report with a decision |
Roles
| Role | Job |
|---|---|
| Camera and rig | Sets up, checks the picture, never leaves it |
| Operator | Runs the product, reads the health warnings, and does nothing else |
| Scribe | Watches the real event, not the screen, and writes GOAL (or your event) with the clock time |
| Safety and permissions lead | Talks to the venue, keeps students out of the way, keeps the paperwork |
| Team lead | Calls start and stop, makes decisions, takes the photos for the report |
Four or five people is enough. If you are three, the lead takes safety.
The scribe is the ground truth
The numbers are only as good as the scribe. The scribe must not see what the system announced, or they will write down what they think it saw. If the real event is hard to judge (was that a goal?), agree the rule on paper before the test.
Turning the log into numbers
debrief.py (lesson 40) pairs every system event with a real event within a few seconds, one to one. Anything left over on the system side is a false alarm; anything left over on the scribe side is a miss. From that, acceptance.py makes:
- Recall: of the real events, how many did you catch?
- Precision: of the events you announced, how many were real?
- False announcements per 10 minutes
- Median delay: how late was the announcement?
Error bars: why ten out of ten is not enough
Suppose the brief says recall of at least 90 percent, and in the test you catch 10 of 10 goals. Is the brief met? The honest answer is "probably, but we have only seen ten goals". If the true recall were 75 percent you would still get 10 of 10 about one time in 18. The tool calculates a 95 percent range (the Wilson interval) from the counts and says:
| Result | Meaning |
|---|---|
| PASS | Even the cautious end of the range meets the target |
| LIKELY | The number meets the target, but the range crosses it: a small test, not proof |
| FAIL | The number misses the target |
| NOT MEASURED | The test did not produce this number |
The decision follows: all PASS is ACCEPT; some LIKELY and no FAIL is ACCEPT WITH CAUTION; any FAIL or NOT MEASURED is NOT YET.
Most student tests end with ACCEPT WITH CAUTION. That is a good honest result. Saying "we caught 10 of 10 so it works" is not.
Rates and delays have one test only
False announcements per 10 minutes, and the delay, are measured once in this lesson. The tool marks them "one test, no error bar". Zero false alarms in 25 minutes is encouraging and it is not a guarantee. The delay also includes the scribe's reaction time of a second or two, and any clock difference (lesson 40): say so in the report.
If it fails, that is the result
NOT YET is a useful result, not a bad grade. The report says what failed, what you think caused it (use the clips from lesson 40 to look), and what you would change first. lesson 51 is then a story about what you learned, plus a live demo of what does work.
Evidence folder
The numbers are claims until the evidence is there. Before you leave, one folder holds: the event log, the raw video, the consent record, the venue permission, and your notes. The tool checks the folder and says what is missing. Keep the folder private (lesson 14): it holds recordings of people.
Honest limits
One venue, one day and one scribe is a small test. Your result is for that place and that light. Say so in the report and in the demo.
What you'll need
- Your capstone product and brief
preflight.py,event_log.pyandhealth.py(lesson 39),debrief.py(lesson 40) andacceptance.py(below)- Permission and consent for the venue and everyone in frame
Build it
This is a field trip, so most of the work happens at the venue and the code is small. Bring preflight.py, event_log.py and health.py from lesson 39, and debrief.py from lesson 40. New in this lesson: acceptance.py, which turns your test into a verdict on your own brief.
Cell 1: the acceptance tool
"""Acceptance test: did the product meet the success criteria in the lesson 47 brief, at the venue, honestly?
Uses debrief.py (lesson 40) to match what the system announced with what the scribe saw."""
import glob
import math
import os
import statistics
from debrief import load_log, match_goals, split_events
def wilson(k, n, z=1.96):
"""95 percent interval for a proportion k out of n. Honest about small samples."""
if n == 0:
return (0.0, 1.0)
p = k / n
d = 1 + z * z / n
centre = (p + z * z / (2 * n)) / d
half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
return (max(0.0, centre - half), min(1.0, centre + half))
def measure(log_path, minutes, tolerance=3.0):
"""Turn the event log of the test into the numbers a brief usually asks for."""
detected, truth = split_events(load_log(log_path))
pairs, fa, missed = match_goals(detected, truth, tolerance)
tp, fp, fn = len(pairs), len(fa), len(missed)
delays = [d - g for d, g in pairs]
return {
"real_events": tp + fn, "tp": tp, "fp": fp, "fn": fn, "minutes": minutes,
"recall": tp / (tp + fn) if tp + fn else None,
"precision": tp / (tp + fp) if tp + fp else None,
"false_per_10min": fp / minutes * 10 if minutes > 0 else None,
"median_delay_s": statistics.median(delays) if delays else None,
"counts": {"recall": (tp, tp + fn), "precision": (tp, tp + fp)}, # k out of n, for the error bars
}
MIN_EVENTS = 5
def judge(criterion, value, counts=None):
"""One criterion -> {'status': PASS | LIKELY | FAIL | NOT MEASURED, 'note': str}.
PASS = the whole error bar is on the right side. LIKELY = the number passes but the error bar crosses the target."""
if value is None:
return {"status": "NOT MEASURED", "note": "no value: the test did not produce this number"}
op, target = criterion["op"], criterion["target"]
passes = {">=": value >= target, ">": value > target, "<=": value <= target, "<": value < target, "==": value == target}[op]
if not passes:
return {"status": "FAIL", "note": f"measured {value:.3g}, needed {op} {target}"}
if counts:
k, n = counts
lo, hi = wilson(k, n)
clear = (lo >= target) if op in (">=", ">") else (hi <= target)
if n < MIN_EVENTS:
return {"status": "LIKELY", "note": f"passes, but only {n} events: far too few to be sure (95 percent range {lo:.2f} to {hi:.2f})"}
if not clear:
return {"status": "LIKELY", "note": f"passes ({k} of {n}), but the 95 percent range {lo:.2f} to {hi:.2f} crosses the target"}
return {"status": "PASS", "note": f"{k} of {n}, 95 percent range {lo:.2f} to {hi:.2f}"}
return {"status": "PASS", "note": f"measured {value:.3g}, needed {op} {target} (one test, no error bar)"}
def evaluate(brief, bindings, measured):
"""bindings: {criterion metric text: key in measured}. A criterion with no binding is NOT MEASURED."""
out = []
for c in brief["success"]:
key = bindings.get(c["metric"])
value = measured.get(key) if key else None
v = judge(c, value, measured.get("counts", {}).get(key) if key else None)
out.append({"metric": c["metric"], "op": c["op"], "target": c["target"], "unit": c["unit"], "value": value, **v})
return out
def decide(verdicts):
s = {v["status"] for v in verdicts}
if not verdicts or s & {"FAIL", "NOT MEASURED"}:
return "NOT YET"
return "ACCEPT WITH CAUTION" if "LIKELY" in s else "ACCEPT"
REQUIRED = {"event log": ["event_log.csv"], "raw video": ["*.mp4", "*.mov", "*.mkv"], "consent record": ["consent*"],
"venue permission": ["permission*", "venue*"], "notes": ["notes*", "*.md", "*.txt"]}
def check_artifacts(folder):
"""The evidence folder: without it the numbers are just claims."""
missing = []
for name, patterns in REQUIRED.items():
found = [p for pat in patterns for p in glob.glob(os.path.join(folder, pat)) if os.path.getsize(p) > 0]
if not found:
missing.append(name)
return missing
def report_markdown(brief, verdicts, decision, run, missing):
lines = [f"# Acceptance report: {brief['name']}", "",
f"**Decision: {decision}**", "",
f"Venue: {run['venue']}. Date: {run['date']}. Length of test: {run['minutes']} minutes. Camera: {run['camera']}.", "",
"| Criterion | Target | Measured | Result | Note |", "|---|---|---|---|---|"]
for v in verdicts:
val = "none" if v["value"] is None else f"{v['value']:.3g}"
lines.append(f"| {v['metric']} | {v['op']} {v['target']} {v['unit']} | {val} | {v['status']} | {v['note']} |")
lines += ["", "## Evidence", ""]
lines.append("All evidence is in the folder." if not missing else "Missing from the evidence folder: " + ", ".join(missing) + ".")
lines += ["", "## What we would do next", "", run.get("next", "(the team writes this)")]
return "\n".join(lines)Cell 2: pretend logs, so you can practise before the trip
"""Write pretend test logs. 'messy' has a missed goal, a false alarm and a duplicate; 'clean' has no mistakes."""
import csv
def write(path, rows):
with open(path, "w", newline="") as f:
w = csv.writer(f); w.writerow(["wall_time", "match_seconds", "kind", "text"]); w.writerows(rows)
def messy(path="event_log.csv"):
write(path, [["19:03:30", 140.0, "goal", "north goal"], ["19:03:29", 139.2, "note", "GOAL red 7"],
["19:07:05", 355.0, "goal", "north goal"],
["19:11:00", 590.0, "note", "GOAL blue 3"],
["19:20:05", 1135.0, "goal", "north goal"], ["19:20:03", 1133.5, "note", "GOAL red 9"],
["19:20:09", 1139.0, "goal", "north goal"]])
return path
def clean(path="event_log_clean.csv", goals=10, delay=1.0, spacing=120.0):
rows = []
for i in range(goals):
t = 60 + i * spacing
rows += [["x", t, "note", f"GOAL {i}"], ["x", t + delay, "goal", "north goal"]]
write(path, rows)
return pathCell 3: a brief to judge against
Your own brief from lesson 47 has a success list in the same form. This is a small one for the practice:
{
"name": "Goal Announcer",
"success": [
{"metric": "goal recall (real goals announced)", "op": ">=", "target": 0.9, "unit": "fraction"},
{"metric": "false announcements per 10 minutes", "op": "<=", "target": 1, "unit": "count"},
{"metric": "delay between goal and announcement", "op": "<=", "target": 3, "unit": "seconds"}
]
}Save it as example_brief.json.
Cell 4: judge a messy test and a clean one
import json
from acceptance import measure, evaluate, decide
from make_demo_logs import messy, clean
brief = json.load(open("example_brief.json"))
bindings = {"goal recall (real goals announced)": "recall",
"false announcements per 10 minutes": "false_per_10min",
"delay between goal and announcement": "median_delay_s"}
for label, path, minutes in [("messy", messy(), 21), ("clean, 10 goals", clean(goals=10), 25), ("clean, 40 goals", clean("big.csv", goals=40), 90)]:
verdicts = evaluate(brief, bindings, measure(path, minutes))
print(label, "->", decide(verdicts))
for v in verdicts:
print(" ", v["status"].ljust(12), v["metric"], "|", v["note"])You should see: the messy test is NOT YET (recall fails), the clean test with 10 goals is ACCEPT WITH CAUTION (10 out of 10 passes, but with only 10 goals the true recall could still be as low as 72 percent), and only the 40-goal test is a plain ACCEPT.
Cell 5: check the evidence folder and write the report
from acceptance import check_artifacts, report_markdown
missing = check_artifacts("evidence") # a folder with your log, raw video, consent, permission and notes
run = {"venue": "Your venue", "date": "2026-12-05", "minutes": 21, "camera": "phone on tripod, 1080p",
"next": "Write what you would fix first."}
verdicts = evaluate(brief, bindings, measure("event_log.csv", 21)) # your own test log and its length in minutes
text = report_markdown(brief, verdicts, decide(verdicts), run, missing)
open("acceptance_report.md", "w").write(text)
print(text)The report goes into your demo (lesson 51) and your model card (lesson 43).
Cell 6: the tests
import json, os, shutil
from acceptance import *
from make_demo_logs import messy, clean
n = 0
def ok(name, cond):
global n
print(("PASS " if cond else "FAIL ") + name); n += bool(cond)
if not cond: raise SystemExit(1)
brief = json.load(open("example_brief.json"))
B = {"goal recall (real goals announced)": "recall", "false announcements per 10 minutes": "false_per_10min",
"delay between goal and announcement": "median_delay_s"}
lo, hi = wilson(10, 10); ok("wilson 10 of 10 is not certain", 0.7 < lo < 0.75 and hi == 1.0)
lo, hi = wilson(40, 40); ok("wilson 40 of 40 clears 0.9", lo > 0.9)
ok("wilson 0 events is 0 to 1", wilson(0, 0) == (0.0, 1.0))
lo, hi = wilson(0, 10); ok("wilson 0 of 10 starts at 0", lo == 0.0 and hi < 0.35)
m = measure(messy(), minutes=21)
ok("messy: 2 right, 2 false, 1 missed", (m["tp"], m["fp"], m["fn"]) == (2, 2, 1))
ok("messy: recall two thirds", abs(m["recall"] - 2 / 3) < 1e-9 and abs(m["precision"] - 0.5) < 1e-9)
ok("messy: false alarms per 10 min", abs(m["false_per_10min"] - 2 / 21 * 10) < 1e-9)
ok("messy: median delay 1.15 s", abs(m["median_delay_s"] - 1.15) < 1e-9)
v = evaluate(brief, B, m)
ok("messy: recall FAILS", v[0]["status"] == "FAIL")
ok("messy: false alarms pass (one test)", v[1]["status"] == "PASS" and "no error bar" in v[1]["note"])
ok("messy: delay passes", v[2]["status"] == "PASS")
ok("messy: NOT YET", decide(v) == "NOT YET")
m = measure(clean(goals=10), minutes=25)
v = evaluate(brief, B, m)
ok("clean 10 of 10: recall only LIKELY", v[0]["status"] == "LIKELY" and "crosses the target" in v[0]["note"])
ok("clean: zero false alarms pass", v[1]["status"] == "PASS" and m["false_per_10min"] == 0)
ok("clean 10 events: ACCEPT WITH CAUTION", decide(v) == "ACCEPT WITH CAUTION")
m = measure(clean("event_log_big.csv", goals=40), minutes=90)
ok("clean 40 of 40: ACCEPT", decide(evaluate(brief, B, m)) == "ACCEPT")
m = measure(clean("event_log_small.csv", goals=3), minutes=10)
v = evaluate(brief, B, m); ok("3 events: LIKELY and says too few", v[0]["status"] == "LIKELY" and "only 3 events" in v[0]["note"])
ok("unbound criterion is NOT MEASURED", evaluate(brief, {}, m)[0]["status"] == "NOT MEASURED" and decide(evaluate(brief, {}, m)) == "NOT YET")
ok("no real events gives no recall, not a crash", measure(clean("e0.csv", goals=0), minutes=10)["recall"] is None)
ok("None value is NOT MEASURED", judge({"op": ">=", "target": 1}, None)["status"] == "NOT MEASURED")
ok("operators work", judge({"op": "<", "target": 5}, 5)["status"] == "FAIL" and judge({"op": ">", "target": 5}, 6)["status"] == "PASS")
ok("empty verdict list is NOT YET", decide([]) == "NOT YET")
ok("delay is one test and only judged by point value", judge({"op": "<=", "target": 3}, 4.2)["status"] == "FAIL")
os.makedirs("ev", exist_ok=True)
ok("empty evidence folder lists all missing", len(check_artifacts("ev")) == 5)
for f, t in [("event_log.csv", "x"), ("match.mp4", "x"), ("consent_signed.pdf", "x"), ("permission_letter.pdf", "x"), ("notes.md", "x")]:
open(f"ev/{f}", "w").write(t)
ok("full evidence folder is complete", check_artifacts("ev") == [])
open("ev/notes.md", "w").write(""); os.remove("ev/consent_signed.pdf")
ok("empty notes and no consent are missing", set(check_artifacts("ev")) == {"consent record", "notes"})
run = {"venue": "St Xavier ground", "date": "2026-12-05", "minutes": 21, "camera": "phone on tripod, 1080p", "next": "Fix the duplicate and the dark corner."}
md = report_markdown(brief, evaluate(brief, B, measure(messy(), 21)), "NOT YET", run, ["consent record"])
ok("report has decision, table and missing evidence", "**Decision: NOT YET**" in md and "| goal recall" in md and "Missing from the evidence folder: consent record." in md)
ok("report has no stray pipes in numbers", all(l.count("|") == 6 for l in md.splitlines() if l.startswith("| goal") or l.startswith("| false") or l.startswith("| delay")))
for p in ("event_log.csv", "event_log_clean.csv", "event_log_big.csv", "event_log_small.csv", "e0.csv"): os.path.exists(p) and os.remove(p)
shutil.rmtree("ev")
print(f"\n{n} checks passed")Expected: 28 checks passed.
If it goes wrong
| Error | Cause | Fix |
|---|---|---|
| Recall is 100 percent but the tool says LIKELY | Few real events, so the error bar is wide | Say so in the report, or test longer to gather more events |
| Many false alarms right at the start | The calibration or zone was not set before the start | Re-run preflight, note the time, and restart the timed test |
| Delay looks large or negative | The scribe's clock and the laptop clock disagree | Compare the clocks first (lesson 40); use the same time source |
| Scribe wrote events by looking at the screen | No separation of roles | Move the scribe, and say so in the report |
| Criterion shows NOT MEASURED | The brief names something the test did not record | Add it to the log next time, or change the brief honestly and say why |
| The video is missing | Raw recording was not started first | Start it before everything else next time. The report must say it is missing |
| Numbers look better than the earlier tests | Different place or lighting, or a changed setting | Compare the conditions before celebrating |
Try it
- Add a second scribe and compare their two logs
- Help with the log and the scribe notes, then judge one criterion by hand
Check what you learnt
What's the difference between a test and a demo?
Who provides the ground truth on the day?
Why put error bars on your result?
Does a good ten-minute test prove the product works?
Which rules from Field Trip 2 still apply?
Conclusion
The final field trip is a test, not a demo. Follow every rule from lesson 39, let the scribe record the truth, turn the log into numbers with acceptance.py, compare them with your criteria, and report the result with honest error bars and limits.
What next?
Next: Demo day: live product showcase. You'll present your product at Demo Day.

