sqrart / qart
Artistic QR codes: the image is encoded into the QR data and Reed-Solomon bits (QArt approach), rendered as color halftone or full-module pixel art. QR versions 1-40, ECC L/M/Q/H.
Requires
- php: ^8.2
- ext-gd: *
- ext-zlib: *
- chillerlan/php-qrcode: ^5.0
Requires (Dev)
- phpunit/phpunit: ^11.5
Suggests
- ext-ffi: Élimination GF(2) native (Rust, ~2x sur une génération v20+) : cargo build --release dans native/, ffi.enable=1
README
The open-source engine behind sqr.art. Every QR code sqr.art produces comes out of this engine — the code you are reading is the one running in production, verifiable end to end (every code is read back by a real decoder before it ships).
| Source image | Encoded QR (halftone) |
![]() |
![]() |
The image on the right is a real, scannable QR code: the photo is encoded into the code's own data and error-correction bits, not pasted on top.
Want the finished product rather than the engine? sqr.art adds dynamic links you can edit after printing, scan statistics, WiFi and SEPA-transfer codes, print-ready posters, and an API.
Artistic QR codes: the image is encoded into the QR's data and Reed–Solomon bits (the QArt approach), rendered as a colour halftone. The decoded URL is a fixed prefix + a unique identifier (serial + solution) for a server-side lookup.
Multi-version: QR v1 to v40, ECC L/M/Q/H. The version sets the grid (17 + 4×v modules per side) and the capacity (v5: 106 characters, v10: 271, v40: 2953). The per-version tables (Reed–Solomon blocks, alignment) are derived from chillerlan — the same library used as the oracle.
new QArtGenerator(prefix: 'https://sqr.art/', version: 5); // simple logos QArtGenerator::suggestVersion('photo.jpg'); // heuristic 5|10|15
Cost and memory grow quickly with the version: v5 < 1 s, v10 ~1 s (warm cache), v20 ~1.3 s warm (15 s cold, 450 MB), v30 ~3 s warm (~1 min cold, 800 MB), v40 ~5 s warm (~3 min cold, 1.2 GB). The native acceleration (see below) halves the warm time from v20 up — the sqr.art demo caps itself at v5/v10/v15.
Requirements
- PHP >= 8.2, the GD extension,
memory_limit>= 512M during generation chillerlan/php-qrcode^5.0 (installed by Composer)
Usage
use SqrArt\QArt\Cache\FileMatrixCache; use SqrArt\QArt\QArtGenerator; use SqrArt\QArt\RenderProfile; $generator = new QArtGenerator( prefix: 'https://sqr.art/', errorBudgetPerBlock: 1, // RS codewords sacrificed per block (capped by version/ECC) matrixCache: new FileMatrixCache('/tmp/qart-cache'), // ~4 s saved per generation maxAttempts: 3, // retry with a new serial if the decode fails ); $result = $generator->generate('photo.jpg', 'qr.png', RenderProfile::screen()); $result->url; // full encoded URL (271 characters) $result->suffix; // store this for the lookup GET /{suffix} $result->serial; // first 8 characters of the suffix (40 bits of entropy) $result->mask; // chosen QR mask (best fidelity score) $result->attempts; // 1 unless a regeneration was needed $result->warnings; // upscaled image, low contrast…
No invalid QR can ship: the generated PNG is decoded back (the ZXing port
bundled in chillerlan) and the URL verified. On failure it regenerates with a
different serial and a reduced error budget, then throws
GenerationFailedException.
Short URL mode (padding bits)
By default the URL fills the QR's whole capacity (UrlMode::Full). In
UrlMode::Short, only the short URL (prefix + 8-character serial) is encoded,
with an early terminator, and the padding bytes carry the image — the
approach of the original QArt and of fuqr:
use SqrArt\QArt\UrlMode; $gen = new QArtGenerator(prefix: 'https://sqr.art/', urlMode: UrlMode::Short); $res = $gen->generate('photo.jpg', 'qr.png'); $res->url; // https://sqr.art/UKmohnVJ — 24 characters, clean at scan time $res->suffix; // the serial alone (8 characters) for the lookup
Two wins: a readable decoded URL and better fidelity (8 degrees of freedom per padding byte versus 5 per URL character — 1972 vs 1235 variables at v10 with this prefix). Decoders ignore the padding content (verified by a real decode on every generation); as with everything else, validate on your target devices before a large print run.
Protected zones
Guarantee that a logo or a face stays faithful: modules in protected zones consume the degrees of freedom first (front of the pivot order), the mask choice minimises the error on the bits the solver cannot control, and the error budget corrects any stragglers in priority.
use SqrArt\QArt\ImportanceMap; $importance = (new ImportanceMap)->protect(x: 0.38, y: 0.18, w: 0.34, h: 0.45); $res = $gen->generate('photo.jpg', 'qr.png', importance: $importance); $res->protectedMismatches; // 0 = zone guaranteed; otherwise a warning
Coordinates are fractions (0..1) of the cropped square. Several zones are
allowed (chained ->protect()). If a zone overlaps the structurally fixed
region (header/prefix, bottom-right) beyond what the mask and budget can
recover, the result says so honestly through protectedMismatches and a
warning — nothing is hidden.
Non-square images (Fit)
use SqrArt\QArt\Fit; $gen->generate('logo-large.png', 'qr.png', fit: Fit::Contain);
Fit::Cover (default) crops to a square (a crop window, or centred).
Fit::Contain keeps the whole image, centred on a white square that blends
into the quiet zone — suited to wide or tall logos.
Painted importance map & cropping
The painted map (an image mask aligned to the cropped square, luminance on a black background = importance) weights module priority — a boost (×3 at most), not a guarantee:
$importance = (new ImportanceMap)->paint(file_get_contents('mask.png')); // free crop: source square as fractions (x, y of the origin, // size as a fraction of the short side) — default: centred square $res = $gen->generate('photo.jpg', 'qr.png', importance: $importance, crop: ['x' => 0.15, 'y' => 0.0, 'size' => 0.8], );
paint() and protect() combine: a hard guarantee on the logo, a brush on the
rest of the subject.
SVG output (print-ready)
Passing a 4th argument to generate() also produces a vector SVG — printable
at any size without artefacts, from the same matrix as the PNG (which stays the
decode-validated reference):
$result = $generator->generate('photo.jpg', 'qr.png', $profile, 'qr.svg'); $result->svgPath; // 'qr.svg'
File size is kept in check by merging sub-pixels into horizontal runs (colours quantised to 32 levels per channel, imperceptible).
Dot and finder styles
use SqrArt\QArt\{DotShape, FinderShape}; $profile = RenderProfile::screen() ->withDotShape(DotShape::Round) // Square | Round | Diamond ->withFinderShape(FinderShape::Rounded) // Square | Rounded ->withFinderColor('#1a2b4c'); // dark brand colour
The finder colour is constrained in luminance (<= 0.35): a colour too light
would break detection and is rejected (QArtException). Styles apply to both
outputs (PNG and SVG).
ECC levels (L/M/Q/H)
use SqrArt\QArt\Ecc; new QArtGenerator(prefix: 'https://sqr.art/', ecc: Ecc::H, errorBudgetPerBlock: 6);
Raising the ECC level lowers the data capacity (v10: 271 characters in L, 119
in H) — hence the modules the solver can control — but raises the number of
sacrificeable ECC codewords by as much: the errorBudgetPerBlock cap is
⌊ecc/block / 2⌋ - 2 (v10-L: 7, v10-H: 12). In practice L maximises halftone
fidelity; H is relevant for high-contrast full-module renders or codes destined
for damaged surfaces. The generator matrix is cached per (version, ECC) pair.
Full-module pixel art
use SqrArt\QArt\{Ecc, RenderMode, RenderProfile}; $gen = new QArtGenerator(prefix: 'https://sqr.art/', ecc: Ecc::H, errorBudgetPerBlock: 8); $gen->generate('photo.jpg', 'qr.png', RenderProfile::screen()->withMode(RenderMode::Module));
In RenderMode::Module, each module is a solid square tinted by the image's
average colour there: the whole QR is the image, dithered (Atkinson) to the
module resolution. No texture, no dots — module luminance is constrained by
lDotDark/lDotLight. This is where ECC-H shines: the lost capacity costs
nothing (fewer modules to control) and every sacrificed codeword forces 8 more
pixels toward the image. Dot shapes are ignored; rounded finders and the brand
colour still apply. PNG and SVG outputs (one rect per run of modules).
Choice of dithering
use SqrArt\QArt\Dithering; RenderProfile::screen()->withDithering(Dithering::FloydSteinberg);
Four algorithms, applied to the halftone texture as well as the pixel-art
target: Atkinson (soft, the historical default), FloydSteinberg (full
diffusion, more detail), Ordered (8×8 Bayer matrix, a regular retro screen),
None (raw thresholding, posterised flats). In pixel art the choice changes the
target the solver pursues; in halftone it only changes the texture (the target
stays the thresholded 3×3 core).
PDF export (print-ready)
use SqrArt\QArt\PdfRenderer; $gen->generate('photo.jpg', 'qr.png', $profile, 'qr.svg', outPdf: 'qr.pdf'); // 100 mm PdfRenderer::toFile($img, $spec, $matrix, 'qr.pdf', $profile, sizeMm: 60.0); // custom size
A vector PDF 1.4 assembled with no dependency (FlateDecode streams), sized in millimetres, quiet zone included (default 100 mm, ~40 mm minimum recommended in halftone). Same visual rules as the SVG — dot styles, rounded finders, full-module pixel art. Adjacent rectangles overlap by ~0.02 mm to neutralise the anti-aliasing seams of viewers. RGB colours (CMYK conversion is left to the printer).
Render profiles
RenderProfile::screen()— soft luminances, on-screen display (default);RenderProfile::print()— more contrasted dots, larger scale; CMYK printing crushes the nuances. Recalibrate on a physical test sheet before a serious print run.
Static payloads (WiFi, vCard, EPC…)
With serialLength: 0 (Short mode only), the encoded content is exactly the
prefix you pass — no random serial is appended. Every byte is accepted (spaces,
UTF-8, line breaks): ideal for standard payloads scanned offline.
use SqrArt\QArt\UrlMode; $gen = new QArtGenerator( prefix: 'WIFI:T:WPA;S:Café de la Plage;P:password;;', urlMode: UrlMode::Short, serialLength: 0, ); $res = $gen->generate('photo.jpg', 'qr.png', $profile); // $res->url === the payload, $res->suffix === '' (nothing to resolve server-side)
The image lives in the padding bytes after the terminator. The probe matrix is shared across all payload lengths of a given (version, ECC) — the first generation of a version warms the cache for every payload.
Reproducible tests
The random generator is injectable: with SeededRandom, the output is
byte-for-byte deterministic (golden tests).
use SqrArt\QArt\Random\SeededRandom; new QArtGenerator(prefix: 'https://sqr.art/', random: new SeededRandom(42));
Native acceleration (Rust, optional)
composer build-native # cargo build --release in native/
php -d ffi.enable=1 ...
The GF(2) Gaussian elimination can be delegated to a Rust library through FFI.
Detection is automatic ($QART_GF2_LIB, otherwise native/target/release/)
and the pure-PHP fallback is transparent; the result is byte-for-byte
identical (verified by the test suite). ffi.enable=1 is required in
CLI/worker contexts (PHP's default is preload).
Measured figures (Apple Silicon, warm matrix cache): the elimination goes from 1.1 s to 0.07 s at v30 (~×16), a full v30 generation from 5.0 s to 2.8 s. Cold, the dominant cost stays the probing of the generator matrix (~1 min at v30, once per version/ECC pair thanks to the cache) — that is chillerlan, not accelerable by this library.
Structure
QArtSpec: QR geometry per (version, ECC): function modules, zigzag, interleavingOracle: conforming render via chillerlan (version/ECC/mask fixed)ImagePipeline: GD, JPEG/PNG/WebP/GIF formats, flattened alpha, autocontrast, choice of dithering (Atkinson, Floyd–Steinberg, Bayer, threshold), per-module targets/confidenceSolver: empirical generator matrix + GF(2) Gaussian elimination with pivots by visual importance; seedable serial; optional native Rust path (Native\Gf2, pure-PHP fallback)Cache/*: generator-matrix cache (depends only on the prefix length — the code is linear)Renderer: 7×7 sub-pixel halftone + 3×3 dots, or full-module pixel art; colour constrained in luminancePdfRenderer: print-ready vector PDF export (page in mm, no dependency)QArtGenerator: orchestration, error budget, decode validation
Tests
composer install
composer test
The suite covers: bit→module mapping against the real render (0 errors across the 8 masks), zigzag count (2768), interleaving bijection, affine alphabet (32 URL-safe values H-W/h-w), image edge cases, end-to-end generation validated by decoding, determinism and caching.
Known limitations
- The prefix/header region (bottom-right) stays structurally uncontrollable.
- Recommended minimum physical print size: ~4×4 cm at 300 dpi (halftone needs ~3× more resolution than a standard QR).
- Test on real smartphones (screen and paper) before production.

