lampbench station / export / label / tm30 / calibrate replace the Python lamp_station.py, lamp_export.py, label_printer.py, tm30.py and calibration.py that lived in HPCS6500-py. The driver is a submodule (HPCS6500/, like mppt-testbench). Workspace crates: tm30 (TM-30-18 port of colour-science 0.4.7), brother-ql (raster protocol, byte-identical to brother_ql) and lamp-label (QR + ID labels for the QL-820NWB). Bundles are byte-identical to lamp_export.py's (golden tests); the station runs end to end in a test against the simulated instrument.
295 lines
9.8 KiB
Rust
295 lines
9.8 KiB
Rust
//! Comparison against Python `render_label` (qrcode 8.2 + Pillow 12.3).
|
|
//!
|
|
//! Golden data from `tools/golden.py`:
|
|
//! * `qr_matrices.txt`: module matrices, versions and masks;
|
|
//! * `label_*.png`: complete Python labels;
|
|
//! * `text_info.txt`: Pillow's chosen font size, text bbox and draw origin.
|
|
|
|
use std::collections::HashMap;
|
|
use std::fs::File;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use lamp_label::{Bitmap, LabelFont, TEXT_HEIGHT, qr, render_label, render_label_with_font};
|
|
|
|
fn golden() -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests")
|
|
.join("golden")
|
|
}
|
|
|
|
fn load_png(path: &Path) -> Bitmap {
|
|
let mut decoder = png::Decoder::new(File::open(path).unwrap());
|
|
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
|
let mut reader = decoder.read_info().unwrap();
|
|
let mut buf = vec![0; reader.output_buffer_size()];
|
|
let info = reader.next_frame(&mut buf).unwrap();
|
|
let channels = info.color_type.samples();
|
|
Bitmap::from_fn(info.width, info.height, |x, y| {
|
|
buf[y as usize * info.line_size + x as usize * channels] < 128
|
|
})
|
|
}
|
|
|
|
/// id -> label png file name
|
|
fn label_files() -> HashMap<String, String> {
|
|
std::fs::read_to_string(golden().join("labels.txt"))
|
|
.unwrap()
|
|
.lines()
|
|
.filter(|l| !l.is_empty())
|
|
.map(|l| {
|
|
let (id, file) = l.split_once('\t').unwrap();
|
|
(id.to_string(), file.to_string())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
struct GoldenQr {
|
|
id: String,
|
|
version: u8,
|
|
mask: u8,
|
|
modules: Vec<Vec<bool>>,
|
|
}
|
|
|
|
fn golden_qr() -> Vec<GoldenQr> {
|
|
let text = std::fs::read_to_string(golden().join("qr_matrices.txt")).unwrap();
|
|
let mut lines = text.lines();
|
|
let mut out = Vec::new();
|
|
while let Some(header) = lines.next() {
|
|
if header.is_empty() {
|
|
continue;
|
|
}
|
|
let mut parts = header.rsplitn(4, ' ');
|
|
let size: usize = parts
|
|
.next()
|
|
.unwrap()
|
|
.strip_prefix("size=")
|
|
.unwrap()
|
|
.parse()
|
|
.unwrap();
|
|
let mask: u8 = parts
|
|
.next()
|
|
.unwrap()
|
|
.strip_prefix("mask=")
|
|
.unwrap()
|
|
.parse()
|
|
.unwrap();
|
|
let version: u8 = parts
|
|
.next()
|
|
.unwrap()
|
|
.strip_prefix("version=")
|
|
.unwrap()
|
|
.parse()
|
|
.unwrap();
|
|
let id = parts.next().unwrap().to_string();
|
|
let modules = (0..size)
|
|
.map(|_| lines.next().unwrap().bytes().map(|b| b == b'1').collect())
|
|
.collect();
|
|
out.push(GoldenQr {
|
|
id,
|
|
version,
|
|
mask,
|
|
modules,
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn qr_matrices_match_python() {
|
|
let cases = golden_qr();
|
|
assert!(cases.len() >= 28, "golden data missing");
|
|
let mut failures = Vec::new();
|
|
for g in &cases {
|
|
let q = qr::encode(&g.id.to_uppercase()).unwrap();
|
|
let same_modules = (0..q.size).all(|r| (0..q.size).all(|c| q.get(c, r) == g.modules[r][c]));
|
|
if q.version != g.version || q.mask != g.mask || q.size != g.modules.len() || !same_modules
|
|
{
|
|
failures.push(format!(
|
|
"{}: rust v{} mask {} vs python v{} mask {} (modules equal: {same_modules})",
|
|
g.id, q.version, q.mask, g.version, g.mask
|
|
));
|
|
}
|
|
}
|
|
println!(
|
|
"QR: {}/{} matrices identical (version, mask and modules)",
|
|
cases.len() - failures.len(),
|
|
cases.len()
|
|
);
|
|
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn qr_region_of_label_matches_python() {
|
|
let files = label_files();
|
|
let qr_rows = 202 - TEXT_HEIGHT;
|
|
let mut checked = 0;
|
|
for g in golden_qr() {
|
|
let py = load_png(&golden().join(&files[&g.id]));
|
|
let rs = render_label(&g.id);
|
|
let diff = (0..qr_rows)
|
|
.flat_map(|y| (0..202).map(move |x| (x, y)))
|
|
.filter(|&(x, y)| py.get(x, y) != rs.get(x, y))
|
|
.count();
|
|
assert_eq!(diff, 0, "{}: {diff} pixels differ in the QR area", g.id);
|
|
checked += 1;
|
|
}
|
|
println!("QR area (rows 0..150) pixel-identical for {checked} labels");
|
|
}
|
|
|
|
struct TextGolden {
|
|
id: String,
|
|
font: String,
|
|
size: u32,
|
|
bbox: (i32, i32, i32, i32),
|
|
origin: (i32, i32),
|
|
}
|
|
|
|
fn text_golden() -> Vec<TextGolden> {
|
|
let text = std::fs::read_to_string(golden().join("text_info.txt")).unwrap();
|
|
text.lines()
|
|
.filter(|l| !l.is_empty())
|
|
.map(|l| {
|
|
let mut f = l.split('\t');
|
|
let id = f.next().unwrap().to_string();
|
|
let mut kv = HashMap::new();
|
|
for p in f {
|
|
let (k, v) = p.split_once('=').unwrap();
|
|
kv.insert(k, v);
|
|
}
|
|
let nums = |s: &str| {
|
|
s.split(',')
|
|
.map(|n| n.parse::<i32>().unwrap())
|
|
.collect::<Vec<_>>()
|
|
};
|
|
let b = nums(kv["bbox"]);
|
|
let o = nums(kv["origin"]);
|
|
TextGolden {
|
|
id,
|
|
font: kv["font"].to_string(),
|
|
size: kv["size"].parse().unwrap(),
|
|
bbox: (b[0], b[1], b[2], b[3]),
|
|
origin: (o[0], o[1]),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn text_strip_close_to_python_with_arial_bold() {
|
|
let arial = Path::new("C:/Windows/Fonts/arialbd.ttf");
|
|
if !arial.exists() {
|
|
eprintln!("arialbd.ttf not available, skipping the text comparison");
|
|
return;
|
|
}
|
|
let font = LabelFont::from_file(arial).unwrap();
|
|
let files = label_files();
|
|
let top = 202 - TEXT_HEIGHT;
|
|
let mut report = Vec::new();
|
|
let mut layout_mismatch = Vec::new();
|
|
let mut too_different = Vec::new();
|
|
for g in text_golden() {
|
|
assert_eq!(g.font, "arialbd.ttf");
|
|
let (rs, layout) = render_label_with_font(&g.id, &font).unwrap();
|
|
let py = load_png(&golden().join(&files[&g.id]));
|
|
let (mut diff, mut ink_py, mut ink_rs) = (0, 0, 0);
|
|
for y in top..202 {
|
|
for x in 0..202 {
|
|
let (p, r) = (py.get(x, y), rs.get(x, y));
|
|
ink_py += usize::from(p);
|
|
ink_rs += usize::from(r);
|
|
diff += usize::from(p != r);
|
|
}
|
|
}
|
|
report.push(format!(
|
|
"{:<16} size py {:>2} rs {:>2} | bbox py {:?} rs {:?} | origin py {:?} rs {:?} | ink py {:>4} rs {:>4} | differing px {:>4} ({:.1} % of ink)",
|
|
g.id,
|
|
g.size,
|
|
layout.size,
|
|
g.bbox,
|
|
layout.bbox,
|
|
g.origin,
|
|
layout.origin,
|
|
ink_py,
|
|
ink_rs,
|
|
diff,
|
|
100.0 * diff as f64 / ink_py.max(1) as f64
|
|
));
|
|
if (layout.size, layout.bbox, layout.origin) != (g.size, g.bbox, g.origin) {
|
|
layout_mismatch.push(g.id.clone());
|
|
}
|
|
// glyph rasterisation may differ (no TrueType hinting), but the text
|
|
// must be essentially the same pixels
|
|
if diff * 4 >= ink_py {
|
|
too_different.push(format!(
|
|
"{}: {diff} differing pixels for {ink_py} ink pixels",
|
|
g.id
|
|
));
|
|
}
|
|
}
|
|
println!("text strip vs Python (arialbd.ttf):\n{}", report.join("\n"));
|
|
assert!(too_different.is_empty(), "{}", too_different.join("\n"));
|
|
// chosen size, text bbox and draw origin must be exactly Pillow's
|
|
assert!(
|
|
layout_mismatch.is_empty(),
|
|
"size/bbox/origin differ for {layout_mismatch:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn builtin_font_fallback_renders() {
|
|
let font = LabelFont::builtin();
|
|
let (img, layout) = render_label_with_font("L0006", &font).unwrap();
|
|
assert_eq!(layout.font, "builtin-5x7");
|
|
assert_eq!(layout.size, 44);
|
|
// 5 glyphs of 5x7 cells at scale 4: 116 x 28, centred in the strip
|
|
assert_eq!(layout.bbox, (0, 0, 116, 28));
|
|
assert_eq!(layout.origin, (43, 162));
|
|
let ink: usize = (150..202)
|
|
.flat_map(|y| (0..202).map(move |x| (x, y)))
|
|
.filter(|&(x, y)| img.get(x, y))
|
|
.count();
|
|
assert!(ink > 500, "text missing ({ink} px)");
|
|
// long IDs shrink
|
|
let (_, layout) = render_label_with_font("L0123456789012345", &font).unwrap();
|
|
assert!(layout.size < 44 && layout.bbox.2 <= 194, "{layout:?}");
|
|
// same QR as with the TrueType font
|
|
let (img2, _) = render_label_with_font("L0006", &LabelFont::system()).unwrap();
|
|
assert!((0..150).all(|y| (0..202).all(|x| img.get(x, y) == img2.get(x, y))));
|
|
}
|
|
|
|
#[test]
|
|
fn png_roundtrip_and_preview() {
|
|
let label = render_label("L0006");
|
|
let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR"));
|
|
let path = dir.join("lamp_label_L0006.png");
|
|
lamp_label::save_png(&label, &path).unwrap();
|
|
assert_eq!(load_png(&path), label);
|
|
println!("preview written to {}", path.display());
|
|
}
|
|
|
|
#[test]
|
|
fn instructions_for_two_labels() {
|
|
let data = lamp_label::label_instructions("L0006", 2).unwrap();
|
|
// Python output for the Python-rendered L0006 label (brother-ql golden data):
|
|
// same length and identical command bytes; only raster lines carrying
|
|
// text pixels may differ.
|
|
let py = std::fs::read(
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../brother-ql/tests/golden/label_L0006_x2.bin"),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(data.len(), py.len());
|
|
// preamble 4 + 400 + 2 + 4, page header 3 + 13 + 4 + 4 + 4 + 5, 202 lines of 93, 0x1A
|
|
let (pre, head, lines) = (410, 33, 202 * 93);
|
|
let page = head + lines + 1;
|
|
assert_eq!(data.len(), pre + 2 * page);
|
|
assert_eq!(data[..pre + head], py[..pre + head]);
|
|
for p in 0..2 {
|
|
let start = pre + p * page;
|
|
assert_eq!(data[start..start + head], py[start..start + head]);
|
|
assert_eq!(data[start + page - 1], 0x1A);
|
|
for l in 0..202 {
|
|
let row = start + head + l * 93;
|
|
assert_eq!(data[row..row + 3], [0x67, 0x00, 90]);
|
|
}
|
|
}
|
|
}
|