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.
273 lines
8.4 KiB
Rust
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));
|
|
}
|
|
}
|