Files
lamp-testbench/crates/lamp-label/tests/golden.rs
T
grabowski 2a3f28001c Lamp testbench in Rust around the HPCS 6500
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.
2026-10-22 17:26:14 +07:00

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]);
}
}
}