Public Access
PhotoDithering
This commit is contained in:
1 parent
3b2a399769
commit
d998b6731e
7 files changed
+304
-3
No files matched your search
@@ -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
@@ -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
@@ -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.');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user