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:
+13
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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