System designHow-to

Send images over a TCP socket in Python with a length header

Python TCP sockets carry no message boundaries, so image two sticks to image one. Add an 8-byte length header and recv exactly that many bytes per capture.

LumaSpec Vision10 min read Share on LinkedIn

On this page 11 sections
  1. 01Why the first image arrives intact and the second never does
  2. 02The framing protocol: fixed command, 8-byte header, payload
  3. 03Read exactly n bytes: the recv_exact helper
  4. 04Server: one file per capture
  5. 05Client: encode once, send once
  6. 06Worked example: a 1,000,000-byte quality-90 JPEG on GigE
  7. 07Prove it: two images in one send
  8. 08Mistakes that put two images in one file
  9. 09When the peer is a PLC, a smart camera or a vision camera
  10. 10Checks before you deploy
  11. 11Questions

You type take picture, the first JPEG lands perfectly, and every capture after that shows the same photo. The socket is not broken and neither is the camera. TCP delivers one ordered byte stream with no message boundaries, so nothing on the wire marks where image one ends and image two begins - and the classic example code saves every capture into one file it opened once. The fix is framing: fixed-length commands, an 8-byte length prefix in front of every image, and recv() loops that read exactly that many bytes into a new file per capture. This guide gives you the protocol, the server and client code, the buffer arithmetic on a GigE link, and the test that proves image two arrives as image two.

Why the first image arrives intact and the second never does

TCP is a reliable byte stream: bytes arrive complete, in order, and identical to what was sent, but with no structure on top (Transmission Control Protocol). Three consequences break the take-picture pattern:

  • One recv() is not one image. recv(1000000) returns whatever is in the buffer right now - half a JPEG, or one and a half. Buffer size does not change this; only a length header tells the receiver where to stop.
  • One file opened once collects everything. Open photo.jpg before the loop and every capture appends to it, so image two's bytes land directly after image one's.
  • A JPEG viewer stops at the first end-of-image marker. The combined file renders as image one forever while its size grows - the exact symptom of a second take picture showing the first photo.

Streams can also merge or split your commands: two take picture requests 100 ms apart may arrive inside one recv(1024), or one may arrive in pieces. Fixed-length commands remove that problem too.

The framing protocol: fixed command, 8-byte header, payload

Both sides agree on three message types. Every field is either a fixed size or an announced length, so one recv() can never cross a message edge:

Message Direction Size Content
Command server to client 16 bytes ASCII, space-padded: take picture, exit
Length header client to server 8 bytes struct.pack('>Q', n): payload length, unsigned 64-bit
Payload client to server n bytes one JPEG, encoded at quality 90
  1. The server sends a 16-byte command, space-padded to its fixed length.
  2. The client encodes one frame with cv2.imencode at JPEG quality 90.
  3. The client prepends the payload length and calls sendall once, header plus payload together.
  4. The receiver reads exactly 16 bytes, then exactly 8, then exactly n.
  5. The receiver writes one new file per capture, named from a counter.

The header comes from struct.pack('>Q', n): > is big-endian network order, Q is an unsigned 64-bit integer, and struct.calcsize('>Q') returns 8. Four bytes would also do - >I caps a single image at 4,294,967,295 bytes, about 4.3 GB - but eight cost nothing. One more subtlety from the way TCP works: even the length prefix itself can arrive split across reads, so every fixed-size read loops too.

Read exactly n bytes: the recv_exact helper

Every read in the protocol goes through one helper. It loops until it holds n bytes, caps the final read at the remainder so it cannot swallow the next message's header, and returns short only when the peer has closed:

import socket
import struct

CHUNK = 4096  # int: max bytes one recv() call may return

def recv_exact(sock: socket.socket, n: int) -> bytes:
    # returns exactly n bytes; returns fewer only if the peer closed
    parts = bytearray()
    while len(parts) < n:
        chunk = sock.recv(min(n - len(parts), CHUNK))
        if not chunk:
            break  # recv() returned b'': peer closed, not 'no data yet'
        parts.extend(chunk)
    return bytes(parts)

That b'' check matters: without it, a vanished client turns the server loop into a busy spin on empty reads.

Server: one file per capture

Three lines fix the classic failure: the output file opens inside the loop under a counter-generated name, every read goes through recv_exact, and a short header or payload breaks the connection instead of writing junk:

HOST = ''      # str: all local interfaces
PORT = 8000    # int: same port on both sides
CMD_LEN = 16   # int: fixed-length ASCII command
HDR = 8        # int: bytes in a '>Q' header

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((HOST, PORT))
server.listen(1)
print('listening on port', PORT)
conn, _ = server.accept()
conn.settimeout(30.0)  # seconds: recv raises socket.timeout when the line goes silent

shot = 0  # int: capture counter for file names
while True:
    cmd = recv_exact(conn, CMD_LEN).rstrip()
    if cmd == b'exit':
        break
    if cmd != b'take picture':
        continue
    head = recv_exact(conn, HDR)
    if len(head) != HDR:
        break  # peer closed mid-message
    (n,) = struct.unpack('>Q', head)
    payload = recv_exact(conn, n)
    if len(payload) != n:
        break  # peer closed mid-image: discard, never write a partial file
    shot += 1
    name = f'capture_{shot:04d}.jpg'
    with open(name, 'wb') as f:  # a new file per capture
        f.write(payload)
    print(name, n, 'bytes')

conn.close()
server.close()

Binding HOST = '' accepts connections on every interface. Binding to the one address socket.gethostbyname(socket.gethostname()) returns accepts only traffic addressed to that adapter - the classic way a two-NIC machine looks unreachable.

Client: encode once, send once

The client mirrors the framing and sends each image exactly once. A second sendall of the same bytes puts two images on the wire under one length header, and the receiver writes the overflow into the next image's slot:

import cv2

HOST = '10.0.0.12'  # str: the receiver's address
PORT = 8000         # int: same port on both sides
CMD_LEN = 16        # int: matches the server

def send_jpeg(sock: socket.socket, frame) -> None:
    ok, enc = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), 90])
    if not ok:
        raise RuntimeError('JPEG encode failed')
    payload = enc.tobytes()  # bytes: exactly one image
    sock.sendall(struct.pack('>Q', len(payload)) + payload)  # header + payload, one call

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect((HOST, PORT))
cap = cv2.VideoCapture(0)  # open once, before the loop
while True:
    cmd = recv_exact(client, CMD_LEN).rstrip()
    if cmd == b'exit':
        break
    if cmd != b'take picture':
        continue
    ok, frame = cap.read()
    if not ok:
        continue
    send_jpeg(client, frame)

cap.release()
client.close()

Worked example: a 1,000,000-byte quality-90 JPEG on GigE

Inputs. Payload n = 1,000,000 bytes - the buffer the original receiver reserved for one image, and a realistic size for a quality-90 JPEG from a 1080p-class webcam. Header = 8 bytes (>Q), command = 16 bytes, CHUNK = 4096 bytes. Link = GigE: 1,000,000,000 bit/s line rate, with a quiet link moving TCP payload at roughly 800 Mbit/s, about 100 MB/s.

Rule. A recv() call returns whatever has already arrived, so the payload takes at least ceil(n / CHUNK) calls, and wire time is bits over goodput:

recv calls for payload = ceil(n / CHUNK) t_wire = (8 + n) * 8 / R

Arithmetic. Bytes on the wire per capture: 8 + 1,000,000 = 1,000,008. Chunk coverage: 4096 x 244 = 999,424 bytes, leaving 1,000,000 - 999,424 = 576 bytes, so the payload needs 245 recv() calls, plus 1 for the header - 246 per capture, and more in practice because recv() returns partial chunks mid-flight. Wire time: 1,000,008 x 8 = 8,000,064 bits; at 800,000,000 bit/s that is 10.0 ms, and at the roughly 940 Mbit/s best case, 8.5 ms.

Decision. One quality-90 capture of this size costs about 10 ms of wire time, so a 50 ms cycle fits it with 40 ms to spare for acquisition and processing. The single recv(1000000) still fails even with a perfectly sized buffer - it returns only what has arrived, in the same 245 pieces - so the header, not the buffer size, is the fix. When captures need to arrive less than 10 ms apart, one GigE link is the limit: add a second NIC or move to 10GigE.

Prove it: two images in one send

The failure mode is coalescing, so the test forces it: both images leave in one sendall, and the framing must still separate them. socket.socketpair() provides an in-memory connection - no camera, no network:

import socket
import struct

def recv_exact(sock, n):  # same helper as above
    parts = bytearray()
    while len(parts) < n:
        chunk = sock.recv(n - len(parts))
        if not chunk:
            break
        parts.extend(chunk)
    return bytes(parts)

tx, rx = socket.socketpair()   # an in-memory connected pair
img_a = bytes(range(256)) * 4  # bytes: 1024 bytes
img_b = bytes(3000)            # bytes: 3000 zero bytes
tx.sendall(struct.pack('>Q', len(img_a)) + img_a
             + struct.pack('>Q', len(img_b)) + img_b)  # ONE send, both images

(n1,) = struct.unpack('>Q', recv_exact(rx, 8))
got_a = recv_exact(rx, n1)
(n2,) = struct.unpack('>Q', recv_exact(rx, 8))
got_b = recv_exact(rx, n2)

assert (n1, len(got_a), got_a) == (1024, 1024, img_a), 'image one corrupted'
assert (n2, len(got_b), got_b) == (3000, 3000, img_b), 'image two corrupted'
print('framing ok: two intact images from one send')

Running it prints framing ok: two intact images from one send. Remove the header from the send and the same test fails on the first unpack: the receiver reads the first 8 bytes of img_a as a length. End to end, take two shots and check the files:

ls -l capture_0001.jpg capture_0002.jpg
cmp capture_0001.jpg capture_0002.jpg   # must report the files differ
python -c 'import cv2; assert cv2.imread("capture_0002.jpg") is not None, "decode failed"'

Two files of similar size that both decode: image two finally arrives as image two.

Mistakes that put two images in one file

  • Trusting one recv(). recv(n) means up to n, never exactly n - loop through recv_exact.
  • Opening the output file once. With photo.jpg opened before the loop, ten captures write ten JPEGs into one file. Open per capture: capture_0001.jpg, capture_0002.jpg and on.
  • Sending the payload twice. A pattern found in published client code calls sendall on the same bytes two times per capture; the header describes only the first copy and the second lands in the next image's header slot.
  • Reading past the remainder. A final recv(CHUNK) beyond the payload count eats the next header - the reason the helper reads min(n - len(parts), CHUNK).
  • Ignoring the empty bytes. recv() returns b'' only when the peer closed; treat it as a disconnect, close and re-accept.
  • Unframed commands. Two commands inside one recv(1024), or one split across two reads, desynchronizes the loop; 16 fixed bytes keep the command boundary exact.

Camera SDK grabs need the same discipline: incomplete frames stay incomplete - discard, requeue, grab a new one.

When the peer is a PLC, a smart camera or a vision camera

The same framing rules hold for three other peers - and one case where you should not write the protocol at all.

A PLC as peer. Controller-side sends often write fixed-size buffers, so a large JPEG must be chunked to fit; one PLC integration hit a 480-byte send buffer, and the fix was the same header-plus-chunks arithmetic (a PLC send instruction with a fixed 480-byte TCP buffer). A worked controller-to-Python TCP example shows the other side of the same exchange (TCP socket communication between a motion controller and a Python client). When a robot controller receives coordinates instead of pixels, the identical fixed-length framing applies, as in send camera offsets to a robot over TCP socket messaging.

A smart camera as peer. If inspection can run on the camera itself, your Python program shrinks to command sender and result reader. The MV-SC5060XM-08M-WBN is a GigE smart camera for exactly that role:

Smart cameras & vision sensorsHikrobot MV-SC5060XM-08M-WBN SC5000X series Smart Camera, 6 MP, Mono, 8 mm focal length, M12-mountMV-SC5060XM-08M-WBN
Interface
GigE
Resolution
6-10 MP
Sensor format
1/2.4"
Shutter
Rolling
$1,629.00View part

The command side of that exchange is the trigger a smart camera over TCP with ASCII commands and read results pattern.

A machine-vision camera as peer. A GigE or USB3 Vision camera does not speak your protocol; you drive it through its SDK against the GenICam standard, which frames the stream for you. The command a GigE Vision camera over Ethernet: capture and save images walkthrough shows that path - the MV-CU020-90GC is a 2.5 MP GigE model in this class:

Area scan camerasHikrobot MV-CU020-90GC 2.5 MP Area Scan Camera, GigE, GMAX4002, ColorMV-CU020-90GC
Sensor format
1/1.7"
Lens mount
C-mount
Interface
GigE
Resolution
Up to 3 MP
$309.00View partBuild a system with it

Browse area scan cameras and smart cameras, or go straight to the camera finder. Once frames flow, feed them straight to inference without temp files: feed socket-streamed frames to YOLO without temp files.

Checks before you deploy

  • Same constants on both ends: CMD_LEN = 16, HDR = 8, >Q big-endian. One byte of disagreement turns every image into garbage.
  • Set a timeout on the accepted connection (conn.settimeout(30.0)), catch socket.timeout, close and re-accept - a silent peer then costs seconds, not the shift.
  • Open port 8000 between exactly the two hosts, and log payload length and elapsed time per capture; a run of short reads is your first sign of a half-closed connection.
  • Bind to '' on the server unless you mean to serve one NIC only. Run Cat5e or better cabling for a GigE camera link; the Ethernet cables for cameras the shop carries cover that run.

With a triggered machine-vision camera the acquisition half becomes a grab loop; the hardware trigger grab loop in Python: acquire and save each frame guide covers that side. To pick the camera, lens and light around this link, start the configurator; more systems guides sit on the systems topic page, and the contact page is the route to help matching parts.

Questions

Why does recv() return fewer bytes than you requested?

recv() hands you whatever has already arrived at your machine, up to the size you asked for; it is a stream read, not a message read. A 1,000,000-byte image arrives as hundreds of packets, so one call often returns a few thousand bytes. Loop until you have collected exactly the length you unpacked from the header, and cap the final read so it cannot swallow the next image's header.

How many bytes should the length header use?

Four bytes with struct.pack('>I', n) cap a single image at 4,294,967,295 bytes, about 4.3 GB, far above any camera frame. Eight bytes ('>Q') remove the ceiling for 4 extra bytes per capture. Either works; the rule is that both ends use the same format, because a reader using the wrong byte order decodes 256 as 65,536 and then waits forever for bytes that never come.

Can I close the connection after every image instead of sending a header?

Yes. If the sender closes or half-closes the write side with shutdown(socket.SHUT_WR) after each image, the receiver loops recv() until it returns b'' and treats that as the end of one message, so no header is needed. The cost is a TCP handshake per capture, a few milliseconds on a local network, and it only works while the connection carries exactly one image. Keep the length header for a long-lived command socket.

How do I send raw pixel data instead of JPEG?

Encode to a lossless container or send the raw array with its dimensions announced first. Size it before you choose: a 1920 x 1080 monochrome 8-bit frame is 1920 x 1080 = 2,073,600 bytes, about 2.1 MB, while the same frame at JPEG quality 90 is often 10 to 20 times smaller. Raw is right when the receiver needs exact pixels; JPEG is fine for archiving and most visual inspections.

Can two cameras send to one server at the same time?

Yes. Call accept() in a loop and give every connection its own thread or asyncio task, with its own recv_exact, and never share one socket between threads. Prefix the saved file name with a camera ID so sequences do not collide: cam1_capture_0007.jpg. The framing protocol does not change at all; only the server loop grows to hold one state per peer.

Does this protocol work for PNG, numpy arrays or video chunks?

The header carries a length, not a type, so the framing does not care what the bytes mean. For a numpy frame, send width, height and dtype in a short fixed-size text header, then the raw buffer. For mixed traffic on one socket, put a one-byte message type in front of the length header. Keep every field fixed-size so a single recv() can never merge two messages.