PhotoDithering

This commit is contained in:
jessikitty committed 2026-09-16 15:01:06 +10:00
1 parent 3b2a399769
commit d998b6731e
7 files changed
+304 -3

No files matched your search

+8
View File
@@ -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)');
+154 -1
View File
@@ -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);
+44 -2
View File
@@ -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.');
+5
View File
@@ -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,