You ran the same camera module, the same USB cable and the same example scripts on both interpreters. Under Python 2 the frames stream to the host; under Python 3 the display stays black and the pixel counter never moves. The camera still captures on its own, so sensor and optics are fine. That combination — a working camera, a port that enumerates, a silent Python 3 host — points at one place: the boundary where the host-side remote procedure call (RPC) library reads bytes off the serial link. This walkthrough isolates the link in four tests, fixes the Python 2-to-3 byte handling, and gives the transfer arithmetic that separates a code fault from a bandwidth limit.
What the symptom already tells you
Three facts narrow the fault before you open a terminal. The camera produces frames on its own, so sensor, lens and on-board firmware are cleared. The module still enumerates as a serial device, so cable and connector are cleared. And the failure follows the interpreter, not the hardware: same wire, same bytes, different result. A fault that tracks a software version this tightly is a host-code fault until proven otherwise. In one case matching this pattern exactly, the stream returned under Python 3 as soon as the host-side scripts were current — the module, the cable and the camera settings never changed. Hold that order: clear the camera, clear the port, prove the bytes, then fix the host code.
Why Python 3 breaks a byte protocol Python 2 tolerated
Python 2 had one string type, and it was 8-bit: a serial read returned text-like bytes, so indexing, ord() and comparisons against string literals all worked by accident. Python 3 split text from binary. bytes is now its own type with stricter rules, and a protocol library written against Python 2 habits fails in predictable ways.
| What you see | Python 3 cause | Fix |
|---|---|---|
| TypeError: cannot concat str to bytes | Reply built from "" literals | Use b"" literals for every binary constant |
| UnicodeDecodeError mid-frame | Frame payload passed through .decode("utf-8") | Decode only ASCII header fields; the payload stays bytes |
| Header match never succeeds, loop times out | bytes compared against a str magic value | Compare against a bytes literal such as b"\xab\xcd" |
| TypeError from ord(buf[0]) | Indexing bytes already yields an int | Drop ord() and use the value directly |
| Window opens, image stays grey | Display fed the wrong object type or length | Build the image from bytes with numpy.frombuffer |
The display layer can fail independently of the protocol: a window that never paints while byte counts climb is a display-library problem, not a transport problem. Treat the protocol and the display as two separate proofs.
Four tests, in the order that finds it fastest
- Prove the camera alone. Run the module's own capture script — save a frame to its on-board storage or preview it in the vendor's IDE. A saved frame clears the camera completely; nothing downstream can be its fault.
-
Prove the port. On Linux, run
ls /dev/ttyACM*; on Windows, check the COM entry in Device Manager. A USB virtual COM port enumerates as ttyACM. Open it from a plain Python session — the baud setting is a formality on a USB CDC link because the data moves at USB speed, but set 115200 so both sides agree. Two virtual COM devices on one host swap between ttyACM0 and ttyACM1 after a replug, so confirm you are reading the right one; assigning cameras to ports reliably covers pinning a stable device path. -
Dump raw bytes before the library. A few lines with no protocol logic separate the wire from the parser:
import serial
port = serial.Serial("/dev/ttyACM0", 115200, timeout=1)
while True:
block = port.read(64)
print(len(block), block[:8].hex())
Bytes scrolling with a repeating header pattern mean the module transmits and the host receives: the fault is in the parse, so apply the fix pattern below. A dump that stays empty means the module is not sending — check that its script selected the USB interface mode rather than the UART pins, and that no other process holds the port open. If the interface itself is in doubt, camera module interface compatibility: what to verify lists the checks.
- Re-run the library's own example receiver under Python 3. Use a current checkout, not an old vendored copy: these libraries carry the Python 3 fixes upstream, and in the case above the current scripts ran on both interpreters unchanged.
Worked example: 160 x 120 RGB565 frames over the USB debug link
Inputs: the module's smallest preview format from its format list, QQVGA at 160 x 120 pixels with 2 bytes per pixel in RGB565; a 6-byte packet header (2 magic bytes, 4 length bytes); and the USB 2.0 full-speed link, 12 Mbit/s raw, that a virtual COM port rides on.
The arithmetic, written out:
- Payload: 160 x 120 x 2 = 38,400 B; with the 6-byte header, 38,406 B — call it 38.4 kB.
- Bits per frame: 38,400 x 8 = 307,200 bit.
- Best case on the wire: 307,200 / 12,000,000 = 0.0256 s, about 26 ms, a 39 fps ceiling that no CDC link reaches.
- Realistic sustained throughput for a USB virtual COM port is around 0.8 MB/s: 38,400 / 819,200 = 0.047 s, about 47 ms per frame, roughly 21 fps.
- The same frame on the module's 115,200 bit/s UART pins: 307,200 / 115,200 = 2.67 s per frame, or 0.37 fps — fine for one snapshot, dead as a live view.
One host-side trap moves the number more than the wire does: a read(1) loop makes one Python call per byte, and at roughly a microsecond per call that adds about 38 ms per frame — as much as the whole transfer — before the link is even loaded.
Decision: with bytes confirmed flowing in test 3, expect about 21 fps for raw QQVGA over this link. A measured 1 to 3 fps means the host read path is at fault — per-byte reads or repeated short timeouts — not the bandwidth. Need three or more times that rate and you compress on-module or change transport, as the last section does. The same hunt on a production camera is covered in finding the frame-rate bottleneck.
The fix pattern for Python 3
Four rules cover nearly every failure in the table above:
- Binary constants get a
bprefix; magic values compare as bytes. - The frame payload is never decoded; only ASCII header fields are.
- Reads happen in blocks sized to the protocol, never byte by byte.
- The display receives bytes or a buffer object, never text.
import serial, struct
port = serial.Serial("/dev/ttyACM0", 115200, timeout=1)
def read_exact(n):
out = bytearray()
while len(out) < n:
out += port.read(n - len(out)) # one call per block, not per byte
return out
head = read_exact(6)
magic, length = struct.unpack("<2sI", head)
if magic != b"\xab\xcd": # bytes literal, never the str form
raise IOError("lost frame sync")
frame = read_exact(length) # payload stays bytes end to end
On the send side the same rule applies in reverse: port.write("snapshot".encode("ascii")), or a b"" literal. For display, wrap the payload once — numpy.frombuffer for raw formats, reshaped to the frame geometry, or a JPEG decode call — and hand the result to the window. The host-side loop is the same discipline as camera module image acquisition without saving files, and a fuller walkthrough of streaming camera frames to a PC over the module's debug protocol shows the receive side end to end.
When it is not the Python 3 boundary
- A stale library copy. If your checkout predates the Python 3 fixes, you are debugging a solved bug. Pull the current release before you patch anything.
- The display layer. A window that opens but never paints, while the test-3 dump scrolls, is a display-library problem — feed it bytes and check the pixel format it expects.
- The device path moved. After a replug, open by a stable identifier (see the udev answer in the questions below) instead of trusting ttyACM0.
- Real dropouts. A port that vanishes mid-run — the USB device gone, not empty frames — is power or cabling. The recovery steps in fixing Python USB camera disconnect crashes apply to any USB camera.
- An Ethernet camera instead. There the analogous symptom, frames that arrive blank or torn, is packet loss: setting the GigE camera packet size before acquisition is the first lever, and diagnosing GigE Vision stream packet loss takes it from there.
Moving up from the debug protocol
The debug link is for bring-up: roughly a megabyte per second of realistic throughput and a Python parse on the host. For any production inspection, move to a machine-vision camera with a real interface. USB3 puts 5 Gbit/s (625 MB/s) on the wire and GigE 1 Gbit/s (125 MB/s) with 100 m cable runs, so the link stops being the limit and the frame grabbers and host adaptors do the receiving.
For a step up in transfer rate from the same bench position:
- Sensor format
- 1/1.7"
- Interface
- USB3
- Resolution
- 12 MP
- Shutter
- Rolling
The LS-CMR-0166 is a 12 MP USB3 area scan camera that runs at 30 fps from a 1/1.7-inch rolling-shutter sensor. At that resolution the USB3 bus, not the camera, sets your ceiling.
A host port and a cable complete the move:
- Interface
- USB3
- Ports
- 4 ports
The MV-GU2204 is a quad USB3.0 adaptor with 4 ports for hosts whose onboard ports share bandwidth, and the LS-CBL-0219 is a 3 m USB3.0 camera cable, high-flex for drag-chain use. Browse the full area scan cameras range or the camera finder, pick longer runs from machine vision cables, and start a matched system at the camera step of the configurator.
Checklist before you blame the camera
- A frame saves to the module's own storage — camera cleared.
- The port lists as ttyACM0 and does not bounce in and out — power and cable cleared.
- The 64-byte hex dump scrolls a repeating header — link cleared, fault is host-side.
- The library checkout is current, with the Python 3 byte fixes in.
- No .decode() anywhere near a frame payload; magic values are b"" literals.
- Reads are block-sized, never read(1) loops.
- Measured rate sits near the computed ceiling — about 21 fps for raw QQVGA on this link.
- The display receives bytes or a buffer, and its expected pixel format matches what you send.
If every line passes and frames still stop between module and host, gather the four test outputs and bring them to us through the contact page. A fault that survives this list is rare enough to be interesting, and the systems topic page collects the related integration guides.
Questions
Is the camera module faulty if Python 2 streams and Python 3 does not?
No. The same bytes leave the module in both cases; only the host-side handling differs. Python 2's 8-bit str type let protocol code mix text and bytes freely, and Python 3's strict bytes type exposes every such mix as a TypeError, a UnicodeDecodeError or a silent header mismatch. Run the on-module capture test to clear the camera, then fix the host library. Automated 2-to-3 converters do not fix this class of bug: they translate syntax, not byte-versus-text semantics.
Does the baud rate matter on a USB virtual COM port?
Not for throughput. A USB CDC link moves data at USB speed regardless of the baud figure you pass when opening the port, so raising it from 115200 to 921600 changes nothing. Set 115200 and leave it. The figure does matter on the module's physical UART pins, where 115200 bit/s needs about 2.7 seconds for a 38,400-byte QQVGA frame — one reason the debug protocol rides on USB instead.
How much does compressing frames to JPEG help on this link?
A lot. A 160 x 120 RGB565 frame is 38,400 bytes; JPEG at a typical 10:1 ratio cuts it to about 3,840 bytes. At the realistic 0.8 MB/s CDC rate that is 3,840 / 819,200 = 4.7 ms of transfer instead of 47 ms, so the wire stops being the limit and the module's encode time sets the pace — expect 30 to 60 fps rather than 21. Compress on-module whenever the host only displays or analyzes, and keep raw transfer for measurement.
Frames arrive but look torn or half grey — is that the same bug?
Related, but the fault is short reads. A serial read returns whatever has arrived when the timeout expires, which can be half a frame, and correct length checking in Python 3 surfaces this instead of hiding it. Loop until the protocol length is satisfied — the read_exact pattern shown above — and set the timeout to at least twice one frame time: about 100 ms for the 47 ms QQVGA frame.
How do I stop the port moving between ttyACM0 and ttyACM1?
Pin it with a udev rule keyed to the device rather than the plug order. A line like SUBSYSTEM==\"tty\", ATTRS{idVendor}==\"abcd\", ATTRS{idProduct}==\"1234\", SYMLINK+=\"cam0\" gives you a stable /dev/cam0 to open; read the vendor and product IDs from lsusb. Opening by the USB serial number works the same way when two identical modules are attached to one host.


