Skip to main content

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}