WEEK 7

Real-Time Perception I — Vision

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.

Homework 6 · a perception service that holds a stated fps SLO P2 spec due · pick your intelligence module Week 8 consumes this stage as a sensor
Part 1

1What is a real-time perception pipeline?

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()
p99, not mean. A pipeline whose average frame takes 20 ms and whose 99th percentile takes 55 ms misses one frame in a hundred — and it will be the frame with the pedestrian in it. Report p50 and p99 per stage or you have not measured anything.

Four stages, four cost drivers

StageCost scales withFirst thing to cut
captureresolution × copiesask the driver for the size you want
preprocesspixels touchedresize once, in one pass
inferenceFLOPs × precisionsmaller model, then int8
post-processcandidate boxesraise the confidence floor before NMS
Nothing here is trained. The course ships a pinned pretrained ONNX model and runs it on CPU. Training is a different course; holding a frame budget with a model you did not train is this one.
60 fps
16.7 ms
30 fps
33.3 ms
15 fps
66.7 ms

Widget 1 · frame-budget calculator

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.

budget
33.3 ms
p50 frame
28.0 ms
p99 frame
34.0 ms
headroom
5.3 ms
achieved fps
30.0
frames dropped
0 %
deadline misses
0 / 100
Read the jitter row. The p50 bar is what you will quote in a demo; the p99 bar is what the system actually experiences. Budget against p99 and the mean stops mattering.
Part 2

2How does it work? Build the pipeline, then price it

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.

Widget 2 · the pixel pipeline, stage by stage

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.

frame 0
raw frame foreground mask contour box + id
pipeline
2.1 ms
of 33.3 ms
6 %
ceiling
476 fps
objects out
0
ids held
—
output type
frame
The stage order is not negotiable. Contours need a mask, boxes need contours, ids need boxes. Turning on a later stage without its predecessor is the most common silent bug in a homework submission — here it just switches its predecessor on for you and tells you.

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.

Widget 3 · throughput versus latency

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.

throughput
—
p50 age at use
—
p99 age at use
—
queue depth
—
dropped
—
10 s later
—
Queue-up is a trap with a delay fuse. An unbounded queue never drops a frame, so it never errors — it just adds latency forever, and by the time anyone notices, the detections being acted on describe a scene ten seconds old. A bounded queue that drops the oldest frame is almost always the right default for perception, because the newest frame is the only one that describes now.
Part 3

3Why does it matter? Accuracy is something you spend

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.

Widget 4 · precision versus latency, against your SLO

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.

chosen
—
mAP kept
—
frame time
—
verdict
—
The feasible region is the only region. An fp32 model at 92 % of the deadline is not “more accurate” than an int8 model at 40 % — it is a model that will miss frames under load, and a missed frame has an accuracy of zero.

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.

Widget 5 · a detection is just an event

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.

stale by
—
as a fraction of a lane
—
# 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)
Part 4

4Five ways a perception service quietly stops being real-time

Every one of these passes its unit tests. Open them.

Reporting the mean frame time

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.

An unbounded queue between the camera and the model

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.

Timestamping the detection when you finish, not when you captured

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.

Loading the model inside the loop

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.

Optimising accuracy the loop cannot afford

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.

Part 5

5Check yourself

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?

Part 6

6Do the work

In class

  • Generate the synthetic video with its ground truth, then run a detector against a fixed frame budget.
  • Instrument all four stages and print p50 / p99 per stage, not a mean.
  • Run the same tape twice: drop-frame, then queue-up. Report fps, p99 per frame, and the age of the detection at the moment it is used.
  • Swap fp32 for int8 on the same tape. Record the latency you bought and the accuracy you spent.
  • Emit each detection as a typed event with t_capture carried end to end.

Before next class

  • HW6 — a perception service that holds a stated fps SLO, with the latency and accuracy table that proves it.
  • P2 spec — commit to one intelligence module: a surveillance detector, a cross-venue signal, or this vision pipeline.
  • Read rtsim/sensors.py. Next week this stage becomes a sensor and the consumer becomes a controller with 20 ms to act.
Code
Get the code
The starter repo is public. Use it as a template, clone it, make install, and put your perception service beside your bot.
Map
Course home
The ten-week map, and where the perception block sits inside it.
Next
Week 8 · The Self-Driving Car →
Close the loop: this week's detection becomes a sensor reading, and a controller has to steer on it.
← Week 6 · Security Analytics Week 8 · Real-Time Perception II →