From 68657dd4a353c96be0034fdac10af59db7e096cc Mon Sep 17 00:00:00 2001 From: ferris007 Date: Sat, 22 Aug 2026 20:56:28 +0900 Subject: [PATCH] feat: read barcodes back out of images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `scan` feature that runs the rendering pipeline backwards and meets it at the same seam: image -> GrayImage -> binarize -> BitMatrix -> Decoder::decode -> payload Nothing in src/scan/ matches on SymbologyKind. The one thing a scan line needs to know about a symbology — how it groups bars and spaces into fixed-width characters — is new SymbologyKind::linear_character metadata, alongside required_quiet_zone and is_linear. Pixel runs are converted to modules one character at a time rather than against a single module-width estimate for the whole symbol, so a printed width that drifts from nominal cannot accumulate into a misread. The threshold is computed per block and smoothed across neighbours, which follows a lighting gradient no global threshold can. Every payload returned has passed Code 128's own check character: a smudge is a miss, not a plausible wrong tracking number routed to the wrong depot. Handled: 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. Not handled, and documented as such: rotation off the axes, perspective, curved surfaces, and QR. This reverses the "image scanning is out of scope" boundary in AGENTS.md and the README. The boundary is rewritten rather than dropped: scanning is in scope as far as it stays arithmetic. Camera-grade decoding is a scanning engine and belongs in one. QR scanning stays out at a concrete cost — rqrr would add seven crates to the published graph, so it remains a dev-dependency verifying our own output. No new dependencies; the resolved graph is unchanged. Builds no_std. Co-Authored-By: Claude Opus 5 (1M context) Entire-Checkpoint: 01M0MN8P909V0PRD86646T6KS4 --- .github/workflows/ci.yml | 4 +- AGENTS.md | 68 +++-- CHANGELOG.md | 33 +++ Cargo.toml | 10 +- README.md | 68 ++++- examples/label.rs | 11 +- src/barcode.rs | 5 +- src/error.rs | 10 + src/lib.rs | 43 ++- src/render/mod.rs | 2 +- src/scan/binarize.rs | 204 +++++++++++++ src/scan/linear.rs | 311 ++++++++++++++++++++ src/scan/mod.rs | 608 +++++++++++++++++++++++++++++++++++++++ src/symbology/code128.rs | 45 +-- src/symbology/mod.rs | 99 +++++++ tests/scan.rs | 324 +++++++++++++++++++++ 16 files changed, 1791 insertions(+), 54 deletions(-) create mode 100644 src/scan/binarize.rs create mode 100644 src/scan/linear.rs create mode 100644 src/scan/mod.rs create mode 100644 tests/scan.rs 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(_)))); +}