Public Access
PhotoDithering
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -833,6 +833,24 @@ function openSiteModal(site) {
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<p class="hint" id="accent-note"></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,
|
||||
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"]');
|
||||
|
||||
@@ -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