DEV Community

Davendra Patel
Davendra Patel

Posted on

How to Make a Custom QR Code That Still Scans (with Python)

If you've ever tried to brand a QR code, you know how it goes: you add your colours, round the corners, drop a logo in the middle, and then a customer's phone just sits there. This post is about how to make a custom QR code that still scans. We'll cover why styled codes fail, what the spec gives you to work with, and some working Python that won't save a code unless it decodes back to the exact URL.

I've been building QR tools for a while, and nearly every "my QR doesn't work" problem comes down to three things: error correction, the quiet zone and contrast.

Why styled QR codes break

A QR code isn't a picture. It's a grid of modules (the little squares) that a scanner samples. To read one, a scanner has to:

  1. Find it. The three big corner squares (finder patterns) tell the scanner where the code is and which way up it is.
  2. Separate it from the background. That needs a clear margin all the way round.
  3. Tell dark from light. Every module gets sorted into "ink" or "paper".
  4. Repair what's missing. Reed–Solomon error correction rebuilds damaged or covered modules.

Styling interferes with every one of those steps.

1. Error correction: your damage budget

QR codes come with four error-correction levels. Roughly, they can recover this much of the data:

Level Recoverable (approx.) Use it when
L 7% Clean, plain codes where small size matters
M 15% The usual default
Q 25% Light styling
H 30% Logos, artwork, anything decorative

A logo in the middle isn't a design element as far as the scanner is concerned. It's damage, and it comes out of the same budget as scuffs, glare and a bit of bad print. Pick H and you get the biggest budget, but the code needs more modules to hold the same URL, so it gets denser. A short URL helps a lot here.

2. The quiet zone: blank space is part of the code

The spec asks for a blank margin four modules wide on every side. Designers crop it because it looks like wasted space. Then the code sits against a photo or pattern, and the scanner can't work out where the code stops. Python's qrcode library defaults to border=4 for exactly this reason. Leave it alone.

3. Contrast: dark on light, with a big gap

Scanners turn the image into black and white. Low contrast and gradients push modules into the wrong bucket. Inverted codes (light on dark) are part of the standard, but plenty of readers still handle them badly. Keep a dark foreground on a light background. I use the WCAG contrast ratio as a quick, measurable check and treat 4.5:1 as the minimum.

How to make a custom QR code that still scans: working Python

This script uses the qrcode library with level H, keeps the 4-module quiet zone, refuses low-contrast or inverted colours, adds a modest centre logo, and then scans its own output with OpenCV. It only saves the file if the decoded text exactly matches the URL.

pip install "qrcode[pil]" opencv-python-headless
Enter fullscreen mode Exit fullscreen mode
"""Make a coloured QR code with a centre logo that still scans."""
import cv2
import numpy as np
import qrcode
from PIL import Image, ImageDraw
from qrcode.image.styledpil import StyledPilImage
from qrcode.image.styles.colormasks import SolidFillColorMask
from qrcode.image.styles.moduledrawers.pil import RoundedModuleDrawer

URL = "https://example.com/menu"
FG = (32, 41, 84)      # dark navy: the "ink"
BG = (255, 248, 235)   # warm cream: the "paper"


def luminance(rgb):
    """WCAG relative luminance of an sRGB colour."""
    def channel(c):
        c = c / 255
        return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
    r, g, b = (channel(c) for c in rgb)
    return 0.2126 * r + 0.7152 * g + 0.0722 * b


def contrast_ratio(a, b):
    hi, lo = sorted((luminance(a), luminance(b)), reverse=True)
    return (hi + 0.05) / (lo + 0.05)


def make_logo(size):
    """Stand-in logo: a simple circle badge. Swap in your own PNG."""
    logo = Image.new("RGBA", (size, size), (0, 0, 0, 0))
    d = ImageDraw.Draw(logo)
    d.ellipse((0, 0, size - 1, size - 1), fill=BG + (255,))
    pad = size // 6
    d.ellipse((pad, pad, size - pad, size - pad), fill=(230, 90, 70, 255))
    return logo


def build(url, logo_fraction=0.22):
    ratio = contrast_ratio(FG, BG)
    # Dark-on-light with a big gap. 4.5:1 is a sensible floor; more is better.
    if ratio < 4.5 or luminance(FG) > luminance(BG):
        raise ValueError(f"Contrast {ratio:.1f}:1 is too low, or colours are inverted")

    qr = qrcode.QRCode(
        error_correction=qrcode.constants.ERROR_CORRECT_H,  # ~30% recoverable
        box_size=20,   # pixels per module
        border=4,      # quiet zone: the spec asks for 4 modules
    )
    qr.add_data(url)
    qr.make(fit=True)

    img = qr.make_image(
        image_factory=StyledPilImage,
        module_drawer=RoundedModuleDrawer(),
        color_mask=SolidFillColorMask(back_color=BG, front_color=FG),
    ).convert("RGB")

    # Keep the logo well under the 30% budget: 22% of the width is ~5% of the area.
    side = int(img.size[0] * logo_fraction)
    logo = make_logo(side)
    pos = ((img.size[0] - side) // 2, (img.size[1] - side) // 2)
    img.paste(logo, pos, logo)
    return img, qr.version, ratio


def decodes_to(img, expected):
    """Scan the finished image with OpenCV and compare the exact text."""
    arr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
    text, _, _ = cv2.QRCodeDetector().detectAndDecode(arr)
    return text == expected, text


if __name__ == "__main__":
    img, version, ratio = build(URL)
    ok, text = decodes_to(img, URL)
    img.save("styled_qr.png")
    print(f"version {version}, contrast {ratio:.1f}:1, decoded={text!r}, match={ok}")
    if not ok:
        raise SystemExit("Does not scan: shrink the logo or raise the contrast")
Enter fullscreen mode Exit fullscreen mode

Output when I ran it (qrcode 8.2, Pillow 12.3, OpenCV 5.0):

version 3, contrast 13.2:1, decoded='https://example.com/menu', match=True
Enter fullscreen mode Exit fullscreen mode

What breaks it: a small experiment

I changed one thing at a time and ran each version through the same OpenCV check. Same URL, rounded modules, solid square "logo" in the centre:

Variation Decoded by OpenCV?
Plain, level H, 4-module quiet zone ✅ Yes
Logo at 22% of width, level H ✅ Yes
Logo at 22% of width, level L ❌ No
Logo at 35% of width, level H ❌ No
Logo at 45% of width, level H ❌ No
No quiet zone, placed on a striped background ❌ No
4-module quiet zone, same striped background ✅ Yes
Low contrast (mid-grey on cream, 2.3:1) ✅ Yes (see note)
Inverted (cream on navy) ❌ No

That low-contrast result is the trap. A clean digital file is the easiest test a code will ever face, so it passes. Print, glare, a cheap camera or a dim room will knock it over first. Software checks catch obvious failures. They don't stand in for scanning a printed proof with a couple of real phones.

Comparing the tools

If you'd rather not write code, here's how some popular tools deal with the same problem. I took these facts from each tool's own pages in October 2026, and prices change.

Tool What it's built for How it helps scanning Price model (Oct 2026)
Python qrcode (above) Full control in code You choose the EC level, quiet zone and checks Free, open source
QRCode Monkey Free static codes with logo, colours and shapes Says codes can have up to 30% error correction, warns about contrast, and asks you to test-scan the preview Static codes free, including for commercial use
QR Code Generator PRO Dynamic codes, tracking, bulk creation Managed platform Paid plans after a 14-day trial; Starter from $9.99/mo billed yearly (USD, excl. VAT)
Uniqode (formerly Beaconstac) Dynamic codes and analytics for teams Managed platform Static codes free; paid plans from $9/mo billed yearly
Canva QR apps A QR code inside a design you're already making Lets you set colours and margin; help docs advise high contrast and space around the code Static free; dynamic codes on Free/Pro carry a Canva logo
QR Petz Turning a link into a QR that's also a character (3D reveal or blocky) "Check your QR" confirms the exact text with two software scanners Free (nothing for sale, confirmed by Davendra 13 Oct 2026)

A different approach: make the code the character

Most branded QR codes start with a working code and add decoration until it nearly breaks. I wanted to try it the other way round, so I built QR Petz. You give it a link and it makes a scannable QR that's also a little character (Peek, Momo, Bunny, Orbit and others). Tilt it and a 3D character appears, or watch the squares rearrange themselves into one. Before you download anything, "Check your QR" reads the code with two software scanners and confirms the decoded text exactly matches your link. You can also export an 8-second loop for social.

Checklist

  • Use error-correction level H for any styled code.
  • Keep the 4-module quiet zone. Never crop it.
  • Dark on light, with a big contrast gap.
  • Keep the logo small. In my test it scanned at 22% of the width and failed at 35%.
  • Shorten the URL so the code needs fewer, bigger modules.
  • Decode the final file and compare the exact text, not just "it found something".
  • Print a proof and scan it with at least two phones before you print the full run.

Top comments (0)