PhotoDithering

This commit is contained in:
2026-09-16 15:01:06 +10:00
parent 3b2a399769
commit d998b6731e
7 changed files with 304 additions and 3 deletions
+20
View File
@@ -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 Change it to Monochrome media.* The roll setting controls how the job is built, not just its
colour. 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** **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 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 outright: the printer shows **Wrong Roll Type** and nothing comes out. The setting is separate
+22
View File
@@ -423,3 +423,25 @@ pre.raw {
color: #fff; color: #fff;
font-weight: 600; 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;
}
+51
View File
@@ -833,6 +833,24 @@ function openSiteModal(site) {
</div> </div>
<p class="hint" id="badge-warning" hidden></p> <p class="hint" id="badge-warning" hidden></p>
<label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label> <label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
<div class="photo-tuning">
<label class="modal-field"><span>Photo rendering</span>
<select name="photoMode" id="photo-mode">
<option value="dither" ${site.photo.mode === 'dither' ? 'selected' : ''}>Error diffusion — best for faces</option>
<option value="threshold" ${site.photo.mode === 'threshold' ? 'selected' : ''}>Hard threshold — crisp, loses detail</option>
<option value="none" ${site.photo.mode === 'none' ? 'selected' : ''}>Leave it to the printer driver</option>
</select></label>
<label class="modal-field"><span>Threshold <b id="photo-threshold-value">${site.photo.threshold}</b>%</span>
<input type="range" name="photoThreshold" id="photo-threshold" min="5" max="95" step="5" value="${site.photo.threshold}"></label>
<label class="modal-field"><span>Contrast <b id="photo-contrast-value">${site.photo.contrast}</b></span>
<input type="range" name="photoContrast" id="photo-contrast" min="-50" max="100" step="10" value="${site.photo.contrast}"></label>
<button type="button" class="ghost" id="photo-preview-btn">Preview the photo settings</button>
<p class="hint">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.</p>
<img id="photo-preview" class="photo-preview" alt="" hidden>
</div>
<label class="inline"><input type="checkbox" name="accent" id="badge-accent" ${site.badge.accent ? 'checked' : ''}> Print the heading and the no-check warning in red</label> <label class="inline"><input type="checkbox" name="accent" id="badge-accent" ${site.badge.accent ? 'checked' : ''}> Print the heading and the no-check warning in red</label>
<p class="hint" id="accent-note"></p> <p class="hint" id="accent-note"></p>
<p class="hint">Two-colour printing is much slower than black alone.</p> <p class="hint">Two-colour printing is much slower than black alone.</p>
@@ -929,6 +947,11 @@ function openSiteModal(site) {
rotate: Number(data.printerRotate) || 0, rotate: Number(data.printerRotate) || 0,
label: data.printerLabel, label: data.printerLabel,
}, },
photo: {
mode: data.photoMode,
threshold: Number(data.photoThreshold),
contrast: Number(data.photoContrast),
},
branding: { branding: {
brand: data.brand || null, brand: data.brand || null,
signout: data.signout || null, signout: data.signout || null,
@@ -1037,6 +1060,34 @@ function wireBannerEditor() {
[pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast)); [pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast));
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. // Red is only possible on the two-colour roll, so say so as the two settings change.
const accentBox = $('#badge-accent'); const accentBox = $('#badge-accent');
const rollSelect = $('#modal-form [name="printerLabel"]'); const rollSelect = $('#modal-form [name="printerLabel"]');
+8
View File
@@ -28,6 +28,9 @@ CREATE TABLE IF NOT EXISTS sites (
printer_model TEXT NOT NULL DEFAULT 'QL-820NWB', printer_model TEXT NOT NULL DEFAULT 'QL-820NWB',
printer_rotate INTEGER NOT NULL DEFAULT 0, printer_rotate INTEGER NOT NULL DEFAULT 0,
printer_label TEXT NOT NULL DEFAULT '62', 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_path TEXT,
banner_height INTEGER NOT NULL DEFAULT 64, banner_height INTEGER NOT NULL DEFAULT 64,
banner_align TEXT NOT NULL DEFAULT 'left', 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 // 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. // from a design setting means a wrong-roll error nobody can explain.
addColumn('sites', 'printer_label', "TEXT NOT NULL DEFAULT '62'"); 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'); addColumn('frequent_visitors', 'company', 'TEXT');
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)'); db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
+154 -1
View File
@@ -63,6 +63,155 @@ export function accentWillPrintRed(site) {
return Boolean(site?.badge_accent) && labelFor(site) === '62red'; 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 */ /* ------------------------------------------------------------ rendering */
function wrapText(ctx, text, maxWidth, maxLines) { function wrapText(ctx, text, maxWidth, maxLines) {
@@ -122,7 +271,10 @@ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY
if (photo) { if (photo) {
if (portrait) { if (portrait) {
const x = Math.round((widthDots - photoSize) / 2); 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) { if (paint) {
ctx.strokeStyle = black; ctx.strokeStyle = black;
ctx.lineWidth = Math.max(2, Math.round(mm(0.3))); 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); const y = Math.round((heightDots - photoSize) / 2);
if (paint) { if (paint) {
ctx.drawImage(photo, pad, y, photoSize, photoSize); ctx.drawImage(photo, pad, y, photoSize, photoSize);
halftone(ctx, pad, y, photoSize, photoSettings(site));
ctx.strokeStyle = black; ctx.strokeStyle = black;
ctx.lineWidth = Math.max(2, Math.round(mm(0.3))); ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
ctx.strokeRect(pad, y, photoSize, photoSize); ctx.strokeRect(pad, y, photoSize, photoSize);
+44 -2
View File
@@ -392,12 +392,14 @@ router.patch('/sites/:id', (req, res) => {
const badge = req.body?.badge || {}; const badge = req.body?.badge || {};
const branding = req.body?.branding || {}; const branding = req.body?.branding || {};
const printerCfg = req.body?.printer || {}; const printerCfg = req.body?.printer || {};
const photoCfg = req.body?.photo || {};
db.prepare( db.prepare(
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?, `UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?, badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?, colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?, 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( ).run(
clean(req.body?.name ?? site.name, 100) || site.name, clean(req.body?.name ?? site.name, 100) || site.name,
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug, req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
@@ -435,7 +437,16 @@ router.patch('/sites/:id', (req, res) => {
: site.printer_rotate, : site.printer_rotate,
printerCfg.label !== undefined printerCfg.label !== undefined
? (Object.hasOwn(printer.ROLL_TYPES, printerCfg.label) ? printerCfg.label : '62') ? (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); , site.id);
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(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) => { router.get('/sites/:id/badge-preview', (req, res) => {
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).send('Not found.'); if (!site) return res.status(404).send('Not found.');
+5
View File
@@ -67,6 +67,11 @@ export function shapeSite(site) {
rotate: site.printer_rotate || 0, rotate: site.printer_rotate || 0,
label: site.printer_label || '62', label: site.printer_label || '62',
}, },
photo: {
mode: site.photo_mode || 'dither',
threshold: site.photo_threshold ?? 50,
contrast: site.photo_contrast ?? 20,
},
badge: { badge: {
enabled: Boolean(site.badge_enabled), enabled: Boolean(site.badge_enabled),
widthMm: site.badge_width_mm, widthMm: site.badge_width_mm,