smart_package_tracker/scan/mod.rs
1//! Reading barcodes back out of images.
2//!
3//! The mirror of [`render`](crate::render), meeting the rest of the crate at
4//! the same seam:
5//!
6//! ```text
7//! image ──▶ GrayImage ──▶ binarize ──▶ BitMatrix ──▶ Decoder::decode ──▶ payload
8//! ```
9//!
10//! A [`Scanner`] locates the symbol and turns pixels back into modules; a
11//! [`Decoder`] turns modules back into a payload. Neither half knows what the
12//! other is doing, so adding a scannable symbology means implementing
13//! [`Decoder`] and describing the symbology's character structure in
14//! [`SymbologyKind::linear_character`], not touching this module.
15//!
16//! # Example
17//!
18//! ```
19//! # #[cfg(all(feature = "scan", feature = "png"))]
20//! # fn main() -> Result<(), smart_package_tracker::Error> {
21//! use smart_package_tracker::{Barcode, RenderOptions, scan};
22//!
23//! let png = Barcode::code128("PKG-9ED9285C")?.to_png(&RenderOptions::default())?;
24//!
25//! let found = scan::scan_png(&png)?;
26//! assert_eq!(found.payload(), "PKG-9ED9285C");
27//! # Ok(())
28//! # }
29//! # #[cfg(not(all(feature = "scan", feature = "png")))]
30//! # fn main() {}
31//! ```
32//!
33//! # What this handles, and what it does not
34//!
35//! Reading a barcode off a photograph of a parcel and reading one out of a
36//! rendered label, a flatbed scan or a screenshot are different problems. This
37//! solves the second.
38//!
39//! Handled: any module width; extra margin or a crop flush to the bars; the
40//! symbol anywhere in a larger image; light-on-dark as well as dark-on-light;
41//! uneven lighting across the image; moderate noise and blur; a label turned
42//! on its side, since columns are scanned as well as rows; and a printed width
43//! that drifts from nominal, because each character is measured against its
44//! own width rather than against one estimate for the whole symbol.
45//!
46//! Not handled: rotation by anything other than a quarter turn, perspective,
47//! and the curvature of a barcode wrapped around a parcel. Those want a
48//! dedicated scanning engine, not a label-generation crate — reach for one of
49//! those if you are decoding camera frames.
50//!
51//! Only linear symbologies can be read. There is no QR decoder here; QR
52//! encodes but does not scan.
53
54mod binarize;
55mod linear;
56
57use alloc::string::String;
58use alloc::vec::Vec;
59
60use crate::error::{Error, Result};
61use crate::symbology::{BitMatrix, Decoder, LinearCharacter, SymbologyKind};
62
63/// Default number of scan lines tried along each axis.
64///
65/// The lines are spread over the image and tried from the middle outwards, so
66/// this is a cost ceiling rather than a resolution: a barcode occupying any
67/// reasonable fraction of the image is crossed by several of them.
68const DEFAULT_SCAN_LINES: u32 = 32;
69
70/// An 8-bit greyscale image: what a scanner actually works on.
71///
72/// Colour carries no information a barcode reader wants, so every input is
73/// reduced to luminance on the way in. Transparent pixels are composited over
74/// white first, because a barcode rendered with
75/// [`Color::TRANSPARENT`](crate::Color::TRANSPARENT) as its background is
76/// black ink on nothing, and "nothing" prints as paper.
77#[derive(Clone, PartialEq, Eq)]
78pub struct GrayImage {
79 width: u32,
80 height: u32,
81 luma: Vec<u8>,
82}
83
84impl GrayImage {
85 /// Wrap an existing 8-bit luminance buffer, one byte per pixel.
86 ///
87 /// # Errors
88 ///
89 /// Returns [`Error::InvalidImage`] if either dimension is zero, if the
90 /// buffer length is not exactly `width * height`, or if the image is
91 /// larger than the crate's pixel-count limit.
92 pub fn from_luma(width: u32, height: u32, luma: Vec<u8>) -> Result<Self> {
93 let expected = check_dimensions(width, height, 1)?;
94 if luma.len() != expected {
95 return Err(Error::InvalidImage(alloc::format!(
96 "expected {expected} bytes for a {width}x{height} greyscale image, got {}",
97 luma.len()
98 )));
99 }
100 Ok(Self {
101 width,
102 height,
103 luma,
104 })
105 }
106
107 /// Convert a packed 8-bit RGB buffer, three bytes per pixel.
108 ///
109 /// # Errors
110 ///
111 /// As [`from_luma`](Self::from_luma), for a buffer of `width * height * 3`.
112 pub fn from_rgb8(width: u32, height: u32, rgb: &[u8]) -> Result<Self> {
113 let expected = check_dimensions(width, height, 3)?;
114 if rgb.len() != expected {
115 return Err(Error::InvalidImage(alloc::format!(
116 "expected {expected} bytes for a {width}x{height} RGB image, got {}",
117 rgb.len()
118 )));
119 }
120 let luma = rgb
121 .chunks_exact(3)
122 .map(|p| luminance(p[0], p[1], p[2]))
123 .collect();
124 Self::from_luma(width, height, luma)
125 }
126
127 /// Convert a packed 8-bit RGBA buffer, four bytes per pixel, compositing
128 /// over a white background.
129 ///
130 /// # Errors
131 ///
132 /// As [`from_luma`](Self::from_luma), for a buffer of `width * height * 4`.
133 pub fn from_rgba8(width: u32, height: u32, rgba: &[u8]) -> Result<Self> {
134 let expected = check_dimensions(width, height, 4)?;
135 if rgba.len() != expected {
136 return Err(Error::InvalidImage(alloc::format!(
137 "expected {expected} bytes for a {width}x{height} RGBA image, got {}",
138 rgba.len()
139 )));
140 }
141 let luma = rgba
142 .chunks_exact(4)
143 .map(|p| {
144 let [r, g, b] = [p[0], p[1], p[2]].map(|c| over_white(c, p[3]));
145 luminance(r, g, b)
146 })
147 .collect();
148 Self::from_luma(width, height, luma)
149 }
150
151 /// Decode a PNG image.
152 ///
153 /// Handles every colour type and bit depth the `png` crate can normalise
154 /// to 8 bits, including palettes and greyscale.
155 ///
156 /// # Errors
157 ///
158 /// Returns [`Error::InvalidImage`] if the bytes are not a PNG this crate
159 /// can read, or if the image exceeds the pixel-count limit.
160 #[cfg(feature = "png")]
161 #[cfg_attr(docsrs, doc(cfg(feature = "png")))]
162 pub fn from_png(bytes: &[u8]) -> Result<Self> {
163 let mut decoder = ::png::Decoder::new(std::io::Cursor::new(bytes));
164 decoder.set_transformations(::png::Transformations::normalize_to_color8());
165
166 let mut reader = decoder
167 .read_info()
168 .map_err(|e| Error::InvalidImage(alloc::format!("{e}")))?;
169
170 // Check the declared size before allocating for it, so a hostile
171 // header cannot ask for a multi-gigabyte buffer.
172 let info = reader.info();
173 check_dimensions(info.width, info.height, 4)?;
174
175 let size = reader
176 .output_buffer_size()
177 .ok_or_else(|| Error::InvalidImage(String::from("image is too large to decode")))?;
178 let mut buf = alloc::vec![0u8; size];
179 let frame = reader
180 .next_frame(&mut buf)
181 .map_err(|e| Error::InvalidImage(alloc::format!("{e}")))?;
182 let pixels = &buf[..frame.buffer_size()];
183 let (width, height) = (frame.width, frame.height);
184
185 match frame.color_type {
186 ::png::ColorType::Grayscale => Self::from_luma(width, height, pixels.to_vec()),
187 ::png::ColorType::GrayscaleAlpha => {
188 let luma = pixels
189 .chunks_exact(2)
190 .map(|p| over_white(p[0], p[1]))
191 .collect();
192 Self::from_luma(width, height, luma)
193 }
194 ::png::ColorType::Rgb => Self::from_rgb8(width, height, pixels),
195 ::png::ColorType::Rgba => Self::from_rgba8(width, height, pixels),
196 other => Err(Error::InvalidImage(alloc::format!(
197 "unsupported PNG colour type {other:?}"
198 ))),
199 }
200 }
201
202 /// Read a PNG file from disk.
203 ///
204 /// # Errors
205 ///
206 /// Returns [`Error::Io`] if the file cannot be read, or whatever
207 /// [`from_png`](Self::from_png) reports.
208 #[cfg(all(feature = "png", feature = "std"))]
209 #[cfg_attr(docsrs, doc(cfg(all(feature = "png", feature = "std"))))]
210 pub fn from_png_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
211 Self::from_png(&std::fs::read(path)?)
212 }
213
214 /// Width in pixels.
215 pub fn width(&self) -> u32 {
216 self.width
217 }
218
219 /// Height in pixels.
220 pub fn height(&self) -> u32 {
221 self.height
222 }
223
224 /// The luminance buffer, row-major, one byte per pixel.
225 pub fn luma(&self) -> &[u8] {
226 &self.luma
227 }
228
229 /// Luminance at `(x, y)`. Out-of-bounds reads as white, matching the way
230 /// [`BitMatrix::get`] tolerates out-of-bounds coordinates.
231 pub fn pixel(&self, x: u32, y: u32) -> u8 {
232 if x >= self.width || y >= self.height {
233 return u8::MAX;
234 }
235 self.luma[(y as usize) * (self.width as usize) + (x as usize)]
236 }
237}
238
239impl core::fmt::Debug for GrayImage {
240 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
241 // The buffer is megabytes; its dimensions are the useful part.
242 f.debug_struct("GrayImage")
243 .field("width", &self.width)
244 .field("height", &self.height)
245 .finish_non_exhaustive()
246 }
247}
248
249/// Reject degenerate or oversized geometry, returning the buffer length a
250/// well-formed image of this size has.
251///
252/// The limit is on the pixel *count*, not on either axis alone: two dimensions
253/// that each look reasonable multiply out into an allocation that is not.
254fn check_dimensions(width: u32, height: u32, channels: usize) -> Result<usize> {
255 if width == 0 || height == 0 {
256 return Err(Error::InvalidImage(alloc::format!(
257 "image has a zero dimension ({width}x{height})"
258 )));
259 }
260 let pixels = u64::from(width) * u64::from(height);
261 if pixels > crate::render::MAX_PIXELS {
262 return Err(Error::InvalidImage(alloc::format!(
263 "image is {} megapixels, over the {} megapixel limit",
264 pixels / 1_000_000,
265 crate::render::MAX_PIXELS / 1_000_000
266 )));
267 }
268 // The pixel-count limit bounds this well inside `usize` on 32-bit targets.
269 Ok((pixels as usize) * channels)
270}
271
272/// Rec. 601 luma, the weighting a monochrome sensor approximates.
273fn luminance(r: u8, g: u8, b: u8) -> u8 {
274 ((77 * u32::from(r) + 150 * u32::from(g) + 29 * u32::from(b)) >> 8) as u8
275}
276
277/// Composite one channel over an opaque white background.
278fn over_white(channel: u8, alpha: u8) -> u8 {
279 let (c, a) = (u32::from(channel), u32::from(alpha));
280 ((c * a + 255 * (255 - a) + 127) / 255) as u8
281}
282
283/// A barcode found in an image.
284#[derive(Clone, Debug, PartialEq, Eq)]
285pub struct Scan {
286 kind: SymbologyKind,
287 payload: String,
288}
289
290impl Scan {
291 /// The decoded payload.
292 pub fn payload(&self) -> &str {
293 &self.payload
294 }
295
296 /// Which symbology it was read as.
297 pub fn kind(&self) -> SymbologyKind {
298 self.kind
299 }
300
301 /// Take ownership of the payload.
302 pub fn into_payload(self) -> String {
303 self.payload
304 }
305}
306
307/// Which way a scan line runs.
308#[derive(Clone, Copy, PartialEq, Eq)]
309enum Axis {
310 /// Left to right.
311 Row,
312 /// Top to bottom, for a label printed on its side.
313 Column,
314}
315
316/// Reads barcodes out of images.
317///
318/// # Examples
319///
320/// ```
321/// # #[cfg(all(feature = "scan", feature = "code128"))]
322/// # fn main() -> Result<(), smart_package_tracker::Error> {
323/// use smart_package_tracker::scan::{GrayImage, Scanner};
324///
325/// # let luma = {
326/// # use smart_package_tracker::symbology::{Code128, Symbology};
327/// # let symbol = Code128.encode("PKG-9ED9285C")?;
328/// # let row: Vec<u8> = core::iter::repeat_n(255u8, 30)
329/// # .chain(symbol.modules().row(0).iter().flat_map(|d| {
330/// # core::iter::repeat_n(if *d { 0u8 } else { 255 }, 3)
331/// # }))
332/// # .chain(core::iter::repeat_n(255u8, 30))
333/// # .collect();
334/// # (0..40).flat_map(|_| row.iter().copied()).collect::<Vec<u8>>()
335/// # };
336/// # let width = (luma.len() / 40) as u32;
337/// let image = GrayImage::from_luma(width, 40, luma)?;
338/// let found = Scanner::new().scan(&image)?;
339///
340/// assert_eq!(found.payload(), "PKG-9ED9285C");
341/// # Ok(())
342/// # }
343/// # #[cfg(not(all(feature = "scan", feature = "code128")))]
344/// # fn main() {}
345/// ```
346#[derive(Clone, Debug, PartialEq, Eq)]
347pub struct Scanner {
348 max_scan_lines: u32,
349}
350
351impl Default for Scanner {
352 fn default() -> Self {
353 Self::new()
354 }
355}
356
357impl Scanner {
358 /// A scanner with default settings.
359 pub fn new() -> Self {
360 Self {
361 max_scan_lines: DEFAULT_SCAN_LINES,
362 }
363 }
364
365 /// How many scan lines to try along each axis.
366 ///
367 /// Raising this helps when the barcode occupies a small part of a large
368 /// image; lowering it bounds the work done before giving up. Zero is
369 /// treated as one.
370 pub fn max_scan_lines(mut self, lines: u32) -> Self {
371 self.max_scan_lines = lines.max(1);
372 self
373 }
374
375 /// Read any barcode this crate can decode out of `image`.
376 ///
377 /// Code 128 is currently the only symbology this crate can decode, so
378 /// this is a shorthand for [`scan_with`](Self::scan_with). It stays a
379 /// separate method because it is where any future symbology gets tried
380 /// without callers changing.
381 ///
382 /// # Errors
383 ///
384 /// Returns [`Error::NoSymbolFound`] if no scan line yields a valid symbol.
385 /// A barcode that is present but unreadable is indistinguishable from one
386 /// that is absent, so there is only the one error.
387 pub fn scan(&self, image: &GrayImage) -> Result<Scan> {
388 self.scan_with(&crate::symbology::Code128, image)
389 }
390
391 /// Read a barcode of one specific symbology out of `image`.
392 ///
393 /// # Errors
394 ///
395 /// Returns [`Error::NoSymbolFound`] if no scan line yields a valid symbol,
396 /// or [`Error::Decode`] if the symbology has no scan-line structure —
397 /// matrix symbologies such as QR cannot be read this way.
398 pub fn scan_with<D: Decoder + ?Sized>(&self, decoder: &D, image: &GrayImage) -> Result<Scan> {
399 let kind = decoder.kind();
400 let character = kind.linear_character().ok_or_else(|| {
401 Error::Decode(alloc::format!(
402 "{kind} cannot be read from an image: it is not a linear symbology"
403 ))
404 })?;
405
406 let matrix = binarize::binarize(image);
407
408 for axis in [Axis::Row, Axis::Column] {
409 if let Some(payload) = self.scan_axis(decoder, character, &matrix, axis) {
410 return Ok(Scan { kind, payload });
411 }
412 }
413
414 Err(Error::NoSymbolFound(alloc::format!(
415 "no {kind} symbol on any of the scan lines tried across a {}x{} image",
416 image.width(),
417 image.height()
418 )))
419 }
420
421 /// Try every scan line along one axis.
422 fn scan_axis<D: Decoder + ?Sized>(
423 &self,
424 decoder: &D,
425 character: LinearCharacter,
426 matrix: &BitMatrix,
427 axis: Axis,
428 ) -> Option<String> {
429 let count = match axis {
430 Axis::Row => matrix.height(),
431 Axis::Column => matrix.width(),
432 };
433
434 for index in scan_line_order(count, self.max_scan_lines) {
435 // The raw line first: it is exact where the image is clean, and
436 // clean is the common case. Only if that fails is it worth
437 // spending a vote across neighbouring lines to beat down speckle,
438 // which costs sharpness at the top and bottom of the bars.
439 for radius in [0, 1] {
440 if radius > 0 && count < 3 {
441 continue;
442 }
443 let line = scan_line(matrix, axis, index, radius);
444 if let Some(payload) = linear::decode_row(decoder, character, &line) {
445 return Some(payload);
446 }
447 // The same symbol printed light-on-dark. Cheaper to try than
448 // to guess from the border, which guesses wrong on an image
449 // cropped flush to the bars.
450 let inverted: Vec<bool> = line.iter().map(|d| !d).collect();
451 if let Some(payload) = linear::decode_row(decoder, character, &inverted) {
452 return Some(payload);
453 }
454 }
455 }
456
457 None
458 }
459}
460
461/// Which lines to try, in the order to try them.
462///
463/// Lines are spread evenly over the axis and then ordered from the middle
464/// outwards: a barcode is usually the middle of its label, and any
465/// human-readable text is at the edge.
466fn scan_line_order(count: u32, max_lines: u32) -> Vec<u32> {
467 let lines = count.min(max_lines.max(1));
468 let mut order: Vec<u32> = (0..lines)
469 // Sample the centre of each of `lines` equal bands rather than the
470 // edges, so neither the first nor the last line lands on the border.
471 .map(|i| ((2 * i + 1) as u64 * count as u64 / (2 * lines as u64)) as u32)
472 .map(|i| i.min(count - 1))
473 .collect();
474 order.dedup();
475
476 let centre = i64::from(count) / 2;
477 order.sort_by_key(|&i| (i64::from(i) - centre).abs());
478 order
479}
480
481/// Extract one scan line, optionally as a majority vote over its neighbours.
482fn scan_line(matrix: &BitMatrix, axis: Axis, index: u32, radius: u32) -> Vec<bool> {
483 let (length, count) = match axis {
484 Axis::Row => (matrix.width(), matrix.height()),
485 Axis::Column => (matrix.height(), matrix.width()),
486 };
487
488 if radius == 0 {
489 return match axis {
490 Axis::Row => matrix.row(index).to_vec(),
491 Axis::Column => (0..length).map(|y| matrix.get(index, y)).collect(),
492 };
493 }
494
495 let lo = index.saturating_sub(radius);
496 let hi = (index + radius + 1).min(count);
497 let voters = hi - lo;
498 (0..length)
499 .map(|pos| {
500 let dark = (lo..hi)
501 .filter(|&i| match axis {
502 Axis::Row => matrix.get(pos, i),
503 Axis::Column => matrix.get(i, pos),
504 })
505 .count() as u32;
506 2 * dark > voters
507 })
508 .collect()
509}
510
511/// Read any barcode this crate can decode out of a PNG image.
512///
513/// # Errors
514///
515/// Returns [`Error::InvalidImage`] if the bytes are not a readable PNG, or
516/// [`Error::NoSymbolFound`] if it holds no barcode.
517#[cfg(feature = "png")]
518#[cfg_attr(docsrs, doc(cfg(feature = "png")))]
519pub fn scan_png(bytes: &[u8]) -> Result<Scan> {
520 Scanner::new().scan(&GrayImage::from_png(bytes)?)
521}
522
523/// Read any barcode this crate can decode out of a PNG file.
524///
525/// # Errors
526///
527/// Returns [`Error::Io`] if the file cannot be read, plus whatever
528/// [`scan_png`] reports.
529#[cfg(all(feature = "png", feature = "std"))]
530#[cfg_attr(docsrs, doc(cfg(all(feature = "png", feature = "std"))))]
531pub fn scan_png_file(path: impl AsRef<std::path::Path>) -> Result<Scan> {
532 scan_png(&std::fs::read(path)?)
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use alloc::vec;
539
540 #[test]
541 fn rejects_a_buffer_that_does_not_match_its_dimensions() {
542 let err = GrayImage::from_luma(4, 4, vec![0; 15]).unwrap_err();
543 assert!(matches!(err, Error::InvalidImage(_)), "got {err:?}");
544 assert!(GrayImage::from_luma(4, 4, vec![0; 16]).is_ok());
545 assert!(GrayImage::from_rgb8(2, 2, &[0; 11]).is_err());
546 assert!(GrayImage::from_rgb8(2, 2, &[0; 12]).is_ok());
547 assert!(GrayImage::from_rgba8(2, 2, &[0; 15]).is_err());
548 assert!(GrayImage::from_rgba8(2, 2, &[0; 16]).is_ok());
549 }
550
551 #[test]
552 fn rejects_a_zero_dimension() {
553 assert!(GrayImage::from_luma(0, 4, vec![]).is_err());
554 assert!(GrayImage::from_luma(4, 0, vec![]).is_err());
555 }
556
557 #[test]
558 fn rejects_an_image_larger_than_the_allocation_limit() {
559 // Neither axis is extreme; their product is. This is the guard that
560 // has to bound the allocation, not each axis on its own.
561 let err = GrayImage::from_luma(20_000, 20_000, Vec::new()).unwrap_err();
562 assert!(
563 alloc::format!("{err}").contains("megapixel"),
564 "expected a pixel-count error, got {err}"
565 );
566 }
567
568 #[test]
569 fn transparent_pixels_composite_over_white() {
570 // Black ink on a fully transparent background is black on paper, not
571 // black on black — a barcode rendered with `Color::TRANSPARENT` has to
572 // stay readable.
573 let rgba = [0, 0, 0, 0, 0, 0, 0, 255];
574 let image = GrayImage::from_rgba8(2, 1, &rgba).unwrap();
575 assert_eq!(image.pixel(0, 0), 255, "transparent should read as paper");
576 assert_eq!(image.pixel(1, 0), 0, "opaque black should read as ink");
577 }
578
579 #[test]
580 fn out_of_bounds_pixels_read_as_paper() {
581 let image = GrayImage::from_luma(2, 2, vec![0; 4]).unwrap();
582 assert_eq!(image.pixel(0, 0), 0);
583 assert_eq!(image.pixel(2, 0), 255);
584 assert_eq!(image.pixel(0, 2), 255);
585 assert_eq!(image.pixel(u32::MAX, u32::MAX), 255);
586 }
587
588 #[test]
589 fn scan_lines_start_in_the_middle_and_stay_in_range() {
590 let order = scan_line_order(100, 8);
591 assert_eq!(order.len(), 8);
592 assert!(order.iter().all(|&i| i < 100));
593 // The first line tried should be near the vertical centre.
594 assert!((40..=60).contains(&order[0]), "started at {}", order[0]);
595 }
596
597 #[test]
598 fn every_line_is_tried_when_there_are_fewer_than_the_cap() {
599 let mut order = scan_line_order(5, 32);
600 order.sort_unstable();
601 assert_eq!(order, vec![0, 1, 2, 3, 4]);
602 }
603
604 #[test]
605 fn a_single_line_image_still_yields_one_line() {
606 assert_eq!(scan_line_order(1, 32), vec![0]);
607 }
608}