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.
This commit is contained in:
commit
2a3f28001c
241 files changed
+46390
No files matched your search
@@ -0,0 +1,294 @@
|
||||
//! 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user