mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-09-18 14:43:44 +08:00
66b4dbe2a1
* qrcode: add QR decoder Decodes a QR code from a grayscale image or a module matrix: adaptive binarization, finder pattern search by run ratios, perspective sampling with alignment pattern refinement, format info, unmasking, block de-interleaving, Reed-Solomon correction, and numeric, alphanumeric, byte (with ECI), and Kanji segments. Fixtures are packed matrices from python-qrcode 8.2 covering every version and level, plus Segno 1.6.6 matrices for the ECI cases. * qrcode: tune decoder for the device Measured on a comma mici with real cabin frames downsampled to 672x380, the size the eSIM screen scans at. Per frame: 30 ms -> 8 ms with no code in view, 42 ms -> 13 ms with a code. - binarize: fixed two-radius fill from one integral image instead of an iterative outward fill (a dark cabin is almost all flat tiles), tile stats on a contiguous layout, flat tiles decided whole so sensor noise cannot become speckle, uint8 threshold compare - finder search: scan every 4th row, build column runs only at candidate columns - alignment search starts at a 2-module radius - Reed-Solomon syndromes through table lookups, placement order cached * qrcode: simplify decoder Fold the function-module mask into _data_coords and the format coordinates into _read_format, evaluate masks directly on the data coordinates, compact the ECI, numeric, and alphanumeric parsing, and flatten the retry loop in decode. No behavior change.
179 lines
7.4 KiB
Python
179 lines
7.4 KiB
Python
import hashlib
|
|
import math
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
from openpilot.common import qrcode as qr
|
|
from openpilot.common.test import OpenpilotTestCase
|
|
|
|
LPA = "LPA:1$rsp.truphone.com$QRF-BETTERROAMING-PMRDGIR2EARDEIT5"
|
|
|
|
|
|
# Matrices generated with python-qrcode 8.2, covering all versions and EC levels.
|
|
# Packed fixtures keep the decoder tests independent of our encoder.
|
|
FIXTURES = {}
|
|
for path in Path(__file__).with_name("fixtures").glob("qrcode_*.npz"):
|
|
with np.load(path) as fixtures:
|
|
FIXTURES.update({key: fixtures[key] for key in fixtures.files})
|
|
|
|
|
|
def fixture(key: str) -> np.ndarray:
|
|
bits = np.unpackbits(FIXTURES[key])
|
|
size = math.isqrt(len(bits))
|
|
return bits[:size * size].reshape(size, size).astype(bool)
|
|
|
|
|
|
def render(matrix: np.ndarray, box: int = 6, border: int = 4) -> np.ndarray:
|
|
img = np.repeat(np.repeat(np.pad(matrix, border), box, axis=0), box, axis=1)
|
|
return np.where(img, 0, 255).astype(np.uint8)
|
|
|
|
|
|
def make(data: str, version: int | None = None, level: int = 0, box: int = 6, border: int = 4):
|
|
matrix = fixture(hashlib.sha256(f"{version}:{level}:{data}".encode()).hexdigest())
|
|
return matrix, render(matrix, box, border)
|
|
|
|
|
|
def warp(img: np.ndarray, H: np.ndarray) -> np.ndarray:
|
|
"""Bilinear resampling through the output -> input homography H, white outside the image."""
|
|
h, w = img.shape
|
|
rows, cols = np.mgrid[0:h, 0:w]
|
|
pts = qr._transform(H, np.column_stack((cols.ravel() + 0.5, rows.ravel() + 0.5))) - 0.5
|
|
x0, y0 = np.floor(pts[:, 0]).astype(int), np.floor(pts[:, 1]).astype(int)
|
|
fx, fy = pts[:, 0] - x0, pts[:, 1] - y0
|
|
padded = np.pad(img.astype(float), 1, constant_values=255)
|
|
|
|
def at(y, x):
|
|
return padded[np.clip(y + 1, 0, h + 1), np.clip(x + 1, 0, w + 1)]
|
|
|
|
out = at(y0, x0) * (1 - fx) * (1 - fy) + at(y0, x0 + 1) * fx * (1 - fy) + at(y0 + 1, x0) * (1 - fx) * fy + at(y0 + 1, x0 + 1) * fx * fy
|
|
return np.clip(out, 0, 255).astype(np.uint8).reshape(h, w)
|
|
|
|
|
|
def rotate(img: np.ndarray, angle: float) -> np.ndarray:
|
|
h, w = img.shape
|
|
t = np.radians(angle)
|
|
R = np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]])
|
|
center = np.array([w / 2, h / 2])
|
|
corners = np.array([(0, 0), (w, 0), (w, h), (0, h)], dtype=float)
|
|
return warp(img, qr._perspective((corners - center) @ R.T + center, corners))
|
|
|
|
|
|
class TestQRCode(OpenpilotTestCase):
|
|
def test_alignment_positions(self):
|
|
assert qr._alignment_positions(7) == [6, 22, 38]
|
|
assert qr._alignment_positions(32) == [6, 34, 60, 86, 112, 138]
|
|
assert qr._alignment_positions(40) == [6, 30, 58, 86, 114, 142, 170]
|
|
|
|
def test_all_versions(self):
|
|
for version in range(1, 41):
|
|
for level in range(4):
|
|
with self.subTest(version=version, level=level):
|
|
data = "".join(chr(ord("a") + i % 26) for i in range(version))
|
|
matrix, img = make(data, version, level, box=3)
|
|
assert qr.decode_matrix(matrix) == data
|
|
assert qr.decode(img) == data
|
|
|
|
def test_modes(self):
|
|
for data in ["0123456789012345", "HELLO WORLD $1.50", LPA, "こんにちは", "ünïcødé", "mixed 123 ABC xyz"]:
|
|
with self.subTest(data=data):
|
|
matrix, img = make(data)
|
|
assert qr.decode_matrix(matrix) == data
|
|
assert qr.decode(img) == data
|
|
|
|
def test_error_correction(self):
|
|
matrix, _ = make(LPA, level=2)
|
|
rng = np.random.default_rng(0)
|
|
flipped = matrix.copy()
|
|
for r, c in rng.integers(9, matrix.shape[0] - 9, size=(40, 2)):
|
|
flipped[r, c] ^= True
|
|
assert qr.decode_matrix(flipped) == LPA
|
|
|
|
def test_large_modules(self):
|
|
for data in ["0123456789012345", "HELLO WORLD $1.50", LPA, "mixed 123 ABC xyz"]:
|
|
for box in [16, 20, 24, 32]:
|
|
for dark, light in [(0, 255), (60, 200), (140, 250)]:
|
|
with self.subTest(data=data, box=box, dark=dark):
|
|
_, img = make(data, box=box)
|
|
img = np.where(img == 0, dark, light).astype(np.uint8)
|
|
assert qr.decode(img) == data
|
|
|
|
def test_image_edges(self):
|
|
# a code touching the image edge must not lose the rows and columns left over from tiling
|
|
matrix, _ = make(LPA)
|
|
for size in (200, 203):
|
|
with self.subTest(size=size):
|
|
img = np.full((size, size), 255, dtype=np.uint8)
|
|
code = render(matrix, box=5, border=0)
|
|
img[size - code.shape[0]:, size - code.shape[1]:] = code
|
|
assert qr.decode(img) == LPA
|
|
|
|
def test_eci(self):
|
|
# qrcode_eci.npz: packed Segno 1.6.6 matrices, generated with mode='byte',
|
|
# eci=True, micro=False and the named encoding. Mixed also includes numeric,
|
|
# alphanumeric, and Kanji segments after changing the byte encoding twice.
|
|
cases = {
|
|
"iso8859-5": "Привет", "utf-16-be": "héllo", "utf-8": "こんにちは",
|
|
"shift_jis": "日本語", "cp1251": "Привет", "iso8859-1": "héllo",
|
|
"mixed": "hélloПривет日本語123ABC漢字",
|
|
}
|
|
for encoding, expected in cases.items():
|
|
with self.subTest(encoding=encoding):
|
|
matrix = fixture(encoding)
|
|
assert qr.decode_matrix(matrix) == expected
|
|
assert qr.decode(render(matrix)) == expected
|
|
|
|
def test_parse_data(self):
|
|
def parse(stream: str) -> str:
|
|
stream += '0' * (-len(stream) % 8)
|
|
return qr._parse_data([int(stream[i:i + 8], 2) for i in range(0, len(stream), 8)], 1)
|
|
|
|
def eci(assignment: str, payload: bytes = b'A') -> str:
|
|
return parse('0111' + assignment + '0100' + f'{len(payload):08b}' + ''.join(f'{b:08b}' for b in payload) + '0000')
|
|
|
|
# ASCII assignment 170 uses the two-byte ECI representation.
|
|
assert eci('1000000010101010') == 'A'
|
|
for assignment in ['00001110', '1000001111100111', '110000010000000000000000', '11100000']:
|
|
with self.subTest(assignment=assignment), self.assertRaises(qr.QRError):
|
|
eci(assignment)
|
|
with self.assertRaises(qr.QRError):
|
|
eci('00011010', b'\xff') # Invalid UTF-8 must not fall back to Latin-1.
|
|
|
|
# out-of-range numeric, alphanumeric, and Kanji values are format errors, not crashes
|
|
for stream in ['0001' + '0000000011' + '1111111111', '0001' + '0000000010' + '1111111',
|
|
'0010' + '000000010' + '11111111111', '0010' + '000000001' + '111111',
|
|
'1000' + '00000001' + '0000000111111']:
|
|
with self.subTest(stream=stream), self.assertRaises(qr.QRError):
|
|
parse(stream)
|
|
|
|
def test_rotation(self):
|
|
for angle in [0, 90, 180, 270, 25, 110]:
|
|
with self.subTest(angle=angle):
|
|
_, img = make(LPA, box=8, border=12)
|
|
assert qr.decode(rotate(img, angle)) == LPA
|
|
|
|
def test_mirrored(self):
|
|
_, img = make(LPA)
|
|
assert qr.decode(img[:, ::-1]) == LPA
|
|
|
|
def test_perspective_and_noise(self):
|
|
_, img = make(LPA, box=10, border=8)
|
|
h, w = img.shape
|
|
corners = np.array([(40, 60), (w - 20, 30), (w - 60, h - 40), (30, h - 90)])
|
|
arr = warp(img, qr._perspective(corners, np.array([(0, 0), (w, 0), (w, h), (0, h)]))).astype(float)
|
|
rng = np.random.default_rng(1)
|
|
arr = arr * 0.6 + 60 + rng.normal(0, 12, arr.shape) # low contrast + noise
|
|
# uneven lighting
|
|
arr += np.linspace(-40, 40, w)[None, :]
|
|
assert qr.decode(np.clip(arr, 0, 255).astype(np.uint8)) == LPA
|
|
|
|
def test_no_code(self):
|
|
rng = np.random.default_rng(2)
|
|
assert qr.decode(rng.integers(0, 256, size=(240, 320), dtype=np.uint8)) is None
|
|
assert qr.decode(np.full((240, 320), 200, dtype=np.uint8)) is None
|
|
|
|
def test_encoder_roundtrip(self):
|
|
for version in range(1, 21):
|
|
with self.subTest(version=version):
|
|
assert qr.decode_matrix(np.array(qr._Qr(version, b"hello").modules)) == "hello"
|