diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4509503..3656609 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,7 @@ jobs: - run: cargo test --all-features - run: cargo test --no-default-features --features code128,svg - run: cargo test --no-default-features --features qr,png + - run: cargo test --no-default-features --features scan - run: cargo test --no-default-features lint: @@ -46,7 +47,8 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: thumbv7em-none-eabihf - - run: cargo build --target thumbv7em-none-eabihf --no-default-features --features code128,svg + # Scanning is pure computation, so it has to keep working on bare metal. + - run: cargo build --target thumbv7em-none-eabihf --no-default-features --features code128,svg,scan licenses: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 711e095..3ae1ef6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,24 +5,31 @@ Orientation for developers and AI agents. Read this before changing code. ## What this crate is -A `no_std` + `alloc` library that mints package tracking IDs (`PKG-9ED9285C`) -and renders them as Code 128 or QR barcodes, to PNG or SVG. No network I/O, no -async, no carrier integrations — those are deliberately out of scope (see -"Boundaries"). +A `no_std` + `alloc` library that mints package tracking IDs (`PKG-9ED9285C`), +renders them as Code 128 or QR barcodes to PNG or SVG, and reads Code 128 back +out of an image. No network I/O, no async, no carrier integrations — those are +deliberately out of scope (see "Boundaries"). ## The one diagram that explains the layout ```text TrackingId ──▶ Symbology::encode ──▶ Symbol ──▶ Renderer::render ──▶ bytes src/id/ src/symbology/ (BitMatrix) src/render/ + + payload ◀── Decoder::decode ──── BitMatrix ◀── binarize ◀── GrayImage ◀── image + src/symbology/ src/scan/ ``` -The `BitMatrix` in the middle is the seam. Encoders never know about pixels; -renderers never know about symbologies. A linear barcode is a 1-row matrix, a -QR code is a square one — that is the *only* difference the renderers see. +The `BitMatrix` in the middle is the seam, and both directions cross it. +Encoders never know about pixels; renderers never know about symbologies; the +scanner never knows which symbology it is reading. A linear barcode is a 1-row +matrix, a QR code is a square one — that is the *only* difference the renderers +see. -**If a change makes a renderer match on `SymbologyKind`, the seam has been -broken. Push the difference into `Symbol`/`SymbologyKind` metadata instead.** +**If a change makes a renderer or the scanner match on `SymbologyKind`, the +seam has been broken. Push the difference into `Symbol`/`SymbologyKind` +metadata instead.** `required_quiet_zone()`, `is_linear()` and +`linear_character()` are what that looks like in practice. ## Repo map @@ -33,15 +40,19 @@ broken. Push the difference into `Symbol`/`SymbologyKind` metadata instead.** | `src/id/mod.rs` | `TrackingId` newtype + parsing/validation. `MAX_LEN`, the charset rule. | | `src/id/generator.rs` | `IdGenerator`/builder: prefix, entropy width, checksum policy. | | `src/id/checksum.rs` | ISO/IEC 7064 MOD 37,36 check character. | -| `src/symbology/mod.rs` | `BitMatrix`, `Symbol`, the `Symbology` trait, `SymbologyKind` metadata. | +| `src/symbology/mod.rs` | `BitMatrix`, `Symbol`, the `Symbology` and `Decoder` traits, `SymbologyKind`/`LinearCharacter` metadata. | | `src/symbology/code128.rs` | Code 128 encode + decode, wrapping the `code128` crate. | | `src/symbology/qr.rs` | QR encode, wrapping the `qrcode` crate. | | `src/render/mod.rs` | `RenderOptions`, `Length`, `Color`, `QuietZone`, and **`Layout`** — the shared geometry calculation. | | `src/render/png.rs` | Rasterises `Layout` into RGBA, wraps it in a PNG with a `pHYs` chunk. | | `src/render/svg.rs` | Emits the same `Layout` as `` runs. | | `src/render/hri.rs` | Embedded 5x7 bitmap font for the human-readable text line. | +| `src/scan/mod.rs` | `GrayImage`, `Scanner`, `Scan`. Image input, scan-line selection, and the row/column sweep. | +| `src/scan/binarize.rs` | Local adaptive threshold: pixels to dark/light modules. | +| `src/scan/linear.rs` | Pixel runs to modules, one character at a time. Symbology-agnostic. | | `tests/integration.rs` | Consumer-level end-to-end tests (Code 128). | | `tests/qr.rs` | QR output decoded back with `rqrr`, an independent decoder. | +| `tests/scan.rs` | Render, degrade the image the way reality does, scan it back. | | `examples/label.rs` | Runnable end-to-end demo: `cargo run --example label`. | ## Invariants — do not break these silently @@ -64,6 +75,11 @@ broken. Push the difference into `Symbol`/`SymbologyKind` metadata instead.** 6. **`no_std` + `alloc` core.** Only `std`, `png`, and `qr` may assume `std`. Use `alloc::` imports, not `std::`, outside `#[cfg(feature = "std")]`. 7. **`#![forbid(unsafe_code)]`.** Non-negotiable. +8. **A scan never returns a guess.** Every payload the scanner returns has + passed the symbology's own check character. A wrong answer routes a parcel + to the wrong depot and looks correct doing it, which is worse than a miss — + so widening what the scanner will accept is a decision to make deliberately, + not a way to raise a hit rate. ## Commands @@ -74,10 +90,10 @@ cargo fmt --all --check cargo run --example label # writes label*.png / label*.svg # no_std still builds -cargo build --target thumbv7em-none-eabihf --no-default-features --features code128,svg +cargo build --target thumbv7em-none-eabihf --no-default-features --features code128,svg,scan # feature combinations compile in isolation (CI covers a subset) -for f in "" std serde os-rng code128 svg qr png; do +for f in "" std serde os-rng code128 svg qr png scan; do cargo check --no-default-features --features "$f" --quiet || echo "FAIL [$f]" done ``` @@ -92,6 +108,11 @@ Raising it is a breaking change. gate it behind a feature. Touch no rendering code. QR was added this way. * **A new renderer** — implement `Renderer` in `src/render/`, consume `Layout`, compute no geometry of your own. +* **Making a symbology scannable** — implement `Decoder` for it and return a + `LinearCharacter` from `SymbologyKind::linear_character()`. `src/scan/` needs + no change; if it does, the metadata is not saying enough. A matrix symbology + cannot be scanned this way at all — a scan line through a QR grid is not a + code — and would need its own locator. * **A new error case** — add a variant to `src/error.rs` with a lowercase, actionable `#[error(...)]` message. The enum is `#[non_exhaustive]`, so this is not a breaking change. @@ -108,13 +129,28 @@ Raising it is a breaking change. this) over one that asserts an implementation detail. Note that a decoder handed a perfectly cropped image is far more forgiving than a scanner pointed at a parcel — see the comment in `tests/qr.rs`. +* Scanning tests start from the crate's own renderer and degrade the image the + way reality does: rotated a quarter turn, inverted, dropped into a larger + scene, speckled, blurred. A scanner tested only on its own pristine output + has not been tested. Noise must come from a fixed seed — see `Lcg` in + `tests/scan.rs` — or the suite passes on the desk and fails in CI. ## Boundaries -Out of scope on purpose: image scanning, shipment events, carrier APIs, GS1 -identifier issuance. They bring network I/O, async runtimes, and vendor licence -terms into a dependency graph that is currently 16 permissively-licensed crates -with no copyleft (`cargo deny check` enforces this). +Out of scope on purpose: shipment events, carrier APIs, GS1 identifier +issuance. They bring network I/O, async runtimes, and vendor licence terms into +a dependency graph that is permissively licensed with no copyleft (`cargo deny +check` enforces this). + +Scanning is in scope only as far as it stays arithmetic. It reads rendered +labels, flatbed scans and screenshots — axis-aligned images — and adds no +dependencies. Camera-grade decoding (perspective correction, symbol +localisation, curved surfaces) is a scanning engine, and rebuilding one inside +a label-generation crate is the wrong shape; depend on `rxing` or similar. +QR scanning is out for the same reason plus a concrete cost: the only viable +decoder, `rqrr`, adds seven crates to the published graph. It stays a +dev-dependency, where it verifies our QR *output* without shipping to +consumers. ## Release — irreversible diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a7f1a2..7ca942f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Image scanning.** `scan::Scanner` reads a Code 128 barcode back out of an + image, behind the new default `scan` feature. `scan::scan_png` and + `scan_png_file` are the shorthand for PNG input; `GrayImage::from_luma`, + `from_rgb8` and `from_rgba8` take a pixel buffer from anywhere else. + + It runs the rendering pipeline backwards and meets it at the same seam — + `image → GrayImage → binarize → BitMatrix → Decoder::decode → payload` — so + nothing in `src/scan/` knows which symbology it is reading. What it handles: + any module width, extra margin or a crop flush to the bars, the symbol + anywhere in a larger image, light-on-dark, uneven lighting, moderate speckle + and blur, and a label applied on its side, since columns are swept as well as + rows. Pixel runs are converted to modules one character at a time using the + new `SymbologyKind::linear_character` metadata, so a printed width that + drifts from nominal cannot accumulate into a misread. + + Out of scope, and documented as such: rotation off the axes, perspective, + curved surfaces, and QR. This reads rendered labels, flatbed scans and + screenshots, not camera frames. + + No new dependencies — the resolved graph is unchanged — and it builds + `no_std`. +- `symbology::Decoder`, the mirror of `Symbology`: modules back to a payload. + Implemented for `Code128`. `symbology::code128::decode` now delegates to it + and is otherwise unchanged. +- `SymbologyKind::linear_character`, describing how a linear symbology groups + bars and spaces into fixed-width characters. A unit test asserts it agrees + with `is_linear` for every variant, so the two cannot drift. +- `Error::InvalidImage` and `Error::NoSymbolFound`. A barcode that is present + but unreadable reports the latter rather than a guess: every payload returned + has passed the symbology's own check character. + ## [0.3.0] - 2026-08-22 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index 2ac0391..98bb14a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ thiserror = { version = "2.0", default-features = false } serde = { version = "1.0", default-features = false, features = ["derive", "alloc"], optional = true } [features] -default = ["std", "os-rng", "code128", "qr", "png", "svg"] +default = ["std", "os-rng", "code128", "qr", "png", "svg", "scan"] # Enables `std`-dependent APIs (file I/O, `std::error::Error`). # Without it the crate is `no_std` + `alloc`: ID generation, Code 128 encoding @@ -58,6 +58,12 @@ qr = ["dep:qrcode", "std"] # SVG rendering. Zero additional dependencies. svg = [] +# Read barcodes back out of images. Zero additional dependencies: it decodes +# with the symbology support already in the crate, which is why it implies +# `code128` — the only symbology here that can be decoded. Works without `std`; +# with `png` it also reads PNG files directly. +scan = ["code128"] + # `Serialize`/`Deserialize` for `TrackingId` and friends. serde = ["dep:serde"] @@ -70,7 +76,7 @@ lto = true [[example]] name = "label" -required-features = ["std", "os-rng", "code128", "qr", "png", "svg"] +required-features = ["std", "os-rng", "code128", "qr", "png", "svg", "scan"] [dev-dependencies] # Integration tests decode our own PNG output to assert on pixels. diff --git a/README.md b/README.md index 643ea8a..3727d3b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # smart-package-tracker -Generate package tracking IDs and render them as Code 128 barcodes, in PNG and SVG. +Generate package tracking IDs, render them as Code 128 barcodes in PNG and SVG, +and read them back out of images. ```rust use smart_package_tracker::{Barcode, RenderOptions, TrackingId}; @@ -30,6 +31,12 @@ Both renderers share a single `Layout` calculation, so PNG and SVG describe identical geometry at identical physical size — an SVG preview matches what the PNG prints. +Scanning is the same pipeline backwards, and meets it at the same bit grid: + +```text +image ──▶ GrayImage ──▶ binarize ──▶ BitMatrix ──▶ Decoder::decode ──▶ payload +``` + ## What it does carefully * **Specification-conformant quiet zones.** Code 128 requires 10 modules per @@ -81,6 +88,40 @@ the `quirc` C library, so a symbol that encodes correctly but renders with a missing quiet zone or a transposed grid fails the build. `rqrr` is a dev-dependency and is not in the published dependency graph. +## Reading barcodes back + +```rust +use smart_package_tracker::scan; + +let found = scan::scan_png_file("label.png")?; +assert_eq!(found.payload(), "PKG-9ED9285C"); +``` + +Any pixel buffer works, not just PNG — `GrayImage::from_luma`, `from_rgb8` and +`from_rgba8` take one directly, so a frame from a camera crate or an image +decoder needs no conversion beyond handing over its bytes. + +**What it handles.** Any module width; extra margin or a crop flush to the +bars; the symbol anywhere in a larger image; light-on-dark as well as +dark-on-light; uneven lighting across the image; moderate speckle and blur; a +label applied on its side, because columns are scanned as well as rows; and a +printed width that drifts from nominal, because each character is measured +against its own width rather than against one estimate for the whole symbol. + +**What it does not.** Rotation by anything other than a quarter turn, +perspective, and the curvature of a barcode wrapped around a parcel. Reading a +label off a photograph and reading one out of a rendered file are different +problems; this solves the second. If you are decoding camera frames, use a +dedicated scanning engine. + +Code 128 only. QR encodes here but does not scan — there is no QR decoder in +the dependency graph, and adding one would put seven crates into it. + +A miss and an unreadable barcode both report `NoSymbolFound` rather than a +guess. Nothing is returned that has not passed the symbology's own check +character, which is what keeps a smudge from becoming a plausible-looking +tracking number routed to the wrong depot. + ## Tracking ID entropy `TrackingId::generate()` defaults to 32 bits of randomness, which produces the @@ -128,6 +169,8 @@ so match with a `_` arm. What each variant means in practice: | `InvalidRenderOptions` | Non-positive DPI/width/height, or geometry over 20,000 px on an axis | The message names the computed size; reduce `module_width`, `height`, or `dpi` | | `Render` | The PNG encoder failed | Rare; the message carries the underlying error | | `Decode` | `Barcode::decode()` was called on a non-Code 128 symbol, or on an invalid pattern | There is no QR decoder in this crate | +| `InvalidImage` | A pixel buffer does not match its stated dimensions, the image exceeds 64 megapixels, or the PNG is unreadable | The message names the expected byte count or the limit | +| `NoSymbolFound` | Scanning found no barcode it could read | Check the symbology is Code 128 and the image is not rotated off-axis; see the scanning section for what is supported | | `Io` | A `*_to_file` helper could not write | Standard `std::io::Error`; only present with the `std` feature | Two failure modes deserve naming because they produce a file that *looks* @@ -150,6 +193,7 @@ correct and does not scan: | `qr` | yes | QR Code encoding. Implies `std`; the `qrcode` crate is not `no_std`. | | `png` | yes | PNG rendering. Implies `std`. | | `svg` | yes | SVG rendering. No extra dependencies. | +| `scan` | yes | Read Code 128 barcodes back out of images. No extra dependencies; implies `code128`, and works without `std`. | | `serde` | no | `Serialize`/`Deserialize` for the public data types. | ## Dependencies @@ -162,14 +206,17 @@ correct and does not scan: | [`getrandom`](https://crates.io/crates/getrandom) | OS cryptographic RNG for ID entropy. Optional (`os-rng`), so bare-metal targets can supply their own | | [`thiserror`](https://crates.io/crates/thiserror) | Error derive | +Scanning adds nothing to this list: it decodes with the Code 128 support +already present, and its binariser and scan lines are plain arithmetic. + All are `MIT OR Apache-2.0`, as is every one of the 16 crates in the resolved transitive graph. There is no copyleft anywhere in it; `cargo deny check` in CI enforces that. -The crate builds as `no_std` + `alloc`. ID generation, Code 128 encoding, and -SVG rendering all work without `std`; `cargo build --target -thumbv7em-none-eabihf --no-default-features --features code128,svg` is verified -in CI. +The crate builds as `no_std` + `alloc`. ID generation, Code 128 encoding and +decoding, SVG rendering, and scanning a pixel buffer all work without `std`; +`cargo build --target thumbv7em-none-eabihf --no-default-features --features +code128,svg,scan` is verified in CI. Human-readable text is drawn with an embedded 5x7 bitmap font rather than a font library, which keeps output identical between PNG and SVG and independent @@ -178,10 +225,13 @@ of what fonts a print server has installed. It covers `0-9`, `A-Z`, space, and ## Not implemented yet -Image scanning, shipment events, and carrier integrations. The `Symbology` and -`Renderer` traits are the extension points for new formats. Carrier -integrations belong in separate crates so that network I/O, async runtimes, and -vendor licence terms stay out of this dependency graph. +Shipment events and carrier integrations. They belong in separate crates so +that network I/O, async runtimes, and vendor licence terms stay out of this +dependency graph. + +Scanning covers linear symbologies on axis-aligned images; QR scanning and +camera-grade decoding are not here. The `Symbology`, `Decoder` and `Renderer` +traits are the extension points for new formats. ## Licensing notes diff --git a/examples/label.rs b/examples/label.rs index 0722225..bc8bab1 100644 --- a/examples/label.rs +++ b/examples/label.rs @@ -2,9 +2,10 @@ //! //! Run with: `cargo run --example label` +use smart_package_tracker::scan; use smart_package_tracker::symbology::{Ecc, Qr}; use smart_package_tracker::{ - Barcode, Checksum, Color, IdGenerator, Length, QuietZone, RenderOptions, + Barcode, Checksum, Color, IdGenerator, Length, QuietZone, RenderOptions, TrackingId, }; fn main() -> Result<(), Box> { @@ -48,6 +49,14 @@ fn main() -> Result<(), Box> { assert_eq!(barcode.decode()?, id.as_str()); println!("round-trip verified"); + // And the *printed* label reads back, which is a stronger claim: this goes + // through the PNG decoder, the binariser and the scan lines rather than + // through the symbol still sitting in memory. + let scanned = scan::scan_png_file("label.png")?; + let recovered = TrackingId::parse(scanned.payload())?; + println!("scanned label.png: {recovered} ({})", scanned.kind()); + assert_eq!(recovered, id); + // The same identifier as a QR code. Note that nothing about the rendering // setup changes: a QR symbol is a square bit grid where Code 128 is a // single row, and the renderers consume the grid either way. diff --git a/src/barcode.rs b/src/barcode.rs index 1547a74..05c9c95 100644 --- a/src/barcode.rs +++ b/src/barcode.rs @@ -185,11 +185,14 @@ impl Barcode { /// successful round trip is therefore evidence that the encoded geometry /// is correct. /// + /// This reads the symbol in memory. To read one out of an image, see + /// [`scan`](crate::scan). + /// /// # Errors /// /// Returns [`Error::Decode`](crate::Error::Decode) if this is not a /// Code 128 barcode, or if the pattern is not valid Code 128. There is no - /// QR decoder in this crate; see the `scan` roadmap item. + /// QR decoder in this crate. #[cfg(feature = "code128")] pub fn decode(&self) -> Result { crate::symbology::code128::decode(&self.symbol) diff --git a/src/error.rs b/src/error.rs index f2a9c1e..6f8dc2a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -72,6 +72,16 @@ pub enum Error { #[error("decoding failed: {0}")] Decode(String), + /// An image could not be interpreted as a pixel buffer. + #[error("invalid image: {0}")] + InvalidImage(String), + + /// [`Scanner`](crate::scan::Scanner) found no readable barcode. A barcode + /// that is present but unreadable is indistinguishable from one that is + /// absent, so both report this. + #[error("no barcode found: {0}")] + NoSymbolFound(String), + /// An I/O error from one of the `*_to_file` helpers. #[cfg(feature = "std")] #[error("i/o error: {0}")] diff --git a/src/lib.rs b/src/lib.rs index f8fbf70..490320f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,28 @@ //! Both renderers share one [`Layout`](render::Layout) calculation, so PNG and //! SVG output describe identical geometry at identical physical size. //! +//! Scanning runs the same pipeline backwards, meeting it at the same grid: +//! +//! ```text +//! image ──▶ GrayImage ──▶ binarize ──▶ BitMatrix ──▶ Decoder::decode ──▶ payload +//! ``` +//! +//! ``` +//! # #[cfg(all(feature = "scan", feature = "png"))] +//! # fn main() -> Result<(), smart_package_tracker::Error> { +//! use smart_package_tracker::{Barcode, RenderOptions, scan}; +//! +//! let png = Barcode::code128("PKG-9ED9285C")?.to_png(&RenderOptions::default())?; +//! assert_eq!(scan::scan_png(&png)?.payload(), "PKG-9ED9285C"); +//! # Ok(()) +//! # } +//! # #[cfg(not(all(feature = "scan", feature = "png")))] +//! # fn main() {} +//! ``` +//! +//! See the [`scan`] module for what image conditions that covers, and what it +//! does not. +//! //! # Choosing an entropy width //! //! [`TrackingId::generate`] defaults to 32 bits of randomness — the familiar @@ -71,15 +93,20 @@ //! | `qr` | yes | QR Code encoding. Implies `std`. | //! | `png` | yes | PNG rendering. Implies `std`. | //! | `svg` | yes | SVG rendering. No extra dependencies. | +//! | `scan` | yes | Read Code 128 barcodes back out of images. No extra dependencies; implies `code128`. | //! | `serde` | no | `Serialize`/`Deserialize` for the public data types. | //! //! # Not yet implemented //! -//! Image scanning, shipment events, and carrier integrations are deliberately -//! absent. The [`Symbology`](symbology::Symbology) and -//! [`Renderer`](render::Renderer) traits are the extension points for new -//! formats; carrier integrations belong in separate crates, so that network -//! I/O and vendor licence terms stay out of this dependency graph. +//! Shipment events and carrier integrations are deliberately absent, and +//! belong in separate crates so that network I/O, async runtimes and vendor +//! licence terms stay out of this dependency graph. +//! +//! Scanning reads linear symbologies only — there is no QR decoder here — and +//! targets rendered labels, flatbed scans and screenshots rather than camera +//! frames. The [`Symbology`](symbology::Symbology), +//! [`Decoder`](symbology::Decoder) and [`Renderer`](render::Renderer) traits +//! are the extension points for new formats. //! //! QR Code is a registered trademark of Denso Wave Incorporated. @@ -99,6 +126,9 @@ extern crate alloc; pub mod error; pub mod id; pub mod render; +#[cfg(feature = "scan")] +#[cfg_attr(docsrs, doc(cfg(feature = "scan")))] +pub mod scan; pub mod symbology; mod barcode; @@ -109,6 +139,9 @@ pub use id::{Checksum, IdGenerator, IdGeneratorBuilder, TrackingId}; pub use render::{hri_supports, Color, Length, QuietZone, RenderOptions, RenderOptionsBuilder}; pub use symbology::{Symbol, SymbologyKind}; +#[cfg(feature = "scan")] +pub use scan::{GrayImage, Scan, Scanner}; + #[cfg(feature = "code128")] pub use symbology::Code128; #[cfg(feature = "qr")] diff --git a/src/render/mod.rs b/src/render/mod.rs index c9ee317..e54cc8d 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -34,7 +34,7 @@ const MAX_DIMENSION_PX: u32 = 20_000; /// into a 1.6 GiB RGBA buffer in a single allocation. 64 megapixels is /// 256 MiB — still far beyond any real label, which at 600 dpi on a 4x6 inch /// stock is under 9 megapixels. -const MAX_PIXELS: u64 = 64_000_000; +pub(crate) const MAX_PIXELS: u64 = 64_000_000; /// A physical or device length. /// diff --git a/src/scan/binarize.rs b/src/scan/binarize.rs new file mode 100644 index 0000000..e4af0f1 --- /dev/null +++ b/src/scan/binarize.rs @@ -0,0 +1,204 @@ +//! Deciding which pixels are dark. +//! +//! A scan-line decoder needs a clean two-level signal, and a real image never +//! arrives as one: a photographed label is brighter on one side, a flatbed +//! scan carries a paper-grey background, an anti-aliased render has grey pixel +//! edges. A single global threshold gets all three wrong somewhere. +//! +//! So the threshold is computed per block and then smoothed across +//! neighbouring blocks, which lets it follow a lighting gradient while still +//! being stable inside a flat region. The approach is the one ZXing calls a +//! hybrid binariser. + +use alloc::vec; +use alloc::vec::Vec; + +use super::GrayImage; +use crate::symbology::BitMatrix; + +/// Side of the square block each local threshold is computed over. +/// +/// At 300 dpi and a 13 mil X-dimension a module is about 4 pixels, so an +/// 8-pixel block spans a bar or two: small enough to track a gradient, large +/// enough to contain both ink and paper. +const BLOCK: u32 = 8; + +/// Radius, in blocks, of the neighbourhood each block's threshold averages +/// over. Two blocks each side is a 5x5 window. +const SMOOTH_RADIUS: u32 = 2; + +/// A block whose luminance spans no more than this holds only one of ink and +/// paper, so its own average says nothing about where the boundary is. +const MIN_DYNAMIC_RANGE: u32 = 24; + +/// Threshold `image` into dark (`true`) and light (`false`) modules. +/// +/// The result is the same size as the image; one pixel is one entry, with no +/// resampling. Turning pixels into *modules* is the scan line's job, because +/// only it knows where the symbol starts. +pub(crate) fn binarize(image: &GrayImage) -> BitMatrix { + let (w, h) = (image.width(), image.height()); + let (bw, bh) = (w.div_ceil(BLOCK), h.div_ceil(BLOCK)); + + let black_points = black_points(image, bw, bh); + let mut matrix = BitMatrix::new(w, h); + + for by in 0..bh { + for bx in 0..bw { + let threshold = smoothed(&black_points, bw, bh, bx, by); + let (x0, y0) = (bx * BLOCK, by * BLOCK); + for y in y0..(y0 + BLOCK).min(h) { + for x in x0..(x0 + BLOCK).min(w) { + if u32::from(image.pixel(x, y)) <= threshold { + matrix.set(x, y, true); + } + } + } + } + } + + matrix +} + +/// The luminance that separates ink from paper within each block. +fn black_points(image: &GrayImage, bw: u32, bh: u32) -> Vec { + let (w, h) = (image.width(), image.height()); + let mut points = vec![0u32; (bw as usize) * (bh as usize)]; + + for by in 0..bh { + for bx in 0..bw { + let (x0, y0) = (bx * BLOCK, by * BLOCK); + let (x1, y1) = ((x0 + BLOCK).min(w), (y0 + BLOCK).min(h)); + + let mut sum = 0u32; + let mut count = 0u32; + let mut min = u32::MAX; + let mut max = 0u32; + for y in y0..y1 { + for x in x0..x1 { + let v = u32::from(image.pixel(x, y)); + sum += v; + count += 1; + min = min.min(v); + max = max.max(v); + } + } + + let point = if count == 0 { + 128 + } else if max - min > MIN_DYNAMIC_RANGE { + sum / count + } else { + // Uniform block. Half its own luminance keeps solid paper + // light, but that would also read solid ink as light, so + // prefer what the already-computed neighbours decided — + // unless this block is darker than all of them, in which + // case it really is the light one. + let mut point = min / 2; + if bx > 0 && by > 0 { + let idx = |x: u32, y: u32| (y as usize) * (bw as usize) + (x as usize); + let neighbours = (points[idx(bx, by - 1)] + + 2 * points[idx(bx - 1, by)] + + points[idx(bx - 1, by - 1)]) + / 4; + if min < neighbours { + point = neighbours; + } + } + point + }; + + points[(by as usize) * (bw as usize) + (bx as usize)] = point; + } + } + + points +} + +/// Average the black points over the neighbourhood around block `(bx, by)`. +fn smoothed(points: &[u32], bw: u32, bh: u32, bx: u32, by: u32) -> u32 { + let x0 = bx.saturating_sub(SMOOTH_RADIUS); + let y0 = by.saturating_sub(SMOOTH_RADIUS); + let x1 = (bx + SMOOTH_RADIUS + 1).min(bw); + let y1 = (by + SMOOTH_RADIUS + 1).min(bh); + + let mut sum = 0u32; + let mut count = 0u32; + for y in y0..y1 { + for x in x0..x1 { + sum += points[(y as usize) * (bw as usize) + (x as usize)]; + count += 1; + } + } + + // `bx`/`by` are always inside the grid, so the window is never empty. + sum / count.max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec::Vec; + + /// Build a grey image from a row pattern repeated down the image. + fn image_from_rows(rows: &[&[u8]]) -> GrayImage { + let w = rows[0].len() as u32; + let h = rows.len() as u32; + let luma: Vec = rows.iter().flat_map(|r| r.iter().copied()).collect(); + GrayImage::from_luma(w, h, luma).unwrap() + } + + #[test] + fn separates_ink_from_paper() { + let row: Vec = (0..64) + .map(|i| if (i / 4) % 2 == 0 { 20 } else { 235 }) + .collect(); + let rows: Vec<&[u8]> = (0..16).map(|_| &row[..]).collect(); + let matrix = binarize(&image_from_rows(&rows)); + + for x in 0..64u32 { + let expect_dark = (x / 4) % 2 == 0; + assert_eq!(matrix.get(x, 8), expect_dark, "pixel {x}"); + } + } + + #[test] + fn follows_a_lighting_gradient() { + // The same bars, but lit so that the darkest paper on the right is + // darker than the lightest ink on the left. No single global + // threshold can separate this; a local one can. + let mut luma = Vec::new(); + for _ in 0..32 { + for x in 0..64u32 { + let shade = 160 - (x as i32) * 2; // 160 down to 34 + let ink = (x / 4) % 2 == 0; + let v = if ink { shade - 30 } else { shade + 30 }; + luma.push(v.clamp(0, 255) as u8); + } + } + let matrix = binarize(&GrayImage::from_luma(64, 32, luma).unwrap()); + + for x in 0..64u32 { + let expect_dark = (x / 4) % 2 == 0; + assert_eq!(matrix.get(x, 16), expect_dark, "pixel {x}"); + } + } + + #[test] + fn a_blank_image_is_all_light() { + let matrix = binarize(&GrayImage::from_luma(32, 32, vec![255; 32 * 32]).unwrap()); + for y in 0..32 { + for x in 0..32 { + assert!(!matrix.get(x, y), "({x}, {y}) should be light"); + } + } + } + + #[test] + fn handles_an_image_smaller_than_one_block() { + // One block wide and high, so the neighbourhood window degenerates. + let matrix = binarize(&GrayImage::from_luma(3, 2, vec![0, 255, 0, 0, 255, 0]).unwrap()); + assert_eq!(matrix.width(), 3); + assert_eq!(matrix.height(), 2); + } +} diff --git a/src/scan/linear.rs b/src/scan/linear.rs new file mode 100644 index 0000000..ac449fc --- /dev/null +++ b/src/scan/linear.rs @@ -0,0 +1,311 @@ +//! Reading a linear symbol off a single scan line. +//! +//! The hard part is not finding the bars, it is deciding how many *modules* +//! wide each one is. A naive scanner estimates one module width for the whole +//! symbol and divides; that fails as soon as the printed width drifts even +//! slightly from nominal, because the error accumulates across the symbol. +//! +//! Instead the runs are cut into characters using +//! [`LinearCharacter`] metadata and each character is renormalised against its +//! own pixel width. A character is a fixed number of modules by definition, so +//! its measured width is a local ruler, and drift never accumulates past one +//! character. Nothing here knows which symbology it is reading. + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::symbology::{BitMatrix, Decoder, LinearCharacter}; + +/// How many candidate starting bars to try per scan line. +/// +/// A line that crosses a table rule or a speck of dirt before reaching the +/// barcode has junk runs in front of it. Trying a few later starts recovers +/// that case; trying every start would be a licence to fabricate a decode. +const MAX_START_CANDIDATES: usize = 3; + +/// How many trailing characters to allow dropping, for junk *after* the +/// symbol. +const MAX_TRUNCATIONS: usize = 2; + +/// A maximal run of identical modules along a scan line. +#[derive(Clone, Copy, Debug)] +struct Run { + dark: bool, + len: u32, +} + +/// Try to read one payload out of `row`. +/// +/// Returns `None` if the line does not carry a symbol this decoder accepts. +/// The decoder validates its own check character, so a wrong cut through the +/// runs fails rather than returning a plausible-looking payload. +pub(crate) fn decode_row( + decoder: &D, + character: LinearCharacter, + row: &[bool], +) -> Option { + let elements = character.elements as usize; + let stop = character.stop_elements as usize; + if elements == 0 || stop == 0 { + return None; + } + + let runs = runs(row); + // Trim the quiet zones. What is left starts and ends with a bar, so every + // dark run sits at an even index. + let runs = trim(&runs); + if runs.len() < elements * 2 + stop { + return None; + } + + for start in (0..MAX_START_CANDIDATES).map(|i| i * 2) { + let Some(available) = runs.len().checked_sub(start) else { + break; + }; + // Longest window of the form `elements * k + stop` that fits, then + // progressively shorter ones in case the line runs on into junk. + let Some(max_characters) = available.checked_sub(stop).map(|rest| rest / elements) else { + continue; + }; + for dropped in 0..=MAX_TRUNCATIONS { + // A symbol is at minimum a start character, a check character and + // the stop pattern. + let Some(characters) = max_characters.checked_sub(dropped).filter(|k| *k >= 2) else { + break; + }; + let window = &runs[start..start + characters * elements + stop]; + let Some(modules) = quantize(window, character) else { + continue; + }; + if let Ok(payload) = decoder.decode(&BitMatrix::from_row(modules)) { + return Some(payload); + } + } + } + + None +} + +/// Run-length encode a scan line. +fn runs(row: &[bool]) -> Vec { + let mut runs: Vec = Vec::new(); + for &dark in row { + match runs.last_mut() { + Some(last) if last.dark == dark => last.len += 1, + _ => runs.push(Run { dark, len: 1 }), + } + } + runs +} + +/// Drop the leading and trailing light runs, so the slice begins and ends with +/// a bar. +fn trim(runs: &[Run]) -> &[Run] { + let start = runs.iter().position(|r| r.dark); + let end = runs.iter().rposition(|r| r.dark); + match (start, end) { + (Some(s), Some(e)) => &runs[s..=e], + _ => &[], + } +} + +/// Convert pixel runs into a module pattern, one character at a time. +fn quantize(window: &[Run], character: LinearCharacter) -> Option> { + let elements = character.elements as usize; + let stop = character.stop_elements as usize; + let characters = window.len().checked_sub(stop)? / elements; + + let mut modules = Vec::new(); + for c in 0..characters { + let group = &window[c * elements..(c + 1) * elements]; + push_group(&mut modules, group, character.modules)?; + } + push_group( + &mut modules, + &window[characters * elements..], + character.stop_modules, + )?; + + if modules.is_empty() { + return None; + } + Some(modules) +} + +/// Apportion one character's fixed module count across its measured elements. +/// +/// Each element takes its proportional share, rounded down, and the modules +/// left over go to whichever elements were rounded down hardest — the largest +/// remainder method, which guarantees the character comes out at exactly its +/// nominal width no matter how the pixels fell. Every element is then forced +/// to at least one module, so a thin bar measured at a fraction of a pixel +/// narrows rather than vanishing. +fn push_group(out: &mut Vec, group: &[Run], modules: u32) -> Option<()> { + let count = group.len(); + if count == 0 || (modules as usize) < count { + return None; + } + + let total: u64 = group.iter().map(|r| u64::from(r.len)).sum(); + if total == 0 { + return None; + } + + let mut widths: Vec = Vec::with_capacity(count); + let mut remainders: Vec<(u64, usize)> = Vec::with_capacity(count); + for (i, run) in group.iter().enumerate() { + let scaled = u64::from(run.len) * u64::from(modules); + widths.push((scaled / total) as u32); + remainders.push((scaled % total, i)); + } + + let assigned: u32 = widths.iter().sum(); + // `assigned` is a sum of floors, so it can only be short of the target. + let mut leftover = modules.checked_sub(assigned)?; + // Ties break on the lower index, keeping the result deterministic. + remainders.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + for &(_, i) in remainders.iter() { + if leftover == 0 { + break; + } + widths[i] += 1; + leftover -= 1; + } + + // Rounding can starve an element that measured very narrow. Since + // `modules >= count`, there is always a wider element to borrow from. + while let Some(starved) = widths.iter().position(|w| *w == 0) { + let (widest, _) = widths + .iter() + .enumerate() + .max_by_key(|(i, w)| (**w, core::cmp::Reverse(*i)))?; + if widths[widest] <= 1 { + return None; + } + widths[widest] -= 1; + widths[starved] += 1; + } + + for (run, width) in group.iter().zip(widths) { + out.extend(core::iter::repeat_n(run.dark, width as usize)); + } + Some(()) +} + +#[cfg(all(test, feature = "code128"))] +mod tests { + use super::*; + use crate::symbology::{Code128, Symbology, SymbologyKind}; + use alloc::vec; + + fn character() -> LinearCharacter { + SymbologyKind::Code128.linear_character().unwrap() + } + + /// Blow a module pattern up to `scale` pixels per module, with `margin` + /// light pixels of quiet zone on each side. + fn stretch(modules: &[bool], scale: usize, margin: usize) -> Vec { + let mut row = vec![false; margin]; + for &m in modules { + row.extend(core::iter::repeat_n(m, scale)); + } + row.extend(core::iter::repeat_n(false, margin)); + row + } + + #[test] + fn reads_a_symbol_at_several_scales() { + let symbol = Code128.encode("PKG-9ED9285C").unwrap(); + let modules = symbol.modules().row(0).to_vec(); + for scale in [1, 2, 3, 4, 7, 11] { + let row = stretch(&modules, scale, 10 * scale); + let got = decode_row(&Code128, character(), &row); + assert_eq!(got.as_deref(), Some("PKG-9ED9285C"), "at scale {scale}"); + } + } + + #[test] + fn reads_a_symbol_with_no_quiet_zone_at_all() { + // Cropped flush to the bars. The quiet zone matters to a scanner + // hunting for the symbol in a wider scene; here the caller has + // already found it. + let symbol = Code128.encode("PKG-9ED9285C").unwrap(); + let row = stretch(symbol.modules().row(0), 3, 0); + assert_eq!( + decode_row(&Code128, character(), &row).as_deref(), + Some("PKG-9ED9285C") + ); + } + + #[test] + fn survives_module_widths_that_do_not_divide_evenly() { + // 3.5 pixels per module: every other module is a pixel wider. A + // scanner that estimated one global module width would drift off the + // pattern part way along. + let symbol = Code128.encode("PKG-9ED9285C").unwrap(); + let modules = symbol.modules().row(0); + let mut row = vec![false; 35]; + for (i, &m) in modules.iter().enumerate() { + row.extend(core::iter::repeat_n(m, if i % 2 == 0 { 4 } else { 3 })); + } + row.extend(core::iter::repeat_n(false, 35)); + assert_eq!( + decode_row(&Code128, character(), &row).as_deref(), + Some("PKG-9ED9285C") + ); + } + + #[test] + fn skips_junk_before_and_after_the_symbol() { + // A rule line to the left and a smudge to the right, as a scan line + // across a label would find. + let symbol = Code128.encode("PKG-9ED9285C").unwrap(); + let mut row = vec![true; 6]; + row.extend(core::iter::repeat_n(false, 20)); + row.extend(stretch(symbol.modules().row(0), 3, 30)); + row.extend(core::iter::repeat_n(true, 5)); + assert_eq!( + decode_row(&Code128, character(), &row).as_deref(), + Some("PKG-9ED9285C") + ); + } + + #[test] + fn refuses_a_line_that_carries_no_symbol() { + assert!(decode_row(&Code128, character(), &[false; 200]).is_none()); + assert!(decode_row(&Code128, character(), &[true; 200]).is_none()); + // Alternating single pixels: plenty of runs, no valid character. + let noise: Vec = (0..400).map(|i| i % 2 == 0).collect(); + assert!(decode_row(&Code128, character(), &noise).is_none()); + } + + #[test] + fn refuses_an_empty_line() { + assert!(decode_row(&Code128, character(), &[]).is_none()); + } + + #[test] + fn a_character_always_comes_out_at_its_nominal_width() { + // Whatever the measurements, apportionment must total exactly the + // module count — that is the property the whole approach rests on. + for widths in [ + [1u32, 1, 1, 1, 1, 1], + [40, 1, 9, 3, 21, 2], + [1, 1, 1, 1, 1, 100], + [7, 7, 7, 7, 7, 7], + ] { + let group: Vec = widths + .iter() + .enumerate() + .map(|(i, &len)| Run { + dark: i % 2 == 0, + len, + }) + .collect(); + let mut out = Vec::new(); + push_group(&mut out, &group, 11).unwrap(); + assert_eq!(out.len(), 11, "widths {widths:?} did not total 11 modules"); + assert_eq!(runs(&out).len(), 6, "an element was lost for {widths:?}"); + } + } +} diff --git a/src/scan/mod.rs b/src/scan/mod.rs new file mode 100644 index 0000000..828e424 --- /dev/null +++ b/src/scan/mod.rs @@ -0,0 +1,608 @@ +//! Reading barcodes back out of images. +//! +//! The mirror of [`render`](crate::render), meeting the rest of the crate at +//! the same seam: +//! +//! ```text +//! image ──▶ GrayImage ──▶ binarize ──▶ BitMatrix ──▶ Decoder::decode ──▶ payload +//! ``` +//! +//! A [`Scanner`] locates the symbol and turns pixels back into modules; a +//! [`Decoder`] turns modules back into a payload. Neither half knows what the +//! other is doing, so adding a scannable symbology means implementing +//! [`Decoder`] and describing the symbology's character structure in +//! [`SymbologyKind::linear_character`], not touching this module. +//! +//! # Example +//! +//! ``` +//! # #[cfg(all(feature = "scan", feature = "png"))] +//! # fn main() -> Result<(), smart_package_tracker::Error> { +//! use smart_package_tracker::{Barcode, RenderOptions, scan}; +//! +//! let png = Barcode::code128("PKG-9ED9285C")?.to_png(&RenderOptions::default())?; +//! +//! let found = scan::scan_png(&png)?; +//! assert_eq!(found.payload(), "PKG-9ED9285C"); +//! # Ok(()) +//! # } +//! # #[cfg(not(all(feature = "scan", feature = "png")))] +//! # fn main() {} +//! ``` +//! +//! # What this handles, and what it does not +//! +//! Reading a barcode off a photograph of a parcel and reading one out of a +//! rendered label, a flatbed scan or a screenshot are different problems. This +//! solves the second. +//! +//! Handled: any module width; extra margin or a crop flush to the bars; the +//! symbol anywhere in a larger image; light-on-dark as well as dark-on-light; +//! uneven lighting across the image; moderate noise and blur; a label turned +//! on its side, since columns are scanned as well as rows; and a printed width +//! that drifts from nominal, because each character is measured against its +//! own width rather than against one estimate for the whole symbol. +//! +//! Not handled: rotation by anything other than a quarter turn, perspective, +//! and the curvature of a barcode wrapped around a parcel. Those want a +//! dedicated scanning engine, not a label-generation crate — reach for one of +//! those if you are decoding camera frames. +//! +//! Only linear symbologies can be read. There is no QR decoder here; QR +//! encodes but does not scan. + +mod binarize; +mod linear; + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::error::{Error, Result}; +use crate::symbology::{BitMatrix, Decoder, LinearCharacter, SymbologyKind}; + +/// Default number of scan lines tried along each axis. +/// +/// The lines are spread over the image and tried from the middle outwards, so +/// this is a cost ceiling rather than a resolution: a barcode occupying any +/// reasonable fraction of the image is crossed by several of them. +const DEFAULT_SCAN_LINES: u32 = 32; + +/// An 8-bit greyscale image: what a scanner actually works on. +/// +/// Colour carries no information a barcode reader wants, so every input is +/// reduced to luminance on the way in. Transparent pixels are composited over +/// white first, because a barcode rendered with +/// [`Color::TRANSPARENT`](crate::Color::TRANSPARENT) as its background is +/// black ink on nothing, and "nothing" prints as paper. +#[derive(Clone, PartialEq, Eq)] +pub struct GrayImage { + width: u32, + height: u32, + luma: Vec, +} + +impl GrayImage { + /// Wrap an existing 8-bit luminance buffer, one byte per pixel. + /// + /// # Errors + /// + /// Returns [`Error::InvalidImage`] if either dimension is zero, if the + /// buffer length is not exactly `width * height`, or if the image is + /// larger than the crate's pixel-count limit. + pub fn from_luma(width: u32, height: u32, luma: Vec) -> Result { + let expected = check_dimensions(width, height, 1)?; + if luma.len() != expected { + return Err(Error::InvalidImage(alloc::format!( + "expected {expected} bytes for a {width}x{height} greyscale image, got {}", + luma.len() + ))); + } + Ok(Self { + width, + height, + luma, + }) + } + + /// Convert a packed 8-bit RGB buffer, three bytes per pixel. + /// + /// # Errors + /// + /// As [`from_luma`](Self::from_luma), for a buffer of `width * height * 3`. + pub fn from_rgb8(width: u32, height: u32, rgb: &[u8]) -> Result { + let expected = check_dimensions(width, height, 3)?; + if rgb.len() != expected { + return Err(Error::InvalidImage(alloc::format!( + "expected {expected} bytes for a {width}x{height} RGB image, got {}", + rgb.len() + ))); + } + let luma = rgb + .chunks_exact(3) + .map(|p| luminance(p[0], p[1], p[2])) + .collect(); + Self::from_luma(width, height, luma) + } + + /// Convert a packed 8-bit RGBA buffer, four bytes per pixel, compositing + /// over a white background. + /// + /// # Errors + /// + /// As [`from_luma`](Self::from_luma), for a buffer of `width * height * 4`. + pub fn from_rgba8(width: u32, height: u32, rgba: &[u8]) -> Result { + let expected = check_dimensions(width, height, 4)?; + if rgba.len() != expected { + return Err(Error::InvalidImage(alloc::format!( + "expected {expected} bytes for a {width}x{height} RGBA image, got {}", + rgba.len() + ))); + } + let luma = rgba + .chunks_exact(4) + .map(|p| { + let [r, g, b] = [p[0], p[1], p[2]].map(|c| over_white(c, p[3])); + luminance(r, g, b) + }) + .collect(); + Self::from_luma(width, height, luma) + } + + /// Decode a PNG image. + /// + /// Handles every colour type and bit depth the `png` crate can normalise + /// to 8 bits, including palettes and greyscale. + /// + /// # Errors + /// + /// Returns [`Error::InvalidImage`] if the bytes are not a PNG this crate + /// can read, or if the image exceeds the pixel-count limit. + #[cfg(feature = "png")] + #[cfg_attr(docsrs, doc(cfg(feature = "png")))] + pub fn from_png(bytes: &[u8]) -> Result { + let mut decoder = ::png::Decoder::new(std::io::Cursor::new(bytes)); + decoder.set_transformations(::png::Transformations::normalize_to_color8()); + + let mut reader = decoder + .read_info() + .map_err(|e| Error::InvalidImage(alloc::format!("{e}")))?; + + // Check the declared size before allocating for it, so a hostile + // header cannot ask for a multi-gigabyte buffer. + let info = reader.info(); + check_dimensions(info.width, info.height, 4)?; + + let size = reader + .output_buffer_size() + .ok_or_else(|| Error::InvalidImage(String::from("image is too large to decode")))?; + let mut buf = alloc::vec![0u8; size]; + let frame = reader + .next_frame(&mut buf) + .map_err(|e| Error::InvalidImage(alloc::format!("{e}")))?; + let pixels = &buf[..frame.buffer_size()]; + let (width, height) = (frame.width, frame.height); + + match frame.color_type { + ::png::ColorType::Grayscale => Self::from_luma(width, height, pixels.to_vec()), + ::png::ColorType::GrayscaleAlpha => { + let luma = pixels + .chunks_exact(2) + .map(|p| over_white(p[0], p[1])) + .collect(); + Self::from_luma(width, height, luma) + } + ::png::ColorType::Rgb => Self::from_rgb8(width, height, pixels), + ::png::ColorType::Rgba => Self::from_rgba8(width, height, pixels), + other => Err(Error::InvalidImage(alloc::format!( + "unsupported PNG colour type {other:?}" + ))), + } + } + + /// Read a PNG file from disk. + /// + /// # Errors + /// + /// Returns [`Error::Io`] if the file cannot be read, or whatever + /// [`from_png`](Self::from_png) reports. + #[cfg(all(feature = "png", feature = "std"))] + #[cfg_attr(docsrs, doc(cfg(all(feature = "png", feature = "std"))))] + pub fn from_png_file(path: impl AsRef) -> Result { + Self::from_png(&std::fs::read(path)?) + } + + /// Width in pixels. + pub fn width(&self) -> u32 { + self.width + } + + /// Height in pixels. + pub fn height(&self) -> u32 { + self.height + } + + /// The luminance buffer, row-major, one byte per pixel. + pub fn luma(&self) -> &[u8] { + &self.luma + } + + /// Luminance at `(x, y)`. Out-of-bounds reads as white, matching the way + /// [`BitMatrix::get`] tolerates out-of-bounds coordinates. + pub fn pixel(&self, x: u32, y: u32) -> u8 { + if x >= self.width || y >= self.height { + return u8::MAX; + } + self.luma[(y as usize) * (self.width as usize) + (x as usize)] + } +} + +impl core::fmt::Debug for GrayImage { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + // The buffer is megabytes; its dimensions are the useful part. + f.debug_struct("GrayImage") + .field("width", &self.width) + .field("height", &self.height) + .finish_non_exhaustive() + } +} + +/// Reject degenerate or oversized geometry, returning the buffer length a +/// well-formed image of this size has. +/// +/// The limit is on the pixel *count*, not on either axis alone: two dimensions +/// that each look reasonable multiply out into an allocation that is not. +fn check_dimensions(width: u32, height: u32, channels: usize) -> Result { + if width == 0 || height == 0 { + return Err(Error::InvalidImage(alloc::format!( + "image has a zero dimension ({width}x{height})" + ))); + } + let pixels = u64::from(width) * u64::from(height); + if pixels > crate::render::MAX_PIXELS { + return Err(Error::InvalidImage(alloc::format!( + "image is {} megapixels, over the {} megapixel limit", + pixels / 1_000_000, + crate::render::MAX_PIXELS / 1_000_000 + ))); + } + // The pixel-count limit bounds this well inside `usize` on 32-bit targets. + Ok((pixels as usize) * channels) +} + +/// Rec. 601 luma, the weighting a monochrome sensor approximates. +fn luminance(r: u8, g: u8, b: u8) -> u8 { + ((77 * u32::from(r) + 150 * u32::from(g) + 29 * u32::from(b)) >> 8) as u8 +} + +/// Composite one channel over an opaque white background. +fn over_white(channel: u8, alpha: u8) -> u8 { + let (c, a) = (u32::from(channel), u32::from(alpha)); + ((c * a + 255 * (255 - a) + 127) / 255) as u8 +} + +/// A barcode found in an image. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Scan { + kind: SymbologyKind, + payload: String, +} + +impl Scan { + /// The decoded payload. + pub fn payload(&self) -> &str { + &self.payload + } + + /// Which symbology it was read as. + pub fn kind(&self) -> SymbologyKind { + self.kind + } + + /// Take ownership of the payload. + pub fn into_payload(self) -> String { + self.payload + } +} + +/// Which way a scan line runs. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Axis { + /// Left to right. + Row, + /// Top to bottom, for a label printed on its side. + Column, +} + +/// Reads barcodes out of images. +/// +/// # Examples +/// +/// ``` +/// # #[cfg(all(feature = "scan", feature = "code128"))] +/// # fn main() -> Result<(), smart_package_tracker::Error> { +/// use smart_package_tracker::scan::{GrayImage, Scanner}; +/// +/// # let luma = { +/// # use smart_package_tracker::symbology::{Code128, Symbology}; +/// # let symbol = Code128.encode("PKG-9ED9285C")?; +/// # let row: Vec = core::iter::repeat_n(255u8, 30) +/// # .chain(symbol.modules().row(0).iter().flat_map(|d| { +/// # core::iter::repeat_n(if *d { 0u8 } else { 255 }, 3) +/// # })) +/// # .chain(core::iter::repeat_n(255u8, 30)) +/// # .collect(); +/// # (0..40).flat_map(|_| row.iter().copied()).collect::>() +/// # }; +/// # let width = (luma.len() / 40) as u32; +/// let image = GrayImage::from_luma(width, 40, luma)?; +/// let found = Scanner::new().scan(&image)?; +/// +/// assert_eq!(found.payload(), "PKG-9ED9285C"); +/// # Ok(()) +/// # } +/// # #[cfg(not(all(feature = "scan", feature = "code128")))] +/// # fn main() {} +/// ``` +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Scanner { + max_scan_lines: u32, +} + +impl Default for Scanner { + fn default() -> Self { + Self::new() + } +} + +impl Scanner { + /// A scanner with default settings. + pub fn new() -> Self { + Self { + max_scan_lines: DEFAULT_SCAN_LINES, + } + } + + /// How many scan lines to try along each axis. + /// + /// Raising this helps when the barcode occupies a small part of a large + /// image; lowering it bounds the work done before giving up. Zero is + /// treated as one. + pub fn max_scan_lines(mut self, lines: u32) -> Self { + self.max_scan_lines = lines.max(1); + self + } + + /// Read any barcode this crate can decode out of `image`. + /// + /// Code 128 is currently the only symbology this crate can decode, so + /// this is a shorthand for [`scan_with`](Self::scan_with). It stays a + /// separate method because it is where any future symbology gets tried + /// without callers changing. + /// + /// # Errors + /// + /// Returns [`Error::NoSymbolFound`] if no scan line yields a valid symbol. + /// A barcode that is present but unreadable is indistinguishable from one + /// that is absent, so there is only the one error. + pub fn scan(&self, image: &GrayImage) -> Result { + self.scan_with(&crate::symbology::Code128, image) + } + + /// Read a barcode of one specific symbology out of `image`. + /// + /// # Errors + /// + /// Returns [`Error::NoSymbolFound`] if no scan line yields a valid symbol, + /// or [`Error::Decode`] if the symbology has no scan-line structure — + /// matrix symbologies such as QR cannot be read this way. + pub fn scan_with(&self, decoder: &D, image: &GrayImage) -> Result { + let kind = decoder.kind(); + let character = kind.linear_character().ok_or_else(|| { + Error::Decode(alloc::format!( + "{kind} cannot be read from an image: it is not a linear symbology" + )) + })?; + + let matrix = binarize::binarize(image); + + for axis in [Axis::Row, Axis::Column] { + if let Some(payload) = self.scan_axis(decoder, character, &matrix, axis) { + return Ok(Scan { kind, payload }); + } + } + + Err(Error::NoSymbolFound(alloc::format!( + "no {kind} symbol on any of the scan lines tried across a {}x{} image", + image.width(), + image.height() + ))) + } + + /// Try every scan line along one axis. + fn scan_axis( + &self, + decoder: &D, + character: LinearCharacter, + matrix: &BitMatrix, + axis: Axis, + ) -> Option { + let count = match axis { + Axis::Row => matrix.height(), + Axis::Column => matrix.width(), + }; + + for index in scan_line_order(count, self.max_scan_lines) { + // The raw line first: it is exact where the image is clean, and + // clean is the common case. Only if that fails is it worth + // spending a vote across neighbouring lines to beat down speckle, + // which costs sharpness at the top and bottom of the bars. + for radius in [0, 1] { + if radius > 0 && count < 3 { + continue; + } + let line = scan_line(matrix, axis, index, radius); + if let Some(payload) = linear::decode_row(decoder, character, &line) { + return Some(payload); + } + // The same symbol printed light-on-dark. Cheaper to try than + // to guess from the border, which guesses wrong on an image + // cropped flush to the bars. + let inverted: Vec = line.iter().map(|d| !d).collect(); + if let Some(payload) = linear::decode_row(decoder, character, &inverted) { + return Some(payload); + } + } + } + + None + } +} + +/// Which lines to try, in the order to try them. +/// +/// Lines are spread evenly over the axis and then ordered from the middle +/// outwards: a barcode is usually the middle of its label, and any +/// human-readable text is at the edge. +fn scan_line_order(count: u32, max_lines: u32) -> Vec { + let lines = count.min(max_lines.max(1)); + let mut order: Vec = (0..lines) + // Sample the centre of each of `lines` equal bands rather than the + // edges, so neither the first nor the last line lands on the border. + .map(|i| ((2 * i + 1) as u64 * count as u64 / (2 * lines as u64)) as u32) + .map(|i| i.min(count - 1)) + .collect(); + order.dedup(); + + let centre = i64::from(count) / 2; + order.sort_by_key(|&i| (i64::from(i) - centre).abs()); + order +} + +/// Extract one scan line, optionally as a majority vote over its neighbours. +fn scan_line(matrix: &BitMatrix, axis: Axis, index: u32, radius: u32) -> Vec { + let (length, count) = match axis { + Axis::Row => (matrix.width(), matrix.height()), + Axis::Column => (matrix.height(), matrix.width()), + }; + + if radius == 0 { + return match axis { + Axis::Row => matrix.row(index).to_vec(), + Axis::Column => (0..length).map(|y| matrix.get(index, y)).collect(), + }; + } + + let lo = index.saturating_sub(radius); + let hi = (index + radius + 1).min(count); + let voters = hi - lo; + (0..length) + .map(|pos| { + let dark = (lo..hi) + .filter(|&i| match axis { + Axis::Row => matrix.get(pos, i), + Axis::Column => matrix.get(i, pos), + }) + .count() as u32; + 2 * dark > voters + }) + .collect() +} + +/// Read any barcode this crate can decode out of a PNG image. +/// +/// # Errors +/// +/// Returns [`Error::InvalidImage`] if the bytes are not a readable PNG, or +/// [`Error::NoSymbolFound`] if it holds no barcode. +#[cfg(feature = "png")] +#[cfg_attr(docsrs, doc(cfg(feature = "png")))] +pub fn scan_png(bytes: &[u8]) -> Result { + Scanner::new().scan(&GrayImage::from_png(bytes)?) +} + +/// Read any barcode this crate can decode out of a PNG file. +/// +/// # Errors +/// +/// Returns [`Error::Io`] if the file cannot be read, plus whatever +/// [`scan_png`] reports. +#[cfg(all(feature = "png", feature = "std"))] +#[cfg_attr(docsrs, doc(cfg(all(feature = "png", feature = "std"))))] +pub fn scan_png_file(path: impl AsRef) -> Result { + scan_png(&std::fs::read(path)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + #[test] + fn rejects_a_buffer_that_does_not_match_its_dimensions() { + let err = GrayImage::from_luma(4, 4, vec![0; 15]).unwrap_err(); + assert!(matches!(err, Error::InvalidImage(_)), "got {err:?}"); + assert!(GrayImage::from_luma(4, 4, vec![0; 16]).is_ok()); + assert!(GrayImage::from_rgb8(2, 2, &[0; 11]).is_err()); + assert!(GrayImage::from_rgb8(2, 2, &[0; 12]).is_ok()); + assert!(GrayImage::from_rgba8(2, 2, &[0; 15]).is_err()); + assert!(GrayImage::from_rgba8(2, 2, &[0; 16]).is_ok()); + } + + #[test] + fn rejects_a_zero_dimension() { + assert!(GrayImage::from_luma(0, 4, vec![]).is_err()); + assert!(GrayImage::from_luma(4, 0, vec![]).is_err()); + } + + #[test] + fn rejects_an_image_larger_than_the_allocation_limit() { + // Neither axis is extreme; their product is. This is the guard that + // has to bound the allocation, not each axis on its own. + let err = GrayImage::from_luma(20_000, 20_000, Vec::new()).unwrap_err(); + assert!( + alloc::format!("{err}").contains("megapixel"), + "expected a pixel-count error, got {err}" + ); + } + + #[test] + fn transparent_pixels_composite_over_white() { + // Black ink on a fully transparent background is black on paper, not + // black on black — a barcode rendered with `Color::TRANSPARENT` has to + // stay readable. + let rgba = [0, 0, 0, 0, 0, 0, 0, 255]; + let image = GrayImage::from_rgba8(2, 1, &rgba).unwrap(); + assert_eq!(image.pixel(0, 0), 255, "transparent should read as paper"); + assert_eq!(image.pixel(1, 0), 0, "opaque black should read as ink"); + } + + #[test] + fn out_of_bounds_pixels_read_as_paper() { + let image = GrayImage::from_luma(2, 2, vec![0; 4]).unwrap(); + assert_eq!(image.pixel(0, 0), 0); + assert_eq!(image.pixel(2, 0), 255); + assert_eq!(image.pixel(0, 2), 255); + assert_eq!(image.pixel(u32::MAX, u32::MAX), 255); + } + + #[test] + fn scan_lines_start_in_the_middle_and_stay_in_range() { + let order = scan_line_order(100, 8); + assert_eq!(order.len(), 8); + assert!(order.iter().all(|&i| i < 100)); + // The first line tried should be near the vertical centre. + assert!((40..=60).contains(&order[0]), "started at {}", order[0]); + } + + #[test] + fn every_line_is_tried_when_there_are_fewer_than_the_cap() { + let mut order = scan_line_order(5, 32); + order.sort_unstable(); + assert_eq!(order, vec![0, 1, 2, 3, 4]); + } + + #[test] + fn a_single_line_image_still_yields_one_line() { + assert_eq!(scan_line_order(1, 32), vec![0]); + } +} diff --git a/src/symbology/code128.rs b/src/symbology/code128.rs index f5b5fa9..66298cb 100644 --- a/src/symbology/code128.rs +++ b/src/symbology/code128.rs @@ -14,7 +14,7 @@ use alloc::string::{String, ToString}; use alloc::vec; use alloc::vec::Vec; -use super::{BitMatrix, Symbol, Symbology, SymbologyKind}; +use super::{BitMatrix, Decoder, Symbol, Symbology, SymbologyKind}; use crate::error::{Error, Result}; /// Quiet zone baked into `code128`'s coordinate space, in modules per side. @@ -83,6 +83,31 @@ impl Symbology for Code128 { } } +impl Decoder for Code128 { + fn kind(&self) -> SymbologyKind { + SymbologyKind::Code128 + } + + fn decode(&self, modules: &BitMatrix) -> Result { + if modules.height() != 1 { + return Err(Error::Decode( + "expected a single-row linear symbol".to_string(), + )); + } + + let bars = to_bars(modules.row(0)); + if bars.is_empty() { + return Err(Error::Decode("symbol contains no bars".to_string())); + } + + let bytes = ::code128::decode(&bars).map_err(|e| Error::Decode(alloc::format!("{e:?}")))?; + + // The decoder returns Latin-1 bytes; widening each byte to a `char` is + // the exact inverse of the encoder's Latin-1 narrowing. + Ok(bytes.into_iter().map(|b| b as char).collect()) + } +} + /// Decode a Code 128 [`Symbol`] back into its payload. /// /// This reconstructs bar/space runs from the module grid and hands them to the @@ -102,23 +127,7 @@ pub fn decode(symbol: &Symbol) -> Result { ))); } - let modules = symbol.modules(); - if modules.height() != 1 { - return Err(Error::Decode( - "expected a single-row linear symbol".to_string(), - )); - } - - let bars = to_bars(modules.row(0)); - if bars.is_empty() { - return Err(Error::Decode("symbol contains no bars".to_string())); - } - - let bytes = ::code128::decode(&bars).map_err(|e| Error::Decode(alloc::format!("{e:?}")))?; - - // The decoder returns Latin-1 bytes; widening each byte to a `char` is the - // exact inverse of the encoder's Latin-1 narrowing. - Ok(bytes.into_iter().map(|b| b as char).collect()) + Code128.decode(symbol.modules()) } /// Convert a module row into the bar/space runs the upstream decoder expects. diff --git a/src/symbology/mod.rs b/src/symbology/mod.rs index 64a36a4..1c6a9db 100644 --- a/src/symbology/mod.rs +++ b/src/symbology/mod.rs @@ -60,6 +60,27 @@ impl SymbologyKind { } } + /// How this symbology groups elements into fixed-width characters, if it + /// is linear. + /// + /// Returns `None` for matrix symbologies, which have no scan-line + /// structure to describe. [`is_linear`](Self::is_linear) and this method + /// always agree. + pub fn linear_character(self) -> Option { + match self { + // ISO/IEC 15417: every Code 128 character is three bars and three + // spaces totalling 11 modules; the stop pattern adds a fourth bar + // and two extra modules. + Self::Code128 => Some(LinearCharacter { + elements: 6, + modules: 11, + stop_elements: 7, + stop_modules: 13, + }), + Self::Qr => None, + } + } + /// Quiet zone the specification requires, in modules per side. /// /// Code 128 requires 10 modules; QR requires 4 on all four sides. Getting @@ -79,6 +100,32 @@ impl fmt::Display for SymbologyKind { } } +/// How a linear symbology groups bars and spaces into fixed-width characters. +/// +/// Every character of a linear symbology occupies a known number of modules +/// spread over a known number of elements — bars and spaces — and the symbol +/// finishes with a wider terminating pattern. A scanner uses this to convert +/// pixel measurements back into modules one character at a time, instead of +/// assuming a single module width holds across the whole symbol. That is what +/// keeps decoding accurate when a label is printed a little off-scale or fed +/// through a scanner at a slight skew. +/// +/// This is metadata for the same reason +/// [`required_quiet_zone`](SymbologyKind::required_quiet_zone) is: it lets +/// [`Scanner`](crate::scan::Scanner) stay ignorant of which symbology it is +/// reading, exactly as the renderers are. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct LinearCharacter { + /// Bars and spaces in one character. + pub elements: u32, + /// Modules in one character. + pub modules: u32, + /// Bars and spaces in the terminating pattern. + pub stop_elements: u32, + /// Modules in the terminating pattern. + pub stop_modules: u32, +} + /// A rectangular grid of dark (`true`) and light (`false`) modules. /// /// The `Debug` implementation renders ASCII art, which makes failing tests @@ -257,6 +304,27 @@ pub trait Symbology { fn encode(&self, data: &str) -> Result; } +/// Recovers a payload from a module grid. +/// +/// The mirror of [`Symbology`]: an encoder turns a payload into a +/// [`BitMatrix`], a decoder turns one back. +/// [`Scanner`](crate::scan::Scanner) works against this trait rather than +/// against a concrete symbology, which is what keeps the scanning side of the +/// crate on the same seam as the rendering side. +pub trait Decoder { + /// Which symbology this decodes. + fn kind(&self) -> SymbologyKind; + + /// Decode `modules`, which must hold the symbol alone, with no quiet zone. + /// + /// # Errors + /// + /// Returns [`Error::Decode`](crate::Error::Decode) if the grid has the + /// wrong shape for this symbology, or if the module pattern is not a valid + /// symbol. + fn decode(&self, modules: &BitMatrix) -> Result; +} + #[cfg(test)] mod tests { use super::*; @@ -296,6 +364,37 @@ mod tests { assert!(format!("{m:?}").contains("#.#")); } + #[test] + fn linearity_and_character_structure_agree() { + // Two pieces of metadata that have to say the same thing. A scanner + // asks for the character structure; a renderer asks whether the + // symbology is linear. If they ever disagree, one of them silently + // stops working. + for kind in [SymbologyKind::Code128, SymbologyKind::Qr] { + assert_eq!( + kind.is_linear(), + kind.linear_character().is_some(), + "{kind} disagrees with itself about being linear" + ); + } + } + + #[test] + fn a_linear_character_is_wider_than_its_element_count() { + // Every element needs at least one module, or a scanner cannot + // apportion a character's width without losing a bar. + for kind in [SymbologyKind::Code128, SymbologyKind::Qr] { + let Some(c) = kind.linear_character() else { + continue; + }; + assert!(c.modules >= c.elements, "{kind}: character too narrow"); + assert!( + c.stop_modules >= c.stop_elements, + "{kind}: stop pattern too narrow" + ); + } + } + #[test] fn code128_requires_a_ten_module_quiet_zone() { assert_eq!(SymbologyKind::Code128.required_quiet_zone(), 10); diff --git a/tests/scan.rs b/tests/scan.rs new file mode 100644 index 0000000..e8ed619 --- /dev/null +++ b/tests/scan.rs @@ -0,0 +1,324 @@ +//! End-to-end scanning tests. +//! +//! Every case here starts from the crate's own renderer and ends at a decoded +//! payload, because that is the loop a caller actually runs: print a label, +//! read it back. Nothing asserts on the scanner's internals — a change that +//! keeps these passing has kept the promise the README makes. +//! +//! The images are deliberately degraded in the ways real ones are: rotated a +//! quarter turn, inverted, buried in a larger scene, speckled, blurred. A +//! scanner that only reads its own pristine output has not been tested. + +#![cfg(all(feature = "scan", feature = "code128", feature = "png"))] + +use smart_package_tracker::render::{Color, Length, QuietZone}; +use smart_package_tracker::scan::{scan_png, GrayImage, Scanner}; +use smart_package_tracker::{Barcode, Error, RenderOptions, SymbologyKind, TrackingId}; + +/// Render a payload to PNG with the crate's defaults. +fn render(payload: &str) -> Vec { + Barcode::code128(payload) + .unwrap() + .to_png(&RenderOptions::default()) + .unwrap() +} + +/// Render, then decode straight back to luminance. +fn render_gray(payload: &str, options: &RenderOptions) -> GrayImage { + let png = Barcode::code128(payload).unwrap().to_png(options).unwrap(); + GrayImage::from_png(&png).unwrap() +} + +/// A deterministic noise source. A test that depends on the seed of the day +/// is a test that fails in CI and not on the desk it was written at. +struct Lcg(u64); + +impl Lcg { + fn next(&mut self) -> u32 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1); + (self.0 >> 33) as u32 + } +} + +#[test] +fn a_rendered_label_reads_back() { + let png = render("PKG-9ED9285C"); + let found = scan_png(&png).unwrap(); + assert_eq!(found.payload(), "PKG-9ED9285C"); + assert_eq!(found.kind(), SymbologyKind::Code128); +} + +#[test] +fn reads_back_at_every_module_width_a_printer_would_use() { + // 13 mil at 300 dpi is the shipping-label default; the extremes bracket + // what a thermal printer and a high-resolution press produce. + for (mils, dpi) in [ + (7.5, 203.0), + (10.0, 203.0), + (13.0, 300.0), + (13.0, 600.0), + (20.0, 600.0), + (40.0, 300.0), + ] { + let options = RenderOptions::builder() + .module_width(Length::Mils(mils)) + .dpi(dpi as u32) + .build() + .unwrap(); + let image = render_gray("PKG-9ED9285C", &options); + let found = Scanner::new() + .scan(&image) + .unwrap_or_else(|e| panic!("{mils} mil at {dpi} dpi: {e}")); + assert_eq!(found.payload(), "PKG-9ED9285C"); + } +} + +#[test] +fn a_one_pixel_module_still_reads() { + // The narrowest a symbol can legally be rendered. There is no slack left + // for a resampling error here. + let options = RenderOptions::builder() + .module_width(Length::Px(1.0)) + .height(Length::Px(40.0)) + .human_readable(false) + .build() + .unwrap(); + let image = render_gray("PKG-9ED9285C", &options); + assert_eq!( + Scanner::new().scan(&image).unwrap().payload(), + "PKG-9ED9285C" + ); +} + +#[test] +fn the_human_readable_line_does_not_fool_the_scanner() { + // The HRI text is a row of dark runs sitting under the symbol. A scanner + // that took the first line with bars on it would read the text as a + // barcode, or fail on it and give up. + let options = RenderOptions::builder() + .human_readable(true) + .build() + .unwrap(); + let image = render_gray("PKG-9ED9285C", &options); + assert_eq!( + Scanner::new().scan(&image).unwrap().payload(), + "PKG-9ED9285C" + ); +} + +#[test] +fn reads_a_label_printed_on_its_side() { + // Labels get applied rotated a quarter turn as often as not. + let image = render_gray("PKG-9ED9285C", &RenderOptions::default()); + let (w, h) = (image.width(), image.height()); + let mut rotated = Vec::with_capacity((w * h) as usize); + for x in 0..w { + for y in (0..h).rev() { + rotated.push(image.pixel(x, y)); + } + } + let rotated = GrayImage::from_luma(h, w, rotated).unwrap(); + assert_eq!( + Scanner::new().scan(&rotated).unwrap().payload(), + "PKG-9ED9285C" + ); +} + +#[test] +fn reads_light_bars_on_a_dark_background() { + let options = RenderOptions::builder() + .colors(Color::WHITE, Color::BLACK) + .build() + .unwrap(); + let image = render_gray("PKG-9ED9285C", &options); + assert_eq!( + Scanner::new().scan(&image).unwrap().payload(), + "PKG-9ED9285C" + ); +} + +#[test] +fn a_transparent_background_reads_as_paper() { + // Black ink over nothing composites to black ink over white, not black on + // black. Getting this wrong makes every transparent PNG unreadable. + let options = RenderOptions::builder() + .colors(Color::BLACK, Color::TRANSPARENT) + .build() + .unwrap(); + let png = Barcode::code128("PKG-9ED9285C") + .unwrap() + .to_png(&options) + .unwrap(); + assert_eq!(scan_png(&png).unwrap().payload(), "PKG-9ED9285C"); +} + +#[test] +fn finds_a_barcode_placed_inside_a_larger_scene() { + // The label pasted onto a page, off-centre, with a rule drawn across it — + // roughly what a flatbed scan of a packing slip looks like. + let label = render_gray("PKG-9ED9285C", &RenderOptions::default()); + let (lw, lh) = (label.width(), label.height()); + let (pw, ph) = (lw + 400, lh + 300); + + let mut page = vec![255u8; (pw * ph) as usize]; + let (ox, oy) = (137u32, 211u32); + for y in 0..lh { + for x in 0..lw { + page[((y + oy) * pw + (x + ox)) as usize] = label.pixel(x, y); + } + } + // A horizontal rule crossing the whole page, above the barcode. + for x in 0..pw { + page[(40 * pw + x) as usize] = 0; + } + + let image = GrayImage::from_luma(pw, ph, page).unwrap(); + assert_eq!( + Scanner::new().scan(&image).unwrap().payload(), + "PKG-9ED9285C" + ); +} + +#[test] +fn survives_speckle_and_blur() { + let image = render_gray("PKG-9ED9285C", &RenderOptions::default()); + let (w, h) = (image.width(), image.height()); + + // Salt and pepper on 2% of pixels, then a 3x3 box blur — a photocopy of a + // photocopy. + let mut rng = Lcg(0x5EED); + let mut luma: Vec = image.luma().to_vec(); + for px in luma.iter_mut() { + if rng.next() % 100 < 2 { + *px = if rng.next() % 2 == 0 { 0 } else { 255 }; + } + } + let blurred: Vec = (0..h) + .flat_map(|y| { + let luma = &luma; + (0..w).map(move |x| { + let mut sum = 0u32; + let mut n = 0u32; + for dy in -1i64..=1 { + for dx in -1i64..=1 { + let (nx, ny) = (x as i64 + dx, y as i64 + dy); + if (0..w as i64).contains(&nx) && (0..h as i64).contains(&ny) { + sum += u32::from(luma[(ny as u32 * w + nx as u32) as usize]); + n += 1; + } + } + } + (sum / n) as u8 + }) + }) + .collect(); + + let image = GrayImage::from_luma(w, h, blurred).unwrap(); + assert_eq!( + Scanner::new().scan(&image).unwrap().payload(), + "PKG-9ED9285C" + ); +} + +#[test] +fn reads_a_symbol_cropped_flush_to_its_bars() { + // No quiet zone at all. Conformance is about a scanner finding the symbol + // in a scene; once it has been cropped for you, the margin is optional. + let options = RenderOptions::builder() + .quiet_zone(QuietZone::None) + .human_readable(false) + .build() + .unwrap(); + let image = render_gray("PKG-9ED9285C", &options); + assert_eq!( + Scanner::new().scan(&image).unwrap().payload(), + "PKG-9ED9285C" + ); +} + +#[test] +fn reads_the_payload_shapes_a_carrier_actually_uses() { + for payload in [ + "PKG-9ED9285C", + "PKG-0123456789ABCDEF3", + "1Z999AA10123456784", + "9400111899223197428490", + "00123456789012345675", + "A", + "Mixed 123 Case!", + ] { + let png = render(payload); + let found = scan_png(&png).unwrap_or_else(|e| panic!("{payload}: {e}")); + assert_eq!(found.payload(), payload); + } +} + +#[test] +fn a_generated_tracking_id_survives_the_whole_loop() { + // Mint, print, scan, parse — the round trip the crate exists for. + let id = TrackingId::generate().unwrap(); + let png = render(id.as_str()); + let scanned = scan_png(&png).unwrap(); + let recovered = TrackingId::parse(scanned.payload()).unwrap(); + assert_eq!(recovered, id); +} + +#[test] +fn a_file_on_disk_reads_back() { + let dir = std::env::temp_dir().join("spt-scan-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("label.png"); + Barcode::code128("PKG-9ED9285C") + .unwrap() + .to_png_file(&path, &RenderOptions::default()) + .unwrap(); + + let found = smart_package_tracker::scan::scan_png_file(&path).unwrap(); + assert_eq!(found.payload(), "PKG-9ED9285C"); + std::fs::remove_file(&path).ok(); +} + +#[test] +fn an_image_with_no_barcode_reports_that_rather_than_inventing_one() { + // A false positive is worse than a miss: it routes a parcel to the wrong + // place and nothing looks wrong. Blank paper, noise, and a QR code — none + // of which this scanner can read — must all come back empty-handed. + let blank = GrayImage::from_luma(400, 200, vec![255; 400 * 200]).unwrap(); + assert!(matches!( + Scanner::new().scan(&blank), + Err(Error::NoSymbolFound(_)) + )); + + let mut rng = Lcg(1); + let noise: Vec = (0..400 * 200).map(|_| (rng.next() & 0xFF) as u8).collect(); + let noise = GrayImage::from_luma(400, 200, noise).unwrap(); + assert!(matches!( + Scanner::new().scan(&noise), + Err(Error::NoSymbolFound(_)) + )); +} + +#[test] +#[cfg(feature = "qr")] +fn a_qr_code_is_a_clean_miss_not_a_wrong_answer() { + // There is no QR decoder here. The scanner must say so rather than + // producing whatever a line through a QR grid happens to look like. + let png = Barcode::qr("PKG-9ED9285C") + .unwrap() + .to_png(&RenderOptions::default()) + .unwrap(); + assert!(matches!(scan_png(&png), Err(Error::NoSymbolFound(_)))); +} + +#[test] +fn malformed_image_bytes_are_an_error_not_a_panic() { + assert!(matches!( + scan_png(b"not a png"), + Err(Error::InvalidImage(_)) + )); + assert!(matches!(scan_png(&[]), Err(Error::InvalidImage(_)))); + // A valid PNG header followed by rubbish. + let mut truncated = render("PKG-9ED9285C"); + truncated.truncate(40); + assert!(matches!(scan_png(&truncated), Err(Error::InvalidImage(_)))); +}