Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

QRTransfert

Transfer a single file over a screen-to-camera link, using QR codes. No network, no pairing, no shared storage — one machine displays an animated QR stream, another decodes a video of it.

Requirements

Python 3.11 or newer.

Installing

pip3 install qrtransfert

# with passphrase encryption support
pip3 install 'qrtransfert[crypto]'

Quick start

On the sending machine:

qrtransfert encode -f report.pdf

Film the window with a phone or a webcam, then on the receiving machine:

qrtransfert decode -f capture.mp4 -d ./received

The decoder stops as soon as it has enough frames, verifies the payload against its SHA-256, and writes the file.

If one capture is not enough, film the same session again and pass both — they combine rather than starting over:

qrtransfert decode -f first.mp4 -f second.mp4 -d ./received
qrtransfert info -f first.mp4          # what is in a capture, and why it failed

python -m qrtransfert works too, when the console script is not on PATH.

How it works

A screen-to-camera link has no back-channel: the receiver cannot ask for a retransmission, and it will miss frames to motion blur, autofocus and refresh straddling.

The obvious design — chunk N lives in frame N, loop forever — turns that into the coupon-collector problem. Capturing K distinct frames out of a loop of K takes about K·ln(K) frame times, so a 1 MiB file that displays in 20 minutes takes hours to actually capture.

QRTransfert uses a systematic LT fountain code instead. The first K frames carry the source blocks verbatim; every frame after that is the XOR of a pseudorandom subset of them, with the subset derived deterministically from the frame's sequence number. The receiver rebuilds the file from any ~K distinct frames, in any order:

Transferring 1 MiB at QR version 20 with --loop-delay 0.6:

frames clean capture with 30% frame loss
indexed loop (v1) 2112 21 min 134 min (6.4 passes)
systematic fountain (v2) 1255 12.6 min 18.8 min

The frame count drops by 1.7× because the payload is raw bytes rather than base64 (+33%) and the error correction is L rather than M (+29%). The rest of the gain is structural: under loss, an indexed loop needs enough passes for every frame to land at least once, while a fountain just needs enough frames to land at all.

Every frame carries a 22-byte self-describing header, so the receiver can lock onto a stream mid-flight, and restarting the sender resets it automatically. Filename, media type, original length, SHA-256, optional gzip and optional encryption live in a container that travels as a unit — there is no way to end up with the bytes but not the filename.

The media type is recorded (inferred from the extension, or --media-type) and also decides whether gzip is attempted at all: a .zip or a .jpg is already entropy-coded, so the compression pass is skipped rather than spent learning what the type already said.

The extension is resolved from a table shipped with the package, not from the host's mime database. mimetypes answers differently depending on the machine — a bare python:3.13-slim image does not know .docx, .7z or .gz, a desktop with media-types installed does — and the sender and receiver are different machines by construction, so the label a file arrives with must not depend on who sent it. Anything outside the table still falls back to mimetypes.

The original length is in the container so decompression can be bounded. A gzip payload arrives over the same untrusted channel as everything else, so the length it declares is a hint that lets us stop early, never a bound to trust — the check is on bytes actually produced. Without it, 100 KB of crafted stream inflates to gigabytes; with it, that payload is refused after 0.1 MB.

For the details, see the module docstrings in src/qrtransfert/protocol/.

Throughput

Per-frame cost, one core:

QR version payload/frame generate decode generate-bound ceiling
10 249 B 2.4 ms 3.1 ms 103 KB/s
20 836 B 6.9 ms 3.5 ms 119 KB/s
40 2931 B 23.2 ms 5.6 ms 123 KB/s

Generation is the binding constraint, not decoding: a version 40 symbol takes 23 ms to build and 5.6 ms to read. The ceiling is what one core can draw, so the actual rate is payload/frame ÷ --loop-delay, capped there.

--loop-delay is the frame period, with generation counted inside it, so a requested rate is the achieved rate until the work alone exceeds the period. When it does, encode says how many frames ran late rather than quietly halving the frame rate.

Higher versions carry more per frame but need a better camera and a sharper screen. Version 20 is the default because it decodes reliably from a phone at arm's length; version 40 needs a good lens and a steady hand.

Maximum transfer size is about 183 MiB (65535 blocks × 2931 bytes at version 40). Oversized transfers are refused up front with the parameters to change.

Privacy

Nothing is transmitted anywhere except as light. No account, no pairing, no network path between the two machines.

By default the channel is not confidential. Whatever is on the sending screen is readable by any camera pointed at it, and the decoder is this same public tool. The property you get by default is no network, not secrecy; encode prints a reminder saying so.

When that matters, encrypt the payload:

qrtransfert encode -f secrets.tar -p 'correct horse battery staple'
qrtransfert decode -f capture.mp4 -p 'correct horse battery staple'

This is AES-256-GCM under a scrypt-derived key (n=2¹⁵). A bystander's camera then sees only ciphertext. GCM is authenticated, so a wrong passphrase and a tampered stream are both rejected rather than yielding plausible garbage. What it does not hide: that a transfer is happening, and roughly how large it is.

Integrity is always checked. Every received file is verified against the SHA-256 recorded by the sender before anything is written; a corrupted capture fails loudly instead of handing over damaged bytes.

Received filenames are untrusted. The name arrives inside the stream, so it is reduced to a safe basename and written inside --output-dir. A stream claiming to be ../../.ssh/authorized_keys lands as .ssh_authorized_keys in the directory you named, and the sanitisation is reported.

Usage

encode

Usage: qrtransfert encode [OPTIONS]

  Display a file as a stream of fountain-coded QR codes.

Options:
  -f PATH                      Path of the file to transfer  [required]
  -o, --out-to-file FILE       Write a video file instead of displaying
  -t, --terminal               Display in the terminal instead of a window
  -c, --qr-color               Pack 3 frames per image (or terminal cell)
  -qv, --qr-version INTEGER    QR code version  [1<=x<=40; default: 20]
  -qcor, --qr-correction [L|M|Q|H]
                               QR error correction level  [default: L]
  -r, --redundancy FLOAT       Frames to emit per source block  [default: 3.0]
  --no-compress                Skip gzip even when it would shrink the payload
  -p, --passphrase TEXT        Encrypt the payload (AES-256-GCM)
  -l, --loop-delay FLOAT       Seconds each frame stays on screen  [default: 0.15]
  -ww, --window-width INTEGER  [default: 600]
  -wh, --window-height INTEGER [default: 600]
  --qr-size INTEGER            Rendered symbol size in pixels  [default: 1050]
  --media-type TEXT            Media type to record (default: from the extension)
  --target-loss FLOAT          Expected frame loss in %, derives --redundancy
  -j, --jobs INTEGER           Processes building symbols; 0 uses every core

Symbol building is the only real throughput ceiling — 23 ms per version 40 frame against 5.6 ms to decode one. --jobs spreads it across cores: 3.6x on four, taking version 40 from 45 to 161 frames per second. Frames are independent by construction, so output is identical whatever --jobs is.

While displaying, both the window and the terminal show where you are in the current pass:

frame 49/72  [██████████████░░░░░░]  68%  ~3s left in pass
Ctrl-C to stop. Pass 1 shows the whole file; later passes only add margin.

One pass is the whole file, once. The code is systematic, so a pass is K frames — every byte on screen exactly once — and a clean capture is complete the moment the bar reaches 100%. It is not sized on --redundancy, which only bounds the length of a written video file.

The counter is a position within a pass, not a countdown to the end: the sender never stops. Passes after the first are pure margin for a lossy capture, and they are not replays — every frame emitted is new fountain output. pass 2 appearing means the first pass was not enough, which is information about your capture, not about the file. In colour mode the count is in displayed images, three fountain frames each.

--redundancy only bounds the length of a written video file; the interactive sinks stream endlessly. --target-loss sets it from the table below instead of guessing: --target-loss 40 picks 3.0x. Measured completion rate at K=128:

redundancy 0% loss 10% 20% 30% 40% 50%
1.6× 100% 74% 26% 1% 0% 0%
2.0× 100% 95% 85% 61% 13% 0%
2.5× 100% 99% 98% 97% 89% 35%
3.0× 100% 100% 100% 100% 98% 91%

--qr-color packs three symbols into the R, G and B channels. It triples the payload per displayed frame, but needs a capture path that preserves colour: chroma subsampling (4:2:0 in most video codecs) and screen-to-camera colour crosstalk both bleed the channels together. Dependable for a written video file or a terminal screenshot, unreliable through a phone camera.

--terminal and --qr-color combine. In the terminal each cell carries all three channels at once, so the symbol occupies exactly the same space and carries three times the payload. Only the eight corners of the RGB cube are ever emitted, and always as 24-bit escapes rather than the basic palette, which terminal themes remap.

A symbol taller than the window makes the terminal scroll, which pushes the top of the symbol — finder patterns included — off screen, and nothing decodes. encode --terminal therefore measures the window and picks a version that fits, saying so:

Terminal is 80x24: using QR version 5 instead of 20 so the symbol fits.

An explicit --qr-version is your decision and is never overridden; if it cannot fit, the command stops and names one that would. The space each version needs, including the three lines of status below it:

version terminal needed mono colour
3 33 x 20 31 B 93 B
5 41 x 24 84 B 252 B
8 53 x 30 170 B 510 B
10 61 x 34 249 B 747 B
20 101 x 54 836 B 2508 B
40 181 x 94 2931 B 8793 B

A wider window buys throughput directly: each step up carries more per frame.

decode

Usage: qrtransfert decode [OPTIONS]

  Rebuild a file from a video of the QR stream.

Options:
  -f PATH                      Video file to decode; repeat to combine  [required]
  -fps, --fps-to-decode INTEGER  Frames per second to sample  [1<=x<=120; default: 10]
  -d, --output-dir DIRECTORY   Directory to write the received file into  [default: .]
  -p, --passphrase TEXT        Passphrase for an encrypted transfer
  --no-color                   Skip RGB channel splitting
  --overwrite                  Overwrite an existing file instead of refusing

Colour packing is detected from the pixels, so --no-color is only a performance hint. There is no threshold to tune: zxing-cpp's adaptive binarizer handles uneven lighting far better than a global cut-off.

Captures only combine within one continuous encode session — a second encode is a different stream, and decode says so rather than silently recovering nothing.

info

Usage: qrtransfert info [OPTIONS]

  Report what is in a capture, without writing anything.

Separates the two failures that look identical from the outside: a high rejected-symbol count means blur or focus, while finding no stream at all means the wrong thing was filmed.

Compatibility

Wire format v2 is not compatible with v1. A video produced by qrtransfert 1.x cannot be decoded by 2.x: v1 was indexed rather than fountain-coded, and base64-encoded its payload.

Container version 2 added the media type and the original length. A capture made against container version 1 is rejected by name rather than misread.

License

MIT.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

qrtransfert-2.0.0rc1.tar.gz (3.6 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

qrtransfert-2.0.0rc1-py3-none-any.whl (54.5 kB view details)

Uploaded Python 3

File details

Details for the file qrtransfert-2.0.0rc1.tar.gz.

File metadata

  • Download URL: qrtransfert-2.0.0rc1.tar.gz
  • Upload date:
  • Size: 3.6 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for qrtransfert-2.0.0rc1.tar.gz
Algorithm Hash digest
SHA256 d9ab8670c6933d1bb12a92a014ae65e6ec48a54c52c4c14bceecd0cba6abe6bd
MD5 709f93ef38cd78d25430afcfd39389a8
BLAKE2b-256 6acd274b5158a1faa77d01f125631a1afc4fef704877d961f595253fcb92dedf

See more details on using hashes here.

File details

Details for the file qrtransfert-2.0.0rc1-py3-none-any.whl.

File metadata

  • Download URL: qrtransfert-2.0.0rc1-py3-none-any.whl
  • Upload date:
  • Size: 54.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for qrtransfert-2.0.0rc1-py3-none-any.whl
Algorithm Hash digest
SHA256 e68d973eeada537b915d16ae6502be5fe1e24f4885e67a19ccbe3b23380b02f5
MD5 d97c7facef537c82b1ea99ace7b69e74
BLAKE2b-256 7a2f9d09cb502804ada5e43bc248e773dae5a2c2363a74b7af2ae594e9117dc8

See more details on using hashes here.

Release history Release notifications | RSS feed

2.0.2

2 files

2.0.1

2 files

2.0.0

2 files

This release

2.0.0rc1 This release

2 files

1.0.2

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page