diff --git a/README.md b/README.md index c039a73..78366b5 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,26 @@ refuses a monochrome job on two-colour tape, saying *Black/Red on White paper is Change it to Monochrome media.* The roll setting controls how the job is built, not just its colour. +### Photos on the badge + +A thermal printer has one bit per dot: every pixel is either burnt or not. A photo therefore has +to be reduced to pure black and white, and how that is done makes the difference between a +recognisable face and a few solid blobs. + +Under **Sites → Edit → Badge printing**: + +| Setting | What it does | +|---|---| +| Error diffusion | Scatters the rounding error into neighbouring dots, so mid tones survive as a pattern. The default, and much the best for faces. | +| Hard threshold | Every pixel darker than the cut becomes solid black. Crisp for line art, ruinous for photographs. | +| Leave it to the driver | Sends greyscale and lets `brother_ql` decide. | +| Threshold | Where the cut falls. Higher is darker. | +| Contrast | Applied before the reduction. Webcam photos are flat and flatten further at one bit, so a lift of 20 to 30 usually helps. | + +**Preview the photo settings** renders the most recent real visitor photo at several settings side +by side, so the choice is made by eye. The halftoning happens in the server's renderer, not in the +printer driver, so the preview and the printed label are the same image. + **Tell it which roll is loaded.** *Roll loaded in the printer* under **Sites → Edit → Printer** must match what is physically in the machine. A two-colour job sent to a plain roll is refused outright: the printer shows **Wrong Roll Type** and nothing comes out. The setting is separate diff --git a/public/css/admin.css b/public/css/admin.css index 13fadfe..2eadb42 100644 --- a/public/css/admin.css +++ b/public/css/admin.css @@ -423,3 +423,25 @@ pre.raw { color: #fff; font-weight: 600; } + +/* --------------------------------------------------- photo halftoning */ + +.photo-tuning { + margin: 12px 0 16px; + padding: 14px; + border: 1px solid var(--rule); + border-radius: 3px; + background: var(--paper); +} +.photo-tuning input[type="range"] { width: 100%; } +.photo-tuning .modal-field span b { font-variant-numeric: tabular-nums; } +.photo-tuning .hint { margin: 10px 0; } + +.photo-preview { + display: block; + width: 100%; + margin-top: 12px; + border: 1px solid var(--rule); + border-radius: 3px; + background: #fff; +} diff --git a/public/js/admin.js b/public/js/admin.js index 8a8fcfb..f4d2573 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -833,6 +833,24 @@ function openSiteModal(site) { +
+ + + + +

The printer has one bit per dot, so a photo has to become pure black and + white. A hard threshold turns a face into solid blocks; error diffusion scatters the + rounding error into neighbouring dots and keeps the tones readable. Higher threshold means + darker. The preview shows the most recent real visitor photo.

+ +

Two-colour printing is much slower than black alone.

@@ -929,6 +947,11 @@ function openSiteModal(site) { rotate: Number(data.printerRotate) || 0, label: data.printerLabel, }, + photo: { + mode: data.photoMode, + threshold: Number(data.photoThreshold), + contrast: Number(data.photoContrast), + }, branding: { brand: data.brand || null, signout: data.signout || null, @@ -1037,6 +1060,34 @@ function wireBannerEditor() { [pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast)); showContrast(); + // Live readouts for the halftone sliders, and a preview on demand. The preview + // is not automatic: it re-renders a real photo five times and is not free. + const modeSel = $('#photo-mode'); + const thr = $('#photo-threshold'); + const con = $('#photo-contrast'); + const refreshLabels = () => { + $('#photo-threshold-value').textContent = thr.value; + $('#photo-contrast-value').textContent = con.value; + const off = modeSel.value === 'none'; + thr.disabled = off; + con.disabled = off; + }; + [thr, con].forEach((el) => el?.addEventListener('input', refreshLabels)); + modeSel?.addEventListener('change', refreshLabels); + refreshLabels(); + + $('#photo-preview-btn')?.addEventListener('click', () => { + const img = $('#photo-preview'); + const params = new URLSearchParams({ + mode: modeSel.value, + threshold: thr.value, + contrast: con.value, + t: Date.now(), + }); + img.src = `/admin/api/sites/${site.id}/photo-preview?${params}`; + img.hidden = false; + }); + // Red is only possible on the two-colour roll, so say so as the two settings change. const accentBox = $('#badge-accent'); const rollSelect = $('#modal-form [name="printerLabel"]'); diff --git a/src/db.js b/src/db.js index 2d1c436..18ac7fc 100644 --- a/src/db.js +++ b/src/db.js @@ -28,6 +28,9 @@ CREATE TABLE IF NOT EXISTS sites ( printer_model TEXT NOT NULL DEFAULT 'QL-820NWB', printer_rotate INTEGER NOT NULL DEFAULT 0, printer_label TEXT NOT NULL DEFAULT '62', + photo_mode TEXT NOT NULL DEFAULT 'dither', + photo_threshold INTEGER NOT NULL DEFAULT 50, + photo_contrast INTEGER NOT NULL DEFAULT 20, banner_path TEXT, banner_height INTEGER NOT NULL DEFAULT 64, banner_align TEXT NOT NULL DEFAULT 'left', @@ -188,6 +191,11 @@ addColumn('sites', 'printer_rotate', 'INTEGER NOT NULL DEFAULT 0'); // the printer refuses a two-colour job on a plain roll, so guessing the media // from a design setting means a wrong-roll error nobody can explain. addColumn('sites', 'printer_label', "TEXT NOT NULL DEFAULT '62'"); +// How the photo is reduced to the printer's one bit per dot. A plain threshold +// turns a face into solid blocks; error diffusion keeps the tones readable. +addColumn('sites', 'photo_mode', "TEXT NOT NULL DEFAULT 'dither'"); +addColumn('sites', 'photo_threshold', 'INTEGER NOT NULL DEFAULT 50'); +addColumn('sites', 'photo_contrast', 'INTEGER NOT NULL DEFAULT 20'); addColumn('frequent_visitors', 'company', 'TEXT'); db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)'); diff --git a/src/printer.js b/src/printer.js index 90394d1..562fda2 100644 --- a/src/printer.js +++ b/src/printer.js @@ -63,6 +63,155 @@ export function accentWillPrintRed(site) { return Boolean(site?.badge_accent) && labelFor(site) === '62red'; } +/* ------------------------------------------------- photo halftoning */ + +export const PHOTO_MODES = { + dither: 'Error diffusion — best for faces', + threshold: 'Hard threshold — crisp, loses detail', + none: 'Leave it to the printer driver', +}; + +/** + * Reduces a photo to the one bit per dot the printer actually has. + * + * Done here rather than left to brother_ql so the preview and the label agree, + * and because the default is a plain threshold: every pixel darker than the cut + * becomes solid black, which turns a face into a few featureless blobs. Error + * diffusion spreads the rounding error into neighbouring pixels instead, so mid + * tones survive as a pattern of dots. + */ +function halftone(ctx, x, y, size, { mode = 'dither', threshold = 50, contrast = 20 } = {}) { + if (mode === 'none') return; + + const image = ctx.getImageData(x, y, size, size); + const { data, width, height } = image; + const cut = Math.max(1, Math.min(99, threshold)) * 2.55; + + // Standard contrast curve, pivoting on mid grey. Webcam photos are flat and + // flatten further when reduced to two tones, so a little lift helps. + const c = Math.max(-100, Math.min(100, contrast)); + const factor = (259 * (c + 255)) / (255 * (259 - c)); + + // Greyscale first, into a float buffer so the diffused error does not clip. + const grey = new Float32Array(width * height); + for (let i = 0; i < width * height; i += 1) { + const r = data[i * 4]; + const g = data[i * 4 + 1]; + const b = data[i * 4 + 2]; + const luma = 0.299 * r + 0.587 * g + 0.114 * b; + grey[i] = Math.max(0, Math.min(255, factor * (luma - 128) + 128)); + } + + for (let py = 0; py < height; py += 1) { + for (let px = 0; px < width; px += 1) { + const i = py * width + px; + const old = grey[i]; + const next = old < cut ? 0 : 255; + grey[i] = next; + + if (mode === 'dither') { + // Floyd-Steinberg: push the rounding error to pixels not yet visited. + const err = old - next; + const spread = (dx, dy, weight) => { + const nx = px + dx; + const ny = py + dy; + if (nx < 0 || nx >= width || ny >= height) return; + grey[ny * width + nx] += err * weight; + }; + spread(1, 0, 7 / 16); + spread(-1, 1, 3 / 16); + spread(0, 1, 5 / 16); + spread(1, 1, 1 / 16); + } + } + } + + for (let i = 0; i < width * height; i += 1) { + const v = grey[i] < 128 ? 0 : 255; + data[i * 4] = v; + data[i * 4 + 1] = v; + data[i * 4 + 2] = v; + data[i * 4 + 3] = 255; + } + ctx.putImageData(image, x, y); +} + +export function photoSettings(site) { + return { + mode: Object.hasOwn(PHOTO_MODES, site?.photo_mode) ? site.photo_mode : 'dither', + threshold: Number.isFinite(Number(site?.photo_threshold)) ? Number(site.photo_threshold) : 50, + contrast: Number.isFinite(Number(site?.photo_contrast)) ? Number(site.photo_contrast) : 20, + }; +} + +/** + * A comparison sheet: the same photo at several settings, side by side, so the + * right one can be chosen by eye rather than by guessing at numbers. + */ +export async function photoPreviewPng(photoPath, current = {}) { + const tile = 260; + const gap = 18; + const caption = 46; + + const settings = photoSettings(current); + const variants = [ + { label: `Current: ${PHOTO_MODES[settings.mode].split(' —')[0]} ${settings.threshold}%, contrast ${settings.contrast}`, ...settings }, + { label: 'Dither, threshold 50, contrast 0', mode: 'dither', threshold: 50, contrast: 0 }, + { label: 'Dither, threshold 50, contrast 30', mode: 'dither', threshold: 50, contrast: 30 }, + { label: 'Dither, threshold 60, contrast 30', mode: 'dither', threshold: 60, contrast: 30 }, + { label: 'Hard threshold 50', mode: 'threshold', threshold: 50, contrast: 0 }, + ]; + + const canvas = createCanvas( + gap + variants.length * (tile + gap), + gap + tile + caption + ); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const abs = photoAbsolutePath(photoPath); + let image = null; + if (abs) { + try { + image = await loadImage(abs); + } catch { + image = null; + } + } + + for (const [index, variant] of variants.entries()) { + const x = gap + index * (tile + gap); + if (image) { + ctx.drawImage(image, x, gap, tile, tile); + halftone(ctx, x, gap, tile, variant); + } else { + ctx.fillStyle = '#f0f0f0'; + ctx.fillRect(x, gap, tile, tile); + ctx.fillStyle = '#555555'; + ctx.font = '14px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('no photo yet', x + tile / 2, gap + tile / 2); + } + ctx.strokeStyle = '#999999'; + ctx.lineWidth = 1; + ctx.strokeRect(x + 0.5, gap + 0.5, tile, tile); + + ctx.fillStyle = index === 0 ? '#0b4f4a' : '#222222'; + ctx.font = `${index === 0 ? 'bold ' : ''}13px sans-serif`; + ctx.textAlign = 'center'; + for (const [line, text] of variant.label.split(', contrast').entries()) { + ctx.fillText( + line === 0 ? text : `contrast${text}`, + x + tile / 2, + gap + tile + 20 + line * 16 + ); + } + } + + return canvas.toBuffer('image/png'); +} + /* ------------------------------------------------------------ rendering */ function wrapText(ctx, text, maxWidth, maxLines) { @@ -122,7 +271,10 @@ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY if (photo) { if (portrait) { const x = Math.round((widthDots - photoSize) / 2); - if (paint) ctx.drawImage(photo, x, cursorY, photoSize, photoSize); + if (paint) { + ctx.drawImage(photo, x, cursorY, photoSize, photoSize); + halftone(ctx, x, cursorY, photoSize, photoSettings(site)); + } if (paint) { ctx.strokeStyle = black; ctx.lineWidth = Math.max(2, Math.round(mm(0.3))); @@ -133,6 +285,7 @@ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY const y = Math.round((heightDots - photoSize) / 2); if (paint) { ctx.drawImage(photo, pad, y, photoSize, photoSize); + halftone(ctx, pad, y, photoSize, photoSettings(site)); ctx.strokeStyle = black; ctx.lineWidth = Math.max(2, Math.round(mm(0.3))); ctx.strokeRect(pad, y, photoSize, photoSize); diff --git a/src/routes/admin.js b/src/routes/admin.js index cff3937..c06bacb 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -392,12 +392,14 @@ router.patch('/sites/:id', (req, res) => { const badge = req.body?.badge || {}; const branding = req.body?.branding || {}; const printerCfg = req.body?.printer || {}; + const photoCfg = req.body?.photo || {}; db.prepare( `UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?, badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?, colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?, banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?, - printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ? WHERE id = ?` + printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ?, + photo_mode = ?, photo_threshold = ?, photo_contrast = ? WHERE id = ?` ).run( clean(req.body?.name ?? site.name, 100) || site.name, req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug, @@ -435,7 +437,16 @@ router.patch('/sites/:id', (req, res) => { : site.printer_rotate, printerCfg.label !== undefined ? (Object.hasOwn(printer.ROLL_TYPES, printerCfg.label) ? printerCfg.label : '62') - : site.printer_label + : site.printer_label, + photoCfg.mode !== undefined + ? (Object.hasOwn(printer.PHOTO_MODES, photoCfg.mode) ? photoCfg.mode : 'dither') + : site.photo_mode, + photoCfg.threshold !== undefined + ? Math.min(95, Math.max(5, Number(photoCfg.threshold) || 50)) + : site.photo_threshold, + photoCfg.contrast !== undefined + ? Math.min(100, Math.max(-100, Number(photoCfg.contrast) || 0)) + : site.photo_contrast , site.id); res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id))); @@ -561,6 +572,37 @@ router.post('/visits/:id/print', async (req, res) => { } }); +/** The same photo at several halftone settings, to choose between by eye. */ +router.get('/sites/:id/photo-preview', async (req, res) => { + const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); + if (!site) return res.status(404).send('Not found.'); + + // Whatever real photo is closest to hand: the most recent visit at this site, + // then any stored recurring visitor photo. + const recent = + db + .prepare( + 'SELECT photo_path FROM visits WHERE site_id = ? AND photo_path IS NOT NULL ORDER BY id DESC LIMIT 1' + ) + .get(site.id) || + db.prepare('SELECT photo_path FROM frequent_visitors WHERE photo_path IS NOT NULL LIMIT 1').get(); + + const overrides = { + photo_mode: req.query.mode || site.photo_mode, + photo_threshold: req.query.threshold || site.photo_threshold, + photo_contrast: req.query.contrast || site.photo_contrast, + }; + + try { + const png = await printer.photoPreviewPng(recent?.photo_path || null, overrides); + res.setHeader('Content-Type', 'image/png'); + res.setHeader('Cache-Control', 'no-store'); + res.send(png); + } catch (err) { + res.status(500).send(`Could not render the preview: ${err.message}`); + } +}); + router.get('/sites/:id/badge-preview', (req, res) => { const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); if (!site) return res.status(404).send('Not found.'); diff --git a/src/sites.js b/src/sites.js index 1c9602d..d317731 100644 --- a/src/sites.js +++ b/src/sites.js @@ -67,6 +67,11 @@ export function shapeSite(site) { rotate: site.printer_rotate || 0, label: site.printer_label || '62', }, + photo: { + mode: site.photo_mode || 'dither', + threshold: site.photo_threshold ?? 50, + contrast: site.photo_contrast ?? 20, + }, badge: { enabled: Boolean(site.badge_enabled), widthMm: site.badge_width_mm,