A CSI camera module stuck at 15 fps inside a Python loop usually has a healthy link behind it. An 8 MP module streams well past that rate into the board's own preview tool, so the missing frames are lost between picam2.capture_array() and your detector. This guide gives you the two measurements that split the problem in half — a native-tool link test and a loop timing test — then works through the four causes in order of probability, the corrected configuration, and the frame-budget arithmetic that tells you when the board, not the code, is the cap.
Prove the link before you touch any code
Run the native tool for your connection. Both commands bypass Python entirely, so the rate they report is what the sensor, the link and the driver deliver together.
For the CSI path, a live preview at the requested rate with no Python overhead:
rpicam-still -t 0 --framerate 60
For a USB UVC camera, a raw streaming test at the resolution and pixel format you plan to run. The tool prints the delivered rate while it streams:
v4l2-ctl -d /dev/video0 --set-fmt-video=width=640,height=480,pixelformat=MJPG --stream-mmap --stream-count=-1
The decision is binary. If the native tool reaches your target, the sensor and link are fine and every missing frame afterwards is a pipeline fault. If it also falls short, the problem is the requested format or the bus. A UVC camera advertised at 360 fps, for example, reaches that figure at 640 × 480 only with MJPG, the compressed format the number assumes.
The four causes, in order of probability
- The frame-rate control sits on a configuration you never apply. In the failing script, FrameRate is set on the video configuration, configure("preview") applies a different one, and create_still_configuration(buffer_count=3) creates a third that is never applied at all. The camera runs at whatever rate the applied configuration selects, not the 30 requested. Test: timestamp 100 capture-only frames; a steady 15 fps with a healthy link points here first.
- The loop copies full-resolution RGB frames. A full-size RGB888 frame from an 8 MP-class sensor is roughly 12 MB, and a worked example in the Picamera2 issue tracker measured about 10 fps with naive capture_array at that size, because every frame is copied into Python as raw bytes. Test: print the array's dimensions and compare them with the detector's needs.
- Per-frame work runs inside the grab loop. Whatever runs after capture_array() sets the ceiling at its own reciprocal: 66.7 ms of work per frame is 15 fps however fast the camera streams. Test: comment out everything after the capture and re-measure.
- On the USB path, an uncompressed format was negotiated. Advertised maximums assume MJPG at reduced resolution; a capture session that never requests MJPG often lands on an uncompressed mode the bus cannot carry at speed. Test: the v4l2-ctl command above, once with MJPG and once with the format your capture session negotiated.
Fix the configuration so 30 fps is actually 30
Apply one configuration for the main loop and set the rate on that same object. Drop the still configuration unless you also take stills.
picam2.preview_configuration.main.size = (800, 600)
picam2.preview_configuration.main.format = "RGB888"
picam2.preview_configuration.controls.FrameRate = 30.0
picam2.preview_configuration.align()
picam2.configure("preview")
- Size the main stream to the detector, not the sensor: 800 × 600 RGB888 is 800 × 600 × 3 B = 1.44 MB per frame, about one eighth of a full-size RGB888 frame.
- Call align() after sizing; it adjusts the request to the nearest size the pipeline supports.
- Start the loop and measure the delivered rate with a monotonic clock over 100 frames of capture_array() alone.
- Compare with the native-tool rate. When the two agree, put the detection back in and measure again.
Why the USB switch made it worse
Uncompressed VGA costs 640 × 480 × 2 B = 614 kB per frame in a 2-bytes-per-pixel mode such as YUY2. USB 2.0 moves about 35 MB/s in practice out of its 480 Mbps bus, so uncompressed VGA tops out near 35 / 0.614 ≈ 57 fps — below the CSI rate you were escaping. The 360 fps figure exists because MJPG compression shrinks each frame under the bus limit, and the streaming test confirms it at 640 × 480. Request MJPG from the capture session before the first frame, and remember the bus is shared with every other device plugged into it.
The frame budget decides everything after the link
The arithmetic clears the obvious suspects. Capturing 800 × 600 RGB888 moves 1.44 MB × 30 = 43 MB/s at the 30 fps target, far under the roughly 120 MB/s the tracker example shows Python sustaining. The cv2.rotate(frame, cv2.ROTATE_180) call moves about 2.9 MB read plus write per frame; even at a pessimistic 1 GB/s of copy bandwidth that is roughly 3 ms, not a 66.7 ms problem.
What remains is the misapplied frame-rate control and the detector itself. If detection costs more than 16.7 ms, no camera upgrade reaches 60 fps — shrink the region of interest or vectorize the pixel work first. And 360 fps leaves 2.8 ms for grab, decode and detection combined, a budget that belongs on multi-core industrial mini PCs, not a Pi-class board; for ground-based scanning, set the rate from travel speed, not a spec-sheet maximum.
When it really is the board
If capture-only fps meets the target but capture-with-detection does not, the detector is the cap and hardware will not help. If capture-only is short at the resolution you need, change the camera path. On a moving platform, rolling-shutter skew adds a second reason to go global shutter:
For a USB3 path with no skew at speed, the LS-BCM-0003 pairs a 1/1.8" global-shutter sensor with USB3 and is specified at 120 fps. That covers a 60 fps target with margin for exposure control.
For a GigE path instead, the MV-CB016-10GM-M-W is a 1.6 MP board-level camera with an M12 mount and a global shutter over GigE.
- Sensor format
- 1/1.8"
- Lens mount
- C-mount
- Sensor format
- 1/2.9"
- Lens mount
- M12
Higher-rate or higher-resolution targets move you to industrial area scan cameras over GigE, USB3 or faster links. The camera finder filters by sensor format, interface and shutter type, the system configurator starts at the camera step, and compact modules sit in the board-level camera category.
Prevention: measure delivered, never requested
- Log delivered fps with a monotonic clock in every long-running capture loop; the stereo camera low fps, no detection guide turns that into a procedure.
- Match the configured frame rate to the processing rate so buffers do not silently fill; the smart camera buffers fix covers the failure mode.
- Keep per-pixel Python loops out of the hot path; the per-pixel loop fix shows why one such loop costs more than the whole frame budget.
- If you must log full-resolution frames while inspecting live, split the streams; the full resolution capture with a low res preview guide shows the pattern.
- The same link-first discipline applies to industrial cameras: camera stuck at 30 fps in a Python GenTL loop proves a link before code gets blamed.
- For short events, trigger the camera instead of streaming continuously; the hardware trigger grab loop in Python is the pattern.
More systems-side rate fixes sit on the systems topic page.
Checklist before you order anything
- Native tool reaches the target rate at your resolution and format: the link is proven, not assumed.
- FrameRate is set on the configuration object you actually apply, and that is the stream the loop captures from.
- The main stream is sized to the detector; no full-resolution RGB copy happens per frame.
- Capture-only and capture-with-detection fps are both measured over at least 100 frames; the gap is the processing cost.
- USB runs are confirmed on MJPG, not an uncompressed mode.
- Per-frame work fits the frame period — 16.7 ms at 60 fps — with at least a third of headroom.
Questions
Does Picamera2 work with a USB camera?
No. Picamera2 drives the CSI camera through the board's camera subsystem; a USB camera is a V4L2 device that OpenCV VideoCapture or v4l2-ctl reads. Keep one code path per camera and test each with its native tool before adding detection code, because a shared script that switches devices at runtime negotiates different formats on each path and masks the real rate.
What frame rate does a field color-detection camera actually need?
Derive it from ground speed. At 1 m/s and 30 fps the scene advances 33 mm between frames, so any plant wider than 33 mm appears in at least two consecutive frames, which is enough to detect and confirm. Raise the rate only when a target can enter and leave the view between two frames, and shorten exposure so travel blur stays under a pixel.
Do I need a global shutter camera on a moving platform?
A rolling-shutter sensor exposes the frame line by line, so platform motion skews each frame. Keep exposure at or below about 1 ms to hold the distortion down; when available light forces longer exposures, a global shutter is the fix. Board-level global-shutter modules over USB3 or GigE are the straightforward swap, and their rate headroom usually covers a 60 fps target too.
How do I measure the fps my loop really delivers?
Read a monotonic clock before the first frame and after frame 100, divide 99 by the elapsed seconds, and run it twice: once with only capture_array() in the loop, once with the detection included. The capture-only figure is your pipeline ceiling; the difference between the runs is the processing cost. Compare both against the native tool's rate from the link test.

