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:
@@ -429,7 +429,7 @@ Both can be used at the same time — they serve different purposes:
|
|||||||
|
|
||||||
### Library Catalog (Koha)
|
### Library Catalog (Koha)
|
||||||
- **Public search terminal** - a self-hosted library catalogue shown as one of the kiosk's pages (duration 0: it stays on screen), so visitors can search by title, author or subject and see whether a book is in
|
- **Public search terminal** - a self-hosted library catalogue shown as one of the kiosk's pages (duration 0: it stays on screen), so visitors can search by title, author or subject and see whether a book is in
|
||||||
- **Add books by scanning** - a Quick Add page: scan or type an ISBN and the book is looked up (Library of Congress via Z39.50, then Open Library) and added with the next barcode. Duplicates, "add another copy", manual entry and Undo are built in
|
- **Add books by scanning** - a Quick Add page: scan or type an ISBN (or tap "Scan with camera" on a phone) and the book is looked up (Library of Congress via Z39.50, then Open Library) and added with the next barcode. Duplicates, "add another copy", manual entry and Undo are built in
|
||||||
- **Spine labels and barcodes** - Avery 5160/5167 and Demco spine templates, with Dewey call numbers split across lines
|
- **Spine labels and barcodes** - Avery 5160/5167 and Demco spine templates, with Dewey call numbers split across lines
|
||||||
- **Docker is installed for you** if it is missing (`lib/docker.sh`); the stack is deployed from this checkout's `library/` to `/opt/kiosk-library`
|
- **Docker is installed for you** if it is missing (`lib/docker.sh`); the stack is deployed from this checkout's `library/` to `/opt/kiosk-library`
|
||||||
- **Who can reach it** - Addons → Library Catalog → Network access: *this kiosk only* (ports bound to `127.0.0.1`) or *other devices on the network*. Docker bypasses `ufw`, so the bind address, not the firewall, is what limits access
|
- **Who can reach it** - Addons → Library Catalog → Network access: *this kiosk only* (ports bound to `127.0.0.1`) or *other devices on the network*. Docker bypasses `ufw`, so the bind address, not the firewall, is what limits access
|
||||||
|
|||||||
+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
|
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.
|
stack of books without looking at the screen.
|
||||||
|
|
||||||
Phone camera scanning is not built in (browsers only allow the camera on HTTPS or
|
### Scanning with a phone camera
|
||||||
`localhost`). A scanner-app that "types" the code into the box works.
|
|
||||||
|
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
|
## Barcodes: your choice
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ FROM debian:bookworm-slim
|
|||||||
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
|
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
RUN apt-get update \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
|
# 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/
|
COPY certs/ /usr/local/share/ca-certificates/extra/
|
||||||
RUN update-ca-certificates
|
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 \
|
RUN useradd --system --uid 10001 --home-dir /app quickadd \
|
||||||
&& mkdir -p /app /data && chown quickadd /data
|
&& mkdir -p /app /data && chown quickadd /data
|
||||||
|
|||||||
@@ -687,6 +687,58 @@ def op_assign(payload):
|
|||||||
"last": fmt_barcode(first + len(items) - 1, s)}
|
"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
|
# ----------------------------------------------------------------------- HTTP
|
||||||
INDEX = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"), "rb").read()
|
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():
|
if not self._guard():
|
||||||
return
|
return
|
||||||
try:
|
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"))
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
if length > 20000:
|
if length > 20000:
|
||||||
return self._send(413, {"error": "too large"})
|
return self._send(413, {"error": "too large"})
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
padding: 10px 16px; background: var(--card); color: var(--ink); font-weight: 600; }
|
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.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; }
|
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); }
|
#status { min-height: 1.5em; margin: 10px 2px; color: var(--muted); }
|
||||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px;
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px;
|
||||||
padding: 14px; margin: 10px 0; display: flex; gap: 14px; }
|
padding: 14px; margin: 10px 0; display: flex; gap: 14px; }
|
||||||
@@ -98,9 +99,14 @@
|
|||||||
|
|
||||||
<form class="scan" id="scan" autocomplete="off">
|
<form class="scan" id="scan" autocomplete="off">
|
||||||
<label class="sr" for="code">ISBN or barcode</label>
|
<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>
|
<button class="primary" type="submit">Add</button>
|
||||||
</form>
|
</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="status" role="status" aria-live="polite"></div>
|
||||||
<div id="result"></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) {
|
function stickerPrompt(r) {
|
||||||
awaiting = true;
|
awaiting = true;
|
||||||
const input = el('input', { placeholder: 'Scan the barcode sticker', autocomplete: 'off', 'aria-label': 'Barcode sticker', inputMode: 'text' });
|
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');
|
const cancel = el('button', { type: 'button' }, 'Cancel');
|
||||||
cancel.onclick = async () => { await api('/api/cancel', { token: r.token }); awaiting = false;
|
cancel.onclick = async () => { await api('/api/cancel', { token: r.token }); awaiting = false;
|
||||||
$('result').replaceChildren(); $('code').focus(); };
|
$('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 => {
|
form.onsubmit = async ev => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const bc = input.value.trim(); if (!bc) return;
|
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