A camera feed that opens in a browser tab is the quickest way to share a vision station with the people who need to see it: no client software, no VNC session, just a URL. The failure most builds hit is structural. The Flask route calls the frame handler once and wraps the result in a Response, so the browser receives one chunk, paints one frame, and the stream ends. This guide shows the pattern that keeps running: a grab thread that encodes each frame into a shared slot, and a /video_feed route that yields one JPEG part per frame for every connected viewer. You will wire it step by step, prove it with curl, and size the serving link for your viewer count.
Why the one-call response fails
A multipart/x-mixed-replace response is not one body. It is an open HTTP response whose body is a sequence of parts: a boundary line, a Content-Type header, a blank line, the JPEG bytes, and a blank line, repeated for as long as the connection stays open. The browser repaints the image each time a part completes.
The response body must therefore be a generator: a function containing a yield statement returns a generator object when called, and Flask drains that generator one chunk at a time for as long as it keeps yielding. The failing pattern from first attempts wraps a direct call instead:
return Response(frame_handler(), mimetype="multipart/x-mixed-replace; boundary=frame")
Here frame_handler is the per-frame callback an SDK grab sample defines, and the route calls it directly. That breaks twice. The callback expects the camera and frame objects as arguments, so calling it with none raises a missing-arguments TypeError before a byte reaches the browser. Called correctly, it still returns one chunk of bytes: the browser renders one frozen frame and the connection closes, because a Response built from bytes has a fixed length. A multipart stream needs a body that never finishes.
Decision: the route returns Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame"), where generate() yields one part per frame and never returns.
The pattern: grab thread, shared slot, per-viewer generator
Keep the camera side and the viewer side decoupled with one shared variable:
- One daemon thread grabs frames from the camera forever, JPEG-encodes each one, and stores the bytes in a module-level slot, signalling a threading.Condition each time.
- Each /video_feed request runs its own generator that waits on the condition, copies the latest bytes under the lock, and yields them as one multipart part.
- A slow viewer just skips ahead to the newest frame. The camera never waits for a browser.
Capture and JPEG encoding release the interpreter lock while they run, so the grab thread and Flask's viewer threads overlap cleanly in one process. When you later add heavy per-frame image processing, that is the point where the grab loop needs its own process, which the multiprocessing fix for camera loops blocked by Python threading covers.
What you need
- A camera your code can read: GigE and USB3 area scan cameras all deliver frames into your process once the driver runtime is installed, and any of them feeds this pattern. The camera finder filters them by interface and resolution.
- A host that stays on the same network as camera and viewers. A bench PC works; on the line, vision controllers are built for headless duty.
- Python with Flask and OpenCV installed (pip install flask opencv-python), plus the camera driver's runtime.
- Sensor format
- 1/1.7"
- Interface
- USB3
- Resolution
- 12 MP
- Shutter
- Rolling
The worked example below runs the numbers for a 12 MP USB3 camera printing 4000 × 3000 at 30 fps; the LS-CMR-0166 is that camera, and a comparable model streams the same way.
- LAN ports
- 5+ LAN ports
- USB 3 ports
- 3-4 USB 3 ports
- Light outputs
- 4 built-in channels
- Memory
- 8 GB
On the line, a vision controller keeps the feed running headless: the MV-VC3301H-128G66 pairs an Intel Core i3 with 8 GB of RAM and a 128 GB SSD, and its 5+ LAN ports let camera traffic and viewer traffic ride separate ports.
Wire it up, step by step
- Declare the app and the shared slot. One module-level variable holds the latest JPEG bytes, and a Condition guards and signals it. Both exist before any thread starts.
import threading
import cv2
from flask import Flask, Response
app = Flask(__name__)
latest_jpeg = None # bytes, the most recent encoded frame
frame_ready = threading.Condition() # signalled once per new frame
- Run the grab loop in its own daemon thread. It reads frames forever, encodes each one, stores the bytes under the lock, and wakes every waiting viewer. Quality 80 keeps the parts small; the library default of 95 makes them noticeably larger, so raise the value only if the browser image looks soft.
def grab_loop():
global latest_jpeg
cap = cv2.VideoCapture(0) # driver index, or the camera's RTSP address
while True:
ok, frame = cap.read()
if not ok:
continue
ok_enc, jpeg = cv2.imencode(".jpg", frame,
[cv2.IMWRITE_JPEG_QUALITY, 80])
if not ok_enc:
continue
with frame_ready:
latest_jpeg = jpeg.tobytes()
frame_ready.notify_all()
- Write the generator that yields one part per frame. Each yielded bytes object is one complete multipart part: boundary, header, blank line, JPEG, blank line. The \r\n endings are part of the format and stay exactly as written.
def generate():
while True:
with frame_ready:
frame_ready.wait()
jpeg = latest_jpeg # copy under the lock
yield (b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n"
+ jpeg + b"\r\n\r\n")
- Return the generator from the route. The mimetype names the boundary, and the boundary string in the body must match it exactly.
@app.route("/video_feed")
def video_feed():
return Response(generate(),
mimetype="multipart/x-mixed-replace; boundary=frame")
- Start the grab thread before Flask serves, reloader off. The reloader restarts the whole process and only works from the main thread, so it cannot live in this layout; threaded=True gives every viewer its own connection thread.
if __name__ == "__main__":
threading.Thread(target=grab_loop, daemon=True).start()
app.run(host="0.0.0.0", port=8888, threaded=True, use_reloader=False)
Run it and prove it works
Start the script, then check three things:
-
curl -sN -D - -o /dev/null --max-time 5 http://127.0.0.1:8888/video_feedprints a 200 status andContent-Type: multipart/x-mixed-replace; boundary=frame, and the connection stays open until the timeout cuts it. -
curl -sN http://127.0.0.1:8888/video_feed | head --bytes 48prints--frameand the JPEG part header. A boundary that keeps repeating is the stream working. - Open
http://127.0.0.1:8888/video_feedin a browser on the host, then from another machine at the host's IP. Both repaint live, and each tab counts as one viewer in the bandwidth math below.
While you tune quality, print the part size from the grab loop: print(len(jpeg)) after the encode gives the bytes per frame, the input the bandwidth example consumes.
SDK callback cameras: same rule, different entry point
Many machine-vision SDKs do not offer a read() call; they call a handler you register once per completed frame, with the camera and frame objects as arguments. The rule is unchanged: the handler takes over the body of grab_loop. On each complete frame it encodes, stores the bytes under the lock, signals the condition, and hands the buffer back to the SDK the way its API requires, so the buffer pool never empties. It must never build or return an HTTP response, and the route must never call it — that direct call is the missing-arguments TypeError from the failing pattern.
Two details on the SDK side decide whether the stream survives contact with the line:
- Give the SDK a deep buffer pool. A handler that only encodes and signals keeps up, but a shallow pool still drops frames on bursts; why three frames beat one in the buffer count works that arithmetic.
- On GigE Vision cameras, run the packet-size adjustment your SDK exposes once, before streaming starts. It sets the GVSP packet size for the link and prevents dropped frames on large transfers.
- If the callback stops firing, the fault is in acquisition state, not Flask; keeping a camera callback alive walks that failure down.
Worked example: a 12 MP, 30 fps camera on one 1 Gbit/s port
Inputs, and where each comes from: the camera prints 4000 × 3000 pixels at 30 fps (LS-CMR-0166 data sheet). The JPEG size per frame is the number you measure on your own scenes, with the print(len(jpeg)) line from the test section; this example takes 2 MB per frame at quality 80, a typical value for a detailed industrial frame. The serving link is one 1 Gbit/s Ethernet port, which carries about 940 Mbit/s of real TCP traffic.
The rule in words: every viewer's connection receives its own copy of every JPEG, so port load equals JPEG bytes per frame, times frame rate, times viewer count, converted to bits.
Arithmetic, viewer by viewer:
- Raw frame: 4000 px × 3000 px × 3 bytes = 36 MB per frame before compression.
- Assumed JPEG at quality 80: about 2 MB per frame, an 18:1 compression on this content. Replace it with your measured value.
- Per full-size viewer: 2 MB × 30 per second = 60 MB/s, and 60 × 8 = 480 Mbit/s.
- Port budget: about 940 Mbit/s usable. One viewer takes 480 of 940, about 51 percent, and fits. Two viewers need 960 Mbit/s and do not fit.
- Dashboard copy at 1280 px wide: (1280 ÷ 4000)² ≈ 0.10 of the pixels, so about 0.2 MB per frame; 0.2 × 30 × 8 = 48 Mbit/s per viewer, and 940 ÷ 48 ≈ 19 viewers on the port.
Result: one full-resolution viewer per 1 Gbit/s port, or about 19 dashboard viewers on the downscaled copy.
Decision: treat one full-size viewer per GigE port as the cutoff. A second port doubles it — the 5+ LAN ports on the controller above exist for exactly that split — and the 1280-wide copy carries a wall of dashboards on the same wire.
Common mistakes and their symptoms
- One frozen frame, then nothing. The route returned bytes instead of a generator. Return Response(generate(), ...) with a yield inside generate().
- A TypeError about missing arguments, raised from the route. The route called an SDK callback with no arguments. The SDK calls handlers; the route calls generate().
- A broken-image icon or a half-painted frame. Part formatting is off. Each part needs \r\n after the boundary line, after the header, and two of them after the JPEG bytes.
- The stream dies when you save the file. debug=True left the reloader on, and a reloader cannot restart from a thread. Pass use_reloader=False.
- The camera stops after a handful of frames. The SDK's buffers were never handed back. Return every buffer, on every frame.
- The image tears when the scene moves. The generator yielded a buffer the grab thread was already overwriting. Copy the bytes under the lock first.
When multipart JPEG is the wrong tool
Keep it for what it does well: live viewing on the machine network, dashboard tiles, and a URL anyone on the floor can open. The Flask development server carries a bench; a plant-wide page belongs behind a production WSGI server with threaded workers. Switch tools in three cases:
- Recorded footage or playback: write files or push RTSP instead; decoding barcodes from a live RTSP or MJPEG stream starts from the RTSP side of that fence.
- Viewers across a WAN: 480 Mbit/s per viewer will not fit an uplink. Serve stills or RTSP across the wide area and keep the multipart feed on the local network.
- An HMI or SCADA screen as the viewer: the same MJPEG-over-HTTP parts appear there, and this MJPEG and JPEG still streaming setup guide shows them wired into an HMI platform. If the surface is an HMI panel rather than a full SCADA node, showing the stream in an HMI web viewer widget is the lighter path, and embedding a live camera feed in your own web dashboard is lighter still.
To pick the camera and the host as one order, start the camera configurator or contact the team. More fixes from this bench sit on the systems topic page.
Questions
Why does /video_feed load one frame and then stop?
The route returned plain bytes, so the browser received a single part and the multipart stream ended. A function that contains a yield statement returns a generator object when you call it; pass that generator to Response and Flask keeps the connection open, yielding one part per frame. Confirm with curl: a working stream repeats the --frame boundary forever, a broken one prints it once and closes.
How many browsers can watch one Flask camera stream?
As many as your link and CPU allow. Each viewer runs its own generator, so the extra CPU per viewer is small, but bandwidth adds up: at 30 fps with roughly 2 MB JPEGs, each full-size viewer costs about 480 Mbit/s, so one 1 Gbit/s port carries one full-size viewer. Serve a downscaled 1280-wide copy and the same port carries about 19 viewers.
Should JPEG encoding happen once or per viewer?
Encode once in the grab thread when every viewer takes the same size, and share the encoded bytes; each viewer then only copies them, which costs almost no CPU. Encode per viewer only when you downscale for dashboards, because each size needs its own cv2.imencode call. Pushing quality past 90 multiplies the file size for little visible gain; 70 to 90 is the useful range.
Why does my SDK camera stop streaming after a fixed number of frames?
The SDK delivered frames into a fixed pool of buffers and the handler never handed them back, so the pool emptied. Return each buffer to the camera the way the SDK's API requires, on every frame, and give the pool enough depth to absorb bursts. Viewer count has nothing to do with it: the grab side runs at full speed whether or not anyone is watching.
Does Flask multipart streaming work over Wi-Fi or across sites?
It works anywhere HTTP works, but the bandwidth math follows the stream: a 480 Mbit/s viewer load will not fit on typical Wi-Fi, and across a WAN it exceeds most uplinks entirely. Keep full-size viewing on the wired machine network. For remote or recorded viewing, push an RTSP stream instead and reserve the multipart feed for the local cell and dashboards.

