Files
lamp-testbench/crates/brother-ql/src/bitmap.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

273 lines
8.4 KiB
Rust

//! A minimal 1-bit image type.
use std::fmt;
/// A 1-bit image. `true` means black (a printed dot), `false` means white.
///
/// Pixels are addressed as `(x, y)` with `(0, 0)` in the top-left corner,
/// `x` growing to the right and `y` growing downwards (the same convention as
/// PIL).
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct Bitmap {
width: u32,
height: u32,
/// Row-major pixels, `true` = black.
pixels: Vec<bool>,
}
impl Bitmap {
/// A new all-white bitmap.
pub fn new(width: u32, height: u32) -> Self {
Self::filled(width, height, false)
}
/// A new bitmap with every pixel set to `black`.
pub fn filled(width: u32, height: u32, black: bool) -> Self {
let len = width as usize * height as usize;
Self {
width,
height,
pixels: vec![black; len],
}
}
/// Builds a bitmap by evaluating `f(x, y)` (returns `true` for black) for every pixel.
pub fn from_fn(width: u32, height: u32, mut f: impl FnMut(u32, u32) -> bool) -> Self {
let mut pixels = Vec::with_capacity(width as usize * height as usize);
for y in 0..height {
for x in 0..width {
pixels.push(f(x, y));
}
}
Self {
width,
height,
pixels,
}
}
/// Width in pixels.
pub fn width(&self) -> u32 {
self.width
}
/// Height in pixels.
pub fn height(&self) -> u32 {
self.height
}
/// `(width, height)`.
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
fn index(&self, x: u32, y: u32) -> usize {
assert!(
x < self.width && y < self.height,
"pixel ({x}, {y}) out of bounds for a {}x{} bitmap",
self.width,
self.height
);
y as usize * self.width as usize + x as usize
}
/// Returns `true` if the pixel is black.
///
/// # Panics
/// If `(x, y)` is outside the bitmap.
pub fn get(&self, x: u32, y: u32) -> bool {
self.pixels[self.index(x, y)]
}
/// Sets a pixel (`true` = black).
///
/// # Panics
/// If `(x, y)` is outside the bitmap.
pub fn set(&mut self, x: u32, y: u32, black: bool) {
let i = self.index(x, y);
self.pixels[i] = black;
}
/// Sets a pixel given signed coordinates; pixels outside the bitmap are ignored.
pub fn set_clipped(&mut self, x: i64, y: i64, black: bool) {
if x >= 0 && y >= 0 && x < i64::from(self.width) && y < i64::from(self.height) {
self.set(x as u32, y as u32, black);
}
}
/// Fills the rectangle `[x, x + w) x [y, y + h)` (clipped to the bitmap).
pub fn fill_rect(&mut self, x: i64, y: i64, w: u32, h: u32, black: bool) {
let x0 = x.max(0);
let y0 = y.max(0);
let x1 = (x + i64::from(w)).min(i64::from(self.width));
let y1 = (y + i64::from(h)).min(i64::from(self.height));
for yy in y0..y1 {
for xx in x0..x1 {
self.set(xx as u32, yy as u32, black);
}
}
}
/// Copies `src` into this bitmap with its top-left corner at `(x, y)`,
/// overwriting both black and white pixels (like PIL `Image.paste` without
/// a mask). Parts falling outside are clipped.
pub fn paste(&mut self, src: &Bitmap, x: i64, y: i64) {
for sy in 0..src.height {
let dy = y + i64::from(sy);
if dy < 0 || dy >= i64::from(self.height) {
continue;
}
for sx in 0..src.width {
let dx = x + i64::from(sx);
if dx < 0 || dx >= i64::from(self.width) {
continue;
}
self.set(dx as u32, dy as u32, src.get(sx, sy));
}
}
}
/// Returns the sub-image `[x, x + w) x [y, y + h)`; pixels outside the
/// source are white.
pub fn crop(&self, x: i64, y: i64, w: u32, h: u32) -> Bitmap {
Bitmap::from_fn(w, h, |cx, cy| {
let sx = x + i64::from(cx);
let sy = y + i64::from(cy);
sx >= 0
&& sy >= 0
&& sx < i64::from(self.width)
&& sy < i64::from(self.height)
&& self.get(sx as u32, sy as u32)
})
}
/// Rotates 90 degrees counter-clockwise (PIL `Image.rotate(90, expand=True)`
/// / `Transpose.ROTATE_90`).
pub fn rotate90(&self) -> Bitmap {
let (w, h) = (self.width, self.height);
Bitmap::from_fn(h, w, |x, y| self.get(w - 1 - y, x))
}
/// Rotates 180 degrees.
pub fn rotate180(&self) -> Bitmap {
let (w, h) = (self.width, self.height);
Bitmap::from_fn(w, h, |x, y| self.get(w - 1 - x, h - 1 - y))
}
/// Rotates 270 degrees counter-clockwise (= 90 degrees clockwise, PIL
/// `Transpose.ROTATE_270`).
pub fn rotate270(&self) -> Bitmap {
let (w, h) = (self.width, self.height);
Bitmap::from_fn(h, w, |x, y| self.get(y, h - 1 - x))
}
/// Mirrors left-right (PIL `Transpose.FLIP_LEFT_RIGHT`).
pub fn flip_horizontal(&self) -> Bitmap {
let w = self.width;
Bitmap::from_fn(w, self.height, |x, y| self.get(w - 1 - x, y))
}
/// Nearest-neighbour resize exactly as Pillow does it for mode `"1"`
/// images (Pillow forces `NEAREST` for 1-bit images whatever filter is
/// requested): source coordinate = `floor(scale * 0.5 + i * scale)` with the
/// position accumulated in `f64` like `ImagingScaleAffine`.
pub fn resize_nearest(&self, width: u32, height: u32) -> Bitmap {
if width == self.width && height == self.height {
return self.clone();
}
let xmap = nearest_map(self.width, width);
let ymap = nearest_map(self.height, height);
Bitmap::from_fn(width, height, |x, y| {
match (xmap[x as usize], ymap[y as usize]) {
(Some(sx), Some(sy)) => self.get(sx, sy),
_ => false,
}
})
}
/// Number of black pixels.
pub fn count_black(&self) -> usize {
self.pixels.iter().filter(|&&p| p).count()
}
/// Row `y` as booleans (`true` = black).
pub fn row(&self, y: u32) -> &[bool] {
let start = self.index(0, y);
&self.pixels[start..start + self.width as usize]
}
/// All pixels in row-major order (`true` = black).
pub fn pixels(&self) -> &[bool] {
&self.pixels
}
}
fn nearest_map(src: u32, dst: u32) -> Vec<Option<u32>> {
let scale = f64::from(src) / f64::from(dst);
let mut pos = scale * 0.5;
let mut out = Vec::with_capacity(dst as usize);
for _ in 0..dst {
// COORD(v) in Pillow: v < 0 ? -1 : (int)v
let i = if pos < 0.0 { -1 } else { pos as i64 };
out.push(if i >= 0 && i < i64::from(src) {
Some(i as u32)
} else {
None
});
pos += scale;
}
out
}
impl fmt::Debug for Bitmap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Bitmap")
.field("width", &self.width)
.field("height", &self.height)
.field("black_pixels", &self.count_black())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rotations_match_pil_direction() {
// single black pixel in the top-left corner of a 3x2 image
let mut b = Bitmap::new(3, 2);
b.set(0, 0, true);
// PIL rotate(90) is counter-clockwise: top-left goes to bottom-left
let r = b.rotate90();
assert_eq!(r.size(), (2, 3));
assert!(r.get(0, 2));
let r = b.rotate270();
assert!(r.get(1, 0));
let r = b.rotate180();
assert!(r.get(2, 1));
assert_eq!(b.rotate90().rotate90(), b.rotate180());
assert_eq!(b.rotate90().rotate270(), b);
assert!(b.flip_horizontal().get(2, 0));
}
#[test]
fn paste_and_crop_clip() {
let mut b = Bitmap::new(4, 4);
let src = Bitmap::filled(3, 3, true);
b.paste(&src, -1, 2);
assert_eq!(b.count_black(), 4);
assert!(b.get(0, 2) && b.get(1, 3));
let c = b.crop(-1, 2, 3, 3);
assert_eq!(c.count_black(), 4);
}
#[test]
fn resize_halves_like_pillow() {
// PIL picks the odd source columns when halving (floor(2*i + 1)).
let b = Bitmap::from_fn(4, 1, |x, _| x % 2 == 1);
let r = b.resize_nearest(2, 1);
assert!(r.get(0, 0) && r.get(1, 0));
}
}