library: scan with a phone camera (Quick Add)
A 'Scan with camera' button opens the phone's own camera app; the photo is shrunk on the phone and read on the server with zbarimg, then goes through the normal add flow (also available on the sticker step). Works over plain HTTP on iPhone and Android - no HTTPS needed. - /api/scan: JPEG/PNG only (checked by magic bytes before ImageMagick sees it), 12 MB cap, login required, 20 s timeout, temp files cleaned up - UPC-A reads (zbar reports 13 digits with a leading zero) normalised to the 12 digits printed on the item so duplicate checks match - tests/gen_photos.py + tests/check_scan.py: phone-like photos (tilt, blur, noise, low light, upside down, EXIF rotation, small, UPC, Code 39 sticker); 10/10 read at the size the page sends Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
"""Regression check for photo scanning: generate phone-like photos of real barcodes and make
|
||||
sure the running Quick Add reads them (at the size the page sends, 2400 px).
|
||||
|
||||
python3 -m venv /tmp/qa-venv && /tmp/qa-venv/bin/pip install zxing-cpp pillow
|
||||
PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/gen_photos.py
|
||||
PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/check_scan.py http://127.0.0.1:8082 admin PASSWORD
|
||||
"""
|
||||
import base64, io, json, os, sys, urllib.request
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
url, user, pw = sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3]
|
||||
D = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
|
||||
auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
|
||||
|
||||
|
||||
def scan(data, kind):
|
||||
req = urllib.request.Request(f"{url}/api/scan?kind={kind}", data=data, method="POST",
|
||||
headers={"Content-Type": "image/jpeg", "Authorization": auth})
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.load(r)["codes"]
|
||||
|
||||
|
||||
def shrunk(path, m=2400): # what the page does before uploading
|
||||
im = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
|
||||
k = min(1, m / max(im.size))
|
||||
im = im.resize((round(im.width * k), round(im.height * k)), Image.LANCZOS)
|
||||
b = io.BytesIO(); im.save(b, "JPEG", quality=90); return b.getvalue()
|
||||
|
||||
|
||||
bad = 0
|
||||
for n in sorted(f for f in os.listdir(D) if f.endswith(".jpg")):
|
||||
base = n[:-4]
|
||||
exp_file = os.path.join(D, base + ".expect")
|
||||
exp = open(exp_file).read() if os.path.exists(exp_file) else None
|
||||
codes = scan(shrunk(os.path.join(D, n)), "sticker" if base.startswith("sticker") else "isbn")
|
||||
ok = (codes[:1] == [exp]) if exp else (codes == [])
|
||||
bad += not ok
|
||||
print(("ok " if ok else "FAIL ") + f"{base:20s} expected {exp!s:14s} got {codes[:2]}")
|
||||
sys.exit(1 if bad else 0)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Generate phone-photo-like test images containing real barcodes."""
|
||||
import random, io, os
|
||||
import zxingcpp
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageEnhance
|
||||
|
||||
OUT = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
random.seed(7)
|
||||
|
||||
|
||||
def barcode(fmt, text, scale=4, height=90):
|
||||
bc = zxingcpp.write_barcode(fmt, text, width=0, height=height, quiet_zone=10)
|
||||
img = Image.fromarray(bc.__array__() if hasattr(bc, "__array__") else bc).convert("RGB")
|
||||
return img.resize((img.width * scale, img.height * scale), Image.NEAREST)
|
||||
|
||||
|
||||
def scene(code_img, w=3024, h=4032, tilt=0, blur=0.0, contrast=1.0, bright=1.0, noise=0, quality=72, exif=None, size_frac=0.45):
|
||||
"""Paste the barcode (on a white label) onto a coloured, busy 'book cover'."""
|
||||
bg = Image.new("RGB", (w, h), (random.randint(20, 90), random.randint(30, 80), random.randint(60, 140)))
|
||||
d = ImageDraw.Draw(bg)
|
||||
for _ in range(60): # clutter: stripes, blobs, text-ish bars
|
||||
x, y = random.randint(0, w), random.randint(0, h)
|
||||
d.rectangle([x, y, x + random.randint(40, 600), y + random.randint(8, 60)],
|
||||
fill=tuple(random.randint(0, 255) for _ in range(3)))
|
||||
label = code_img.copy()
|
||||
k = (w * size_frac) / label.width
|
||||
label = label.resize((int(label.width * k), int(label.height * k)), Image.LANCZOS)
|
||||
pad = Image.new("RGB", (label.width + 60, label.height + 60), "white")
|
||||
pad.paste(label, (30, 30))
|
||||
if tilt:
|
||||
pad = pad.rotate(tilt, expand=True, resample=Image.BICUBIC, fillcolor="white")
|
||||
bg.paste(pad, ((w - pad.width) // 2 + random.randint(-80, 80), (h - pad.height) // 2 + random.randint(-300, 300)))
|
||||
if blur:
|
||||
bg = bg.filter(ImageFilter.GaussianBlur(blur))
|
||||
bg = ImageEnhance.Contrast(bg).enhance(contrast)
|
||||
bg = ImageEnhance.Brightness(bg).enhance(bright)
|
||||
if noise:
|
||||
px = bg.load()
|
||||
for _ in range(noise):
|
||||
x, y = random.randint(0, w - 1), random.randint(0, h - 1)
|
||||
v = random.randint(0, 255); px[x, y] = (v, v, v)
|
||||
buf = io.BytesIO()
|
||||
kw = {"quality": quality}
|
||||
if exif is not None:
|
||||
e = Image.Exif(); e[0x0112] = exif; kw["exif"] = e
|
||||
bg.save(buf, "JPEG", **kw)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
E13 = zxingcpp.BarcodeFormat.EAN13
|
||||
UPCA = zxingcpp.BarcodeFormat.UPCA
|
||||
C39 = zxingcpp.BarcodeFormat.Code39
|
||||
|
||||
cases = {
|
||||
# name: (expected, bytes)
|
||||
"isbn_clean": ("9780345339683", scene(barcode(E13, "9780345339683"))),
|
||||
"isbn_tilt8": ("9780345339683", scene(barcode(E13, "9780345339683"), tilt=8, blur=1.2)),
|
||||
"isbn_tilt_neg12": ("9780441172719", scene(barcode(E13, "9780441172719"), tilt=-12, blur=1.5, contrast=0.8)),
|
||||
"isbn_dim_noisy": ("9780345339683", scene(barcode(E13, "9780345339683"), blur=1.4, contrast=0.7, bright=0.7, noise=60000, quality=55)),
|
||||
"isbn_small": ("9780441172719", scene(barcode(E13, "9780441172719"), size_frac=0.22, blur=1.0)),
|
||||
"isbn_exif_rot90": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(90, expand=True), exif=6)),
|
||||
"isbn_upside_down": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(180, expand=True))),
|
||||
"upc_a": ("036000291452", scene(barcode(UPCA, "036000291452"), tilt=5, blur=1.0)),
|
||||
}
|
||||
for name, (exp, data) in cases.items():
|
||||
open(f"{OUT}/{name}.jpg", "wb").write(data)
|
||||
open(f"{OUT}/{name}.expect", "w").write(exp)
|
||||
|
||||
# sticker (Code 39, 7 digits) and a photo with no barcode at all
|
||||
open(f"{OUT}/sticker_c39.jpg", "wb").write(scene(barcode(C39, "0000042", scale=3), tilt=4, blur=1.0, size_frac=0.5))
|
||||
open(f"{OUT}/sticker_c39.expect", "w").write("0000042")
|
||||
blank = Image.new("RGB", (3024, 4032), (90, 60, 40)); ImageDraw.Draw(blank).rectangle([400, 900, 2600, 1300], fill=(200, 180, 60))
|
||||
b = io.BytesIO(); blank.save(b, "JPEG", quality=80); open(f"{OUT}/no_barcode.jpg", "wb").write(b.getvalue())
|
||||
print("wrote", len(os.listdir(OUT)) // 2, "photos to", OUT)
|
||||
for n in sorted(os.listdir(OUT)):
|
||||
if n.endswith(".jpg"):
|
||||
print(f" {n:22s} {os.path.getsize(os.path.join(OUT, n)) / 1e6:5.1f} MB")
|
||||
Reference in New Issue
Block a user