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:
Claude
2026-11-06 13:12:58 +00:00
parent 023a64b62c
commit 5fc7450af7
7 changed files with 252 additions and 7 deletions
+13 -2
View File
@@ -51,8 +51,19 @@ for many of them.
The page beeps: high for added, double for a duplicate, low for not found. You can scan a
stack of books without looking at the screen.
Phone camera scanning is not built in (browsers only allow the camera on HTTPS or
`localhost`). A scanner-app that "types" the code into the box works.
### Scanning with a phone camera
Open the Add books page in your phone's browser and tap **Scan with camera**. Your phone's own camera app
opens; take a photo with the barcode filling the frame, and the book is looked up and added just as if you had
typed it. In "scan a pre-printed sticker" mode the same button appears on the sticker step.
* It works over plain HTTP on both iPhone and Android: the photo is taken by the phone's camera app, shrunk on
the phone, and read on the server (`zbarimg`). Only JPEG and PNG photos are accepted.
* It reads ISBN-13 and ISBN-10 barcodes, and UPC/EAN codes on discs and games.
* Tips: get close so the barcode fills most of the frame, keep it flat and well lit, and hold steady. If it
can't read the photo you are told, and nothing is added.
* A live viewfinder that reads continuously would need HTTPS (browsers only allow that for secure pages); a
Bluetooth scanner paired to the phone also works, since it types into the page.
## Barcodes: your choice
+2 -2
View File
@@ -5,7 +5,7 @@ FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz tzdata \
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools tzdata \
&& rm -rf /var/lib/apt/lists/*
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
@@ -13,7 +13,7 @@ RUN apt-get update \
COPY certs/ /usr/local/share/ca-certificates/extra/
RUN update-ca-certificates
RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null
RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null
RUN useradd --system --uid 10001 --home-dir /app quickadd \
&& mkdir -p /app /data && chown quickadd /data
+62
View File
@@ -687,6 +687,58 @@ def op_assign(payload):
"last": fmt_barcode(first + len(items) - 1, s)}
# ------------------------------------------------------------ photo scanning
MAX_IMAGE = 12 * 1024 * 1024
ZBAR_TIMEOUT = 20
def image_kind(data):
"""Only JPEG and PNG are accepted: the decoder goes through ImageMagick, which
has a long history of bugs in rarely used formats, so everything else is refused
before it gets anywhere near it."""
if data[:3] == b"\xff\xd8\xff":
return "jpg"
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "png"
return None
def decode_photo(data, kind):
"""Read barcodes from a photo with zbarimg. kind 'isbn' returns codes usable as an
ISBN/UPC (ISBNs first); kind 'sticker' returns anything Koha could store as a barcode."""
ext = image_kind(data)
if not ext:
raise ValueError("not a JPEG or PNG image")
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "photo." + ext)
with open(path, "wb") as f:
f.write(data)
try:
out = subprocess.run(["zbarimg", "--raw", "-q", path], capture_output=True, text=True,
timeout=ZBAR_TIMEOUT).stdout
except (subprocess.TimeoutExpired, OSError):
return []
codes = []
for line in out.splitlines():
line = line.strip()
if not line:
continue
if kind == "sticker":
ok = line if STICKER_RE.fullmatch(line) else None
else:
ok = clean_code(line)
# zbar reports a UPC-A (12 digits) as a 13-digit EAN with a leading zero.
# Use the 12 digits printed on the item, as a keyboard scanner would type
# them, so lookups and duplicate checks match.
if ok and len(ok) == 13 and ok[0] == "0":
ok = ok[1:]
if ok and ok not in codes:
codes.append(ok)
if kind != "sticker": # book ISBNs (978/979) before UPCs and short codes
codes.sort(key=lambda c: 0 if (len(c) == 13 and c[:3] in ("978", "979")) else 1 if len(c) == 10 else 2)
return codes
# ----------------------------------------------------------------------- HTTP
INDEX = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"), "rb").read()
@@ -755,6 +807,16 @@ class Handler(BaseHTTPRequestHandler):
if not self._guard():
return
try:
if self.path.split("?")[0] == "/api/scan":
length = int(self.headers.get("Content-Length", "0"))
if not 0 < length <= MAX_IMAGE:
return self._send(413, {"status": "error", "message": "That photo is too large."})
kind = "sticker" if "kind=sticker" in self.path else "isbn"
try:
codes = decode_photo(self.rfile.read(length), kind)
except ValueError:
return self._send(415, {"status": "error", "message": "Please send a JPEG or PNG photo."})
return self._send(200, {"status": "ok", "codes": codes})
length = int(self.headers.get("Content-Length", "0"))
if length > 20000:
return self._send(413, {"error": "too large"})
+58 -2
View File
@@ -44,6 +44,7 @@
padding: 10px 16px; background: var(--card); color: var(--ink); font-weight: 600; }
button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.cam-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0 0; }
#status { min-height: 1.5em; margin: 10px 2px; color: var(--muted); }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px;
padding: 14px; margin: 10px 0; display: flex; gap: 14px; }
@@ -98,9 +99,14 @@
<form class="scan" id="scan" autocomplete="off">
<label class="sr" for="code">ISBN or barcode</label>
<input id="code" inputmode="numeric" placeholder="Scan or type an ISBN, then Enter" autofocus>
<input id="code" inputmode="numeric" placeholder="Scan or type an ISBN" autofocus>
<button class="primary" type="submit">Add</button>
</form>
<div class="cam-row">
<button type="button" id="cam">Scan with camera</button>
<input type="file" id="camfile" accept="image/*" capture="environment" hidden>
<span class="small">Opens your phone's camera: fill the frame with the barcode and hold steady.</span>
</div>
<div id="status" role="status" aria-live="polite"></div>
<div id="result"></div>
@@ -273,6 +279,49 @@
}
}
// ---- phone camera: take a photo, shrink it, have the server read the barcode
async function shrink(file, max = 2400) {
let bmp;
try { bmp = await createImageBitmap(file, { imageOrientation: 'from-image' }); }
catch (e) { bmp = await new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = URL.createObjectURL(file); }); }
const w = bmp.width, h = bmp.height, k = Math.min(1, max / Math.max(w, h));
const c = document.createElement('canvas'); c.width = Math.round(w * k); c.height = Math.round(h * k);
c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height);
return new Promise(ok => c.toBlob(ok, 'image/jpeg', 0.9));
}
// Opens the camera, resolves with the codes found ([] if none, null if cancelled).
function takePhoto(kind) {
return new Promise(resolve => {
const input = $('camfile');
input.onchange = async () => {
const f = input.files[0]; input.value = '';
if (!f) return resolve(null);
$('status').textContent = 'Reading the barcode…';
try {
const blob = await shrink(f);
const r = await fetch('/api/scan?kind=' + kind, { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: blob });
resolve(r.ok ? (await r.json()).codes : []);
} catch (e) { resolve([]); }
};
input.click();
});
}
function noBarcode() {
beep(SOUND.notfound);
$('status').textContent = '';
show('warn', el('div', { className: 'body' }, el('div', { className: 'title' }, "Couldn't read a barcode from that photo"),
el('div', { className: 'meta' }, 'Move closer so the barcode fills the frame, keep the phone steady and the barcode flat and well lit, then try again. You can also type the number.')));
}
$('cam').onclick = async () => {
const codes = await takePhoto('isbn');
if (codes === null) { $('status').textContent = ''; return; }
if (!codes.length) return noBarcode();
add(codes[0]);
};
function stickerPrompt(r) {
awaiting = true;
const input = el('input', { placeholder: 'Scan the barcode sticker', autocomplete: 'off', 'aria-label': 'Barcode sticker', inputMode: 'text' });
@@ -280,7 +329,14 @@
const cancel = el('button', { type: 'button' }, 'Cancel');
cancel.onclick = async () => { await api('/api/cancel', { token: r.token }); awaiting = false;
$('result').replaceChildren(); $('code').focus(); };
form.append(input, el('div', { className: 'actions' }, el('button', { className: 'primary', type: 'submit' }, 'Add'), cancel));
const camBtn = el('button', { type: 'button' }, 'Scan sticker with camera');
camBtn.onclick = async () => {
const codes = await takePhoto('sticker');
if (codes === null) return;
if (!codes.length) { beep(SOUND.notfound); $('status').textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
handle(await api('/api/finish', { token: r.token, barcode: codes[0] }), r.code);
};
form.append(input, el('div', { className: 'actions' }, el('button', { className: 'primary', type: 'submit' }, 'Add'), camBtn, cancel));
form.onsubmit = async ev => {
ev.preventDefault();
const bc = input.value.trim(); if (!bc) return;
+39
View File
@@ -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)
+77
View File
@@ -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")