#!/usr/bin/env python3 """Cross-check kernel/core/qr.c against independent implementations. The self-checks inside tools/qrtest.c verify what has a local notion of correctness -- table arithmetic, the Reed-Solomon generator polynomials, whether a finder pattern is where it should be. They cannot verify the parts that only mean something to a decoder: zigzag placement order, mask selection, and block interleaving. All three have the same failure mode, which is a symbol that looks exactly like a QR code and is noise. Two oracles, in order of authority: 1. **A real decoder** (OpenCV). Does the symbol read back as the exact bytes that went in? This is the only test that matches what the feature is for -- somebody pointing a phone at a crashed machine -- and it is the one that decides pass or fail. 2. **A second encoder** (segno), matrix compared module for module. Stricter than decoding when it agrees, because it pins down the exact mask and placement rather than merely something readable. Applied only to payloads that exactly fill their version, for the reason below. segno adds a spurious zero pad codeword to every byte-mode symbol. Its write_padding_bits() does `buff.extend([0] * (8 - (length % 8)))`, which emits a whole byte when the stream is already on a codeword boundary -- and in byte mode the stream is always 8L+16 bits after the terminator, so it always is. ISO/IEC 18004 makes that conditional ("if the bit stream length is such that it does not end at a codeword boundary"), and segno quotes exactly that sentence above the line. It is harmless -- decoders read `count` bytes and ignore padding -- but it means the two encoders cannot agree bit-for-bit on any payload that needs padding, so those are compared by decoding only. pip install --target opencv-python-headless numpy segno PYTHONPATH= python tools/qrcheck.py Requires build/tools/qrtest (make qrtest). Both libraries are optional; whatever is installed gets used. """ import os import subprocess import sys # Normalised and absolute: Windows CreateProcess rejects a relative path written # with forward slashes, even when the file is plainly there. QRTEST = os.path.abspath(os.path.join("build", "tools", "qrtest.exe" if os.name == "nt" else "qrtest")) try: import cv2 import numpy as np except ImportError: cv2 = None try: import segno except ImportError: segno = None if cv2 is None and segno is None: sys.exit("qrcheck: needs opencv-python-headless and/or segno") if not os.path.exists(QRTEST): sys.exit(f"qrcheck: {QRTEST} not found -- run `make qrtest`") def ours(text): """Our encoder's matrix, as a list of strings of '0'/'1'.""" out = subprocess.run([QRTEST, "-m", text], capture_output=True, text=True) if out.returncode != 0: raise RuntimeError(f"qrtest failed on {text!r}") return out.stdout.split() def our_mask(m): """The mask our encoder chose, read back out of the format bits. Read from the symbol rather than reported by it, because the point is to check what was actually written. """ bits = 0 for i in range(15): if i <= 5: b = int(m[i][8]) elif i == 6: b = int(m[7][8]) elif i == 7: b = int(m[8][8]) elif i == 8: b = int(m[8][7]) else: b = int(m[8][14 - i]) bits |= b << i return ((bits ^ 0x5412) >> 10) & 7 def render(m, scale, border): n = len(m) side = (n + 2 * border) * scale img = np.full((side, side), 255, np.uint8) for y in range(n): for x in range(n): if m[y][x] == "1": img[(y + border) * scale:(y + border + 1) * scale, (x + border) * scale:(x + border + 1) * scale] = 0 return img # OpenCV's detector is not a reference implementation and does fail on symbols # that are perfectly valid -- sensitive to scale and quiet-zone width in ways # that have nothing to do with the encoding. Several renderings are tried before # concluding anything, and a payload that still will not decode is re-tested # against segno's symbol for the same input: if that fails too, the detector is # what is at fault, not the encoder. Without that second step this check reports # false failures, which is worse than not running it. RENDERINGS = [(8, 4), (6, 6), (8, 6), (4, 4), (12, 6), (16, 4)] def decode(m): for scale, border in RENDERINGS: text, _, _ = cv2.QRCodeDetector().detectAndDecode(render(m, scale, border)) if text: return text return "" # Byte-mode capacity per version at level L, from ISO/IEC 18004 table 7 -- # written out rather than computed, so it checks qr.c instead of agreeing with # it. The exact-fill cases below are built from it, and a disagreement shows up # as a version mismatch rather than passing quietly. CAPACITY = dict(enumerate([ 17, 32, 53, 78, 106, 134, 154, 192, 230, 271, 321, 367, 425, 458, 520, 586, 644, 718, 792, 858, 929, 1003, 1091, 1171, 1273, 1367, 1465, 1528, 1628, 1732, 1840, 1952, 2068, 2188, 2303, 2431, 2563, 2699, 2809, 2953, ], start=1)) alpha = lambda n: "".join(chr(65 + i % 26) for i in range(n)) CASES = [ "HELLO", "SCHOS", "a", "0123456789", "SCHOS PANIC\ntrap 14 err 4 cpu 0\nrip ffffffff80104e2a", bytes(range(1, 40)).decode("latin-1"), # non-printable payload ] # One payload that exactly fills each version, and one just under, so both the # padded and unpadded paths are covered at every version: several Reed-Solomon # blocks from 6, blocks of two lengths from 6 too, the version information # from 7, the 16-bit character count from 10. for v, cap in CAPACITY.items(): CASES.append(alpha(cap)) CASES.append(alpha(cap - 1)) fails = 0 print("decode (OpenCV)" if cv2 else "decode -- skipped, no opencv") for text in CASES: if not cv2: break m = ours(text) v = (len(m) - 17) // 4 got = decode(m) if got == text: print(f" [ok] v{v} {len(text):3d}B") continue # Before blaming the encoder, check whether an independently generated # symbol for the same payload decodes either. if segno and not decode([ "".join(str(x) for x in row) for row in segno.make(text.encode("latin-1"), error="l", version=v, boost_error=False, mode="byte").matrix]): print(f" [detector] v{v} {len(text):3d}B -- segno's symbol does not " f"decode either; OpenCV's limitation, not ours") continue fails += 1 print(f" [FAIL] v{v} {len(text):3d}B decoded {got[:40]!r}") print("\nmatrix vs segno (exact-fill payloads only)" if segno else "\nsegno -- skipped") for v, cap in CAPACITY.items(): if not segno: break text = alpha(cap) m = ours(text) mask = our_mask(m) q = segno.make(text.encode("latin-1"), error="l", version=v, mask=mask, boost_error=False, mode="byte") ref = ["".join(str(x) for x in row) for row in q.matrix] ok = m == ref fails += not ok n = len(m) diff = "" if ok else f" {sum(1 for a, b in zip(''.join(m), ''.join(ref)) if a != b)}/{n*n} differ" print(f" [{'ok' if ok else 'FAIL'}] v{v} mask {mask} {cap:3d}B{diff}") print() print("qrcheck: FAILED" if fails else "qrcheck: passed") sys.exit(1 if fails else 0)