Dithering
Pictures dithered to a few colours: black and white for thermal receipt and label printers, the 3 and 7 colours of e-ink, or any palette, by Floyd–Steinberg, Atkinson, Stucki, Bayer or threshold; and 1-bit rows packed for printers.
Version 1.0.0 · 2 functions · 3 crates
Functions
What use dither.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use dither_image, dither_bits from dither (Quillo 0.6).
dither_image(path: str, out_path: str, options: any) → anyor an errorA picture dithered and saved as a PNG at out_path (folders made): options are method ("floyd-steinberg", "atkinson", "stucki", "bayer", "threshold"), palette ("mono", "eink-3", "eink-7", "gray-4", "gameboy", or a list of #rrggbb), width to resize to first (384 dots for 58 mm paper, 576 for 80 mm), contrast (1.0; more for photos on receipts). { path, width, height, colors }, colors the pixels of each palette colour.
dither_bits(path: str, options: any) → anyor an errorA picture as 1-bit rows for a printer or an e-ink panel: { width, height, bytes_per_row, base64 }, 8 dots a byte, the leftmost the highest bit, 1 for black, rows padded to whole bytes, as ESC/POS GS v 0, ZPL ^GF and TSPL BITMAP take them. options as for dither_image (no palette: black and white).
Get it
$ quillo install dither # dither.qwer into this folder$ quillo install dither --example # and its example, dither.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/dither.qwer$ curl -O https://quillolang.org/plugins/files/dither.qwe$ curl -O https://quillolang.org/plugins/files/dither_sample.pngThen, in your program, all of it or only what you call:
use dither.qweruse dither_image, dither_bits from dither // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }base64 = "0.22"serde_json = { version = "1", features = ["preserve_order"] }Example
// A photo for a café's receipt printer (black dots only, 384 wide on 58 mm paper) and for a 7-colour e-ink sign.//// quillo run dither.qwe then open http://localhost:8080/ditheruse "dither.qwer"let PHOTO = "dither_sample.png"route GET /dither { { receipt: dither_image(PHOTO, "data/dither/receipt.png", { method: "atkinson", width: 384, contrast: 1.2 })?, eink: dither_image(PHOTO, "data/dither/eink.png", { palette: "eink-7" })?.width, ordered: dither_image(PHOTO, "data/dither/bayer.png", { method: "bayer", palette: ["#000000", "#ff0000", "#ffffff"] })?.colors, printer: slice(json.stringify(dither_bits(PHOTO, { width: 384 })?), 0, 80), bad: dither_image(PHOTO, "data/dither/x.png", { method: "magic" }).error, }}route GET /receipt.png { dither_image(PHOTO, "data/dither/receipt.png", { method: "atkinson", width: 384, contrast: 1.2 })? file("data/dither/receipt.png")}route GET /eink.png { dither_image(PHOTO, "data/dither/eink.png", { palette: "eink-7" })? file("data/dither/eink.png")}$ quillo run dither.qwe$ curl 'localhost:8080/dither'$ curl 'localhost:8080/receipt.png'Source
//! Pictures dithered to a few colours: black and white for thermal receipt and label printers, the 3 and 7 colours of e-ink displays, or any palette; Floyd–Steinberg, Atkinson, Stucki, Bayer ordered and plain threshold; and 1-bit rows packed for printers.//! version: 1.0.0//!//! [dependencies]//! image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }//! base64 = "0.22"//! serde_json = { version = "1", features = ["preserve_order"] }use base64::Engine;use image::imageops::FilterType;use image::{Rgb, RgbImage};use serde_json::{json, Value};fn hex(c: &str) -> Result<[f32; 3], String> { let h = c.trim().trim_start_matches('#'); let v = u32::from_str_radix(h, 16).ok().filter(|_| h.len() == 6).ok_or_else(|| format!("`{}` is not a #rrggbb colour", c))?; Ok([(v >> 16 & 255) as f32, (v >> 8 & 255) as f32, (v & 255) as f32])}fn palette(v: &Value) -> Result<Vec<[f32; 3]>, String> { let named = |n: &str| -> Option<Vec<&'static str>> { Some(match n { "mono" | "" => vec!["#000000", "#ffffff"], "eink-3" => vec!["#000000", "#ffffff", "#c81e1e"], "eink-3-yellow" => vec!["#000000", "#ffffff", "#e6c800"], "eink-7" => vec!["#000000", "#ffffff", "#00a000", "#0000c8", "#c80000", "#ffdc00", "#ff8000"], "gray-4" => vec!["#000000", "#555555", "#aaaaaa", "#ffffff"], "gameboy" => vec!["#0f380f", "#306230", "#8bac0f", "#9bbc0f"], _ => return None, }) }; match v { Value::Null => Ok(vec![[0.0; 3], [255.0; 3]]), Value::String(s) => named(s).ok_or_else(|| format!("`{}` is not a palette: mono, eink-3, eink-3-yellow, eink-7, gray-4, gameboy, or a list of colours", s))?.iter().map(|c| hex(c)).collect(), Value::Array(a) if a.len() >= 2 && a.len() <= 256 => a.iter().map(|c| hex(c.as_str().unwrap_or(""))).collect(), _ => Err("a palette is a name or a list of 2 to 256 #rrggbb colours".into()), }}fn load(path: &str, width: i64) -> Result<RgbImage, String> { let img = image::open(path).map_err(|e| format!("{}: {}", path, e))?; let img = if width > 0 && width as u32 != img.width() { let h = ((img.height() as f64 * width as f64 / img.width() as f64).round() as u32).max(1); img.resize_exact(width as u32, h, FilterType::Lanczos3) } else { img }; // Transparency on white, as on paper. let rgba = img.to_rgba8(); let mut out = RgbImage::new(rgba.width(), rgba.height()); for (x, y, p) in rgba.enumerate_pixels() { let a = p[3] as f32 / 255.0; let m = |c: u8| (c as f32 * a + 255.0 * (1.0 - a)).round() as u8; out.put_pixel(x, y, Rgb([m(p[0]), m(p[1]), m(p[2])])); } Ok(out)}fn nearest(c: [f32; 3], pal: &[[f32; 3]]) -> usize { let mut best = (0, f32::MAX); for (i, p) in pal.iter().enumerate() { // Weighted by how the eye sees each channel. let d = 0.30 * (c[0] - p[0]).powi(2) + 0.59 * (c[1] - p[1]).powi(2) + 0.11 * (c[2] - p[2]).powi(2); if d < best.1 { best = (i, d) } } best.0}const BAYER: [[f32; 8]; 8] = [ [0., 32., 8., 40., 2., 34., 10., 42.], [48., 16., 56., 24., 50., 18., 58., 26.], [12., 44., 4., 36., 14., 46., 6., 38.], [60., 28., 52., 20., 62., 30., 54., 22.], [3., 35., 11., 43., 1., 33., 9., 41.], [51., 19., 59., 27., 49., 17., 57., 25.], [15., 47., 7., 39., 13., 45., 5., 37.], [63., 31., 55., 23., 61., 29., 53., 21.],];// Each pixel's palette index.fn dither(img: &RgbImage, pal: &[[f32; 3]], method: &str, contrast: f32) -> Result<Vec<usize>, String> { let (w, h) = (img.width() as usize, img.height() as usize); let mut buf: Vec<[f32; 3]> = img.pixels().map(|p| { let f = |c: u8| ((c as f32 - 128.0) * contrast + 128.0).clamp(0.0, 255.0); [f(p[0]), f(p[1]), f(p[2])] }).collect(); let kernel: &[(i32, i32, f32)] = match method { "floyd-steinberg" | "" => &[(1, 0, 7.0 / 16.0), (-1, 1, 3.0 / 16.0), (0, 1, 5.0 / 16.0), (1, 1, 1.0 / 16.0)], "atkinson" => &[(1, 0, 0.125), (2, 0, 0.125), (-1, 1, 0.125), (0, 1, 0.125), (1, 1, 0.125), (0, 2, 0.125)], "stucki" => &[(1, 0, 8.0 / 42.0), (2, 0, 4.0 / 42.0), (-2, 1, 2.0 / 42.0), (-1, 1, 4.0 / 42.0), (0, 1, 8.0 / 42.0), (1, 1, 4.0 / 42.0), (2, 1, 2.0 / 42.0), (-2, 2, 1.0 / 42.0), (-1, 2, 2.0 / 42.0), (0, 2, 4.0 / 42.0), (1, 2, 2.0 / 42.0), (2, 2, 1.0 / 42.0)], "bayer" | "threshold" => &[], m => return Err(format!("`{}` is not a method: floyd-steinberg, atkinson, stucki, bayer or threshold", m)), }; let spread = 255.0 / (pal.len() as f32 - 1.0).max(1.0); let mut out = vec![0usize; w * h]; for y in 0..h { for x in 0..w { let i = y * w + x; let mut c = buf[i]; if method == "bayer" { let t = (BAYER[y % 8][x % 8] + 0.5) / 64.0 - 0.5; c = [c[0] + t * spread, c[1] + t * spread, c[2] + t * spread]; } let k = nearest(c, pal); out[i] = k; if !kernel.is_empty() { let e = [c[0] - pal[k][0], c[1] - pal[k][1], c[2] - pal[k][2]]; for &(dx, dy, f) in kernel { let (nx, ny) = (x as i32 + dx, y as i32 + dy); if nx >= 0 && (nx as usize) < w && (ny as usize) < h { let j = ny as usize * w + nx as usize; for ch in 0..3 { buf[j][ch] += e[ch] * f } } } } } } Ok(out)}fn opts(o: &Value) -> (String, i64, f32) { let method = o.get("method").and_then(|m| m.as_str()).unwrap_or("floyd-steinberg").to_lowercase(); let width = o.get("width").and_then(|w| w.as_i64()).unwrap_or(0); let contrast = o.get("contrast").and_then(|c| c.as_f64()).unwrap_or(1.0) as f32; (method, width, contrast)}/// A picture dithered and saved as a PNG at `out_path` (folders made): `options` are `method` (`"floyd-steinberg"`, `"atkinson"`, `"stucki"`, `"bayer"`, `"threshold"`), `palette` (`"mono"`, `"eink-3"`, `"eink-7"`, `"gray-4"`, `"gameboy"`, or a list of `#rrggbb`), `width` to resize to first (384 dots for 58 mm paper, 576 for 80 mm), `contrast` (1.0; more for photos on receipts)./// `{ path, width, height, colors }`, `colors` the pixels of each palette colour.pub fn dither_image(path: &str, out_path: &str, options: serde_json::Value) -> Result<Value, String> { let (method, width, contrast) = opts(&options); let pal = palette(options.get("palette").unwrap_or(&Value::Null))?; let img = load(path, width)?; let idx = dither(&img, &pal, &method, contrast)?; let mut out = RgbImage::new(img.width(), img.height()); let mut counts = vec![0u64; pal.len()]; for (i, p) in out.pixels_mut().enumerate() { let c = pal[idx[i]]; counts[idx[i]] += 1; *p = Rgb([c[0] as u8, c[1] as u8, c[2] as u8]); } if let Some(d) = std::path::Path::new(out_path).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; } out.save_with_format(out_path, image::ImageFormat::Png).map_err(|e| format!("{}: {}", out_path, e))?; Ok(json!({ "path": out_path, "width": out.width(), "height": out.height(), "colors": counts }))}/// A picture as 1-bit rows for a printer or an e-ink panel: `{ width, height, bytes_per_row, base64 }`, 8 dots a byte, the leftmost the highest bit, 1 for black, rows padded to whole bytes, as ESC/POS `GS v 0`, ZPL `^GF` and TSPL `BITMAP` take them. `options` as for `dither_image` (no palette: black and white).pub fn dither_bits(path: &str, options: serde_json::Value) -> Result<Value, String> { let (method, width, contrast) = opts(&options); let img = load(path, width)?; let pal = vec![[0.0f32; 3], [255.0; 3]]; let idx = dither(&img, &pal, &method, contrast)?; let (w, h) = (img.width() as usize, img.height() as usize); let bpr = (w + 7) / 8; let mut bytes = vec![0u8; bpr * h]; for y in 0..h { for x in 0..w { if idx[y * w + x] == 0 { bytes[y * bpr + x / 8] |= 0x80 >> (x % 8) } } } let black = bytes.iter().map(|b| b.count_ones() as u64).sum::<u64>(); Ok(json!({ "width": w, "height": h, "bytes_per_row": bpr, "black_dots": black, "base64": base64::engine::general_purpose::STANDARD.encode(&bytes) }))}Related
ocr.qwer · 1.0.0Text read from pictures in pure Rust: photos and scans of documents, receipts, labels and screenshots to plain text, row by row or line by line with boxes, parts of the page read by range (pixels or percentages), several form fields at once, only the characters you allow, and phone photos turned upright first.13 views · 9 downloadsReceipt printers escpos.qwer · 1.0.0Receipts for ESC/POS thermal printers: aligned and bold lines, two-column rows, QR codes and barcodes, the cutter and the cash drawer, as base64, a file, or sent to port 9100.7 views · 6 downloadsBrother QL and P-touch ptouch.qwer · 1.0.0Brother QL label printers and P-touch tape printers in raster mode: text made into a label picture, any picture made into the raster job for a DK roll or TZe tape, cut, compressed, sent over the network, and the printer's status read.7 views · 7 downloadsAnimated GIF gif.qwer · 1.0.0Animated GIFs made from a list of pictures with the delay and size you choose, and GIFs read back: frames, size and length.6 views · 7 downloadsASCII art ascii_art.qwer · 1.0.0Pictures turned into ASCII art: characters from light to dark, plain for terminals and emails, or coloured as HTML.6 views · 10 downloadsBarcodes barcode.qwer · 1.0.0EAN-13, EAN-8, UPC-A, Code 128 and Code 39 barcodes as SVG or PNG, with the digits underneath and check digits worked out.6 views · 5 downloadsThread 0 posts
On Dithering: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.