Public Access
Target a fixed ink coverage, defaulting to 33.3%
Left alone, coverage follows whatever the photo happened to be, which came out around 44% and printed heavier than wanted — thermal dots spread as the paper heats, so they land fatter than they look on screen. The brightness offset needed to hit a target is solved for rather than searched: error diffusion preserves mean tone, so the first guess is analytic and up to three correction passes handle the clipping. Measured 33.6% across normal, heavily darkened and heavily brightened versions of the same photo, in under 6 ms, so badges now print at a consistent density regardless of lighting. Set targetCoverage to null to opt out.
This commit is contained in:
+70
-9
@@ -9,7 +9,7 @@ import { createCanvas } from '@napi-rs/canvas';
|
|||||||
* error diffusion trades spatial resolution for apparent tone instead, which
|
* error diffusion trades spatial resolution for apparent tone instead, which
|
||||||
* is what makes a photo readable at 300 dpi.
|
* is what makes a photo readable at 300 dpi.
|
||||||
*
|
*
|
||||||
* Two things matter for this to look like a face rather than noise:
|
* Three things matter for this to look like a face rather than noise:
|
||||||
*
|
*
|
||||||
* 1. Dither at the exact pixel size the photo will occupy. Rescaling a
|
* 1. Dither at the exact pixel size the photo will occupy. Rescaling a
|
||||||
* dithered image resamples the dot pattern back into greys, and the later
|
* dithered image resamples the dot pattern back into greys, and the later
|
||||||
@@ -20,6 +20,12 @@ import { createCanvas } from '@napi-rs/canvas';
|
|||||||
* produces flat mush. Normalising to the full range first gives the error
|
* produces flat mush. Normalising to the full range first gives the error
|
||||||
* diffusion something to work with.
|
* diffusion something to work with.
|
||||||
*
|
*
|
||||||
|
* 3. Aim for a fixed ink coverage. Thermal dots spread as the paper heats, so
|
||||||
|
* they come out fatter on the label than they look on screen, and a
|
||||||
|
* digitally "correct" image prints muddy. Targeting coverage also makes
|
||||||
|
* every badge print at the same density regardless of how the visitor
|
||||||
|
* happened to be lit.
|
||||||
|
*
|
||||||
* Text is deliberately NOT dithered anywhere — dithered glyph edges look furry
|
* Text is deliberately NOT dithered anywhere — dithered glyph edges look furry
|
||||||
* at this resolution. Only photographs go through here.
|
* at this resolution. Only photographs go through here.
|
||||||
*/
|
*/
|
||||||
@@ -100,6 +106,58 @@ function floydSteinberg(grey, width, height) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Add a constant to every sample, clamped to the printable range. */
|
||||||
|
function applyShift(grey, shift) {
|
||||||
|
if (!shift) return;
|
||||||
|
for (let p = 0; p < grey.length; p++) {
|
||||||
|
grey[p] = Math.min(255, Math.max(0, grey[p] + shift));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fraction of dots that would burn, for a given luminance buffer. */
|
||||||
|
function coverageOf(grey, width, height) {
|
||||||
|
const trial = Float32Array.from(grey);
|
||||||
|
floydSteinberg(trial, width, height);
|
||||||
|
let black = 0;
|
||||||
|
for (let p = 0; p < trial.length; p++) if (trial[p] < 128) black++;
|
||||||
|
return black / trial.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the brightness offset that lands the dithered result on a given ink
|
||||||
|
* coverage.
|
||||||
|
*
|
||||||
|
* Error diffusion preserves mean tone, so coverage is roughly 1 - mean/255 and
|
||||||
|
* the first guess can be computed directly rather than searched for. Clipping
|
||||||
|
* at the ends of the range spoils that slightly, so up to three cheap
|
||||||
|
* correction passes follow. Each pass is one dither over a few tens of
|
||||||
|
* thousands of samples — the whole thing runs in about 6 ms for a 24 mm photo.
|
||||||
|
*
|
||||||
|
* Capped at +/-120 so a very dark or very bright capture degrades into
|
||||||
|
* something faint rather than a blank square.
|
||||||
|
*/
|
||||||
|
function solveShiftForCoverage(grey, width, height, target, maxPasses = 3) {
|
||||||
|
const clamp = (v) => Math.min(120, Math.max(-120, v));
|
||||||
|
|
||||||
|
let mean = 0;
|
||||||
|
for (let p = 0; p < grey.length; p++) mean += grey[p];
|
||||||
|
mean /= grey.length;
|
||||||
|
|
||||||
|
let shift = clamp(255 * (1 - target) - mean);
|
||||||
|
|
||||||
|
for (let pass = 0; pass < maxPasses; pass++) {
|
||||||
|
const trial = Float32Array.from(grey);
|
||||||
|
applyShift(trial, shift);
|
||||||
|
const actual = coverageOf(trial, width, height);
|
||||||
|
const error = actual - target;
|
||||||
|
if (Math.abs(error) < 0.005) break;
|
||||||
|
// Coverage moves roughly linearly with the offset over this range.
|
||||||
|
shift = clamp(shift + error * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
return shift;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dither a photo to 1-bit at a given square size.
|
* Dither a photo to 1-bit at a given square size.
|
||||||
*
|
*
|
||||||
@@ -109,12 +167,16 @@ function floydSteinberg(grey, width, height) {
|
|||||||
* @param {Object} [options]
|
* @param {Object} [options]
|
||||||
* @param {boolean} [options.levels=true] Stretch contrast first
|
* @param {boolean} [options.levels=true] Stretch contrast first
|
||||||
* @param {number} [options.brightness=0] -100..100, nudge before dithering.
|
* @param {number} [options.brightness=0] -100..100, nudge before dithering.
|
||||||
* Negative darkens; useful if badges
|
* Negative darkens.
|
||||||
* print washed out on old rolls.
|
* @param {number} [options.targetCoverage=0.333]
|
||||||
|
* Fraction of dots to burn, 0..1. The brightness needed to hit it is
|
||||||
|
* solved for, so every photo prints at the same density however it was
|
||||||
|
* lit. Raise it for a heavier print, lower it for a lighter one. Pass
|
||||||
|
* null to leave density alone and print whatever the photo gives.
|
||||||
* @returns {import('@napi-rs/canvas').Canvas} ready to pass to drawImage
|
* @returns {import('@napi-rs/canvas').Canvas} ready to pass to drawImage
|
||||||
*/
|
*/
|
||||||
export function ditherPhoto(image, size, options = {}) {
|
export function ditherPhoto(image, size, options = {}) {
|
||||||
const { levels = true, brightness = 0 } = options;
|
const { levels = true, brightness = 0, targetCoverage = 0.333 } = options;
|
||||||
|
|
||||||
const canvas = createCanvas(size, size);
|
const canvas = createCanvas(size, size);
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
@@ -149,11 +211,10 @@ export function ditherPhoto(image, size, options = {}) {
|
|||||||
|
|
||||||
if (levels) autoLevels(grey);
|
if (levels) autoLevels(grey);
|
||||||
|
|
||||||
if (brightness !== 0) {
|
if (brightness !== 0) applyShift(grey, (brightness / 100) * 255);
|
||||||
const shift = (brightness / 100) * 255;
|
|
||||||
for (let p = 0; p < grey.length; p++) {
|
if (targetCoverage != null) {
|
||||||
grey[p] = Math.min(255, Math.max(0, grey[p] + shift));
|
applyShift(grey, solveShiftForCoverage(grey, size, size, targetCoverage));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
floydSteinberg(grey, size, size);
|
floydSteinberg(grey, size, size);
|
||||||
|
|||||||
Reference in New Issue
Block a user