A camera is a metronome. At 30 fps a frame arrives every 33 ms whether or not you are finished with the last one — so perception is not a modelling problem, it is a budgeting problem, and the only interesting question is what you can decide before the next frame lands.
Four stages between a photon and a number your system can act on — capture, preprocess, inference, post-process — and one hard constraint: their sum must fit inside one frame period.
Every real-time vision system has the same spine. Capture pulls a frame off the device or the file and pays for a copy. Preprocess resizes, converts colour space and normalises — cheap per pixel, expensive if you do it three times. Inference runs the model. Post-process turns raw tensors into objects: threshold, non-maximum suppression, associate with the previous frame, assign an id.
Offline you measure this in accuracy. Online you measure it in milliseconds, because the frame period is a deadline, not a target. At 30 fps you have 33.3 ms. Spend 40 and you are not “a bit slow” — you are running at 25 fps and every answer you produce describes a world that has already moved on.
That is the whole difference between this week and a Kaggle notebook. The notebook optimises accuracy with time as a free variable. Here time is fixed by physics and accuracy is the variable you trade.
# the shape of every frame loop you will write this week while cap.isOpened(): t0 = time.perf_counter() ok, frame = cap.read() # capture if not ok: break x = preprocess(frame) # resize / normalise out = session.run(None, {"images": x}) # inference (ONNX) dets = postprocess(out) # boxes + ids budget_ms = 1000.0 / TARGET_FPS spent = (time.perf_counter() - t0) * 1e3 if spent > budget_ms: # the only line that makes it real-time misses += 1; drop_next_frame()
| Stage | Cost scales with | First thing to cut |
|---|---|---|
| capture | resolution × copies | ask the driver for the size you want |
| preprocess | pixels touched | resize once, in one pass |
| inference | FLOPs × precision | smaller model, then int8 |
| post-process | candidate boxes | raise the confidence floor before NMS |
Pick a frame rate, then spend the budget. The bar turns red the instant the stages sum past one frame period — and the drop counter starts, because the camera does not slow down to match you.
Four stages on a synthetic video with ground truth — three shapes on a near-black background, stable integer ids, so a detector can be scored on IoU and a tracker on id stability instead of being eyeballed.
The lab's video is generated, not filmed, for three reasons: it needs no webcam, it is byte-identical on every machine so latency numbers are comparable between teams, and it ships ground truth. Background subtraction is nearly free on it, which is the point — it lets you see the cost curve of each stage without a detector's accuracy noise on top.
Turn stages on from the top down. Each one adds pixels of output and milliseconds of cost; the last one, tracking, is the only one that gives an object an identity across time.
Now the choice that separates a service from a script. Your pipeline can run one frame at a time, or it can wait, collect a batch, and run several at once. Batching amortises the fixed cost of a model call over more frames, so throughput rises — and every frame in the batch has to wait for the batch to fill, so latency rises with it. You cannot have both. Pick the one your consumer needs.
One producer (the camera), one consumer (your pipeline), one queue between them. Set the batch size and the overflow policy, then watch which number you bought and which one you sold.
Quantisation is the cleanest example of the trade this whole course is about: give up a fraction of a percent of accuracy, get back half your latency, and only then does the model fit inside the loop.
The same weights can be stored and multiplied at different precisions. fp32 is the training default. fp16 halves the memory traffic. int8 quantisation maps each tensor onto 256 levels with a scale and a zero point, which is a real loss of information — and typically a small one, because the network was never using that precision anyway. Pruning and distillation go further and cost more.
Five illustrative operating points on one hardware budget. Set the fps you promised and the widget picks the most accurate point that still fits — and names what you gave up to get there. The numbers are illustrative, chosen to show the shape; measure your own.
And now the sentence that connects this week to everything you have already built: a detection is just an event. It has a timestamp, a schema, a producer and a consumer, exactly like a market-data tick in week 2 or an alert in week 6. Once you see it that way, the perception service stops being a computer-vision project and becomes another node on the bus you have been running since week 3 — which is precisely how it plugs into your P2 module.
Click a stage. The panel shows what it owns, what it emits, what it costs and how it fails — and the age slider shows how wrong a decision becomes when it acts on a detection that is already old.
# the seam: one Pydantic model, and the vision service is just another producer class Detection(BaseModel): t_capture: float # when the PHOTON arrived, not when you finished t_emit: float # so the consumer can compute its own staleness label: str conf: float box: tuple[int, int, int, int] track_id: int # stable across frames — that is what tracking buys # producer: the perception service await bus.publish(det.model_dump_json()) # never a raw dict # consumer: the loop that already exists (week 3-4 for a bot, week 8 for a car) def on_event(self, det: Detection): if now() - det.t_capture > self.max_age: # staleness is the consumer's job return None return self.decide(det)
Every one of these passes its unit tests. Open them.
Your loop averages 21 ms against a 33 ms budget, so you write “30 fps, comfortable headroom” in the report. Meanwhile one frame in fifty takes 60 ms — a page fault, a garbage collection, a resize on a bigger frame, the model's slow path — and every one of those is a missed deadline that your average hid.
Averages are the wrong statistic for a deadline because a deadline is a threshold, not a target. Nothing you care about is linear in the mean.
Fix: keep a per-stage histogram, report p50 / p99 / max, and count deadline misses as a first-class metric. If you can only keep one number, keep p99.
asyncio.Queue() with no maxsize, one frame pushed per camera callback. The
pipeline is 10 % slower than the camera, so the queue grows by three frames a second. Nothing raises,
nothing logs, memory creeps — and after two minutes your detections describe a scene from twenty
seconds ago while the dashboard still shows 30 fps.
This is the same bug as week 2's backpressure demo, wearing a camera. Latency is not visible in a throughput metric.
Fix: bound the queue, drop the oldest frame on overflow, and export the queue depth and the drop count. A perception service that never drops a frame is a perception service that is lying about its latency.
t = time.time() at the end of postprocess(). Now every consumer downstream
thinks the observation is fresh, and no one can measure end-to-end age — because the one number that
would reveal it has been overwritten.
It also destroys your ability to fuse two sensors, which is week 8's problem: fusing observations requires knowing when each was taken, not when it was delivered.
Fix: stamp t_capture the moment the frame is read, carry it through every stage
untouched, and let each consumer compute its own staleness against its own deadline.
onnxruntime.InferenceSession(path) inside the frame handler — or a resize allocating a
fresh array every frame, or the session created per request in a web handler. The first frame takes
900 ms, the rest are fine, and your p99 is permanently broken by a cost you pay once but measure
forever.
The generalisation is the one from week 4: nothing that can be done before the loop belongs inside it. Allocation, file I/O, imports, warm-up.
Fix: load the session once at start-up, run two or three warm-up inferences before you declare readiness, pre-allocate the input buffer, and reuse it.
You swap in a bigger backbone for two points of mAP. Frame time goes from 22 ms to 48 ms against a 33 ms budget, so the pipeline drops every third frame. Measured against the frames it processed, accuracy improved; measured against the frames that arrived, it fell.
This is why the accuracy metric for a real-time system must be computed per arrived frame, with a miss counted as a miss.
Fix: define the SLO first (“25 fps sustained, p99 under 40 ms”), treat it as a constraint rather than a goal, and only then maximise accuracy inside it. Quantise before you shrink the architecture — it is usually the cheapest millisecond you will ever buy.
Instant feedback, no grading. Pick the answer you would defend in a design review.
Q1Your pipeline's mean frame time is 28 ms and its p99 is 40 ms. The camera runs at 30 fps. What does the system actually deliver?
Q2You raise the inference batch size from 1 to 8. What happens to throughput and to per-frame latency?
Q3Your fp32 model needs 48 ms per frame against a 33 ms budget. Accuracy drops 1.8 points if you quantise it to int8, which runs in 19 ms. What do you ship?
t_capture carried end to end.rtsim/sensors.py. Next week this stage becomes a sensor and the consumer becomes a
controller with 20 ms to act.make install, and put your perception service beside your bot.