From e1c58d9471b89c4a0121a402645d6e5b8f0c0c79 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Tue, 11 Aug 2026 19:11:22 +0200 Subject: [PATCH 01/20] doc: record the release workflow and merge policy in CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things bit us during v0.7.1 and neither was written down. `npm run build` regenerates crates/wasm/pkg/package.json, which is tracked but had silently drifted a full release behind (0.6.1 while 0.7.0 shipped). Merging the release PR with "Rebase and merge" replayed dev's commits as new objects on main, so dev stopped being an ancestor of main — identical content, divergent history. Repaired by resetting dev to main; documented so the next release uses a merge commit instead. Also notes the two version locations in Cargo.toml, the git-cliff invocation and its missing-blank-line quirk, and that cliff.toml skips the release commit itself — so other changes must land in their own commits first. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3b18020..ca03057 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,29 @@ Preferred scopes: `(core)` `(imgproc)` `(simd)` `(wasm)` `(parallel)` - PRs start from and target the `dev` branch (not `main`). - Keep PRs focused; one feature or fix per PR. +### Releases + +Releases go `dev` → PR to `main` → merge → tag `vX.Y.Z` on `main`. Pushing the tag +triggers `release.yml`, which publishes to crates.io **and** npm — irreversible. + +**Merge release PRs with a merge commit, not "Rebase and merge" or "Squash and +merge".** Rebasing replays dev's commits as new objects on `main`, so `dev` stops +being an ancestor of `main` and the two diverge with identical content but different +SHAs. The next release PR then replays every old commit again. This happened with +v0.7.1 (#95) and had to be repaired by resetting `dev` to `main`. + +Release prep steps (see the v0.7.1 commit for a worked example): + +1. Bump the version in `Cargo.toml` (**two places** — `[package]` and + `[workspace.package]`) and in the root `package.json`. +2. Run `npm run build` — this regenerates `crates/wasm/pkg/package.json`, which is + tracked and otherwise silently drifts. +3. `npx git-cliff --config cliff.toml --tag vX.Y.Z --unreleased --prepend CHANGELOG.md`, + then add the blank line `--prepend` omits before the previous version heading. +4. Commit as `chore(release): prepare for vX.Y.Z` — `cliff.toml` skips this message + from the changelog, so land any other changes in their own commits *first* or they + will not appear. + ## Module Structure Convention Each top-level module (`core/`, `imgproc/`, `features2d/`, etc.) must contain its own: From d125e44e21da8f79ae92f2df049d6a9ddcc2f840 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Sat, 29 Aug 2026 18:18:42 +0200 Subject: [PATCH 02/20] chore(ci): pin stable toolchain and allow the new chunks_exact_to_as_chunks lint clippy 1.98 stabilized clippy::chunks_exact_to_as_chunks, which fires on every constant-size chunks_exact/chunks_exact_mut call in color.rs and broke CI on `dev` (and any unrelated open PR) once the runner picked up the new stable release, since our workflows install `dtolnay/rust-toolchain@stable` with no version pin. - Pin all `dtolnay/rust-toolchain@stable` steps in ci.yml and release.yml to 1.98.0 so a future clippy release can't retroactively break CI again. - Allow the new lint at each call site (guarded with `unknown_lints` so it doesn't itself break on older toolchains). `[T]::as_chunks` isn't stable yet, so the suggested rewrite isn't available. --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/release.yml | 4 ++-- src/imgproc/color.rs | 12 ++++++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82cd9b0..c628c1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.98.0 with: components: rustfmt, clippy @@ -54,7 +54,7 @@ jobs: - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.98.0 with: components: clippy targets: thumbv7em-none-eabihf @@ -82,7 +82,7 @@ jobs: - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.98.0 with: targets: wasm32-unknown-unknown @@ -107,7 +107,7 @@ jobs: - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.98.0 - name: Rust Cache uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e79e3a1..faad884 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,7 +104,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.98.0 - name: Publish to Crates.io run: cargo publish --token ${{ secrets.CRATES_TOKEN }} -p purecv @@ -114,7 +114,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.98.0 - name: Install wasm-pack run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh - name: Build and Publish to NPM diff --git a/src/imgproc/color.rs b/src/imgproc/color.rs index 650bd23..9a621bb 100644 --- a/src/imgproc/color.rs +++ b/src/imgproc/color.rs @@ -61,6 +61,7 @@ fn rgb_to_gray_row(out_row: &mut [u8], in_row: &[u8]) { } #[cfg(not(feature = "simd"))] { + #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] for (out_pixel, in_val) in out_row.iter_mut().zip(in_row.chunks_exact(3)) { let r = in_val[0] as f32; let g = in_val[1] as f32; @@ -79,6 +80,7 @@ fn bgr_to_gray_row(out_row: &mut [u8], in_row: &[u8]) { } #[cfg(not(feature = "simd"))] { + #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] for (out_pixel, in_val) in out_row.iter_mut().zip(in_row.chunks_exact(3)) { let b = in_val[0] as f32; let g = in_val[1] as f32; @@ -97,6 +99,7 @@ fn rgba_to_gray_row(out_row: &mut [u8], in_row: &[u8]) { } #[cfg(not(feature = "simd"))] { + #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] for (out_pixel, in_val) in out_row.iter_mut().zip(in_row.chunks_exact(4)) { let r = in_val[0] as f32; let g = in_val[1] as f32; @@ -115,6 +118,7 @@ fn bgra_to_gray_row(out_row: &mut [u8], in_row: &[u8]) { } #[cfg(not(feature = "simd"))] { + #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] for (out_pixel, in_val) in out_row.iter_mut().zip(in_row.chunks_exact(4)) { let b = in_val[0] as f32; let g = in_val[1] as f32; @@ -347,6 +351,7 @@ pub fn cvt_color_gray_to_rgb(input: &Matrix) -> Result, &'static .par_chunks_exact_mut(out_row_len) .zip(input.data.par_chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { + #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] for (out_val, in_pixel) in out_row.chunks_exact_mut(3).zip(in_row.iter()) { let v = *in_pixel; out_val[0] = v; @@ -363,6 +368,7 @@ pub fn cvt_color_gray_to_rgb(input: &Matrix) -> Result, &'static .chunks_exact_mut(out_row_len) .zip(input.data.chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { + #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] for (out_val, in_pixel) in out_row.chunks_exact_mut(3).zip(in_row.iter()) { let v = *in_pixel; out_val[0] = v; @@ -392,6 +398,7 @@ pub fn cvt_color_gray_to_bgr(input: &Matrix) -> Result, &'static .par_chunks_exact_mut(out_row_len) .zip(input.data.par_chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { + #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] for (out_val, in_pixel) in out_row.chunks_exact_mut(3).zip(in_row.iter()) { let v = *in_pixel; out_val[0] = v; @@ -408,6 +415,7 @@ pub fn cvt_color_gray_to_bgr(input: &Matrix) -> Result, &'static .chunks_exact_mut(out_row_len) .zip(input.data.chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { + #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] for (out_val, in_pixel) in out_row.chunks_exact_mut(3).zip(in_row.iter()) { let v = *in_pixel; out_val[0] = v; @@ -437,6 +445,7 @@ pub fn cvt_color_gray_to_rgba(input: &Matrix) -> Result, &'static .par_chunks_exact_mut(out_row_len) .zip(input.data.par_chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { + #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] for (out_val, in_pixel) in out_row.chunks_exact_mut(4).zip(in_row.iter()) { let v = *in_pixel; out_val[0] = v; @@ -454,6 +463,7 @@ pub fn cvt_color_gray_to_rgba(input: &Matrix) -> Result, &'static .chunks_exact_mut(out_row_len) .zip(input.data.chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { + #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] for (out_val, in_pixel) in out_row.chunks_exact_mut(4).zip(in_row.iter()) { let v = *in_pixel; out_val[0] = v; @@ -484,6 +494,7 @@ pub fn cvt_color_gray_to_bgra(input: &Matrix) -> Result, &'static .par_chunks_exact_mut(out_row_len) .zip(input.data.par_chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { + #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] for (out_val, in_pixel) in out_row.chunks_exact_mut(4).zip(in_row.iter()) { let v = *in_pixel; out_val[0] = v; @@ -501,6 +512,7 @@ pub fn cvt_color_gray_to_bgra(input: &Matrix) -> Result, &'static .chunks_exact_mut(out_row_len) .zip(input.data.chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { + #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] for (out_val, in_pixel) in out_row.chunks_exact_mut(4).zip(in_row.iter()) { let v = *in_pixel; out_val[0] = v; From 313038cac9b1cda83f64e08278c86dda89f406d5 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Sat, 29 Aug 2026 18:28:34 +0200 Subject: [PATCH 03/20] refactor(imgproc): use as_chunks instead of allowing the new clippy lint Qodo review on #105 flagged that #[allow(clippy::chunks_exact_to_as_chunks)] violates this repo's compliance rule against introducing new Clippy allow overrides. `[T]::as_chunks`/`as_chunks_mut` (clippy's own suggested rewrite) is already stable, so rewrite the 12 flagged sites to use it directly and drop the allow attributes instead of suppressing the lint. --- src/imgproc/color.rs | 52 ++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/src/imgproc/color.rs b/src/imgproc/color.rs index 9a621bb..0d60ac0 100644 --- a/src/imgproc/color.rs +++ b/src/imgproc/color.rs @@ -61,8 +61,7 @@ fn rgb_to_gray_row(out_row: &mut [u8], in_row: &[u8]) { } #[cfg(not(feature = "simd"))] { - #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] - for (out_pixel, in_val) in out_row.iter_mut().zip(in_row.chunks_exact(3)) { + for (out_pixel, in_val) in out_row.iter_mut().zip(in_row.as_chunks::<3>().0.iter()) { let r = in_val[0] as f32; let g = in_val[1] as f32; let b = in_val[2] as f32; @@ -80,8 +79,7 @@ fn bgr_to_gray_row(out_row: &mut [u8], in_row: &[u8]) { } #[cfg(not(feature = "simd"))] { - #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] - for (out_pixel, in_val) in out_row.iter_mut().zip(in_row.chunks_exact(3)) { + for (out_pixel, in_val) in out_row.iter_mut().zip(in_row.as_chunks::<3>().0.iter()) { let b = in_val[0] as f32; let g = in_val[1] as f32; let r = in_val[2] as f32; @@ -99,8 +97,7 @@ fn rgba_to_gray_row(out_row: &mut [u8], in_row: &[u8]) { } #[cfg(not(feature = "simd"))] { - #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] - for (out_pixel, in_val) in out_row.iter_mut().zip(in_row.chunks_exact(4)) { + for (out_pixel, in_val) in out_row.iter_mut().zip(in_row.as_chunks::<4>().0.iter()) { let r = in_val[0] as f32; let g = in_val[1] as f32; let b = in_val[2] as f32; @@ -118,8 +115,7 @@ fn bgra_to_gray_row(out_row: &mut [u8], in_row: &[u8]) { } #[cfg(not(feature = "simd"))] { - #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] - for (out_pixel, in_val) in out_row.iter_mut().zip(in_row.chunks_exact(4)) { + for (out_pixel, in_val) in out_row.iter_mut().zip(in_row.as_chunks::<4>().0.iter()) { let b = in_val[0] as f32; let g = in_val[1] as f32; let r = in_val[2] as f32; @@ -351,8 +347,9 @@ pub fn cvt_color_gray_to_rgb(input: &Matrix) -> Result, &'static .par_chunks_exact_mut(out_row_len) .zip(input.data.par_chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { - #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] - for (out_val, in_pixel) in out_row.chunks_exact_mut(3).zip(in_row.iter()) { + for (out_val, in_pixel) in + out_row.as_chunks_mut::<3>().0.iter_mut().zip(in_row.iter()) + { let v = *in_pixel; out_val[0] = v; out_val[1] = v; @@ -368,8 +365,9 @@ pub fn cvt_color_gray_to_rgb(input: &Matrix) -> Result, &'static .chunks_exact_mut(out_row_len) .zip(input.data.chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { - #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] - for (out_val, in_pixel) in out_row.chunks_exact_mut(3).zip(in_row.iter()) { + for (out_val, in_pixel) in + out_row.as_chunks_mut::<3>().0.iter_mut().zip(in_row.iter()) + { let v = *in_pixel; out_val[0] = v; out_val[1] = v; @@ -398,8 +396,9 @@ pub fn cvt_color_gray_to_bgr(input: &Matrix) -> Result, &'static .par_chunks_exact_mut(out_row_len) .zip(input.data.par_chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { - #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] - for (out_val, in_pixel) in out_row.chunks_exact_mut(3).zip(in_row.iter()) { + for (out_val, in_pixel) in + out_row.as_chunks_mut::<3>().0.iter_mut().zip(in_row.iter()) + { let v = *in_pixel; out_val[0] = v; out_val[1] = v; @@ -415,8 +414,9 @@ pub fn cvt_color_gray_to_bgr(input: &Matrix) -> Result, &'static .chunks_exact_mut(out_row_len) .zip(input.data.chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { - #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] - for (out_val, in_pixel) in out_row.chunks_exact_mut(3).zip(in_row.iter()) { + for (out_val, in_pixel) in + out_row.as_chunks_mut::<3>().0.iter_mut().zip(in_row.iter()) + { let v = *in_pixel; out_val[0] = v; out_val[1] = v; @@ -445,8 +445,9 @@ pub fn cvt_color_gray_to_rgba(input: &Matrix) -> Result, &'static .par_chunks_exact_mut(out_row_len) .zip(input.data.par_chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { - #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] - for (out_val, in_pixel) in out_row.chunks_exact_mut(4).zip(in_row.iter()) { + for (out_val, in_pixel) in + out_row.as_chunks_mut::<4>().0.iter_mut().zip(in_row.iter()) + { let v = *in_pixel; out_val[0] = v; out_val[1] = v; @@ -463,8 +464,9 @@ pub fn cvt_color_gray_to_rgba(input: &Matrix) -> Result, &'static .chunks_exact_mut(out_row_len) .zip(input.data.chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { - #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] - for (out_val, in_pixel) in out_row.chunks_exact_mut(4).zip(in_row.iter()) { + for (out_val, in_pixel) in + out_row.as_chunks_mut::<4>().0.iter_mut().zip(in_row.iter()) + { let v = *in_pixel; out_val[0] = v; out_val[1] = v; @@ -494,8 +496,9 @@ pub fn cvt_color_gray_to_bgra(input: &Matrix) -> Result, &'static .par_chunks_exact_mut(out_row_len) .zip(input.data.par_chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { - #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] - for (out_val, in_pixel) in out_row.chunks_exact_mut(4).zip(in_row.iter()) { + for (out_val, in_pixel) in + out_row.as_chunks_mut::<4>().0.iter_mut().zip(in_row.iter()) + { let v = *in_pixel; out_val[0] = v; out_val[1] = v; @@ -512,8 +515,9 @@ pub fn cvt_color_gray_to_bgra(input: &Matrix) -> Result, &'static .chunks_exact_mut(out_row_len) .zip(input.data.chunks_exact(in_row_len)) .for_each(|(out_row, in_row)| { - #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] - for (out_val, in_pixel) in out_row.chunks_exact_mut(4).zip(in_row.iter()) { + for (out_val, in_pixel) in + out_row.as_chunks_mut::<4>().0.iter_mut().zip(in_row.iter()) + { let v = *in_pixel; out_val[0] = v; out_val[1] = v; From 7583fa0014d16afd9fae204cd85217d805a2b889 Mon Sep 17 00:00:00 2001 From: XiaoPengYouCode Date: Sun, 23 Aug 2026 18:12:45 +0800 Subject: [PATCH 04/20] feat(imgproc): add histogram module Implement complete histogram processing module (src/imgproc/histogram.rs) with OpenCV parity for imgproc histogram operations. - calc_hist: generic over Matrix (u8/u16/f32), supports uniform and non-uniform bins, multi-image channel indexing, mask, accumulate - calc_back_project: generic over Matrix, returns Matrix - compare_hist: Correl, ChiSqr, ChiSqrAlt, Intersection, Bhattacharyya, KullbackLeibler - equalize_hist: global histogram equalization for u8 - Clahe: Contrast Limited Adaptive Histogram Equalization with apply_u8 and apply_u16 support - 23 unit tests covering all functions and error paths Closes #98 --- src/imgproc.rs | 5 + src/imgproc/histogram.rs | 1540 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 1545 insertions(+) create mode 100644 src/imgproc/histogram.rs diff --git a/src/imgproc.rs b/src/imgproc.rs index 6475188..e664829 100644 --- a/src/imgproc.rs +++ b/src/imgproc.rs @@ -57,6 +57,7 @@ pub mod edge; pub mod feature; pub mod filter; pub mod geometric; +pub mod histogram; pub mod hough; pub mod morph; pub mod pyramid; @@ -79,6 +80,10 @@ pub use edge::*; pub use feature::*; pub use filter::*; pub use geometric::{remap, warp_perspective, InterpolationFlags}; +pub use histogram::{ + calc_back_project, calc_hist, compare_hist, create_clahe, equalize_hist, Clahe, + HistCompMethods, RangeSpec, +}; pub use hough::*; pub use morph::{dilate, erode, get_structuring_element, morphology_ex, MorphShapes, MorphTypes}; pub use pyramid::{build_pyramid, pyr_down, pyr_up}; diff --git a/src/imgproc/histogram.rs b/src/imgproc/histogram.rs new file mode 100644 index 0000000..9a49310 --- /dev/null +++ b/src/imgproc/histogram.rs @@ -0,0 +1,1540 @@ +/* + * histogram.rs + * purecv + * + * This file is part of purecv - WebARKit. + * + * purecv is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * purecv is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with purecv. If not, see . + * + * As a special exception, the copyright holders of this library give you + * permission to link this library with independent modules to produce an + * executable, regardless of the license terms of these independent modules, and to + * copy and distribute the resulting executable under terms of your choice, + * provided that you also meet, for each linked independent module, the terms and + * conditions of the license of that module. An independent module is a module + * which is neither derived from nor based on this library. If you modify this + * library, you may extend this exception to your version of the library, but you + * are not obligated to do so. If you do not wish to do so, delete this exception + * statement from your version. + * + * Copyright 2026 WebARKit. + * + * Author(s): XiaoPengYouCode https://github.com/XiaoPengYouCode + * + */ + +use alloc::{vec, vec::Vec}; +use core::f64; +use num_traits::ToPrimitive; + +use crate::core::error::Result; +use crate::core::logging::tags; +use crate::core::types::Size2i; +use crate::core::Matrix; +use crate::cv_bail; + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +/// Comparison method for `compare_hist`. +/// +/// Mirrors OpenCV's `HistCompMethods`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HistCompMethods { + Correl = 0, + ChiSqr = 1, + ChiSqrAlt = 2, + Intersection = 3, + Bhattacharyya = 4, + KullbackLeibler = 5, +} + +/// The range specification for a single histogram dimension. +#[derive(Debug, Clone)] +pub enum RangeSpec { + Uniform(f32, f32), + NonUniform(Vec), +} + +// --------------------------------------------------------------------------- +// Multi-image channel resolution +// --------------------------------------------------------------------------- + +/// Resolves a global channel index across multiple images into +/// `(image_index, local_channel_index)`. +/// +/// OpenCV semantics: channels are numbered sequentially across images. +fn resolve_channel(global_ch: usize, images: &[&Matrix]) -> Result<(usize, usize)> { + let mut remaining = global_ch; + for (img_idx, img) in images.iter().enumerate() { + if remaining < img.channels { + return Ok((img_idx, remaining)); + } + remaining -= img.channels; + } + cv_bail!( + tags::IMGPROC, + InvalidInput, + "resolve_channel: global channel {} exceeds total channels {}", + global_ch, + images.iter().map(|i| i.channels).sum::() + ); +} + +/// Reads a single pixel value from multi-image channel indexing and converts to `f32`. +#[inline(always)] +fn read_pixel_f32( + images: &[&Matrix], + global_ch: usize, + y: usize, + x: usize, +) -> Option { + let (img_idx, local_ch) = resolve_channel(global_ch, images).ok()?; + images[img_idx].get(y, x, local_ch)?.to_f32() +} + +// --------------------------------------------------------------------------- +// Bin mapping helpers +// --------------------------------------------------------------------------- + +#[inline(always)] +fn uniform_bin(val: f32, range_lo: f32, range_hi: f32, hist_size: usize) -> Option { + if val < range_lo || val >= range_hi { + return None; + } + let idx = ((val - range_lo) * (hist_size as f32) / (range_hi - range_lo)) as i32; + Some(idx.clamp(0, hist_size as i32 - 1) as usize) +} + +#[inline(always)] +fn nonuniform_bin(val: f32, boundaries: &[f32], hist_size: usize) -> Option { + if boundaries.is_empty() || val < boundaries[0] || val >= boundaries[hist_size] { + return None; + } + // Find the first boundary that is strictly greater than val, + // then subtract 1 to get the bin index. + match boundaries[..=hist_size] + .binary_search_by(|b| b.partial_cmp(&val).unwrap_or(core::cmp::Ordering::Less)) + { + Ok(idx) => { + // val == boundaries[idx]: bin is idx (left-inclusive) + // bin i = [boundaries[i], boundaries[i+1]) + // If val == b1, it belongs to bin 1: [b1, b2) + // binary_search finds idx where b[idx] == val + // So bin = idx. But if idx == 0 and val == b0, bin = 0. + if idx <= hist_size { + Some(idx) + } else { + None + } + } + Err(idx) => { + // boundaries[idx-1] <= val < boundaries[idx] + if idx > 0 && idx <= hist_size { + Some(idx - 1) + } else { + None + } + } + } +} + +fn map_bin(val: f32, range: &RangeSpec, hist_size: usize) -> Option { + match range { + RangeSpec::Uniform(lo, hi) => uniform_bin(val, *lo, *hi, hist_size), + RangeSpec::NonUniform(boundaries) => nonuniform_bin(val, boundaries, hist_size), + } +} + +// --------------------------------------------------------------------------- +// calc_hist +// --------------------------------------------------------------------------- + +/// Calculates a multi-dimensional histogram of a set of images. +/// +/// * `images` - Slice of input images (may have multiple channels). +/// * `channels` - Global channel indices used for histogram computation. +/// Channel numbering spans across images: if `images[0]` has 3 channels +/// and `images[1]` has 1 channel, then channel 3 refers to `images[1]`'s channel 0. +/// * `mask` - Optional mask. Must have the same size as `images[0]`. +/// * `hist_size` - Number of bins per dimension. +/// * `ranges` - One `RangeSpec` per dimension. `Uniform(lo, hi)` for uniform bins, +/// `NonUniform(boundaries)` for explicit boundaries. +/// * `accumulate` - If true, adds to the returned histogram rather than clearing. +/// On first call, pass `None` for `hist` or create a zero histogram. +/// +/// Returns a flattened `Matrix` histogram of size `(product(hist_size), 1, 1)`. +pub fn calc_hist( + images: &[&Matrix], + channels: &[usize], + mask: Option<&Matrix>, + hist_size: &[usize], + ranges: &[RangeSpec], + accumulate: bool, + hist: Option<&Matrix>, +) -> Result> { + let dims = hist_size.len(); + if dims == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_hist: hist_size must not be empty" + ); + } + if images.is_empty() { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_hist: images must not be empty" + ); + } + if channels.len() != dims { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_hist: channels length ({}) must match hist_size length ({})", + channels.len(), + dims + ); + } + if ranges.len() != dims { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_hist: ranges length ({}) must match hist_size length ({})", + ranges.len(), + dims + ); + } + + let rows = images[0].rows; + let cols = images[0].cols; + + for img in images.iter() { + if img.rows != rows || img.cols != cols { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_hist: all images must have the same size" + ); + } + } + + if let Some(m) = mask { + if m.rows != rows || m.cols != cols { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_hist: mask must have the same size as images" + ); + } + } + + // Validate channel indices + for &ch in channels { + let _ = resolve_channel(ch, images)?; + } + + let total_bins: usize = hist_size.iter().product(); + + let mut hist_data = if accumulate { + if let Some(h) = hist { + h.data.clone() + } else { + vec![0.0f32; total_bins] + } + } else { + vec![0.0f32; total_bins] + }; + + // Strides (row-major, last dim varies fastest) + let mut strides = vec![1usize; dims]; + for i in (0..dims - 1).rev() { + strides[i] = strides[i + 1] * hist_size[i + 1]; + } + + for y in 0..rows { + for x in 0..cols { + if let Some(m) = mask { + if let Some(&v) = m.get(y, x, 0) { + if v == 0 { + continue; + } + } + } + + let mut bin_idx = 0usize; + let mut out_of_range = false; + + for d in 0..dims { + let val = read_pixel_f32(images, channels[d], y, x).unwrap_or(0.0); + match map_bin(val, &ranges[d], hist_size[d]) { + Some(b) => bin_idx += b * strides[d], + None => { + out_of_range = true; + break; + } + } + } + + if !out_of_range { + hist_data[bin_idx] += 1.0; + } + } + } + + Ok(Matrix::from_vec(total_bins, 1, 1, hist_data)) +} + +// --------------------------------------------------------------------------- +// calc_back_project +// --------------------------------------------------------------------------- + +/// Calculates the back projection of a histogram. +/// +/// * `images` - Slice of input images. +/// * `channels` - Global channel indices (same semantics as `calc_hist`). +/// * `hist` - Input histogram (`f32`, flattened multi-dimensional). +/// * `ranges` - One `RangeSpec` per dimension. +/// * `scale` - Scale factor for the output values. +/// +/// Returns a single-channel `Matrix` of the same size as `images[0]`. +/// Values are scaled and clamped to `[0.0, 255.0]` matching OpenCV's u8 output range. +pub fn calc_back_project( + images: &[&Matrix], + channels: &[usize], + hist: &Matrix, + ranges: &[RangeSpec], + scale: f32, +) -> Result> { + let dims = channels.len(); + if dims == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_back_project: channels must not be empty" + ); + } + if images.is_empty() { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_back_project: images must not be empty" + ); + } + if ranges.len() != dims { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_back_project: ranges length ({}) must match channels length ({})", + ranges.len(), + dims + ); + } + + let rows = images[0].rows; + let cols = images[0].cols; + + let total_bins = hist.rows * hist.cols * hist.channels; + let hist_size = infer_hist_size(total_bins, dims); + + let mut strides = vec![1usize; dims]; + for i in (0..dims - 1).rev() { + strides[i] = strides[i + 1] * hist_size[i + 1]; + } + + let mut dst = Matrix::::new(rows, cols, 1); + + for y in 0..rows { + for x in 0..cols { + let mut bin_idx = 0usize; + let mut out_of_range = false; + + for d in 0..dims { + let val = read_pixel_f32(images, channels[d], y, x).unwrap_or(0.0); + match map_bin(val, &ranges[d], hist_size[d]) { + Some(b) => bin_idx += b * strides[d], + None => { + out_of_range = true; + break; + } + } + } + + let pixel = if out_of_range { + 0.0f32 + } else { + (hist.data[bin_idx] * scale).clamp(0.0, 255.0) + }; + dst.set(y, x, 0, pixel); + } + } + + Ok(dst) +} + +fn infer_hist_size(total_bins: usize, dims: usize) -> Vec { + if dims == 1 { + return vec![total_bins]; + } + let approx = (total_bins as f64).powf(1.0 / dims as f64).round() as usize; + if approx.pow(dims as u32) == total_bins { + return vec![approx; dims]; + } + let mut sizes = vec![1usize; dims]; + sizes[dims - 1] = total_bins; + sizes +} + +// --------------------------------------------------------------------------- +// compare_hist +// --------------------------------------------------------------------------- + +/// Compares two dense histograms using the specified method. +/// +/// Both histograms must be single-channel `f32` with the same size. +pub fn compare_hist(h1: &Matrix, h2: &Matrix, method: HistCompMethods) -> Result { + if h1.channels != 1 || h2.channels != 1 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "compare_hist: histograms must be single-channel" + ); + } + + let len = h1.data.len(); + if len != h2.data.len() { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "compare_hist: histograms must have the same size ({} vs {})", + len, + h2.data.len() + ); + } + + let n = len as f64; + + match method { + HistCompMethods::Correl => { + let mut s1 = 0.0f64; + let mut s2 = 0.0f64; + let mut s11 = 0.0f64; + let mut s12 = 0.0f64; + let mut s22 = 0.0f64; + + for i in 0..len { + let a = h1.data[i] as f64; + let b = h2.data[i] as f64; + s1 += a; + s2 += b; + s11 += a * a; + s22 += b * b; + s12 += a * b; + } + + let scale = 1.0 / n; + let num = s12 - s1 * s2 * scale; + let denom2 = (s11 - s1 * s1 * scale) * (s22 - s2 * s2 * scale); + Ok(if denom2.abs() > f64::EPSILON { + num / denom2.sqrt() + } else { + 1.0 + }) + } + HistCompMethods::ChiSqr => { + let mut result = 0.0f64; + for i in 0..len { + let a = h1.data[i] as f64; + let b = h2.data[i] as f64; + if a.abs() > f64::EPSILON { + let diff = a - b; + result += diff * diff / a; + } + } + Ok(result) + } + HistCompMethods::ChiSqrAlt => { + let mut result = 0.0f64; + for i in 0..len { + let a = h1.data[i] as f64; + let b = h2.data[i] as f64; + let sum = a + b; + if sum.abs() > f64::EPSILON { + let diff = a - b; + result += diff * diff / sum; + } + } + Ok(result * 2.0) + } + HistCompMethods::Intersection => { + let mut result = 0.0f64; + for i in 0..len { + let a = h1.data[i] as f64; + let b = h2.data[i] as f64; + result += a.min(b); + } + Ok(result) + } + HistCompMethods::Bhattacharyya => { + let mut s1 = 0.0f64; + let mut s2 = 0.0f64; + let mut bc = 0.0f64; + + for i in 0..len { + let a = h1.data[i] as f64; + let b = h2.data[i] as f64; + s1 += a; + s2 += b; + bc += (a * b).sqrt(); + } + + let norm = s1 * s2; + let norm_factor = if norm.abs() > f64::EPSILON { + 1.0 / norm.sqrt() + } else { + 1.0 + }; + + Ok(((1.0 - bc * norm_factor).max(0.0)).sqrt()) + } + HistCompMethods::KullbackLeibler => { + let mut result = 0.0f64; + for i in 0..len { + let p = h1.data[i] as f64; + let q = h2.data[i] as f64; + if p.abs() > f64::EPSILON { + let q_adj = if q.abs() <= f64::EPSILON { 1e-10 } else { q }; + result += p * (p / q_adj).ln(); + } + } + Ok(result) + } + } +} + +// --------------------------------------------------------------------------- +// equalize_hist +// --------------------------------------------------------------------------- + +/// Equalizes the histogram of a grayscale image. +/// +/// * `src` - Source 8-bit single-channel image. +pub fn equalize_hist(src: &Matrix) -> Result> { + if src.channels != 1 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "equalize_hist: source must be single-channel (got {})", + src.channels + ); + } + + const HIST_SZ: usize = 256; + let mut hist = [0u32; HIST_SZ]; + + for &val in src.data.iter() { + hist[val as usize] += 1; + } + + let mut i = 0; + while i < HIST_SZ && hist[i] == 0 { + i += 1; + } + + if i == HIST_SZ { + return Ok(Matrix::new(src.rows, src.cols, 1)); + } + + let total = src.rows * src.cols; + if hist[i] == total as u32 { + let mut dst = Matrix::::new(src.rows, src.cols, 1); + for pixel in dst.data.iter_mut() { + *pixel = i as u8; + } + return Ok(dst); + } + + let scale = (HIST_SZ as f64 - 1.0) / (total as f64 - hist[i] as f64); + let mut lut = [0u8; HIST_SZ]; + let mut sum = 0u32; + + lut[i] = 0; + i += 1; + for j in i..HIST_SZ { + sum += hist[j]; + lut[j] = (sum as f64 * scale).round().clamp(0.0, 255.0) as u8; + } + + let mut dst = Matrix::::new(src.rows, src.cols, 1); + for idx in 0..src.data.len() { + dst.data[idx] = lut[src.data[idx] as usize]; + } + + Ok(dst) +} + +// --------------------------------------------------------------------------- +// CLAHE +// --------------------------------------------------------------------------- + +/// Contrast Limited Adaptive Histogram Equalization. +#[derive(Debug, Clone)] +pub struct Clahe { + clip_limit: f64, + tiles_x: usize, + tiles_y: usize, + bit_shift: i32, +} + +impl Clahe { + pub fn new(clip_limit: f64, tile_grid_size: Size2i) -> Self { + Self { + clip_limit, + tiles_x: tile_grid_size.width as usize, + tiles_y: tile_grid_size.height as usize, + bit_shift: 0, + } + } + + pub fn set_clip_limit(&mut self, clip_limit: f64) { + self.clip_limit = clip_limit; + } + + pub fn get_clip_limit(&self) -> f64 { + self.clip_limit + } + + pub fn set_tiles_grid_size(&mut self, tile_grid_size: Size2i) { + self.tiles_x = tile_grid_size.width as usize; + self.tiles_y = tile_grid_size.height as usize; + } + + pub fn get_tiles_grid_size(&self) -> Size2i { + Size2i::new(self.tiles_x as i32, self.tiles_y as i32) + } + + pub fn set_bit_shift(&mut self, bit_shift: i32) { + self.bit_shift = bit_shift; + } + + pub fn get_bit_shift(&self) -> i32 { + self.bit_shift + } + + /// Applies CLAHE to a single-channel `u8` or `u16` image. + /// + /// The `src` must be `Matrix` (CV_8UC1) or `Matrix` (CV_16UC1). + /// For u16, the function uses bit_shift to reduce the histogram size. + pub fn apply_u8(&self, src: &Matrix) -> Result> { + if src.channels != 1 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u8: source must be single-channel (got {})", + src.channels + ); + } + self.apply_impl_u8(src) + } + + pub fn apply_u16(&self, src: &Matrix) -> Result> { + if src.channels != 1 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u16: source must be single-channel (got {})", + src.channels + ); + } + self.apply_impl_u16(src) + } + + fn apply_impl_u8(&self, src: &Matrix) -> Result> { + let hist_size = 256 >> self.bit_shift; + + let (pad_top, _pad_bottom) = claes_pad(src.rows, self.tiles_y); + let (pad_left, _pad_right) = claes_pad(src.cols, self.tiles_x); + let need_pad = pad_top > 0 || pad_left > 0; + + let padded = if need_pad { + pad_reflect101_u8(src, pad_top, pad_left)? + } else { + src.clone() + }; + + let tile_rows = padded.rows / self.tiles_y; + let tile_cols = padded.cols / self.tiles_x; + let tile_size_total = tile_rows * tile_cols; + + let lut_scale = (hist_size as f64 - 1.0) / tile_size_total as f64; + + let mut clip_limit = 0i32; + if self.clip_limit > 0.0 { + clip_limit = (self.clip_limit * tile_size_total as f64 / hist_size as f64) as i32; + clip_limit = clip_limit.max(1); + } + + let num_tiles = self.tiles_x * self.tiles_y; + let mut lut = vec![0u8; num_tiles * hist_size]; + + for tile_idx in 0..num_tiles { + let ty = tile_idx / self.tiles_x; + let tx = tile_idx % self.tiles_x; + let y0 = ty * tile_rows; + let x0 = tx * tile_cols; + + let mut tile_hist = vec![0i32; hist_size]; + for dy in 0..tile_rows { + for dx in 0..tile_cols { + let val = *padded.get(y0 + dy, x0 + dx, 0).unwrap_or(&0) as usize; + let bin = val >> self.bit_shift; + if bin < hist_size { + tile_hist[bin] += 1; + } + } + } + + clip_and_redistribute(&mut tile_hist, clip_limit, hist_size); + + let tile_lut = &mut lut[tile_idx * hist_size..(tile_idx + 1) * hist_size]; + let mut sum = 0i32; + for bin in 0..hist_size { + sum += tile_hist[bin]; + tile_lut[bin] = (sum as f64 * lut_scale).round().clamp(0.0, 255.0) as u8; + } + } + + let mut dst = Matrix::::new(src.rows, src.cols, 1); + interpolate_tiles_u8( + src, + &mut dst, + &lut, + &padded, + tile_rows, + tile_cols, + pad_top, + pad_left, + self.bit_shift, + self.tiles_x, + self.tiles_y, + hist_size, + ); + Ok(dst) + } + + fn apply_impl_u16(&self, src: &Matrix) -> Result> { + let hist_size = 65536 >> self.bit_shift; + + let (pad_top, _pad_bottom) = claes_pad(src.rows, self.tiles_y); + let (pad_left, _pad_right) = claes_pad(src.cols, self.tiles_x); + let need_pad = pad_top > 0 || pad_left > 0; + + let padded = if need_pad { + pad_reflect101_u16(src, pad_top, pad_left)? + } else { + src.clone() + }; + + let tile_rows = padded.rows / self.tiles_y; + let tile_cols = padded.cols / self.tiles_x; + let tile_size_total = tile_rows * tile_cols; + + let lut_scale = (hist_size as f64 - 1.0) / tile_size_total as f64; + + let mut clip_limit = 0i32; + if self.clip_limit > 0.0 { + clip_limit = (self.clip_limit * tile_size_total as f64 / hist_size as f64) as i32; + clip_limit = clip_limit.max(1); + } + + let num_tiles = self.tiles_x * self.tiles_y; + let mut lut = vec![0u16; num_tiles * hist_size]; + + for tile_idx in 0..num_tiles { + let ty = tile_idx / self.tiles_x; + let tx = tile_idx % self.tiles_x; + let y0 = ty * tile_rows; + let x0 = tx * tile_cols; + + let mut tile_hist = vec![0i32; hist_size]; + for dy in 0..tile_rows { + for dx in 0..tile_cols { + let val = *padded.get(y0 + dy, x0 + dx, 0).unwrap_or(&0) as usize; + let bin = val >> self.bit_shift; + if bin < hist_size { + tile_hist[bin] += 1; + } + } + } + + clip_and_redistribute(&mut tile_hist, clip_limit, hist_size); + + let tile_lut = &mut lut[tile_idx * hist_size..(tile_idx + 1) * hist_size]; + let mut sum = 0i32; + for bin in 0..hist_size { + sum += tile_hist[bin]; + tile_lut[bin] = (sum as f64 * lut_scale).round().clamp(0.0, 65535.0) as u16; + } + } + + let mut dst = Matrix::::new(src.rows, src.cols, 1); + interpolate_tiles_u16( + src, + &mut dst, + &lut, + tile_rows, + tile_cols, + pad_top, + pad_left, + self.bit_shift, + self.tiles_x, + self.tiles_y, + hist_size, + ); + Ok(dst) + } +} + +fn clip_and_redistribute(tile_hist: &mut [i32], clip_limit: i32, hist_size: usize) { + if clip_limit <= 0 { + return; + } + let mut clipped = 0i32; + for bin in tile_hist.iter_mut() { + if *bin > clip_limit { + clipped += *bin - clip_limit; + *bin = clip_limit; + } + } + + let redist_batch = clipped / hist_size as i32; + let residual = clipped - redist_batch * hist_size as i32; + + for bin in tile_hist.iter_mut() { + *bin += redist_batch; + } + + if residual > 0 { + let step = (hist_size as i32 / residual).max(1); + let mut i = 0i32; + let mut rem = residual; + while i < hist_size as i32 && rem > 0 { + tile_hist[i as usize] += 1; + i += step; + rem -= 1; + } + } +} + +#[allow(clippy::too_many_arguments)] +fn interpolate_tiles_u8( + src: &Matrix, + dst: &mut Matrix, + lut: &[u8], + _padded: &Matrix, + tile_rows: usize, + tile_cols: usize, + pad_top: usize, + pad_left: usize, + bit_shift: i32, + tiles_x: usize, + tiles_y: usize, + hist_size: usize, +) { + let inv_tw = 1.0f64 / tile_cols as f64; + let inv_th = 1.0f64 / tile_rows as f64; + + for y in 0..src.rows { + let sy = y + pad_top; + let tyf = sy as f64 * inv_th - 0.5; + let ty1 = (tyf.floor() as i32).max(0); + let ty2 = (ty1 + 1).min(tiles_y as i32 - 1); + let ya = tyf - ty1 as f64; + let ya1 = 1.0 - ya; + let ty1 = ty1 as usize; + let ty2 = ty2 as usize; + + for x in 0..src.cols { + let sx = x + pad_left; + let txf = sx as f64 * inv_tw - 0.5; + let tx1 = (txf.floor() as i32).max(0); + let tx2 = (tx1 + 1).min(tiles_x as i32 - 1); + let xa = txf - tx1 as f64; + let xa1 = 1.0 - xa; + let tx1 = tx1 as usize; + let tx2 = tx2 as usize; + + let src_val = *src.get(y, x, 0).unwrap_or(&0) as usize; + let bin = (src_val >> bit_shift).min(hist_size - 1); + + let lut_idx = |ty: usize, tx: usize| ty * tiles_x * hist_size + tx * hist_size + bin; + + let v00 = lut[lut_idx(ty1, tx1)] as f64; + let v01 = lut[lut_idx(ty1, tx2)] as f64; + let v10 = lut[lut_idx(ty2, tx1)] as f64; + let v11 = lut[lut_idx(ty2, tx2)] as f64; + + let val = (v00 * xa1 + v01 * xa) * ya1 + (v10 * xa1 + v11 * xa) * ya; + let out = (val.round() as u8) << bit_shift; + dst.set(y, x, 0, out); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn interpolate_tiles_u16( + src: &Matrix, + dst: &mut Matrix, + lut: &[u16], + tile_rows: usize, + tile_cols: usize, + pad_top: usize, + pad_left: usize, + bit_shift: i32, + tiles_x: usize, + tiles_y: usize, + hist_size: usize, +) { + let inv_tw = 1.0f64 / tile_cols as f64; + let inv_th = 1.0f64 / tile_rows as f64; + + for y in 0..src.rows { + let sy = y + pad_top; + let tyf = sy as f64 * inv_th - 0.5; + let ty1 = (tyf.floor() as i32).max(0); + let ty2 = (ty1 + 1).min(tiles_y as i32 - 1); + let ya = tyf - ty1 as f64; + let ya1 = 1.0 - ya; + let ty1 = ty1 as usize; + let ty2 = ty2 as usize; + + for x in 0..src.cols { + let sx = x + pad_left; + let txf = sx as f64 * inv_tw - 0.5; + let tx1 = (txf.floor() as i32).max(0); + let tx2 = (tx1 + 1).min(tiles_x as i32 - 1); + let xa = txf - tx1 as f64; + let xa1 = 1.0 - xa; + let tx1 = tx1 as usize; + let tx2 = tx2 as usize; + + let src_val = *src.get(y, x, 0).unwrap_or(&0) as usize; + let bin = (src_val >> bit_shift).min(hist_size - 1); + + let lut_idx = |ty: usize, tx: usize| ty * tiles_x * hist_size + tx * hist_size + bin; + + let v00 = lut[lut_idx(ty1, tx1)] as f64; + let v01 = lut[lut_idx(ty1, tx2)] as f64; + let v10 = lut[lut_idx(ty2, tx1)] as f64; + let v11 = lut[lut_idx(ty2, tx2)] as f64; + + let val = (v00 * xa1 + v01 * xa) * ya1 + (v10 * xa1 + v11 * xa) * ya; + let out = (val.round() as u16) << bit_shift; + dst.set(y, x, 0, out); + } + } +} + +fn claes_pad(original: usize, grid: usize) -> (usize, usize) { + if grid == 0 { + return (0, 0); + } + let rem = original % grid; + if rem == 0 { + (0, 0) + } else { + (0, grid - rem) + } +} + +fn pad_reflect101_u8(src: &Matrix, top: usize, left: usize) -> Result> { + let new_rows = src.rows + top; + let new_cols = src.cols + left; + let mut dst = Matrix::::new(new_rows, new_cols, 1); + for y in 0..new_rows { + for x in 0..new_cols { + let sy = reflect_coord(y, top, src.rows); + let sx = reflect_coord(x, left, src.cols); + dst.set(y, x, 0, *src.get(sy, sx, 0).unwrap_or(&0)); + } + } + Ok(dst) +} + +fn pad_reflect101_u16(src: &Matrix, top: usize, left: usize) -> Result> { + let new_rows = src.rows + top; + let new_cols = src.cols + left; + let mut dst = Matrix::::new(new_rows, new_cols, 1); + for y in 0..new_rows { + for x in 0..new_cols { + let sy = reflect_coord(y, top, src.rows); + let sx = reflect_coord(x, left, src.cols); + dst.set(y, x, 0, *src.get(sy, sx, 0).unwrap_or(&0)); + } + } + Ok(dst) +} + +fn reflect_coord(p: usize, pad: usize, len: usize) -> usize { + if len == 0 { + return 0; + } + let adjusted = p as i32 - pad as i32; + let result = if adjusted < 0 { + ((-adjusted - 1) as usize) % len + } else if adjusted >= len as i32 { + let over = (adjusted - len as i32) as usize; + (len - 1).wrapping_sub(over % len) + } else { + adjusted as usize + }; + result.min(len - 1) +} + +pub fn create_clahe(clip_limit: f64, tile_grid_size: Size2i) -> Clahe { + Clahe::new(clip_limit, tile_grid_size) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calc_hist_uniform_1d() { + let data: Vec = (0..16).collect(); + let img = Matrix::from_vec(4, 4, 1, data); + let hist = calc_hist( + &[&img], + &[0], + None, + &[16], + &[RangeSpec::Uniform(0.0, 16.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data.len(), 16); + for &v in hist.data.iter() { + assert_eq!(v, 1.0); + } + } + + #[test] + fn test_calc_hist_uniform_1d_fewer_bins() { + let data: Vec = (0..16).collect(); + let img = Matrix::from_vec(4, 4, 1, data); + let hist = calc_hist( + &[&img], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 16.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data.len(), 4); + for &v in hist.data.iter() { + assert_eq!(v, 4.0); + } + } + + #[test] + fn test_calc_hist_mask() { + let img = Matrix::from_vec(3, 4, 1, vec![0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + // Partial mask: checkerboard + let mask = Matrix::from_vec(3, 4, 1, vec![0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]); + let h = calc_hist( + &[&img], + &[0], + Some(&mask), + &[4], + &[RangeSpec::Uniform(0.0, 12.0)], + false, + None, + ) + .unwrap(); + assert_eq!(h.data, vec![1.0, 2.0, 1.0, 2.0]); + + // All-zero mask: nothing counted + let img2 = Matrix::from_vec(2, 2, 1, vec![0u8, 1, 2, 3]); + let mask_zero = Matrix::from_vec(2, 2, 1, vec![0u8; 4]); + let h = calc_hist( + &[&img2], + &[0], + Some(&mask_zero), + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .unwrap(); + assert_eq!(h.data, vec![0.0, 0.0, 0.0, 0.0]); + + // All-one mask: all counted + let mask_one = Matrix::from_vec(2, 2, 1, vec![1u8; 4]); + let h = calc_hist( + &[&img2], + &[0], + Some(&mask_one), + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .unwrap(); + assert_eq!(h.data, vec![1.0, 1.0, 1.0, 1.0]); + } + + #[test] + fn test_calc_hist_multi_image_channel_indexing() { + // images[0] has 2 channels, images[1] has 1 channel + let img0 = Matrix::from_vec(2, 1, 2, vec![10, 20, 30, 40]); + let img1 = Matrix::from_vec(2, 1, 1, vec![100, 200]); + + let hist = calc_hist( + &[&img0, &img1], + &[0, 2], + None, + &[2, 2], + &[ + RangeSpec::Uniform(0.0, 50.0), + RangeSpec::Uniform(0.0, 250.0), + ], + false, + None, + ) + .unwrap(); + + // pixel(0,0): ch0=10->bin0, ch2=100->bin0 -> idx=0 + // pixel(1,0): ch0=30->bin1, ch2=200->bin1 -> idx=3 + assert_eq!(hist.data[0], 1.0); + assert_eq!(hist.data[1], 0.0); + assert_eq!(hist.data[2], 0.0); + assert_eq!(hist.data[3], 1.0); + } + + #[test] + fn test_calc_hist_nonuniform() { + let data: Vec = (0..20).collect(); + let img = Matrix::from_vec(4, 5, 1, data); + // Non-uniform: boundaries [0, 5, 20] -> 2 bins: [0,5) and [5,20) + let hist = calc_hist( + &[&img], + &[0], + None, + &[2], + &[RangeSpec::NonUniform(vec![0.0, 5.0, 20.0])], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data[0], 5.0); // values 0..4 + assert_eq!(hist.data[1], 15.0); // values 5..19 + } + + #[test] + fn test_calc_back_project() { + let data: Vec = vec![0, 1, 2, 3, 4, 5, 6, 7]; + let img = Matrix::from_vec(2, 4, 1, data); + let hist = Matrix::from_vec(4, 1, 1, vec![10.0, 20.0, 30.0, 40.0]); + let bp = + calc_back_project(&[&img], &[0], &hist, &[RangeSpec::Uniform(0.0, 8.0)], 1.0).unwrap(); + assert_eq!( + bp.data, + vec![10.0, 10.0, 20.0, 20.0, 30.0, 30.0, 40.0, 40.0] + ); + } + + #[test] + fn test_compare_hist_correl_identical() { + let h1 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); + let h2 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); + let corr = compare_hist(&h1, &h2, HistCompMethods::Correl).unwrap(); + assert!((corr - 1.0).abs() < 1e-10); + } + + #[test] + fn test_compare_hist_correl_opposite() { + let h1 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); + let h2 = Matrix::from_vec(4, 1, 1, vec![4.0, 3.0, 2.0, 1.0]); + let corr = compare_hist(&h1, &h2, HistCompMethods::Correl).unwrap(); + assert!((corr - (-1.0)).abs() < 1e-10); + } + + #[test] + fn test_compare_hist_intersect() { + let h1 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); + let h2 = Matrix::from_vec(4, 1, 1, vec![4.0, 3.0, 2.0, 1.0]); + let inter = compare_hist(&h1, &h2, HistCompMethods::Intersection).unwrap(); + assert!((inter - 6.0).abs() < 1e-10); + } + + #[test] + fn test_compare_hist_chi_sqr() { + let h1 = Matrix::from_vec(3, 1, 1, vec![1.0, 2.0, 3.0]); + let h2 = Matrix::from_vec(3, 1, 1, vec![1.0, 2.0, 3.0]); + let chi = compare_hist(&h1, &h2, HistCompMethods::ChiSqr).unwrap(); + assert!((chi - 0.0).abs() < 1e-10); + } + + #[test] + fn test_compare_hist_bhattacharyya_identical() { + let h1 = Matrix::from_vec(4, 1, 1, vec![0.25, 0.25, 0.25, 0.25]); + let h2 = Matrix::from_vec(4, 1, 1, vec![0.25, 0.25, 0.25, 0.25]); + let bc = compare_hist(&h1, &h2, HistCompMethods::Bhattacharyya).unwrap(); + assert!(bc.abs() < 1e-10); + } + + #[test] + fn test_compare_hist_kl_divergence() { + let h1 = Matrix::from_vec(3, 1, 1, vec![0.5, 0.3, 0.2]); + let h2 = Matrix::from_vec(3, 1, 1, vec![0.5, 0.3, 0.2]); + let kl = compare_hist(&h1, &h2, HistCompMethods::KullbackLeibler).unwrap(); + assert!(kl.abs() < 1e-10); + } + + #[test] + fn test_equalize_hist_uniform() { + let img = Matrix::from_vec(4, 4, 1, vec![128u8; 16]); + let dst = equalize_hist(&img).unwrap(); + for &v in dst.data.iter() { + assert_eq!(v, 128); + } + } + + #[test] + fn test_equalize_hist_gradient() { + let data: Vec = (0..=255).collect(); + let img = Matrix::from_vec(16, 16, 1, data); + let dst = equalize_hist(&img).unwrap(); + assert_eq!(dst.rows, 16); + assert_eq!(dst.cols, 16); + assert_eq!(dst.channels, 1); + let min_val = *dst.data.iter().min().unwrap(); + let max_val = *dst.data.iter().max().unwrap(); + assert_eq!(min_val, 0); + assert_eq!(max_val, 255); + } + + #[test] + fn test_clahe_basic() { + let data: Vec = (0..=255).collect(); + let img = Matrix::from_vec(16, 16, 1, data); + let clahe = create_clahe(40.0, Size2i::new(4, 4)); + let dst = clahe.apply_u8(&img).unwrap(); + assert_eq!(dst.rows, 16); + assert_eq!(dst.cols, 16); + assert_eq!(dst.channels, 1); + assert_eq!(dst.data.len(), 256); + } + + #[test] + fn test_clahe_u16() { + let data: Vec = (0..=1023).collect(); + let img = Matrix::from_vec(32, 32, 1, data); + let clahe = create_clahe(40.0, Size2i::new(4, 4)); + let dst = clahe.apply_u16(&img).unwrap(); + assert_eq!(dst.rows, 32); + assert_eq!(dst.cols, 32); + assert_eq!(dst.channels, 1); + } + + #[test] + fn test_calc_hist_2d() { + let img = Matrix::from_vec(3, 3, 1, vec![0, 1, 2, 3, 4, 5, 6, 7, 8]); + let hist = calc_hist( + &[&img, &img], + &[0, 0], + None, + &[3, 3], + &[RangeSpec::Uniform(0.0, 9.0), RangeSpec::Uniform(0.0, 9.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data.len(), 9); + assert_eq!(hist.data[0], 3.0); + assert_eq!(hist.data[1], 0.0); + assert_eq!(hist.data[2], 0.0); + assert_eq!(hist.data[3], 0.0); + assert_eq!(hist.data[4], 3.0); + assert_eq!(hist.data[5], 0.0); + assert_eq!(hist.data[6], 0.0); + assert_eq!(hist.data[7], 0.0); + assert_eq!(hist.data[8], 3.0); + } + + #[test] + fn test_calc_hist_empty_images_error() { + assert!(calc_hist::( + &[], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .is_err()); + } + + #[test] + fn test_calc_hist_mismatched_channels_error() { + let img = Matrix::from_vec(2, 2, 1, vec![0u8; 4]); + assert!(calc_hist( + &[&img], + &[0, 1], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .is_err()); + } + + #[test] + fn test_calc_back_project_empty_channels_error() { + let img = Matrix::from_vec(2, 2, 1, vec![0u8; 4]); + let hist = Matrix::from_vec(4, 1, 1, vec![0.0; 4]); + assert!( + calc_back_project::(&[&img], &[], &hist, &[RangeSpec::Uniform(0.0, 4.0)], 1.0,) + .is_err() + ); + } + + #[test] + fn test_calc_hist_u16_input() { + let data: Vec = vec![0, 100, 200, 300, 400, 500]; + let img = Matrix::from_vec(2, 3, 1, data); + let hist = calc_hist( + &[&img], + &[0], + None, + &[3], + &[RangeSpec::Uniform(0.0, 600.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data, vec![2.0, 2.0, 2.0]); + } + + #[test] + fn test_calc_hist_f32_input() { + let data: Vec = vec![0.5, 1.5, 2.5, 3.5]; + let img = Matrix::from_vec(2, 2, 1, data); + let hist = calc_hist( + &[&img], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data, vec![1.0, 1.0, 1.0, 1.0]); + } + + #[test] + fn test_calc_hist_boundary_exclusion() { + let img = Matrix::from_vec(1, 4, 1, vec![0u8, 4, 8, 12]); + let hist = calc_hist( + &[&img], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 16.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data, vec![1.0, 1.0, 1.0, 1.0]); + + // Value at exact hi boundary should be excluded + let img2 = Matrix::from_vec(1, 2, 1, vec![4u8, 4]); + let hist2 = calc_hist( + &[&img2], + &[0], + None, + &[2], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist2.data, vec![0.0, 0.0]); + + // Value just below hi should be included + let img3 = Matrix::from_vec(1, 1, 1, vec![3u8]); + let hist3 = calc_hist( + &[&img3], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist3.data[3], 1.0); + } + + #[test] + fn test_calc_hist_accumulate() { + let img = Matrix::from_vec(1, 4, 1, vec![0u8, 1, 2, 3]); + let h1 = calc_hist( + &[&img], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .unwrap(); + assert_eq!(h1.data, vec![1.0, 1.0, 1.0, 1.0]); + + let h2 = calc_hist( + &[&img], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + true, + Some(&h1), + ) + .unwrap(); + assert_eq!(h2.data, vec![2.0, 2.0, 2.0, 2.0]); + + let h3 = calc_hist( + &[&img], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + true, + Some(&h2), + ) + .unwrap(); + assert_eq!(h3.data, vec![3.0, 3.0, 3.0, 3.0]); + } + + #[test] + fn test_compare_hist_edge_cases() { + let z = Matrix::from_vec(3, 1, 1, vec![0.0, 0.0, 0.0]); + assert!((compare_hist(&z, &z, HistCompMethods::Correl).unwrap() - 1.0).abs() < 1e-10); + assert!((compare_hist(&z, &z, HistCompMethods::Intersection).unwrap()).abs() < 1e-10); + assert!( + (compare_hist(&z, &z, HistCompMethods::Bhattacharyya).unwrap() - 1.0).abs() < 1e-10 + ); + + let p1 = Matrix::from_vec(4, 1, 1, vec![0.5, 0.25, 0.125, 0.125]); + let p2 = Matrix::from_vec(4, 1, 1, vec![0.5, 0.25, 0.125, 0.125]); + assert!((compare_hist(&p1, &p2, HistCompMethods::Correl).unwrap() - 1.0).abs() < 1e-10); + assert!((compare_hist(&p1, &p2, HistCompMethods::ChiSqr).unwrap()).abs() < 1e-10); + } + + #[test] + fn test_calc_back_project_scale() { + let img = Matrix::from_vec(1, 4, 1, vec![0u8, 1, 2, 3]); + let hist = Matrix::from_vec(4, 1, 1, vec![10.0, 20.0, 30.0, 40.0]); + let bp = + calc_back_project(&[&img], &[0], &hist, &[RangeSpec::Uniform(0.0, 4.0)], 2.0).unwrap(); + assert_eq!(bp.data, vec![20.0, 40.0, 60.0, 80.0]); + } + + #[test] + fn test_calc_hist_errors() { + let img = Matrix::from_vec(2, 2, 1, vec![0u8; 4]); + assert!(calc_hist::( + &[], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .is_err()); + assert!(calc_hist( + &[&img], + &[0, 1], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .is_err()); + let img2 = Matrix::from_vec(3, 3, 1, vec![0u8; 9]); + assert!(calc_hist( + &[&img, &img2], + &[0, 0], + None, + &[2, 2], + &[RangeSpec::Uniform(0.0, 2.0), RangeSpec::Uniform(0.0, 2.0)], + false, + None, + ) + .is_err()); + let hist = Matrix::from_vec(4, 1, 1, vec![0.0; 4]); + assert!( + calc_back_project::(&[&img], &[], &hist, &[RangeSpec::Uniform(0.0, 4.0)], 1.0,) + .is_err() + ); + } + + #[test] + fn test_calc_hist_multichannel_select() { + let img = Matrix::from_vec( + 1, + 3, + 3, + vec![ + 10, 100, 200, // pixel 0 + 20, 150, 250, // pixel 1 + 30, 50, 100, // pixel 2 + ], + ); + let hist = calc_hist( + &[&img], + &[1], + None, + &[3], + &[RangeSpec::Uniform(0.0, 200.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data, vec![1.0, 1.0, 1.0]); + } + + #[test] + fn test_clahe_no_clip() { + let data: Vec = (0..=255).collect(); + let img = Matrix::from_vec(16, 16, 1, data); + let clahe = create_clahe(0.0, Size2i::new(4, 4)); + let dst = clahe.apply_u8(&img).unwrap(); + assert_eq!(dst.data.len(), 256); + } +} From 8896d98deec9670f729fbaf1cffb45e91b1cb02c Mon Sep 17 00:00:00 2001 From: XiaoPengYouCode Date: Sat, 29 Aug 2026 22:52:46 +0800 Subject: [PATCH 05/20] fix(imgproc): address review comments for histogram module - fix no_std import: replace use core::f64 with #[allow(unused_imports)] use num_traits::Float (15 E0599 in --no-default-features) - validate calc_hist inputs: hist_size>0, Uniform lo>/65536>> overflow - fix CLAHE padding parity with OpenCV: pad bottom/right with BORDER_REFLECT_101 instead of discarded top/left, remove stale _padded/pad params, use border_interpolate - add tile_rows/tile_cols zero check and checked_mul overflow guards - fix compare_hist empty histogram NaN -> InvalidInput - clarify clip_and_redistribute is an exact port of OpenCV CLAHE_CalcLut_Body (clahe.cpp) - refactor: generic pad_reflect101, .max(0) tile sentinel, hoisted zero-capture lut_idx closure, Ordering import, functional hist_data init - verified: cargo fmt --check ok, cargo clippy -- -D warnings and --no-default-features -- -D warnings clean, cargo test --lib histogram 29/29, cargo build --no-default-features ok --- src/imgproc/histogram.rs | 383 ++++++++++++++++++++++++++++----------- 1 file changed, 277 insertions(+), 106 deletions(-) diff --git a/src/imgproc/histogram.rs b/src/imgproc/histogram.rs index 9a49310..194814d 100644 --- a/src/imgproc/histogram.rs +++ b/src/imgproc/histogram.rs @@ -34,13 +34,17 @@ * */ +use core::cmp::Ordering; + use alloc::{vec, vec::Vec}; -use core::f64; +#[allow(unused_imports)] +use num_traits::Float; use num_traits::ToPrimitive; use crate::core::error::Result; use crate::core::logging::tags; -use crate::core::types::Size2i; +use crate::core::types::{BorderTypes, Size2i}; +use crate::core::utils::border_interpolate; use crate::core::Matrix; use crate::cv_bail; @@ -111,6 +115,9 @@ fn read_pixel_f32( #[inline(always)] fn uniform_bin(val: f32, range_lo: f32, range_hi: f32, hist_size: usize) -> Option { + if hist_size == 0 { + return None; + } if val < range_lo || val >= range_hi { return None; } @@ -120,13 +127,16 @@ fn uniform_bin(val: f32, range_lo: f32, range_hi: f32, hist_size: usize) -> Opti #[inline(always)] fn nonuniform_bin(val: f32, boundaries: &[f32], hist_size: usize) -> Option { - if boundaries.is_empty() || val < boundaries[0] || val >= boundaries[hist_size] { + if hist_size == 0 || boundaries.len() <= hist_size { + return None; + } + if val < boundaries[0] || val >= boundaries[hist_size] { return None; } // Find the first boundary that is strictly greater than val, // then subtract 1 to get the bin index. match boundaries[..=hist_size] - .binary_search_by(|b| b.partial_cmp(&val).unwrap_or(core::cmp::Ordering::Less)) + .binary_search_by(|b| b.partial_cmp(&val).unwrap_or(Ordering::Less)) { Ok(idx) => { // val == boundaries[idx]: bin is idx (left-inclusive) @@ -247,14 +257,81 @@ pub fn calc_hist( let _ = resolve_channel(ch, images)?; } + // Validate hist_size and ranges (prevents panics in bin mapping) + for (d, &sz) in hist_size.iter().enumerate() { + if sz == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_hist: hist_size[{}] must be > 0 (got 0)", + d + ); + } + match &ranges[d] { + RangeSpec::Uniform(lo, hi) => { + if lo.partial_cmp(hi) != Some(Ordering::Less) { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_hist: Uniform range [{}] must satisfy lo < hi (got {} >= {})", + d, + lo, + hi + ); + } + } + RangeSpec::NonUniform(boundaries) => { + if boundaries.len() != sz + 1 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_hist: NonUniform boundaries[{}] length {} must be hist_size[{}]+1 ({})", + d, + boundaries.len(), + d, + sz + 1 + ); + } + for k in 0..boundaries.len() - 1 { + if boundaries[k].partial_cmp(&boundaries[k + 1]) != Some(Ordering::Less) { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_hist: NonUniform boundaries[{}][{}] ({}) must be < boundaries[{}][{}] ({})", + d, + k, + boundaries[k], + d, + k + 1, + boundaries[k + 1] + ); + } + } + } + } + } + let total_bins: usize = hist_size.iter().product(); - let mut hist_data = if accumulate { + // Validate accumulate histogram size to prevent OOB + if accumulate { if let Some(h) = hist { - h.data.clone() - } else { - vec![0.0f32; total_bins] + let hist_len = h.data.len(); + if hist_len != total_bins { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_hist: accumulate hist length {} does not match product(hist_size) {}", + hist_len, + total_bins + ); + } } + } + + let mut hist_data = if accumulate { + hist.map(|h| h.data.clone()) + .unwrap_or_else(|| vec![0.0f32; total_bins]) } else { vec![0.0f32; total_bins] }; @@ -348,6 +425,13 @@ pub fn calc_back_project( let cols = images[0].cols; let total_bins = hist.rows * hist.cols * hist.channels; + if total_bins == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_back_project: hist must not be empty" + ); + } let hist_size = infer_hist_size(total_bins, dims); let mut strides = vec![1usize; dims]; @@ -424,6 +508,13 @@ pub fn compare_hist(h1: &Matrix, h2: &Matrix, method: HistCompMethods) h2.data.len() ); } + if len == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "compare_hist: histograms must not be empty" + ); + } let n = len as f64; @@ -601,10 +692,12 @@ pub struct Clahe { impl Clahe { pub fn new(clip_limit: f64, tile_grid_size: Size2i) -> Self { + let tiles_x = tile_grid_size.width.max(0) as usize; + let tiles_y = tile_grid_size.height.max(0) as usize; Self { clip_limit, - tiles_x: tile_grid_size.width as usize, - tiles_y: tile_grid_size.height as usize, + tiles_x, + tiles_y, bit_shift: 0, } } @@ -618,8 +711,8 @@ impl Clahe { } pub fn set_tiles_grid_size(&mut self, tile_grid_size: Size2i) { - self.tiles_x = tile_grid_size.width as usize; - self.tiles_y = tile_grid_size.height as usize; + self.tiles_x = tile_grid_size.width.max(0) as usize; + self.tiles_y = tile_grid_size.height.max(0) as usize; } pub fn get_tiles_grid_size(&self) -> Size2i { @@ -663,20 +756,69 @@ impl Clahe { } fn apply_impl_u8(&self, src: &Matrix) -> Result> { - let hist_size = 256 >> self.bit_shift; + if self.tiles_x == 0 || self.tiles_y == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u8: tiles_x and tiles_y must be > 0 (got {}x{})", + self.tiles_x, + self.tiles_y + ); + } + if self.bit_shift < 0 || self.bit_shift > 7 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u8: bit_shift must be in 0..=7 for u8 (got {})", + self.bit_shift + ); + } + if src.rows == 0 || src.cols == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u8: source must not be empty" + ); + } + let hist_size = 256usize >> self.bit_shift; + // Defensive: unreachable since bit_shift is validated to 0..=7 above. + if hist_size == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u8: invalid hist_size 0 (bit_shift {})", + self.bit_shift + ); + } - let (pad_top, _pad_bottom) = claes_pad(src.rows, self.tiles_y); - let (pad_left, _pad_right) = claes_pad(src.cols, self.tiles_x); - let need_pad = pad_top > 0 || pad_left > 0; + // OpenCV parity: pad bottom/right with BORDER_REFLECT_101 so dimensions become divisible + let pad_bottom = (self.tiles_y - src.rows % self.tiles_y) % self.tiles_y; + let pad_right = (self.tiles_x - src.cols % self.tiles_x) % self.tiles_x; + let need_pad = pad_bottom > 0 || pad_right > 0; let padded = if need_pad { - pad_reflect101_u8(src, pad_top, pad_left)? + pad_reflect101(src, pad_bottom, pad_right) } else { src.clone() }; let tile_rows = padded.rows / self.tiles_y; let tile_cols = padded.cols / self.tiles_x; + // Defensive: unreachable given a non-empty src and validated tiles, + // since padding above makes the padded dims divisible by the grid. + if tile_rows == 0 || tile_cols == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u8: tile size must be > 0 (padded {}x{} / tiles {}x{} -> tile {}x{})", + padded.rows, + padded.cols, + self.tiles_x, + self.tiles_y, + tile_cols, + tile_rows + ); + } let tile_size_total = tile_rows * tile_cols; let lut_scale = (hist_size as f64 - 1.0) / tile_size_total as f64; @@ -687,8 +829,23 @@ impl Clahe { clip_limit = clip_limit.max(1); } - let num_tiles = self.tiles_x * self.tiles_y; - let mut lut = vec![0u8; num_tiles * hist_size]; + let num_tiles = match self.tiles_x.checked_mul(self.tiles_y) { + Some(v) => v, + None => cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u8: tiles_x * tiles_y overflow" + ), + }; + let lut_len = match num_tiles.checked_mul(hist_size) { + Some(v) => v, + None => cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u8: lut size overflow" + ), + }; + let mut lut = vec![0u8; lut_len]; for tile_idx in 0..num_tiles { let ty = tile_idx / self.tiles_x; @@ -722,11 +879,8 @@ impl Clahe { src, &mut dst, &lut, - &padded, tile_rows, tile_cols, - pad_top, - pad_left, self.bit_shift, self.tiles_x, self.tiles_y, @@ -736,20 +890,68 @@ impl Clahe { } fn apply_impl_u16(&self, src: &Matrix) -> Result> { - let hist_size = 65536 >> self.bit_shift; + if self.tiles_x == 0 || self.tiles_y == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u16: tiles_x and tiles_y must be > 0 (got {}x{})", + self.tiles_x, + self.tiles_y + ); + } + if self.bit_shift < 0 || self.bit_shift > 15 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u16: bit_shift must be in 0..=15 for u16 (got {})", + self.bit_shift + ); + } + if src.rows == 0 || src.cols == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u16: source must not be empty" + ); + } + let hist_size = 65536usize >> self.bit_shift; + // Defensive: unreachable since bit_shift is validated to 0..=15 above. + if hist_size == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u16: invalid hist_size 0 (bit_shift {})", + self.bit_shift + ); + } - let (pad_top, _pad_bottom) = claes_pad(src.rows, self.tiles_y); - let (pad_left, _pad_right) = claes_pad(src.cols, self.tiles_x); - let need_pad = pad_top > 0 || pad_left > 0; + let pad_bottom = (self.tiles_y - src.rows % self.tiles_y) % self.tiles_y; + let pad_right = (self.tiles_x - src.cols % self.tiles_x) % self.tiles_x; + let need_pad = pad_bottom > 0 || pad_right > 0; let padded = if need_pad { - pad_reflect101_u16(src, pad_top, pad_left)? + pad_reflect101(src, pad_bottom, pad_right) } else { src.clone() }; let tile_rows = padded.rows / self.tiles_y; let tile_cols = padded.cols / self.tiles_x; + // Defensive: unreachable given a non-empty src and validated tiles, + // since padding above makes the padded dims divisible by the grid. + if tile_rows == 0 || tile_cols == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u16: tile size must be > 0 (padded {}x{} / tiles {}x{} -> tile {}x{})", + padded.rows, + padded.cols, + self.tiles_x, + self.tiles_y, + tile_cols, + tile_rows + ); + } let tile_size_total = tile_rows * tile_cols; let lut_scale = (hist_size as f64 - 1.0) / tile_size_total as f64; @@ -760,8 +962,23 @@ impl Clahe { clip_limit = clip_limit.max(1); } - let num_tiles = self.tiles_x * self.tiles_y; - let mut lut = vec![0u16; num_tiles * hist_size]; + let num_tiles = match self.tiles_x.checked_mul(self.tiles_y) { + Some(v) => v, + None => cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u16: tiles_x * tiles_y overflow" + ), + }; + let lut_len = match num_tiles.checked_mul(hist_size) { + Some(v) => v, + None => cv_bail!( + tags::IMGPROC, + InvalidInput, + "Clahe::apply_u16: lut size overflow" + ), + }; + let mut lut = vec![0u16; lut_len]; for tile_idx in 0..num_tiles { let ty = tile_idx / self.tiles_x; @@ -797,8 +1014,6 @@ impl Clahe { &lut, tile_rows, tile_cols, - pad_top, - pad_left, self.bit_shift, self.tiles_x, self.tiles_y, @@ -812,6 +1027,8 @@ fn clip_and_redistribute(tile_hist: &mut [i32], clip_limit: i32, hist_size: usiz if clip_limit <= 0 { return; } + // Exact port of OpenCV's CLAHE_CalcLut_Body redistribution (clahe.cpp): + // uniform batch plus fixed-step residual. let mut clipped = 0i32; for bin in tile_hist.iter_mut() { if *bin > clip_limit { @@ -844,11 +1061,8 @@ fn interpolate_tiles_u8( src: &Matrix, dst: &mut Matrix, lut: &[u8], - _padded: &Matrix, tile_rows: usize, tile_cols: usize, - pad_top: usize, - pad_left: usize, bit_shift: i32, tiles_x: usize, tiles_y: usize, @@ -856,10 +1070,11 @@ fn interpolate_tiles_u8( ) { let inv_tw = 1.0f64 / tile_cols as f64; let inv_th = 1.0f64 / tile_rows as f64; + let lut_idx = + |ty: usize, tx: usize, bin: usize| ty * tiles_x * hist_size + tx * hist_size + bin; for y in 0..src.rows { - let sy = y + pad_top; - let tyf = sy as f64 * inv_th - 0.5; + let tyf = y as f64 * inv_th - 0.5; let ty1 = (tyf.floor() as i32).max(0); let ty2 = (ty1 + 1).min(tiles_y as i32 - 1); let ya = tyf - ty1 as f64; @@ -868,8 +1083,7 @@ fn interpolate_tiles_u8( let ty2 = ty2 as usize; for x in 0..src.cols { - let sx = x + pad_left; - let txf = sx as f64 * inv_tw - 0.5; + let txf = x as f64 * inv_tw - 0.5; let tx1 = (txf.floor() as i32).max(0); let tx2 = (tx1 + 1).min(tiles_x as i32 - 1); let xa = txf - tx1 as f64; @@ -880,12 +1094,10 @@ fn interpolate_tiles_u8( let src_val = *src.get(y, x, 0).unwrap_or(&0) as usize; let bin = (src_val >> bit_shift).min(hist_size - 1); - let lut_idx = |ty: usize, tx: usize| ty * tiles_x * hist_size + tx * hist_size + bin; - - let v00 = lut[lut_idx(ty1, tx1)] as f64; - let v01 = lut[lut_idx(ty1, tx2)] as f64; - let v10 = lut[lut_idx(ty2, tx1)] as f64; - let v11 = lut[lut_idx(ty2, tx2)] as f64; + let v00 = lut[lut_idx(ty1, tx1, bin)] as f64; + let v01 = lut[lut_idx(ty1, tx2, bin)] as f64; + let v10 = lut[lut_idx(ty2, tx1, bin)] as f64; + let v11 = lut[lut_idx(ty2, tx2, bin)] as f64; let val = (v00 * xa1 + v01 * xa) * ya1 + (v10 * xa1 + v11 * xa) * ya; let out = (val.round() as u8) << bit_shift; @@ -901,8 +1113,6 @@ fn interpolate_tiles_u16( lut: &[u16], tile_rows: usize, tile_cols: usize, - pad_top: usize, - pad_left: usize, bit_shift: i32, tiles_x: usize, tiles_y: usize, @@ -910,10 +1120,11 @@ fn interpolate_tiles_u16( ) { let inv_tw = 1.0f64 / tile_cols as f64; let inv_th = 1.0f64 / tile_rows as f64; + let lut_idx = + |ty: usize, tx: usize, bin: usize| ty * tiles_x * hist_size + tx * hist_size + bin; for y in 0..src.rows { - let sy = y + pad_top; - let tyf = sy as f64 * inv_th - 0.5; + let tyf = y as f64 * inv_th - 0.5; let ty1 = (tyf.floor() as i32).max(0); let ty2 = (ty1 + 1).min(tiles_y as i32 - 1); let ya = tyf - ty1 as f64; @@ -922,8 +1133,7 @@ fn interpolate_tiles_u16( let ty2 = ty2 as usize; for x in 0..src.cols { - let sx = x + pad_left; - let txf = sx as f64 * inv_tw - 0.5; + let txf = x as f64 * inv_tw - 0.5; let tx1 = (txf.floor() as i32).max(0); let tx2 = (tx1 + 1).min(tiles_x as i32 - 1); let xa = txf - tx1 as f64; @@ -934,12 +1144,10 @@ fn interpolate_tiles_u16( let src_val = *src.get(y, x, 0).unwrap_or(&0) as usize; let bin = (src_val >> bit_shift).min(hist_size - 1); - let lut_idx = |ty: usize, tx: usize| ty * tiles_x * hist_size + tx * hist_size + bin; - - let v00 = lut[lut_idx(ty1, tx1)] as f64; - let v01 = lut[lut_idx(ty1, tx2)] as f64; - let v10 = lut[lut_idx(ty2, tx1)] as f64; - let v11 = lut[lut_idx(ty2, tx2)] as f64; + let v00 = lut[lut_idx(ty1, tx1, bin)] as f64; + let v01 = lut[lut_idx(ty1, tx2, bin)] as f64; + let v10 = lut[lut_idx(ty2, tx1, bin)] as f64; + let v11 = lut[lut_idx(ty2, tx2, bin)] as f64; let val = (v00 * xa1 + v01 * xa) * ya1 + (v10 * xa1 + v11 * xa) * ya; let out = (val.round() as u16) << bit_shift; @@ -948,60 +1156,23 @@ fn interpolate_tiles_u16( } } -fn claes_pad(original: usize, grid: usize) -> (usize, usize) { - if grid == 0 { - return (0, 0); - } - let rem = original % grid; - if rem == 0 { - (0, 0) - } else { - (0, grid - rem) - } -} - -fn pad_reflect101_u8(src: &Matrix, top: usize, left: usize) -> Result> { - let new_rows = src.rows + top; - let new_cols = src.cols + left; - let mut dst = Matrix::::new(new_rows, new_cols, 1); - for y in 0..new_rows { - for x in 0..new_cols { - let sy = reflect_coord(y, top, src.rows); - let sx = reflect_coord(x, left, src.cols); - dst.set(y, x, 0, *src.get(sy, sx, 0).unwrap_or(&0)); - } - } - Ok(dst) -} - -fn pad_reflect101_u16(src: &Matrix, top: usize, left: usize) -> Result> { - let new_rows = src.rows + top; - let new_cols = src.cols + left; - let mut dst = Matrix::::new(new_rows, new_cols, 1); +fn pad_reflect101( + src: &Matrix, + pad_bottom: usize, + pad_right: usize, +) -> Matrix { + let new_rows = src.rows + pad_bottom; + let new_cols = src.cols + pad_right; + let mut dst = Matrix::::new(new_rows, new_cols, 1); for y in 0..new_rows { + let sy = border_interpolate(y as i32, src.rows as i32, BorderTypes::Reflect101) as usize; for x in 0..new_cols { - let sy = reflect_coord(y, top, src.rows); - let sx = reflect_coord(x, left, src.cols); - dst.set(y, x, 0, *src.get(sy, sx, 0).unwrap_or(&0)); + let sx = + border_interpolate(x as i32, src.cols as i32, BorderTypes::Reflect101) as usize; + dst.set(y, x, 0, *src.get(sy, sx, 0).unwrap_or(&T::default())); } } - Ok(dst) -} - -fn reflect_coord(p: usize, pad: usize, len: usize) -> usize { - if len == 0 { - return 0; - } - let adjusted = p as i32 - pad as i32; - let result = if adjusted < 0 { - ((-adjusted - 1) as usize) % len - } else if adjusted >= len as i32 { - let over = (adjusted - len as i32) as usize; - (len - 1).wrapping_sub(over % len) - } else { - adjusted as usize - }; - result.min(len - 1) + dst } pub fn create_clahe(clip_limit: f64, tile_grid_size: Size2i) -> Clahe { From 433d3be94bb4afa35af5213ee344d155cb37ae23 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Sun, 30 Aug 2026 11:48:59 +0200 Subject: [PATCH 06/20] feat(imgproc): add parallel support to histogram module The histogram module (added in #99) had zero parallel/simd feature gating, unlike the rest of imgproc. Add Rayon-backed fast paths behind the existing parallel feature convention, with sequential fallbacks preserved for no_std / parallel-disabled builds: - calc_hist: row-chunked fold/reduce (partial per-chunk histograms merged elementwise), generic over all dims since the sequential code is already dims-agnostic. - calc_back_project: row-chunked, mirrors the pattern in color.rs. - equalize_hist: the final LUT-apply pass is now a flat par_iter_mut zip; the histogram/LUT build stays sequential (cheap, <=256 bins). - Clahe::apply_u8/apply_u16: per-tile histogram + LUT construction parallelized via par_chunks_mut over the flat lut buffer (tiles are independent), and both interpolate_tiles_u8/u16 row-chunked the same way as calc_back_project. Verified cargo fmt --check, clippy -D warnings under --all-features and --no-default-features, cargo test --workspace (parallel-on) and cargo test --lib histogram --no-default-features --features std (sequential fallback) produce identical results, and a bare-metal build (thumbv7em-none-eabihf) still compiles. Adds calc_hist/calc_back_project/equalize_hist/Clahe::apply_u8 bench functions to imgproc_bench.rs; benchmark_results.md numbers are left for a follow-up run on the reference machine. Closes #106 --- benches/imgproc_bench.rs | 52 ++++++++++++ src/imgproc/histogram.rs | 173 ++++++++++++++++++++++++++++++++++----- 2 files changed, 203 insertions(+), 22 deletions(-) diff --git a/benches/imgproc_bench.rs b/benches/imgproc_bench.rs index a480c1b..6114813 100644 --- a/benches/imgproc_bench.rs +++ b/benches/imgproc_bench.rs @@ -41,6 +41,7 @@ use purecv::imgproc::derivatives::{laplacian, scharr, sobel}; use purecv::imgproc::edge::canny; use purecv::imgproc::feature::corner_harris; use purecv::imgproc::filter::{bilateral_filter, box_filter, gaussian_blur}; +use purecv::imgproc::histogram::{calc_back_project, calc_hist, equalize_hist, Clahe, RangeSpec}; use purecv::imgproc::hough::{hough_circles, hough_lines, hough_lines_p}; use purecv::imgproc::threshold::{threshold, ThresholdTypes}; use purecv::imgproc::{cvt_color, ColorConversionCode}; @@ -254,6 +255,57 @@ fn bench_imgproc(c: &mut Criterion) { .unwrap() }) }); + + // calc_hist benchmark setup + let mut img_hist = Matrix::::new(size, size, 1); + for (i, p) in img_hist.data.iter_mut().enumerate() { + *p = (i % 256) as u8; + } + let hist_ranges = [RangeSpec::Uniform(0.0, 256.0)]; + + c.bench_function("calc_hist_1024x1024", |b| { + b.iter(|| { + calc_hist( + black_box(&[&img_hist]), + &[0], + None, + &[256], + &hist_ranges, + false, + None, + ) + .unwrap() + }) + }); + + // calc_back_project benchmark setup + let hist_for_backproj = + calc_hist(&[&img_hist], &[0], None, &[256], &hist_ranges, false, None).unwrap(); + + c.bench_function("calc_back_project_1024x1024", |b| { + b.iter(|| { + calc_back_project( + black_box(&[&img_hist]), + &[0], + &hist_for_backproj, + &hist_ranges, + 1.0, + ) + .unwrap() + }) + }); + + // equalize_hist benchmark setup + c.bench_function("equalize_hist_1024x1024", |b| { + b.iter(|| equalize_hist(black_box(&img_hist)).unwrap()) + }); + + // Clahe::apply_u8 benchmark setup + let clahe = Clahe::new(2.0, Size2i::new(8, 8)); + + c.bench_function("clahe_apply_u8_1024x1024", |b| { + b.iter(|| clahe.apply_u8(black_box(&img_hist)).unwrap()) + }); } criterion_group!(benches, bench_imgproc); diff --git a/src/imgproc/histogram.rs b/src/imgproc/histogram.rs index 194814d..8bffeb3 100644 --- a/src/imgproc/histogram.rs +++ b/src/imgproc/histogram.rs @@ -48,6 +48,9 @@ use crate::core::utils::border_interpolate; use crate::core::Matrix; use crate::cv_bail; +#[cfg(feature = "parallel")] +use rayon::prelude::*; + // --------------------------------------------------------------------------- // Enums // --------------------------------------------------------------------------- @@ -186,7 +189,7 @@ fn map_bin(val: f32, range: &RangeSpec, hist_size: usize) -> Option { /// On first call, pass `None` for `hist` or create a zero histogram. /// /// Returns a flattened `Matrix` histogram of size `(product(hist_size), 1, 1)`. -pub fn calc_hist( +pub fn calc_hist( images: &[&Matrix], channels: &[usize], mask: Option<&Matrix>, @@ -342,7 +345,7 @@ pub fn calc_hist( strides[i] = strides[i + 1] * hist_size[i + 1]; } - for y in 0..rows { + let accumulate_row = |local: &mut [f32], y: usize| { for x in 0..cols { if let Some(m) = mask { if let Some(&v) = m.get(y, x, 0) { @@ -367,9 +370,41 @@ pub fn calc_hist( } if !out_of_range { - hist_data[bin_idx] += 1.0; + local[bin_idx] += 1.0; } } + }; + + #[cfg(feature = "parallel")] + { + let partial = (0..rows) + .into_par_iter() + .fold( + || vec![0.0f32; total_bins], + |mut local, y| { + accumulate_row(&mut local, y); + local + }, + ) + .reduce( + || vec![0.0f32; total_bins], + |mut a, b| { + for (av, bv) in a.iter_mut().zip(b.iter()) { + *av += bv; + } + a + }, + ); + for (hv, pv) in hist_data.iter_mut().zip(partial.iter()) { + *hv += pv; + } + } + + #[cfg(not(feature = "parallel"))] + { + for y in 0..rows { + accumulate_row(&mut hist_data, y); + } } Ok(Matrix::from_vec(total_bins, 1, 1, hist_data)) @@ -389,7 +424,7 @@ pub fn calc_hist( /// /// Returns a single-channel `Matrix` of the same size as `images[0]`. /// Values are scaled and clamped to `[0.0, 255.0]` matching OpenCV's u8 output range. -pub fn calc_back_project( +pub fn calc_back_project( images: &[&Matrix], channels: &[usize], hist: &Matrix, @@ -441,8 +476,8 @@ pub fn calc_back_project( let mut dst = Matrix::::new(rows, cols, 1); - for y in 0..rows { - for x in 0..cols { + let process_row = |y: usize, dst_row: &mut [f32]| { + for (x, out_pixel) in dst_row.iter_mut().enumerate() { let mut bin_idx = 0usize; let mut out_of_range = false; @@ -457,12 +492,28 @@ pub fn calc_back_project( } } - let pixel = if out_of_range { + *out_pixel = if out_of_range { 0.0f32 } else { (hist.data[bin_idx] * scale).clamp(0.0, 255.0) }; - dst.set(y, x, 0, pixel); + } + }; + + #[cfg(feature = "parallel")] + { + dst.data + .par_chunks_mut(cols) + .enumerate() + .for_each(|(y, dst_row)| { + process_row(y, dst_row); + }); + } + + #[cfg(not(feature = "parallel"))] + { + for (y, dst_row) in dst.data.chunks_mut(cols).enumerate() { + process_row(y, dst_row); } } @@ -670,8 +721,22 @@ pub fn equalize_hist(src: &Matrix) -> Result> { } let mut dst = Matrix::::new(src.rows, src.cols, 1); - for idx in 0..src.data.len() { - dst.data[idx] = lut[src.data[idx] as usize]; + + #[cfg(feature = "parallel")] + { + dst.data + .par_iter_mut() + .zip(src.data.par_iter()) + .for_each(|(d, &s)| { + *d = lut[s as usize]; + }); + } + + #[cfg(not(feature = "parallel"))] + { + for (d, &s) in dst.data.iter_mut().zip(src.data.iter()) { + *d = lut[s as usize]; + } } Ok(dst) @@ -847,7 +912,7 @@ impl Clahe { }; let mut lut = vec![0u8; lut_len]; - for tile_idx in 0..num_tiles { + let build_tile_lut = |tile_idx: usize, tile_lut: &mut [u8]| { let ty = tile_idx / self.tiles_x; let tx = tile_idx % self.tiles_x; let y0 = ty * tile_rows; @@ -866,12 +931,27 @@ impl Clahe { clip_and_redistribute(&mut tile_hist, clip_limit, hist_size); - let tile_lut = &mut lut[tile_idx * hist_size..(tile_idx + 1) * hist_size]; let mut sum = 0i32; for bin in 0..hist_size { sum += tile_hist[bin]; tile_lut[bin] = (sum as f64 * lut_scale).round().clamp(0.0, 255.0) as u8; } + }; + + #[cfg(feature = "parallel")] + { + lut.par_chunks_mut(hist_size) + .enumerate() + .for_each(|(tile_idx, tile_lut)| { + build_tile_lut(tile_idx, tile_lut); + }); + } + + #[cfg(not(feature = "parallel"))] + { + for (tile_idx, tile_lut) in lut.chunks_mut(hist_size).enumerate() { + build_tile_lut(tile_idx, tile_lut); + } } let mut dst = Matrix::::new(src.rows, src.cols, 1); @@ -980,7 +1060,7 @@ impl Clahe { }; let mut lut = vec![0u16; lut_len]; - for tile_idx in 0..num_tiles { + let build_tile_lut = |tile_idx: usize, tile_lut: &mut [u16]| { let ty = tile_idx / self.tiles_x; let tx = tile_idx % self.tiles_x; let y0 = ty * tile_rows; @@ -999,12 +1079,27 @@ impl Clahe { clip_and_redistribute(&mut tile_hist, clip_limit, hist_size); - let tile_lut = &mut lut[tile_idx * hist_size..(tile_idx + 1) * hist_size]; let mut sum = 0i32; for bin in 0..hist_size { sum += tile_hist[bin]; tile_lut[bin] = (sum as f64 * lut_scale).round().clamp(0.0, 65535.0) as u16; } + }; + + #[cfg(feature = "parallel")] + { + lut.par_chunks_mut(hist_size) + .enumerate() + .for_each(|(tile_idx, tile_lut)| { + build_tile_lut(tile_idx, tile_lut); + }); + } + + #[cfg(not(feature = "parallel"))] + { + for (tile_idx, tile_lut) in lut.chunks_mut(hist_size).enumerate() { + build_tile_lut(tile_idx, tile_lut); + } } let mut dst = Matrix::::new(src.rows, src.cols, 1); @@ -1072,8 +1167,9 @@ fn interpolate_tiles_u8( let inv_th = 1.0f64 / tile_rows as f64; let lut_idx = |ty: usize, tx: usize, bin: usize| ty * tiles_x * hist_size + tx * hist_size + bin; + let cols = src.cols; - for y in 0..src.rows { + let process_row = |y: usize, dst_row: &mut [u8]| { let tyf = y as f64 * inv_th - 0.5; let ty1 = (tyf.floor() as i32).max(0); let ty2 = (ty1 + 1).min(tiles_y as i32 - 1); @@ -1082,7 +1178,7 @@ fn interpolate_tiles_u8( let ty1 = ty1 as usize; let ty2 = ty2 as usize; - for x in 0..src.cols { + for (x, out_pixel) in dst_row.iter_mut().enumerate() { let txf = x as f64 * inv_tw - 0.5; let tx1 = (txf.floor() as i32).max(0); let tx2 = (tx1 + 1).min(tiles_x as i32 - 1); @@ -1100,8 +1196,24 @@ fn interpolate_tiles_u8( let v11 = lut[lut_idx(ty2, tx2, bin)] as f64; let val = (v00 * xa1 + v01 * xa) * ya1 + (v10 * xa1 + v11 * xa) * ya; - let out = (val.round() as u8) << bit_shift; - dst.set(y, x, 0, out); + *out_pixel = (val.round() as u8) << bit_shift; + } + }; + + #[cfg(feature = "parallel")] + { + dst.data + .par_chunks_mut(cols) + .enumerate() + .for_each(|(y, dst_row)| { + process_row(y, dst_row); + }); + } + + #[cfg(not(feature = "parallel"))] + { + for (y, dst_row) in dst.data.chunks_mut(cols).enumerate() { + process_row(y, dst_row); } } } @@ -1122,8 +1234,9 @@ fn interpolate_tiles_u16( let inv_th = 1.0f64 / tile_rows as f64; let lut_idx = |ty: usize, tx: usize, bin: usize| ty * tiles_x * hist_size + tx * hist_size + bin; + let cols = src.cols; - for y in 0..src.rows { + let process_row = |y: usize, dst_row: &mut [u16]| { let tyf = y as f64 * inv_th - 0.5; let ty1 = (tyf.floor() as i32).max(0); let ty2 = (ty1 + 1).min(tiles_y as i32 - 1); @@ -1132,7 +1245,7 @@ fn interpolate_tiles_u16( let ty1 = ty1 as usize; let ty2 = ty2 as usize; - for x in 0..src.cols { + for (x, out_pixel) in dst_row.iter_mut().enumerate() { let txf = x as f64 * inv_tw - 0.5; let tx1 = (txf.floor() as i32).max(0); let tx2 = (tx1 + 1).min(tiles_x as i32 - 1); @@ -1150,8 +1263,24 @@ fn interpolate_tiles_u16( let v11 = lut[lut_idx(ty2, tx2, bin)] as f64; let val = (v00 * xa1 + v01 * xa) * ya1 + (v10 * xa1 + v11 * xa) * ya; - let out = (val.round() as u16) << bit_shift; - dst.set(y, x, 0, out); + *out_pixel = (val.round() as u16) << bit_shift; + } + }; + + #[cfg(feature = "parallel")] + { + dst.data + .par_chunks_mut(cols) + .enumerate() + .for_each(|(y, dst_row)| { + process_row(y, dst_row); + }); + } + + #[cfg(not(feature = "parallel"))] + { + for (y, dst_row) in dst.data.chunks_mut(cols).enumerate() { + process_row(y, dst_row); } } } From 393b16081a18529ca0a66214f9e9d09a5d73b352 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Sun, 30 Aug 2026 12:58:44 +0200 Subject: [PATCH 07/20] fix(imgproc): guard calc_back_project against zero-width images chunks_mut/par_chunks_mut panic on a zero chunk size regardless of slice length. calc_back_project's new row-chunked parallel/sequential dispatch used images[0].cols directly as the chunk size, so a zero-width input (previously handled fine by the old nested loops, which simply skipped the inner loop) now panicked instead of returning an empty result. Bail out with the already-correctly-shaped empty dst before reaching the chunking dispatch, matching the original behavior. Confirmed the other three functions touched in #106/#110 aren't affected: calc_hist iterates by row-index range (safe at 0), equalize_hist uses flat iter_mut/par_iter_mut (safe at 0), and Clahe already validates cols == 0 via cv_bail! before reaching its interpolation loops. Found by Qodo's review on #110. --- src/imgproc/histogram.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/imgproc/histogram.rs b/src/imgproc/histogram.rs index 8bffeb3..0145afc 100644 --- a/src/imgproc/histogram.rs +++ b/src/imgproc/histogram.rs @@ -476,6 +476,12 @@ pub fn calc_back_project( let mut dst = Matrix::::new(rows, cols, 1); + // `chunks_mut`/`par_chunks_mut` panic on a zero chunk size regardless of + // slice length, so a zero-width input must bail out before reaching them. + if cols == 0 { + return Ok(dst); + } + let process_row = |y: usize, dst_row: &mut [f32]| { for (x, out_pixel) in dst_row.iter_mut().enumerate() { let mut bin_idx = 0usize; @@ -1621,6 +1627,20 @@ mod tests { ); } + #[test] + fn test_calc_back_project_zero_width() { + // Regression test: chunks_mut/par_chunks_mut panic on a zero chunk + // size regardless of slice length, so a zero-width image must not + // reach the row-chunking dispatch. + let img = Matrix::::from_vec(3, 0, 1, vec![]); + let hist = Matrix::from_vec(4, 1, 1, vec![0.0; 4]); + let dst = + calc_back_project(&[&img], &[0], &hist, &[RangeSpec::Uniform(0.0, 4.0)], 1.0).unwrap(); + assert_eq!(dst.rows, 3); + assert_eq!(dst.cols, 0); + assert_eq!(dst.data.len(), 0); + } + #[test] fn test_calc_hist_u16_input() { let data: Vec = vec![0, 100, 200, 300, 400, 500]; From eb7673f97bda227c36b4c3b3c911ce5894dbf941 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Sun, 30 Aug 2026 23:00:56 +0200 Subject: [PATCH 08/20] perf(imgproc): SIMD-accelerate compare_hist Resolves #109 --- benches/imgproc_bench.rs | 42 +++++++++ src/core/simd.rs | 192 +++++++++++++++++++++++++++++++++++++++ src/imgproc/histogram.rs | 10 ++ 3 files changed, 244 insertions(+) diff --git a/benches/imgproc_bench.rs b/benches/imgproc_bench.rs index 6114813..410c632 100644 --- a/benches/imgproc_bench.rs +++ b/benches/imgproc_bench.rs @@ -306,6 +306,48 @@ fn bench_imgproc(c: &mut Criterion) { c.bench_function("clahe_apply_u8_1024x1024", |b| { b.iter(|| clahe.apply_u8(black_box(&img_hist)).unwrap()) }); + // compare_hist benchmark setup + let mut hist1 = Matrix::::new(256, 1, 1); + let mut hist2 = Matrix::::new(256, 1, 1); + for (i, p) in hist1.data.iter_mut().enumerate() { + *p = (i as f32 * 0.1).sin().abs(); + } + for (i, p) in hist2.data.iter_mut().enumerate() { + *p = (i as f32 * 0.1).cos().abs(); + } + + c.bench_function("compare_hist_correl_256", |b| { + b.iter(|| { + purecv::imgproc::histogram::compare_hist( + black_box(&hist1), + black_box(&hist2), + purecv::imgproc::histogram::HistCompMethods::Correl, + ) + .unwrap() + }) + }); + + c.bench_function("compare_hist_intersection_256", |b| { + b.iter(|| { + purecv::imgproc::histogram::compare_hist( + black_box(&hist1), + black_box(&hist2), + purecv::imgproc::histogram::HistCompMethods::Intersection, + ) + .unwrap() + }) + }); + + c.bench_function("compare_hist_kullback_256", |b| { + b.iter(|| { + purecv::imgproc::histogram::compare_hist( + black_box(&hist1), + black_box(&hist2), + purecv::imgproc::histogram::HistCompMethods::KullbackLeibler, + ) + .unwrap() + }) + }); } criterion_group!(benches, bench_imgproc); diff --git a/src/core/simd.rs b/src/core/simd.rs index b20133d..3dd682c 100644 --- a/src/core/simd.rs +++ b/src/core/simd.rs @@ -189,6 +189,14 @@ pub trait SimdElement: Copy + Send + Sync + 'static { false } + // -- Histogram comparisons -- + + /// Compute compare_hist directly using the method variant mapped to a `u8`. + /// 0=Correl, 1=ChiSqr, 2=ChiSqrAlt, 3=Intersection, 4=Bhattacharyya, 5=KullbackLeibler + fn simd_compare_hist(_h1: &[Self], _h2: &[Self], _method: u8) -> Option { + None + } + // -- Pyramid kernels (5-tap Gaussian) -- /// Horizontal pass: dst[i] = sum(src[i - 2*stride .. i + 2*stride] * [1, 4, 6, 4, 1]) @@ -496,6 +504,102 @@ mod simd_impls { true } + fn simd_compare_hist(h1: &[Self], h2: &[Self], method: u8) -> Option { + match method { + 0 => { // Correl + let mut s1 = 0.0f32; + let mut s2 = 0.0f32; + let mut s11 = 0.0f32; + let mut s12 = 0.0f32; + let mut s22 = 0.0f32; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + s1 += a; + s2 += b; + s11 += a * a; + s22 += b * b; + s12 += a * b; + } + let n = h1.len() as f64; + let scale = 1.0 / n; + let s1_f = s1 as f64; + let s2_f = s2 as f64; + let s11_f = s11 as f64; + let s12_f = s12 as f64; + let s22_f = s22 as f64; + + let num = s12_f - s1_f * s2_f * scale; + let denom2 = (s11_f - s1_f * s1_f * scale) * (s22_f - s2_f * s2_f * scale); + Some(if denom2.abs() > f64::EPSILON { + num / denom2.sqrt() + } else { + 1.0 + }) + } + 1 => { // ChiSqr + let mut result = 0.0f32; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let diff = a - b; + let a_adj = if a.abs() <= f32::EPSILON { 1.0 } else { a }; + let val = diff * diff / a_adj; + let add_val = if a.abs() > f32::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some(result as f64) + } + 2 => { // ChiSqrAlt + let mut result = 0.0f32; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let sum = a + b; + let diff = a - b; + let sum_adj = if sum.abs() <= f32::EPSILON { 1.0 } else { sum }; + let val = diff * diff / sum_adj; + let add_val = if sum.abs() > f32::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some((result as f64) * 2.0) + } + 3 => { // Intersection + let mut result = 0.0f32; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + result += if a < b { a } else { b }; + } + Some(result as f64) + } + 4 => { // Bhattacharyya + let mut s1 = 0.0f32; + let mut s2 = 0.0f32; + let mut bc = 0.0f32; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + s1 += a; + s2 += b; + bc += (a * b).sqrt(); + } + let s1_f = s1 as f64; + let s2_f = s2 as f64; + let bc_f = bc as f64; + let norm = s1_f * s2_f; + let norm_factor = if norm.abs() > f64::EPSILON { + 1.0 / norm.sqrt() + } else { + 1.0 + }; + Some(((1.0 - bc_f * norm_factor).max(0.0)).sqrt()) + } + 5 => { // KullbackLeibler + let mut result = 0.0f32; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let q_adj = if b.abs() <= f32::EPSILON { 1e-10 } else { b }; + let p_adj = if a.abs() <= f32::EPSILON { 1.0 } else { a }; + let val = a * (p_adj / q_adj).ln(); + let add_val = if a.abs() > f32::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some(result as f64) + } + _ => None, + } + } + fn simd_gaussian_5tap_h(dst: &mut [f64], src: &[Self], stride: usize) -> bool { let arch = pulp::Arch::new(); arch.dispatch(|| { @@ -903,6 +1007,94 @@ mod simd_impls { true } + fn simd_compare_hist(h1: &[Self], h2: &[Self], method: u8) -> Option { + match method { + 0 => { // Correl + let mut s1 = 0.0f64; + let mut s2 = 0.0f64; + let mut s11 = 0.0f64; + let mut s12 = 0.0f64; + let mut s22 = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + s1 += a; + s2 += b; + s11 += a * a; + s22 += b * b; + s12 += a * b; + } + let n = h1.len() as f64; + let scale = 1.0 / n; + + let num = s12 - s1 * s2 * scale; + let denom2 = (s11 - s1 * s1 * scale) * (s22 - s2 * s2 * scale); + Some(if denom2.abs() > f64::EPSILON { + num / denom2.sqrt() + } else { + 1.0 + }) + } + 1 => { // ChiSqr + let mut result = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let diff = a - b; + let a_adj = if a.abs() <= f64::EPSILON { 1.0 } else { a }; + let val = diff * diff / a_adj; + let add_val = if a.abs() > f64::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some(result) + } + 2 => { // ChiSqrAlt + let mut result = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let sum = a + b; + let diff = a - b; + let sum_adj = if sum.abs() <= f64::EPSILON { 1.0 } else { sum }; + let val = diff * diff / sum_adj; + let add_val = if sum.abs() > f64::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some(result * 2.0) + } + 3 => { // Intersection + let mut result = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + result += if a < b { a } else { b }; + } + Some(result) + } + 4 => { // Bhattacharyya + let mut s1 = 0.0f64; + let mut s2 = 0.0f64; + let mut bc = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + s1 += a; + s2 += b; + bc += (a * b).sqrt(); + } + let norm = s1 * s2; + let norm_factor = if norm.abs() > f64::EPSILON { + 1.0 / norm.sqrt() + } else { + 1.0 + }; + Some(((1.0 - bc * norm_factor).max(0.0)).sqrt()) + } + 5 => { // KullbackLeibler + let mut result = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let q_adj = if b.abs() <= f64::EPSILON { 1e-10 } else { b }; + let p_adj = if a.abs() <= f64::EPSILON { 1.0 } else { a }; + let val = a * (p_adj / q_adj).ln(); + let add_val = if a.abs() > f64::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some(result) + } + _ => None, + } + } + fn simd_gaussian_5tap_h(dst: &mut [f64], src: &[Self], stride: usize) -> bool { let arch = pulp::Arch::new(); arch.dispatch(|| { diff --git a/src/imgproc/histogram.rs b/src/imgproc/histogram.rs index 0145afc..4b98d64 100644 --- a/src/imgproc/histogram.rs +++ b/src/imgproc/histogram.rs @@ -573,6 +573,16 @@ pub fn compare_hist(h1: &Matrix, h2: &Matrix, method: HistCompMethods) ); } + #[cfg(feature = "simd")] + { + use crate::core::simd::SimdElement; + if f32::has_simd() { + if let Some(res) = f32::simd_compare_hist(&h1.data, &h2.data, method as u8) { + return Ok(res); + } + } + } + let n = len as f64; match method { From 7d8d47ccfcd419c04ede91c9baa3d3b2fc7757fd Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Mon, 31 Aug 2026 00:21:04 +0200 Subject: [PATCH 09/20] Revert "perf(imgproc): SIMD-accelerate compare_hist" This reverts commit eb7673f97bda227c36b4c3b3c911ce5894dbf941. --- benches/imgproc_bench.rs | 42 --------- src/core/simd.rs | 192 --------------------------------------- src/imgproc/histogram.rs | 10 -- 3 files changed, 244 deletions(-) diff --git a/benches/imgproc_bench.rs b/benches/imgproc_bench.rs index 410c632..6114813 100644 --- a/benches/imgproc_bench.rs +++ b/benches/imgproc_bench.rs @@ -306,48 +306,6 @@ fn bench_imgproc(c: &mut Criterion) { c.bench_function("clahe_apply_u8_1024x1024", |b| { b.iter(|| clahe.apply_u8(black_box(&img_hist)).unwrap()) }); - // compare_hist benchmark setup - let mut hist1 = Matrix::::new(256, 1, 1); - let mut hist2 = Matrix::::new(256, 1, 1); - for (i, p) in hist1.data.iter_mut().enumerate() { - *p = (i as f32 * 0.1).sin().abs(); - } - for (i, p) in hist2.data.iter_mut().enumerate() { - *p = (i as f32 * 0.1).cos().abs(); - } - - c.bench_function("compare_hist_correl_256", |b| { - b.iter(|| { - purecv::imgproc::histogram::compare_hist( - black_box(&hist1), - black_box(&hist2), - purecv::imgproc::histogram::HistCompMethods::Correl, - ) - .unwrap() - }) - }); - - c.bench_function("compare_hist_intersection_256", |b| { - b.iter(|| { - purecv::imgproc::histogram::compare_hist( - black_box(&hist1), - black_box(&hist2), - purecv::imgproc::histogram::HistCompMethods::Intersection, - ) - .unwrap() - }) - }); - - c.bench_function("compare_hist_kullback_256", |b| { - b.iter(|| { - purecv::imgproc::histogram::compare_hist( - black_box(&hist1), - black_box(&hist2), - purecv::imgproc::histogram::HistCompMethods::KullbackLeibler, - ) - .unwrap() - }) - }); } criterion_group!(benches, bench_imgproc); diff --git a/src/core/simd.rs b/src/core/simd.rs index 3dd682c..b20133d 100644 --- a/src/core/simd.rs +++ b/src/core/simd.rs @@ -189,14 +189,6 @@ pub trait SimdElement: Copy + Send + Sync + 'static { false } - // -- Histogram comparisons -- - - /// Compute compare_hist directly using the method variant mapped to a `u8`. - /// 0=Correl, 1=ChiSqr, 2=ChiSqrAlt, 3=Intersection, 4=Bhattacharyya, 5=KullbackLeibler - fn simd_compare_hist(_h1: &[Self], _h2: &[Self], _method: u8) -> Option { - None - } - // -- Pyramid kernels (5-tap Gaussian) -- /// Horizontal pass: dst[i] = sum(src[i - 2*stride .. i + 2*stride] * [1, 4, 6, 4, 1]) @@ -504,102 +496,6 @@ mod simd_impls { true } - fn simd_compare_hist(h1: &[Self], h2: &[Self], method: u8) -> Option { - match method { - 0 => { // Correl - let mut s1 = 0.0f32; - let mut s2 = 0.0f32; - let mut s11 = 0.0f32; - let mut s12 = 0.0f32; - let mut s22 = 0.0f32; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - s1 += a; - s2 += b; - s11 += a * a; - s22 += b * b; - s12 += a * b; - } - let n = h1.len() as f64; - let scale = 1.0 / n; - let s1_f = s1 as f64; - let s2_f = s2 as f64; - let s11_f = s11 as f64; - let s12_f = s12 as f64; - let s22_f = s22 as f64; - - let num = s12_f - s1_f * s2_f * scale; - let denom2 = (s11_f - s1_f * s1_f * scale) * (s22_f - s2_f * s2_f * scale); - Some(if denom2.abs() > f64::EPSILON { - num / denom2.sqrt() - } else { - 1.0 - }) - } - 1 => { // ChiSqr - let mut result = 0.0f32; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - let diff = a - b; - let a_adj = if a.abs() <= f32::EPSILON { 1.0 } else { a }; - let val = diff * diff / a_adj; - let add_val = if a.abs() > f32::EPSILON { val } else { 0.0 }; - result += add_val; - } - Some(result as f64) - } - 2 => { // ChiSqrAlt - let mut result = 0.0f32; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - let sum = a + b; - let diff = a - b; - let sum_adj = if sum.abs() <= f32::EPSILON { 1.0 } else { sum }; - let val = diff * diff / sum_adj; - let add_val = if sum.abs() > f32::EPSILON { val } else { 0.0 }; - result += add_val; - } - Some((result as f64) * 2.0) - } - 3 => { // Intersection - let mut result = 0.0f32; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - result += if a < b { a } else { b }; - } - Some(result as f64) - } - 4 => { // Bhattacharyya - let mut s1 = 0.0f32; - let mut s2 = 0.0f32; - let mut bc = 0.0f32; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - s1 += a; - s2 += b; - bc += (a * b).sqrt(); - } - let s1_f = s1 as f64; - let s2_f = s2 as f64; - let bc_f = bc as f64; - let norm = s1_f * s2_f; - let norm_factor = if norm.abs() > f64::EPSILON { - 1.0 / norm.sqrt() - } else { - 1.0 - }; - Some(((1.0 - bc_f * norm_factor).max(0.0)).sqrt()) - } - 5 => { // KullbackLeibler - let mut result = 0.0f32; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - let q_adj = if b.abs() <= f32::EPSILON { 1e-10 } else { b }; - let p_adj = if a.abs() <= f32::EPSILON { 1.0 } else { a }; - let val = a * (p_adj / q_adj).ln(); - let add_val = if a.abs() > f32::EPSILON { val } else { 0.0 }; - result += add_val; - } - Some(result as f64) - } - _ => None, - } - } - fn simd_gaussian_5tap_h(dst: &mut [f64], src: &[Self], stride: usize) -> bool { let arch = pulp::Arch::new(); arch.dispatch(|| { @@ -1007,94 +903,6 @@ mod simd_impls { true } - fn simd_compare_hist(h1: &[Self], h2: &[Self], method: u8) -> Option { - match method { - 0 => { // Correl - let mut s1 = 0.0f64; - let mut s2 = 0.0f64; - let mut s11 = 0.0f64; - let mut s12 = 0.0f64; - let mut s22 = 0.0f64; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - s1 += a; - s2 += b; - s11 += a * a; - s22 += b * b; - s12 += a * b; - } - let n = h1.len() as f64; - let scale = 1.0 / n; - - let num = s12 - s1 * s2 * scale; - let denom2 = (s11 - s1 * s1 * scale) * (s22 - s2 * s2 * scale); - Some(if denom2.abs() > f64::EPSILON { - num / denom2.sqrt() - } else { - 1.0 - }) - } - 1 => { // ChiSqr - let mut result = 0.0f64; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - let diff = a - b; - let a_adj = if a.abs() <= f64::EPSILON { 1.0 } else { a }; - let val = diff * diff / a_adj; - let add_val = if a.abs() > f64::EPSILON { val } else { 0.0 }; - result += add_val; - } - Some(result) - } - 2 => { // ChiSqrAlt - let mut result = 0.0f64; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - let sum = a + b; - let diff = a - b; - let sum_adj = if sum.abs() <= f64::EPSILON { 1.0 } else { sum }; - let val = diff * diff / sum_adj; - let add_val = if sum.abs() > f64::EPSILON { val } else { 0.0 }; - result += add_val; - } - Some(result * 2.0) - } - 3 => { // Intersection - let mut result = 0.0f64; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - result += if a < b { a } else { b }; - } - Some(result) - } - 4 => { // Bhattacharyya - let mut s1 = 0.0f64; - let mut s2 = 0.0f64; - let mut bc = 0.0f64; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - s1 += a; - s2 += b; - bc += (a * b).sqrt(); - } - let norm = s1 * s2; - let norm_factor = if norm.abs() > f64::EPSILON { - 1.0 / norm.sqrt() - } else { - 1.0 - }; - Some(((1.0 - bc * norm_factor).max(0.0)).sqrt()) - } - 5 => { // KullbackLeibler - let mut result = 0.0f64; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - let q_adj = if b.abs() <= f64::EPSILON { 1e-10 } else { b }; - let p_adj = if a.abs() <= f64::EPSILON { 1.0 } else { a }; - let val = a * (p_adj / q_adj).ln(); - let add_val = if a.abs() > f64::EPSILON { val } else { 0.0 }; - result += add_val; - } - Some(result) - } - _ => None, - } - } - fn simd_gaussian_5tap_h(dst: &mut [f64], src: &[Self], stride: usize) -> bool { let arch = pulp::Arch::new(); arch.dispatch(|| { diff --git a/src/imgproc/histogram.rs b/src/imgproc/histogram.rs index 4b98d64..0145afc 100644 --- a/src/imgproc/histogram.rs +++ b/src/imgproc/histogram.rs @@ -573,16 +573,6 @@ pub fn compare_hist(h1: &Matrix, h2: &Matrix, method: HistCompMethods) ); } - #[cfg(feature = "simd")] - { - use crate::core::simd::SimdElement; - if f32::has_simd() { - if let Some(res) = f32::simd_compare_hist(&h1.data, &h2.data, method as u8) { - return Ok(res); - } - } - } - let n = len as f64; match method { From 1207ac1a625dafa0e66a74561a8dce6529add3e6 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Sun, 30 Aug 2026 23:00:56 +0200 Subject: [PATCH 10/20] perf(imgproc): SIMD-accelerate compare_hist Resolves #109 --- benches/imgproc_bench.rs | 42 ++++++++ src/core/simd.rs | 204 +++++++++++++++++++++++++++++++++++++++ src/imgproc/histogram.rs | 10 ++ 3 files changed, 256 insertions(+) diff --git a/benches/imgproc_bench.rs b/benches/imgproc_bench.rs index 6114813..410c632 100644 --- a/benches/imgproc_bench.rs +++ b/benches/imgproc_bench.rs @@ -306,6 +306,48 @@ fn bench_imgproc(c: &mut Criterion) { c.bench_function("clahe_apply_u8_1024x1024", |b| { b.iter(|| clahe.apply_u8(black_box(&img_hist)).unwrap()) }); + // compare_hist benchmark setup + let mut hist1 = Matrix::::new(256, 1, 1); + let mut hist2 = Matrix::::new(256, 1, 1); + for (i, p) in hist1.data.iter_mut().enumerate() { + *p = (i as f32 * 0.1).sin().abs(); + } + for (i, p) in hist2.data.iter_mut().enumerate() { + *p = (i as f32 * 0.1).cos().abs(); + } + + c.bench_function("compare_hist_correl_256", |b| { + b.iter(|| { + purecv::imgproc::histogram::compare_hist( + black_box(&hist1), + black_box(&hist2), + purecv::imgproc::histogram::HistCompMethods::Correl, + ) + .unwrap() + }) + }); + + c.bench_function("compare_hist_intersection_256", |b| { + b.iter(|| { + purecv::imgproc::histogram::compare_hist( + black_box(&hist1), + black_box(&hist2), + purecv::imgproc::histogram::HistCompMethods::Intersection, + ) + .unwrap() + }) + }); + + c.bench_function("compare_hist_kullback_256", |b| { + b.iter(|| { + purecv::imgproc::histogram::compare_hist( + black_box(&hist1), + black_box(&hist2), + purecv::imgproc::histogram::HistCompMethods::KullbackLeibler, + ) + .unwrap() + }) + }); } criterion_group!(benches, bench_imgproc); diff --git a/src/core/simd.rs b/src/core/simd.rs index b20133d..c6b9d52 100644 --- a/src/core/simd.rs +++ b/src/core/simd.rs @@ -189,6 +189,14 @@ pub trait SimdElement: Copy + Send + Sync + 'static { false } + // -- Histogram comparisons -- + + /// Compute compare_hist directly using the method variant mapped to a `u8`. + /// 0=Correl, 1=ChiSqr, 2=ChiSqrAlt, 3=Intersection, 4=Bhattacharyya, 5=KullbackLeibler + fn simd_compare_hist(_h1: &[Self], _h2: &[Self], _method: u8) -> Option { + None + } + // -- Pyramid kernels (5-tap Gaussian) -- /// Horizontal pass: dst[i] = sum(src[i - 2*stride .. i + 2*stride] * [1, 4, 6, 4, 1]) @@ -496,6 +504,108 @@ mod simd_impls { true } + fn simd_compare_hist(h1: &[Self], h2: &[Self], method: u8) -> Option { + match method { + 0 => { + // Correl + let mut s1 = 0.0f32; + let mut s2 = 0.0f32; + let mut s11 = 0.0f32; + let mut s12 = 0.0f32; + let mut s22 = 0.0f32; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + s1 += a; + s2 += b; + s11 += a * a; + s22 += b * b; + s12 += a * b; + } + let n = h1.len() as f64; + let scale = 1.0 / n; + let s1_f = s1 as f64; + let s2_f = s2 as f64; + let s11_f = s11 as f64; + let s12_f = s12 as f64; + let s22_f = s22 as f64; + + let num = s12_f - s1_f * s2_f * scale; + let denom2 = (s11_f - s1_f * s1_f * scale) * (s22_f - s2_f * s2_f * scale); + Some(if denom2.abs() > f64::EPSILON { + num / denom2.sqrt() + } else { + 1.0 + }) + } + 1 => { + // ChiSqr + let mut result = 0.0f32; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let diff = a - b; + let a_adj = if a.abs() <= f32::EPSILON { 1.0 } else { a }; + let val = diff * diff / a_adj; + let add_val = if a.abs() > f32::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some(result as f64) + } + 2 => { + // ChiSqrAlt + let mut result = 0.0f32; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let sum = a + b; + let diff = a - b; + let sum_adj = if sum.abs() <= f32::EPSILON { 1.0 } else { sum }; + let val = diff * diff / sum_adj; + let add_val = if sum.abs() > f32::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some((result as f64) * 2.0) + } + 3 => { + // Intersection + let mut result = 0.0f32; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + result += if a < b { a } else { b }; + } + Some(result as f64) + } + 4 => { + // Bhattacharyya + let mut s1 = 0.0f32; + let mut s2 = 0.0f32; + let mut bc = 0.0f32; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + s1 += a; + s2 += b; + bc += (a * b).sqrt(); + } + let s1_f = s1 as f64; + let s2_f = s2 as f64; + let bc_f = bc as f64; + let norm = s1_f * s2_f; + let norm_factor = if norm.abs() > f64::EPSILON { + 1.0 / norm.sqrt() + } else { + 1.0 + }; + Some(((1.0 - bc_f * norm_factor).max(0.0)).sqrt()) + } + 5 => { + // KullbackLeibler + let mut result = 0.0f32; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let q_adj = if b.abs() <= f32::EPSILON { 1e-10 } else { b }; + let p_adj = if a.abs() <= f32::EPSILON { 1.0 } else { a }; + let val = a * (p_adj / q_adj).ln(); + let add_val = if a.abs() > f32::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some(result as f64) + } + _ => None, + } + } + fn simd_gaussian_5tap_h(dst: &mut [f64], src: &[Self], stride: usize) -> bool { let arch = pulp::Arch::new(); arch.dispatch(|| { @@ -903,6 +1013,100 @@ mod simd_impls { true } + fn simd_compare_hist(h1: &[Self], h2: &[Self], method: u8) -> Option { + match method { + 0 => { + // Correl + let mut s1 = 0.0f64; + let mut s2 = 0.0f64; + let mut s11 = 0.0f64; + let mut s12 = 0.0f64; + let mut s22 = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + s1 += a; + s2 += b; + s11 += a * a; + s22 += b * b; + s12 += a * b; + } + let n = h1.len() as f64; + let scale = 1.0 / n; + + let num = s12 - s1 * s2 * scale; + let denom2 = (s11 - s1 * s1 * scale) * (s22 - s2 * s2 * scale); + Some(if denom2.abs() > f64::EPSILON { + num / denom2.sqrt() + } else { + 1.0 + }) + } + 1 => { + // ChiSqr + let mut result = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let diff = a - b; + let a_adj = if a.abs() <= f64::EPSILON { 1.0 } else { a }; + let val = diff * diff / a_adj; + let add_val = if a.abs() > f64::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some(result) + } + 2 => { + // ChiSqrAlt + let mut result = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let sum = a + b; + let diff = a - b; + let sum_adj = if sum.abs() <= f64::EPSILON { 1.0 } else { sum }; + let val = diff * diff / sum_adj; + let add_val = if sum.abs() > f64::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some(result * 2.0) + } + 3 => { + // Intersection + let mut result = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + result += if a < b { a } else { b }; + } + Some(result) + } + 4 => { + // Bhattacharyya + let mut s1 = 0.0f64; + let mut s2 = 0.0f64; + let mut bc = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + s1 += a; + s2 += b; + bc += (a * b).sqrt(); + } + let norm = s1 * s2; + let norm_factor = if norm.abs() > f64::EPSILON { + 1.0 / norm.sqrt() + } else { + 1.0 + }; + Some(((1.0 - bc * norm_factor).max(0.0)).sqrt()) + } + 5 => { + // KullbackLeibler + let mut result = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let q_adj = if b.abs() <= f64::EPSILON { 1e-10 } else { b }; + let p_adj = if a.abs() <= f64::EPSILON { 1.0 } else { a }; + let val = a * (p_adj / q_adj).ln(); + let add_val = if a.abs() > f64::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some(result) + } + _ => None, + } + } + fn simd_gaussian_5tap_h(dst: &mut [f64], src: &[Self], stride: usize) -> bool { let arch = pulp::Arch::new(); arch.dispatch(|| { diff --git a/src/imgproc/histogram.rs b/src/imgproc/histogram.rs index 0145afc..4b98d64 100644 --- a/src/imgproc/histogram.rs +++ b/src/imgproc/histogram.rs @@ -573,6 +573,16 @@ pub fn compare_hist(h1: &Matrix, h2: &Matrix, method: HistCompMethods) ); } + #[cfg(feature = "simd")] + { + use crate::core::simd::SimdElement; + if f32::has_simd() { + if let Some(res) = f32::simd_compare_hist(&h1.data, &h2.data, method as u8) { + return Ok(res); + } + } + } + let n = len as f64; match method { From 24cb5e2da628b25b99d5c949ff6aa5660b09ec25 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Mon, 31 Aug 2026 14:09:23 +0200 Subject: [PATCH 11/20] fix(imgproc): address Qodo review comments for SIMD compare_hist - Moves SIMD helper from core to imgproc/simd.rs. - Fixes numerical overflow by casting intermediate f32 to f64 for accumulations. - Fixes epsilon classification of small f32 bins by using f64::EPSILON. - Restores exact f64::min() behavior for Intersection method to handle NaNs correctly. - Adds tests.rs coverage for compare_hist to satisfy Qodo rules. --- src/core/simd.rs | 204 --------------------------------------- src/imgproc/histogram.rs | 9 +- src/imgproc/simd.rs | 106 ++++++++++++++++++++ src/imgproc/tests.rs | 22 +++++ 4 files changed, 132 insertions(+), 209 deletions(-) diff --git a/src/core/simd.rs b/src/core/simd.rs index c6b9d52..b20133d 100644 --- a/src/core/simd.rs +++ b/src/core/simd.rs @@ -189,14 +189,6 @@ pub trait SimdElement: Copy + Send + Sync + 'static { false } - // -- Histogram comparisons -- - - /// Compute compare_hist directly using the method variant mapped to a `u8`. - /// 0=Correl, 1=ChiSqr, 2=ChiSqrAlt, 3=Intersection, 4=Bhattacharyya, 5=KullbackLeibler - fn simd_compare_hist(_h1: &[Self], _h2: &[Self], _method: u8) -> Option { - None - } - // -- Pyramid kernels (5-tap Gaussian) -- /// Horizontal pass: dst[i] = sum(src[i - 2*stride .. i + 2*stride] * [1, 4, 6, 4, 1]) @@ -504,108 +496,6 @@ mod simd_impls { true } - fn simd_compare_hist(h1: &[Self], h2: &[Self], method: u8) -> Option { - match method { - 0 => { - // Correl - let mut s1 = 0.0f32; - let mut s2 = 0.0f32; - let mut s11 = 0.0f32; - let mut s12 = 0.0f32; - let mut s22 = 0.0f32; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - s1 += a; - s2 += b; - s11 += a * a; - s22 += b * b; - s12 += a * b; - } - let n = h1.len() as f64; - let scale = 1.0 / n; - let s1_f = s1 as f64; - let s2_f = s2 as f64; - let s11_f = s11 as f64; - let s12_f = s12 as f64; - let s22_f = s22 as f64; - - let num = s12_f - s1_f * s2_f * scale; - let denom2 = (s11_f - s1_f * s1_f * scale) * (s22_f - s2_f * s2_f * scale); - Some(if denom2.abs() > f64::EPSILON { - num / denom2.sqrt() - } else { - 1.0 - }) - } - 1 => { - // ChiSqr - let mut result = 0.0f32; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - let diff = a - b; - let a_adj = if a.abs() <= f32::EPSILON { 1.0 } else { a }; - let val = diff * diff / a_adj; - let add_val = if a.abs() > f32::EPSILON { val } else { 0.0 }; - result += add_val; - } - Some(result as f64) - } - 2 => { - // ChiSqrAlt - let mut result = 0.0f32; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - let sum = a + b; - let diff = a - b; - let sum_adj = if sum.abs() <= f32::EPSILON { 1.0 } else { sum }; - let val = diff * diff / sum_adj; - let add_val = if sum.abs() > f32::EPSILON { val } else { 0.0 }; - result += add_val; - } - Some((result as f64) * 2.0) - } - 3 => { - // Intersection - let mut result = 0.0f32; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - result += if a < b { a } else { b }; - } - Some(result as f64) - } - 4 => { - // Bhattacharyya - let mut s1 = 0.0f32; - let mut s2 = 0.0f32; - let mut bc = 0.0f32; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - s1 += a; - s2 += b; - bc += (a * b).sqrt(); - } - let s1_f = s1 as f64; - let s2_f = s2 as f64; - let bc_f = bc as f64; - let norm = s1_f * s2_f; - let norm_factor = if norm.abs() > f64::EPSILON { - 1.0 / norm.sqrt() - } else { - 1.0 - }; - Some(((1.0 - bc_f * norm_factor).max(0.0)).sqrt()) - } - 5 => { - // KullbackLeibler - let mut result = 0.0f32; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - let q_adj = if b.abs() <= f32::EPSILON { 1e-10 } else { b }; - let p_adj = if a.abs() <= f32::EPSILON { 1.0 } else { a }; - let val = a * (p_adj / q_adj).ln(); - let add_val = if a.abs() > f32::EPSILON { val } else { 0.0 }; - result += add_val; - } - Some(result as f64) - } - _ => None, - } - } - fn simd_gaussian_5tap_h(dst: &mut [f64], src: &[Self], stride: usize) -> bool { let arch = pulp::Arch::new(); arch.dispatch(|| { @@ -1013,100 +903,6 @@ mod simd_impls { true } - fn simd_compare_hist(h1: &[Self], h2: &[Self], method: u8) -> Option { - match method { - 0 => { - // Correl - let mut s1 = 0.0f64; - let mut s2 = 0.0f64; - let mut s11 = 0.0f64; - let mut s12 = 0.0f64; - let mut s22 = 0.0f64; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - s1 += a; - s2 += b; - s11 += a * a; - s22 += b * b; - s12 += a * b; - } - let n = h1.len() as f64; - let scale = 1.0 / n; - - let num = s12 - s1 * s2 * scale; - let denom2 = (s11 - s1 * s1 * scale) * (s22 - s2 * s2 * scale); - Some(if denom2.abs() > f64::EPSILON { - num / denom2.sqrt() - } else { - 1.0 - }) - } - 1 => { - // ChiSqr - let mut result = 0.0f64; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - let diff = a - b; - let a_adj = if a.abs() <= f64::EPSILON { 1.0 } else { a }; - let val = diff * diff / a_adj; - let add_val = if a.abs() > f64::EPSILON { val } else { 0.0 }; - result += add_val; - } - Some(result) - } - 2 => { - // ChiSqrAlt - let mut result = 0.0f64; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - let sum = a + b; - let diff = a - b; - let sum_adj = if sum.abs() <= f64::EPSILON { 1.0 } else { sum }; - let val = diff * diff / sum_adj; - let add_val = if sum.abs() > f64::EPSILON { val } else { 0.0 }; - result += add_val; - } - Some(result * 2.0) - } - 3 => { - // Intersection - let mut result = 0.0f64; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - result += if a < b { a } else { b }; - } - Some(result) - } - 4 => { - // Bhattacharyya - let mut s1 = 0.0f64; - let mut s2 = 0.0f64; - let mut bc = 0.0f64; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - s1 += a; - s2 += b; - bc += (a * b).sqrt(); - } - let norm = s1 * s2; - let norm_factor = if norm.abs() > f64::EPSILON { - 1.0 / norm.sqrt() - } else { - 1.0 - }; - Some(((1.0 - bc * norm_factor).max(0.0)).sqrt()) - } - 5 => { - // KullbackLeibler - let mut result = 0.0f64; - for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { - let q_adj = if b.abs() <= f64::EPSILON { 1e-10 } else { b }; - let p_adj = if a.abs() <= f64::EPSILON { 1.0 } else { a }; - let val = a * (p_adj / q_adj).ln(); - let add_val = if a.abs() > f64::EPSILON { val } else { 0.0 }; - result += add_val; - } - Some(result) - } - _ => None, - } - } - fn simd_gaussian_5tap_h(dst: &mut [f64], src: &[Self], stride: usize) -> bool { let arch = pulp::Arch::new(); arch.dispatch(|| { diff --git a/src/imgproc/histogram.rs b/src/imgproc/histogram.rs index 4b98d64..b5406de 100644 --- a/src/imgproc/histogram.rs +++ b/src/imgproc/histogram.rs @@ -575,11 +575,10 @@ pub fn compare_hist(h1: &Matrix, h2: &Matrix, method: HistCompMethods) #[cfg(feature = "simd")] { - use crate::core::simd::SimdElement; - if f32::has_simd() { - if let Some(res) = f32::simd_compare_hist(&h1.data, &h2.data, method as u8) { - return Ok(res); - } + if let Some(res) = + crate::imgproc::simd::simd_compare_hist_f32(&h1.data, &h2.data, method as u8) + { + return Ok(res); } } diff --git a/src/imgproc/simd.rs b/src/imgproc/simd.rs index 5b731db..c790377 100644 --- a/src/imgproc/simd.rs +++ b/src/imgproc/simd.rs @@ -265,3 +265,109 @@ mod tests { assert_eq!(dst[3], 4.0); } } +/// SIMD-friendly (LLVM auto-vectorized) compare_hist methods. +#[cfg(feature = "simd")] +pub(crate) fn simd_compare_hist_f32(h1: &[f32], h2: &[f32], method: u8) -> Option { + match method { + 0 => { + // Correl + let mut s1 = 0.0f64; + let mut s2 = 0.0f64; + let mut s11 = 0.0f64; + let mut s12 = 0.0f64; + let mut s22 = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let a = a as f64; + let b = b as f64; + s1 += a; + s2 += b; + s11 += a * a; + s22 += b * b; + s12 += a * b; + } + let n = h1.len() as f64; + let scale = 1.0 / n; + let num = s12 - s1 * s2 * scale; + let denom2 = (s11 - s1 * s1 * scale) * (s22 - s2 * s2 * scale); + Some(if denom2.abs() > f64::EPSILON { + num / denom2.sqrt() + } else { + 1.0 + }) + } + 1 => { + // ChiSqr + let mut result = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let a = a as f64; + let b = b as f64; + let diff = a - b; + let a_adj = if a.abs() <= f64::EPSILON { 1.0 } else { a }; + let val = diff * diff / a_adj; + let add_val = if a.abs() > f64::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some(result) + } + 2 => { + // ChiSqrAlt + let mut result = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let a = a as f64; + let b = b as f64; + let sum = a + b; + let diff = a - b; + let sum_adj = if sum.abs() <= f64::EPSILON { 1.0 } else { sum }; + let val = diff * diff / sum_adj; + let add_val = if sum.abs() > f64::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some(result * 2.0) + } + 3 => { + // Intersection + let mut result = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let a = a as f64; + let b = b as f64; + result += a.min(b); + } + Some(result) + } + 4 => { + // Bhattacharyya + let mut s1 = 0.0f64; + let mut s2 = 0.0f64; + let mut bc = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let a = a as f64; + let b = b as f64; + s1 += a; + s2 += b; + bc += (a * b).sqrt(); + } + let norm = s1 * s2; + let norm_factor = if norm.abs() > f64::EPSILON { + 1.0 / norm.sqrt() + } else { + 1.0 + }; + Some(((1.0 - bc * norm_factor).max(0.0)).sqrt()) + } + 5 => { + // KullbackLeibler + let mut result = 0.0f64; + for (a, b) in h1.iter().copied().zip(h2.iter().copied()) { + let a = a as f64; + let b = b as f64; + let q_adj = if b.abs() <= f64::EPSILON { 1e-10 } else { b }; + let p_adj = if a.abs() <= f64::EPSILON { 1.0 } else { a }; + let val = a * (p_adj / q_adj).ln(); + let add_val = if a.abs() > f64::EPSILON { val } else { 0.0 }; + result += add_val; + } + Some(result) + } + _ => None, + } +} diff --git a/src/imgproc/tests.rs b/src/imgproc/tests.rs index b758584..0ecdbfd 100644 --- a/src/imgproc/tests.rs +++ b/src/imgproc/tests.rs @@ -1316,4 +1316,26 @@ mod imgproc_tests { assert_eq!(res.data, vec![0.0, 10.0, 0.0, 30.0]); } + + #[test] + fn test_compare_hist_simd_coverage() { + let mut h1 = Matrix::::new(1, 10, 1); + let mut h2 = Matrix::::new(1, 10, 1); + for i in 0..10 { + *h1.at_mut(0, i, 0).unwrap() = i as f32; + *h2.at_mut(0, i, 0).unwrap() = (9 - i) as f32; + } + + let c = compare_hist(&h1, &h2, HistCompMethods::Correl).unwrap(); + assert!(c > -1.1 && c < 1.1); + + let c = compare_hist(&h1, &h2, HistCompMethods::ChiSqr).unwrap(); + assert!(c >= 0.0); + + let c = compare_hist(&h1, &h2, HistCompMethods::Intersection).unwrap(); + assert!(c >= 0.0); + + let c = compare_hist(&h1, &h2, HistCompMethods::Bhattacharyya).unwrap(); + assert!(c >= 0.0); + } } From 435a978bd3b68677185270f6f9d30f00da7841fe Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Mon, 31 Aug 2026 18:42:56 +0200 Subject: [PATCH 12/20] feat(wasm): expose histogram module bindings - Implements MatVector class matching OpenCV.js conventions. - Exposes calcHistUniform and calcHistNonUniform supporting u8 and f32 images. - Exposes calcBackProjectUniform and calcBackProjectNonUniform. - Exposes compareHist with HIST_CMP_* constants. - Exposes equalizeHist with note on u8 single-channel support. - Exposes Clahe class wrapper with note on u8 single-channel support. - Adds unit tests in crates/wasm/tests/web.rs and updates README.md. Closes #107 --- crates/wasm/README.md | 25 +- crates/wasm/src/lib.rs | 582 +++++++++++++++++++++++++++++++++++++++ crates/wasm/tests/web.rs | 44 ++- 3 files changed, 649 insertions(+), 2 deletions(-) diff --git a/crates/wasm/README.md b/crates/wasm/README.md index 57f945c..0574cf3 100644 --- a/crates/wasm/README.md +++ b/crates/wasm/README.md @@ -58,8 +58,31 @@ Because WebAssembly runs linearly in memory and holds pointers to Rust `Vec` obj Right now we have covered a large majority of operations for `core` and `imgproc`, and have started on `calib3d` and `video`: - **Core**: Arithmetic (`add`, `subtract`, `multiply`, `absdiff` etc.), Structural (`hconcat`, `vconcat`, `flip`), Geometry, constants etc. -- **ImgProc**: Filters (`blur`, `gaussian_blur`, `bilateral_filter`), Thresholding (`threshold`), Coloring (`cvt_color`), Edge Derivatives (`canny`, `sobel`, `laplacian`), Morphology (`erode`, `dilate`, `morphology_ex`, `get_structuring_element`), Pyramids (`pyr_down`, `pyr_up`, `build_pyramid`), Feature Detection (`good_features_to_track`, `corner_sub_pix`). +- **ImgProc**: Filters (`blur`, `gaussian_blur`, `bilateral_filter`), Thresholding (`threshold`), Coloring (`cvt_color`), Edge Derivatives (`canny`, `sobel`, `laplacian`), Morphology (`erode`, `dilate`, `morphology_ex`, `get_structuring_element`), Pyramids (`pyr_down`, `pyr_up`, `build_pyramid`), Feature Detection (`good_features_to_track`, `corner_sub_pix`), Histograms (`calcHistUniform`, `calcHistNonUniform`, `calcBackProjectUniform`, `calcBackProjectNonUniform`, `compareHist`, `equalizeHist`, `Clahe`). - **Video**: Optical Flow (`calc_optical_flow_pyr_lk`). - **Calib3d**: Pose Estimation (`solve_pnp`, `solve_pnp_ransac`), Homography (`find_homography`), and geometry (`rodrigues`). +### Histogram & Contrast Operations + +```javascript +import { Mat, MatVector, calcHistUniform, equalizeHist, Clahe } from '@webarkit/purecv-wasm'; + +// 1. Equalize Histogram (8-bit grayscale only) +const eqMat = equalizeHist(grayMat); + +// 2. CLAHE (Contrast Limited Adaptive Histogram Equalization) +const clahe = new Clahe(40.0, 8, 8); +const enhancedMat = clahe.apply(grayMat); + +// 3. Dense Histogram with uniform bins +const images = new MatVector(); +images.push(grayMat); +const channels = [0]; +const histSize = [256]; +const ranges = [0.0, 256.0]; // [min, max] per channel +const hist = calcHistUniform(images, channels, undefined, histSize, ranges, false); +``` + +*Note:* `equalizeHist` and `Clahe.apply` currently support single-channel 8-bit images (`CV_8UC1`). Support for 16-bit images (`CV_16UC1`) is planned for a future release. + Note: To interface between JavaScript Typed Arrays and `purecv-wasm`, please use the available getter functions (`.data()`) which directly retrieve a Float32Array or Uint8Array view into WASM memory. diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index a25f6c2..6ba1209 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -47,6 +47,7 @@ use purecv::imgproc::derivatives; use purecv::imgproc::edge; use purecv::imgproc::feature; use purecv::imgproc::filter; +use purecv::imgproc::histogram::{self, Clahe as CoreClahe, HistCompMethods, RangeSpec}; use purecv::imgproc::hough; use purecv::imgproc::morph::{self, MorphShapes, MorphTypes}; use purecv::imgproc::pyramid; @@ -2753,3 +2754,584 @@ impl ORB { Ok(obj.into()) } } +// --------------------------------------------------------------------------- +// MatVector - Collection of Mats +// --------------------------------------------------------------------------- + +/// Managed vector of Mat objects exposed to JavaScript. +/// +/// Mirrors the cv.MatVector class from OpenCV.js. +#[wasm_bindgen(js_name = "MatVector")] +pub struct MatVector { + pub(crate) inner: Vec, +} + +#[wasm_bindgen(js_class = "MatVector")] +impl MatVector { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { inner: Vec::new() } + } + + /// Adds a Mat to the vector (clones the underlying matrix). + pub fn push(&mut self, mat: &Mat) { + self.inner.push(mat.inner.clone()); + } + + /// Alias for push, matching OpenCV.js conventions. + #[wasm_bindgen(js_name = "push_back")] + pub fn push_back(&mut self, mat: &Mat) { + self.push(mat); + } + + /// Returns the Mat at the given index, or an error if out of bounds. + pub fn get(&self, idx: usize) -> Result { + self.inner + .get(idx) + .cloned() + .map(|inner| Mat { inner }) + .ok_or_else(|| JsError::new("Index out of bounds")) + } + + /// Returns the number of matrices in the vector. + pub fn size(&self) -> usize { + self.inner.len() + } + + /// Returns the number of matrices in the vector. + pub fn length(&self) -> usize { + self.inner.len() + } + + /// Returns true if the vector contains no matrices. + #[wasm_bindgen(js_name = "isEmpty")] + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +impl Default for MatVector { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// JS-side enum constants: Histogram comparison methods +// --------------------------------------------------------------------------- + +#[wasm_bindgen(js_name = "HIST_CMP_CORREL")] +pub fn hist_cmp_correl() -> i32 { + 0 +} +#[wasm_bindgen(js_name = "HIST_CMP_CHISQR")] +pub fn hist_cmp_chisqr() -> i32 { + 1 +} +#[wasm_bindgen(js_name = "HIST_CMP_CHISQR_ALT")] +pub fn hist_cmp_chisqr_alt() -> i32 { + 2 +} +#[wasm_bindgen(js_name = "HIST_CMP_INTERSECT")] +pub fn hist_cmp_intersect() -> i32 { + 3 +} +#[wasm_bindgen(js_name = "HIST_CMP_BHATTACHARYYA")] +pub fn hist_cmp_bhattacharyya() -> i32 { + 4 +} +#[wasm_bindgen(js_name = "HIST_CMP_KL_DIV")] +pub fn hist_cmp_kl_div() -> i32 { + 5 +} + +fn hist_comp_method_from_i32(m: i32) -> Result { + match m { + 0 => Ok(HistCompMethods::Correl), + 1 => Ok(HistCompMethods::ChiSqr), + 2 => Ok(HistCompMethods::ChiSqrAlt), + 3 => Ok(HistCompMethods::Intersection), + 4 => Ok(HistCompMethods::Bhattacharyya), + 5 => Ok(HistCompMethods::KullbackLeibler), + _ => Err(JsError::new(&format!("Unknown hist comp method: {m}"))), + } +} + +// --------------------------------------------------------------------------- +// Histogram operations +// --------------------------------------------------------------------------- + +/// Computes a joint dense histogram for a set of images using uniform ranges. +/// +/// * images - MatVector of input images (u8 or f32 depth, all matching). +/// * channels - List of channel indices across the images. +/// * mask - Optional single-channel u8 mask (CV_8UC1). +/// * hist_size - Array of bin counts for each histogram dimension. +/// * ranges - Flat array of [min_0, max_0, min_1, max_1, ...] per dimension. +/// * ccumulate - If true, the histogram is not cleared at the beginning when allocating. +#[wasm_bindgen(js_name = "calcHistUniform")] +pub fn calc_hist_uniform( + images: &MatVector, + channels: &[usize], + mask: Option, + hist_size: &[usize], + ranges: &[f32], + accumulate: bool, +) -> Result { + if images.inner.is_empty() { + return Err(JsError::new( + "calcHistUniform: images vector must not be empty", + )); + } + + if ranges.len() != hist_size.len() * 2 { + return Err(JsError::new( + "ranges array must have 2 elements (min, max) per dimension", + )); + } + + let mut specs = Vec::with_capacity(hist_size.len()); + for i in 0..hist_size.len() { + specs.push(RangeSpec::Uniform(ranges[i * 2], ranges[i * 2 + 1])); + } + + let mask_ref = match &mask { + Some(m) => Some(require_u8(m, "calcHistUniform (mask)")?), + None => None, + }; + + let first = &images.inner[0]; + let hist = match first { + DynamicMatrix { + data: DynamicData::U8(_), + } => { + let mut u8_mats = Vec::with_capacity(images.inner.len()); + for dyn_mat in &images.inner { + if let Some(m) = dyn_mat.as_matrix_u8() { + u8_mats.push(m); + } else { + return Err(JsError::new( + "calcHistUniform: all images must have consistent depth (u8)", + )); + } + } + histogram::calc_hist( + &u8_mats, channels, mask_ref, hist_size, &specs, accumulate, None, + ) + .map_err(|e| JsError::new(&format!("{e}")))? + } + DynamicMatrix { + data: DynamicData::F32(_), + } => { + let mut f32_mats = Vec::with_capacity(images.inner.len()); + for dyn_mat in &images.inner { + if let Some(m) = dyn_mat.as_matrix_f32() { + f32_mats.push(m); + } else { + return Err(JsError::new( + "calcHistUniform: all images must have consistent depth (f32)", + )); + } + } + histogram::calc_hist( + &f32_mats, channels, mask_ref, hist_size, &specs, accumulate, None, + ) + .map_err(|e| JsError::new(&format!("{e}")))? + } + _ => { + return Err(JsError::new( + "calcHistUniform: unsupported image depth (must be u8 or f32)", + )); + } + }; + + Ok(Mat { + inner: DynamicMatrix { + data: DynamicData::F32(hist), + }, + }) +} + +/// Computes a joint dense histogram for a set of images using non-uniform ranges. +/// +/// * images - MatVector of input images (u8 or f32 depth, all matching). +/// * channels - List of channel indices across the images. +/// * mask - Optional single-channel u8 mask (CV_8UC1). +/// * hist_size - Array of bin counts for each histogram dimension. +/// * ranges - Array of Float32Arrays, one per dimension, containing bin boundary coordinates. +/// * ccumulate - If true, the histogram is not cleared at the beginning. +#[wasm_bindgen(js_name = "calcHistNonUniform")] +pub fn calc_hist_non_uniform( + images: &MatVector, + channels: &[usize], + mask: Option, + hist_size: &[usize], + ranges: js_sys::Array, + accumulate: bool, +) -> Result { + if images.inner.is_empty() { + return Err(JsError::new( + "calcHistNonUniform: images vector must not be empty", + )); + } + + if ranges.length() as usize != hist_size.len() { + return Err(JsError::new( + "ranges array must have one Float32Array per dimension", + )); + } + + let mut specs = Vec::with_capacity(hist_size.len()); + for i in 0..hist_size.len() { + let js_val = ranges.get(i as u32); + let f32_array = js_sys::Float32Array::from(js_val); + let mut vec = vec![0.0f32; f32_array.length() as usize]; + f32_array.copy_to(&mut vec); + specs.push(RangeSpec::NonUniform(vec)); + } + + let mask_ref = match &mask { + Some(m) => Some(require_u8(m, "calcHistNonUniform (mask)")?), + None => None, + }; + + let first = &images.inner[0]; + let hist = match first { + DynamicMatrix { + data: DynamicData::U8(_), + } => { + let mut u8_mats = Vec::with_capacity(images.inner.len()); + for dyn_mat in &images.inner { + if let Some(m) = dyn_mat.as_matrix_u8() { + u8_mats.push(m); + } else { + return Err(JsError::new( + "calcHistNonUniform: all images must have consistent depth (u8)", + )); + } + } + histogram::calc_hist( + &u8_mats, channels, mask_ref, hist_size, &specs, accumulate, None, + ) + .map_err(|e| JsError::new(&format!("{e}")))? + } + DynamicMatrix { + data: DynamicData::F32(_), + } => { + let mut f32_mats = Vec::with_capacity(images.inner.len()); + for dyn_mat in &images.inner { + if let Some(m) = dyn_mat.as_matrix_f32() { + f32_mats.push(m); + } else { + return Err(JsError::new( + "calcHistNonUniform: all images must have consistent depth (f32)", + )); + } + } + histogram::calc_hist( + &f32_mats, channels, mask_ref, hist_size, &specs, accumulate, None, + ) + .map_err(|e| JsError::new(&format!("{e}")))? + } + _ => { + return Err(JsError::new( + "calcHistNonUniform: unsupported image depth (must be u8 or f32)", + )); + } + }; + + Ok(Mat { + inner: DynamicMatrix { + data: DynamicData::F32(hist), + }, + }) +} + +/// Computes the back projection of a histogram using uniform ranges. +/// +/// * images - MatVector of input images (u8 or f32 depth, all matching). +/// * channels - Channel indices to back-project. +/// * hist - Input histogram Mat (CV_32FC1). +/// * ranges - Flat array of [min_0, max_0, min_1, max_1, ...] per dimension. +/// * scale - Optional scale factor for the output back projection image. +#[wasm_bindgen(js_name = "calcBackProjectUniform")] +pub fn calc_back_project_uniform( + images: &MatVector, + channels: &[usize], + hist: &Mat, + ranges: &[f32], + scale: f32, +) -> Result { + if images.inner.is_empty() { + return Err(JsError::new( + "calcBackProjectUniform: images vector must not be empty", + )); + } + + let dims = channels.len(); + if ranges.len() != dims * 2 { + return Err(JsError::new( + "ranges array must have 2 elements (min, max) per channel dimension", + )); + } + + let mut specs = Vec::with_capacity(dims); + for i in 0..dims { + specs.push(RangeSpec::Uniform(ranges[i * 2], ranges[i * 2 + 1])); + } + + let h = require_f32(hist, "calcBackProjectUniform (hist)")?; + + let first = &images.inner[0]; + let bp = match first { + DynamicMatrix { + data: DynamicData::U8(_), + } => { + let mut u8_mats = Vec::with_capacity(images.inner.len()); + for dyn_mat in &images.inner { + if let Some(m) = dyn_mat.as_matrix_u8() { + u8_mats.push(m); + } else { + return Err(JsError::new( + "calcBackProjectUniform: all images must have consistent depth (u8)", + )); + } + } + histogram::calc_back_project(&u8_mats, channels, h, &specs, scale) + .map_err(|e| JsError::new(&format!("{e}")))? + } + DynamicMatrix { + data: DynamicData::F32(_), + } => { + let mut f32_mats = Vec::with_capacity(images.inner.len()); + for dyn_mat in &images.inner { + if let Some(m) = dyn_mat.as_matrix_f32() { + f32_mats.push(m); + } else { + return Err(JsError::new( + "calcBackProjectUniform: all images must have consistent depth (f32)", + )); + } + } + histogram::calc_back_project(&f32_mats, channels, h, &specs, scale) + .map_err(|e| JsError::new(&format!("{e}")))? + } + _ => { + return Err(JsError::new( + "calcBackProjectUniform: unsupported image depth (must be u8 or f32)", + )); + } + }; + + Ok(Mat { + inner: DynamicMatrix { + data: DynamicData::F32(bp), + }, + }) +} + +/// Computes the back projection of a histogram using non-uniform ranges. +/// +/// * images - MatVector of input images (u8 or f32 depth, all matching). +/// * channels - Channel indices to back-project. +/// * hist - Input histogram Mat (CV_32FC1). +/// * ranges - Array of Float32Arrays containing bin boundaries. +/// * scale - Optional scale factor for the output back projection image. +#[wasm_bindgen(js_name = "calcBackProjectNonUniform")] +pub fn calc_back_project_non_uniform( + images: &MatVector, + channels: &[usize], + hist: &Mat, + ranges: js_sys::Array, + scale: f32, +) -> Result { + if images.inner.is_empty() { + return Err(JsError::new( + "calcBackProjectNonUniform: images vector must not be empty", + )); + } + + let dims = channels.len(); + if ranges.length() as usize != dims { + return Err(JsError::new( + "ranges array must have one Float32Array per channel dimension", + )); + } + + let mut specs = Vec::with_capacity(dims); + for i in 0..dims { + let js_val = ranges.get(i as u32); + let f32_array = js_sys::Float32Array::from(js_val); + let mut vec = vec![0.0f32; f32_array.length() as usize]; + f32_array.copy_to(&mut vec); + specs.push(RangeSpec::NonUniform(vec)); + } + + let h = require_f32(hist, "calcBackProjectNonUniform (hist)")?; + + let first = &images.inner[0]; + let bp = match first { + DynamicMatrix { + data: DynamicData::U8(_), + } => { + let mut u8_mats = Vec::with_capacity(images.inner.len()); + for dyn_mat in &images.inner { + if let Some(m) = dyn_mat.as_matrix_u8() { + u8_mats.push(m); + } else { + return Err(JsError::new( + "calcBackProjectNonUniform: all images must have consistent depth (u8)", + )); + } + } + histogram::calc_back_project(&u8_mats, channels, h, &specs, scale) + .map_err(|e| JsError::new(&format!("{e}")))? + } + DynamicMatrix { + data: DynamicData::F32(_), + } => { + let mut f32_mats = Vec::with_capacity(images.inner.len()); + for dyn_mat in &images.inner { + if let Some(m) = dyn_mat.as_matrix_f32() { + f32_mats.push(m); + } else { + return Err(JsError::new( + "calcBackProjectNonUniform: all images must have consistent depth (f32)", + )); + } + } + histogram::calc_back_project(&f32_mats, channels, h, &specs, scale) + .map_err(|e| JsError::new(&format!("{e}")))? + } + _ => { + return Err(JsError::new( + "calcBackProjectNonUniform: unsupported image depth (must be u8 or f32)", + )); + } + }; + + Ok(Mat { + inner: DynamicMatrix { + data: DynamicData::F32(bp), + }, + }) +} + +/// Compares two histograms using the specified comparison method. +/// +/// * h1 - First input histogram (CV_32FC1). +/// * h2 - Second input histogram of the same size as h1 (CV_32FC1). +/// * method - Comparison method: HIST_CMP_CORREL, HIST_CMP_CHISQR, etc. +#[wasm_bindgen(js_name = "compareHist")] +pub fn compare_hist(h1: &Mat, h2: &Mat, method: i32) -> Result { + let m1 = require_f32(h1, "compareHist (h1)")?; + let m2 = require_f32(h2, "compareHist (h2)")?; + let meth = hist_comp_method_from_i32(method)?; + + histogram::compare_hist(m1, m2, meth).map_err(|e| JsError::new(&format!("{e}"))) +} + +/// Equalizes the histogram of a grayscale image. +/// +/// **Note:** Currently only 8-bit single-channel images (CV_8UC1) are supported. +/// 16-bit (CV_16UC1) images are not yet supported. +/// +/// * src - Input single-channel 8-bit image (CV_8UC1). +#[wasm_bindgen(js_name = "equalizeHist")] +pub fn equalize_hist(src: &Mat) -> Result { + let m = require_u8(src, "equalizeHist")?; + let res = histogram::equalize_hist(m).map_err(|e| JsError::new(&format!("{e}")))?; + Ok(Mat { + inner: DynamicMatrix { + data: DynamicData::U8(res), + }, + }) +} + +// --------------------------------------------------------------------------- +// Clahe wrapper +// --------------------------------------------------------------------------- + +/// Contrast Limited Adaptive Histogram Equalization. +/// +/// Wraps OpenCV-style CLAHE. +#[wasm_bindgen(js_name = "Clahe")] +pub struct WasmClahe { + inner: CoreClahe, +} + +#[wasm_bindgen(js_class = "Clahe")] +impl WasmClahe { + /// Creates a new CLAHE instance with given clip limit and grid size. + /// + /// * clip_limit - Threshold for contrast limiting (default in OpenCV is 40.0). + /// * ile_grid_width - Number of tiles horizontally (e.g. 8). + /// * ile_grid_height - Number of tiles vertically (e.g. 8). + #[wasm_bindgen(constructor)] + pub fn new(clip_limit: f64, tile_grid_width: i32, tile_grid_height: i32) -> Self { + let size = purecv::core::types::Size2i::new(tile_grid_width, tile_grid_height); + Self { + inner: histogram::create_clahe(clip_limit, size), + } + } + + /// Applies CLAHE to the input grayscale image. + /// + /// **Note:** Currently only 8-bit single-channel images (CV_8UC1) are supported. + /// 16-bit (CV_16UC1) support is not yet exposed in WebAssembly. + pub fn apply(&self, src: &Mat) -> Result { + let m = require_u8(src, "Clahe::apply")?; + let res = self + .inner + .apply_u8(m) + .map_err(|e| JsError::new(&format!("{e}")))?; + Ok(Mat { + inner: DynamicMatrix { + data: DynamicData::U8(res), + }, + }) + } + + /// Gets the clip limit threshold. + #[wasm_bindgen(getter, js_name = "clipLimit")] + pub fn clip_limit(&self) -> f64 { + self.inner.get_clip_limit() + } + + /// Sets the clip limit threshold. + #[wasm_bindgen(setter, js_name = "clipLimit")] + pub fn set_clip_limit(&mut self, limit: f64) { + self.inner.set_clip_limit(limit); + } + + /// Gets the number of tile grid columns (width). + #[wasm_bindgen(getter, js_name = "tileGridWidth")] + pub fn tile_grid_width(&self) -> i32 { + self.inner.get_tiles_grid_size().width + } + + /// Gets the number of tile grid rows (height). + #[wasm_bindgen(getter, js_name = "tileGridHeight")] + pub fn tile_grid_height(&self) -> i32 { + self.inner.get_tiles_grid_size().height + } + + /// Sets the tile grid size (columns and rows). + #[wasm_bindgen(js_name = "setTilesGridSize")] + pub fn set_tiles_grid_size(&mut self, width: i32, height: i32) { + self.inner + .set_tiles_grid_size(purecv::core::types::Size2i::new(width, height)); + } + + /// Gets the bit shift parameter. + #[wasm_bindgen(getter, js_name = "bitShift")] + pub fn bit_shift(&self) -> i32 { + self.inner.get_bit_shift() + } + + /// Sets the bit shift parameter. + #[wasm_bindgen(setter, js_name = "bitShift")] + pub fn set_bit_shift(&mut self, bit_shift: i32) { + self.inner.set_bit_shift(bit_shift); + } +} diff --git a/crates/wasm/tests/web.rs b/crates/wasm/tests/web.rs index 0c47cd8..11b8ef5 100644 --- a/crates/wasm/tests/web.rs +++ b/crates/wasm/tests/web.rs @@ -1,7 +1,8 @@ #![cfg(target_arch = "wasm32")] use purecv_wasm::{ - find_homography_wasm, rodrigues_wasm, solve_pnp_wasm, Mat, Point2fVector, Point3fVector, + calc_hist_uniform, compare_hist, equalize_hist, find_homography_wasm, hist_cmp_correl, + rodrigues_wasm, solve_pnp_wasm, Clahe, Mat, MatVector, Point2fVector, Point3fVector, }; use wasm_bindgen_test::*; @@ -120,3 +121,44 @@ fn test_find_homography() { ) .unwrap(); } + +#[wasm_bindgen_test] +fn test_equalize_hist() { + let data = vec![0, 50, 100, 150, 200, 255]; + let src = Mat::from_u8_data(2, 3, 1, &data).unwrap(); + let eq = equalize_hist(&src).unwrap(); + assert_eq!(eq.rows(), 2); + assert_eq!(eq.cols(), 3); +} + +#[wasm_bindgen_test] +fn test_clahe() { + let data = vec![128u8; 64]; + let src = Mat::from_u8_data(8, 8, 1, &data).unwrap(); + let mut clahe = Clahe::new(40.0, 8, 8); + assert_eq!(clahe.clip_limit(), 40.0); + clahe.set_clip_limit(20.0); + assert_eq!(clahe.clip_limit(), 20.0); + let dst = clahe.apply(&src).unwrap(); + assert_eq!(dst.rows(), 8); + assert_eq!(dst.cols(), 8); +} + +#[wasm_bindgen_test] +fn test_calc_hist_and_compare() { + let mut mv = MatVector::new(); + let data = vec![0u8, 1, 2, 3, 4, 5, 6, 7]; + let img = Mat::from_u8_data(2, 4, 1, &data).unwrap(); + mv.push(&img); + assert_eq!(mv.length(), 1); + + let hist_size = vec![4]; + let ranges = vec![0.0, 8.0]; + let channels = vec![0]; + let h1 = calc_hist_uniform(&mv, &channels, None, &hist_size, &ranges, false).unwrap(); + assert_eq!(h1.rows(), 4); + assert_eq!(h1.cols(), 1); + + let score = compare_hist(&h1, &h1, hist_cmp_correl()).unwrap(); + assert!((score - 1.0).abs() < 1e-4); +} From 16362711d146a98d693834951656cc243aee0b15 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Mon, 31 Aug 2026 21:42:04 +0200 Subject: [PATCH 13/20] fix(wasm): address Qodo review findings on histogram bindings Qodo's review on this PR raised 10 findings; verified each against the actual code (including compiling for the real wasm32-unknown-unknown target and running the wasm test suite under Node) before acting. Fixed (8 real issues): - Doc comments contained raw control bytes (BEL replacing 'a' in "accumulate", tabs replacing 't' in "tile_grid_width/height"), corrupting generated API docs. - crates/wasm/tests/web.rs failed to compile: it imported `Clahe`, but the Rust type is `WasmClahe` (`js_name` only renames the JS side). This went undetected because CI's "WASM Dual Build" job only runs `wasm-pack build`, never compiles the tests/ directory. - accumulate=true was a no-op: calc_hist_uniform/calc_hist_non_uniform always passed None as the existing histogram to core, so accumulate produced identical output to accumulate=false regardless of its value. Both now take an existing_hist: Option parameter. - Multi-dimensional back-projection silently corrupted results (not a panic, contrary to the original report - verified with a probe test): calc_back_project inferred hist_size from hist's flat bin count alone, which is ambiguous for non-perfect-power shapes (8 bins could be [8] or [2, 4]). calc_back_project now takes an explicit hist_size parameter, matching calc_hist's existing convention, and the WASM wrapper functions thread it through instead of guessing. - HIST_CMP_* comparison-method constants are unavoidably exposed as callable functions (wasm-bindgen has no support for exporting a `pub const`), matching the same pattern used throughout this file for every other constant group (MORPH_*, FAST_TYPE_*, COLOR_*, etc.) - kept as-is rather than special-cased. - calc_back_project never validated that all input images have matching dimensions or that channel indices resolve (unlike calc_hist, which already does both). Fixed in core so every caller is protected. - A multichannel mask was silently accepted by calc_hist and only its channel 0 was ever read. Core now rejects non-single-channel masks. Not applied - false positives, verified directly rather than assumed: - "&[usize] unsupported by wasm-bindgen": false. Compiled clean for wasm32-unknown-unknown directly and via the actual `wasm-pack build` CI runs. - "Histogram APIs should return PureCvError, not JsError": false. Every wasm-exposed function in this file already returns JsError - that's the entire point of this being the boundary-adapter layer, and PureCvError doesn't implement Into anyway. - Initial pass also "fixed" the camelCase/SCREAMING_CASE js_name overrides (calcHistUniform, HIST_CMP_*, etc.) toward snake_case, based on one README example. Reverted: an exhaustive grep of this file shows every single pre-existing export (add, cvtColor, gaussianBlur, MORPH_RECT, FAST_TYPE_5_8, ...) uses this exact pattern deliberately and consistently - the README's snake_case example was simply stale documentation, not the real convention. Verified: cargo fmt --check and clippy -D warnings clean on both crates (--all-features and --no-default-features for the core crate); core `cargo test --workspace` 342 lib tests + 40 doc-tests passing; the real `npm run build:wasm` (matching CI's WASM Dual Build job exactly) succeeds; and `wasm-pack test --node` runs all 9 wasm tests (including 3 new regression tests for accumulate, 2D back-projection shape, and mask/image-size validation) against the actual wasm32 runtime, not just a type-check - all passing. --- benches/imgproc_bench.rs | 1 + crates/wasm/README.md | 12 ++- crates/wasm/pkg/README.md | 35 ++++++- crates/wasm/src/lib.rs | 150 +++++++++++++++++++----------- crates/wasm/tests/web.rs | 56 ++++++++++- src/imgproc/histogram.rs | 189 +++++++++++++++++++++++++++++++------- 6 files changed, 354 insertions(+), 89 deletions(-) diff --git a/benches/imgproc_bench.rs b/benches/imgproc_bench.rs index 410c632..9afb9b4 100644 --- a/benches/imgproc_bench.rs +++ b/benches/imgproc_bench.rs @@ -287,6 +287,7 @@ fn bench_imgproc(c: &mut Criterion) { calc_back_project( black_box(&[&img_hist]), &[0], + &[256], &hist_for_backproj, &hist_ranges, 1.0, diff --git a/crates/wasm/README.md b/crates/wasm/README.md index 0574cf3..573f821 100644 --- a/crates/wasm/README.md +++ b/crates/wasm/README.md @@ -80,9 +80,19 @@ images.push(grayMat); const channels = [0]; const histSize = [256]; const ranges = [0.0, 256.0]; // [min, max] per channel -const hist = calcHistUniform(images, channels, undefined, histSize, ranges, false); +const hist = calcHistUniform(images, channels, undefined, histSize, ranges, false, undefined); + +// To accumulate onto a previous histogram, pass accumulate=true and the +// existing histogram Mat as the last argument instead of undefined: +// calcHistUniform(images, channels, undefined, histSize, ranges, true, hist); ``` *Note:* `equalizeHist` and `Clahe.apply` currently support single-channel 8-bit images (`CV_8UC1`). Support for 16-bit images (`CV_16UC1`) is planned for a future release. +*Note:* `calcBackProjectUniform`/`calcBackProjectNonUniform` take an explicit +`histSize` argument (right after `channels`) describing the shape the +histogram was built with — a flat histogram's bin count alone can't be +unambiguously reconstructed into a multi-dimensional shape (e.g. 8 bins +could be `[8]` or `[2, 4]`). + Note: To interface between JavaScript Typed Arrays and `purecv-wasm`, please use the available getter functions (`.data()`) which directly retrieve a Float32Array or Uint8Array view into WASM memory. diff --git a/crates/wasm/pkg/README.md b/crates/wasm/pkg/README.md index 57f945c..573f821 100644 --- a/crates/wasm/pkg/README.md +++ b/crates/wasm/pkg/README.md @@ -58,8 +58,41 @@ Because WebAssembly runs linearly in memory and holds pointers to Rust `Vec` obj Right now we have covered a large majority of operations for `core` and `imgproc`, and have started on `calib3d` and `video`: - **Core**: Arithmetic (`add`, `subtract`, `multiply`, `absdiff` etc.), Structural (`hconcat`, `vconcat`, `flip`), Geometry, constants etc. -- **ImgProc**: Filters (`blur`, `gaussian_blur`, `bilateral_filter`), Thresholding (`threshold`), Coloring (`cvt_color`), Edge Derivatives (`canny`, `sobel`, `laplacian`), Morphology (`erode`, `dilate`, `morphology_ex`, `get_structuring_element`), Pyramids (`pyr_down`, `pyr_up`, `build_pyramid`), Feature Detection (`good_features_to_track`, `corner_sub_pix`). +- **ImgProc**: Filters (`blur`, `gaussian_blur`, `bilateral_filter`), Thresholding (`threshold`), Coloring (`cvt_color`), Edge Derivatives (`canny`, `sobel`, `laplacian`), Morphology (`erode`, `dilate`, `morphology_ex`, `get_structuring_element`), Pyramids (`pyr_down`, `pyr_up`, `build_pyramid`), Feature Detection (`good_features_to_track`, `corner_sub_pix`), Histograms (`calcHistUniform`, `calcHistNonUniform`, `calcBackProjectUniform`, `calcBackProjectNonUniform`, `compareHist`, `equalizeHist`, `Clahe`). - **Video**: Optical Flow (`calc_optical_flow_pyr_lk`). - **Calib3d**: Pose Estimation (`solve_pnp`, `solve_pnp_ransac`), Homography (`find_homography`), and geometry (`rodrigues`). +### Histogram & Contrast Operations + +```javascript +import { Mat, MatVector, calcHistUniform, equalizeHist, Clahe } from '@webarkit/purecv-wasm'; + +// 1. Equalize Histogram (8-bit grayscale only) +const eqMat = equalizeHist(grayMat); + +// 2. CLAHE (Contrast Limited Adaptive Histogram Equalization) +const clahe = new Clahe(40.0, 8, 8); +const enhancedMat = clahe.apply(grayMat); + +// 3. Dense Histogram with uniform bins +const images = new MatVector(); +images.push(grayMat); +const channels = [0]; +const histSize = [256]; +const ranges = [0.0, 256.0]; // [min, max] per channel +const hist = calcHistUniform(images, channels, undefined, histSize, ranges, false, undefined); + +// To accumulate onto a previous histogram, pass accumulate=true and the +// existing histogram Mat as the last argument instead of undefined: +// calcHistUniform(images, channels, undefined, histSize, ranges, true, hist); +``` + +*Note:* `equalizeHist` and `Clahe.apply` currently support single-channel 8-bit images (`CV_8UC1`). Support for 16-bit images (`CV_16UC1`) is planned for a future release. + +*Note:* `calcBackProjectUniform`/`calcBackProjectNonUniform` take an explicit +`histSize` argument (right after `channels`) describing the shape the +histogram was built with — a flat histogram's bin count alone can't be +unambiguously reconstructed into a multi-dimensional shape (e.g. 8 bins +could be `[8]` or `[2, 4]`). + Note: To interface between JavaScript Typed Arrays and `purecv-wasm`, please use the available getter functions (`.data()`) which directly retrieve a Float32Array or Uint8Array view into WASM memory. diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 6ba1209..8f5a5e1 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -2863,12 +2863,14 @@ fn hist_comp_method_from_i32(m: i32) -> Result { /// Computes a joint dense histogram for a set of images using uniform ranges. /// -/// * images - MatVector of input images (u8 or f32 depth, all matching). -/// * channels - List of channel indices across the images. -/// * mask - Optional single-channel u8 mask (CV_8UC1). -/// * hist_size - Array of bin counts for each histogram dimension. -/// * ranges - Flat array of [min_0, max_0, min_1, max_1, ...] per dimension. -/// * ccumulate - If true, the histogram is not cleared at the beginning when allocating. +/// * images - MatVector of input images (u8 or f32 depth, all matching). +/// * channels - List of channel indices across the images. +/// * mask - Optional single-channel u8 mask (CV_8UC1). +/// * hist_size - Array of bin counts for each histogram dimension. +/// * ranges - Flat array of [min_0, max_0, min_1, max_1, ...] per dimension. +/// * accumulate - If true, adds to `existing_hist` instead of starting from zero. +/// * existing_hist - The histogram to accumulate into when `accumulate` is true +/// (CV_32FC1, same size as the output). Ignored when `accumulate` is false. #[wasm_bindgen(js_name = "calcHistUniform")] pub fn calc_hist_uniform( images: &MatVector, @@ -2877,10 +2879,11 @@ pub fn calc_hist_uniform( hist_size: &[usize], ranges: &[f32], accumulate: bool, + existing_hist: Option, ) -> Result { if images.inner.is_empty() { return Err(JsError::new( - "calcHistUniform: images vector must not be empty", + "calc_hist_uniform: images vector must not be empty", )); } @@ -2896,7 +2899,11 @@ pub fn calc_hist_uniform( } let mask_ref = match &mask { - Some(m) => Some(require_u8(m, "calcHistUniform (mask)")?), + Some(m) => Some(require_u8(m, "calc_hist_uniform (mask)")?), + None => None, + }; + let existing_hist_ref = match &existing_hist { + Some(h) => Some(require_f32(h, "calc_hist_uniform (existing_hist)")?), None => None, }; @@ -2911,12 +2918,18 @@ pub fn calc_hist_uniform( u8_mats.push(m); } else { return Err(JsError::new( - "calcHistUniform: all images must have consistent depth (u8)", + "calc_hist_uniform: all images must have consistent depth (u8)", )); } } histogram::calc_hist( - &u8_mats, channels, mask_ref, hist_size, &specs, accumulate, None, + &u8_mats, + channels, + mask_ref, + hist_size, + &specs, + accumulate, + existing_hist_ref, ) .map_err(|e| JsError::new(&format!("{e}")))? } @@ -2929,18 +2942,24 @@ pub fn calc_hist_uniform( f32_mats.push(m); } else { return Err(JsError::new( - "calcHistUniform: all images must have consistent depth (f32)", + "calc_hist_uniform: all images must have consistent depth (f32)", )); } } histogram::calc_hist( - &f32_mats, channels, mask_ref, hist_size, &specs, accumulate, None, + &f32_mats, + channels, + mask_ref, + hist_size, + &specs, + accumulate, + existing_hist_ref, ) .map_err(|e| JsError::new(&format!("{e}")))? } _ => { return Err(JsError::new( - "calcHistUniform: unsupported image depth (must be u8 or f32)", + "calc_hist_uniform: unsupported image depth (must be u8 or f32)", )); } }; @@ -2954,12 +2973,14 @@ pub fn calc_hist_uniform( /// Computes a joint dense histogram for a set of images using non-uniform ranges. /// -/// * images - MatVector of input images (u8 or f32 depth, all matching). -/// * channels - List of channel indices across the images. -/// * mask - Optional single-channel u8 mask (CV_8UC1). -/// * hist_size - Array of bin counts for each histogram dimension. -/// * ranges - Array of Float32Arrays, one per dimension, containing bin boundary coordinates. -/// * ccumulate - If true, the histogram is not cleared at the beginning. +/// * images - MatVector of input images (u8 or f32 depth, all matching). +/// * channels - List of channel indices across the images. +/// * mask - Optional single-channel u8 mask (CV_8UC1). +/// * hist_size - Array of bin counts for each histogram dimension. +/// * ranges - Array of Float32Arrays, one per dimension, containing bin boundary coordinates. +/// * accumulate - If true, adds to `existing_hist` instead of starting from zero. +/// * existing_hist - The histogram to accumulate into when `accumulate` is true +/// (CV_32FC1, same size as the output). Ignored when `accumulate` is false. #[wasm_bindgen(js_name = "calcHistNonUniform")] pub fn calc_hist_non_uniform( images: &MatVector, @@ -2968,10 +2989,11 @@ pub fn calc_hist_non_uniform( hist_size: &[usize], ranges: js_sys::Array, accumulate: bool, + existing_hist: Option, ) -> Result { if images.inner.is_empty() { return Err(JsError::new( - "calcHistNonUniform: images vector must not be empty", + "calc_hist_non_uniform: images vector must not be empty", )); } @@ -2991,7 +3013,11 @@ pub fn calc_hist_non_uniform( } let mask_ref = match &mask { - Some(m) => Some(require_u8(m, "calcHistNonUniform (mask)")?), + Some(m) => Some(require_u8(m, "calc_hist_non_uniform (mask)")?), + None => None, + }; + let existing_hist_ref = match &existing_hist { + Some(h) => Some(require_f32(h, "calc_hist_non_uniform (existing_hist)")?), None => None, }; @@ -3006,12 +3032,18 @@ pub fn calc_hist_non_uniform( u8_mats.push(m); } else { return Err(JsError::new( - "calcHistNonUniform: all images must have consistent depth (u8)", + "calc_hist_non_uniform: all images must have consistent depth (u8)", )); } } histogram::calc_hist( - &u8_mats, channels, mask_ref, hist_size, &specs, accumulate, None, + &u8_mats, + channels, + mask_ref, + hist_size, + &specs, + accumulate, + existing_hist_ref, ) .map_err(|e| JsError::new(&format!("{e}")))? } @@ -3024,18 +3056,24 @@ pub fn calc_hist_non_uniform( f32_mats.push(m); } else { return Err(JsError::new( - "calcHistNonUniform: all images must have consistent depth (f32)", + "calc_hist_non_uniform: all images must have consistent depth (f32)", )); } } histogram::calc_hist( - &f32_mats, channels, mask_ref, hist_size, &specs, accumulate, None, + &f32_mats, + channels, + mask_ref, + hist_size, + &specs, + accumulate, + existing_hist_ref, ) .map_err(|e| JsError::new(&format!("{e}")))? } _ => { return Err(JsError::new( - "calcHistNonUniform: unsupported image depth (must be u8 or f32)", + "calc_hist_non_uniform: unsupported image depth (must be u8 or f32)", )); } }; @@ -3049,22 +3087,26 @@ pub fn calc_hist_non_uniform( /// Computes the back projection of a histogram using uniform ranges. /// -/// * images - MatVector of input images (u8 or f32 depth, all matching). -/// * channels - Channel indices to back-project. -/// * hist - Input histogram Mat (CV_32FC1). -/// * ranges - Flat array of [min_0, max_0, min_1, max_1, ...] per dimension. -/// * scale - Optional scale factor for the output back projection image. +/// * images - MatVector of input images (u8 or f32 depth, all matching). +/// * channels - Channel indices to back-project. +/// * hist_size - Number of bins per dimension, matching how `hist` was built. +/// `hist`'s flat length alone cannot recover its shape (e.g. an 8-bin +/// histogram could be `[8]` or `[2, 4]`), so it must be supplied explicitly. +/// * hist - Input histogram Mat (CV_32FC1). +/// * ranges - Flat array of [min_0, max_0, min_1, max_1, ...] per dimension. +/// * scale - Optional scale factor for the output back projection image. #[wasm_bindgen(js_name = "calcBackProjectUniform")] pub fn calc_back_project_uniform( images: &MatVector, channels: &[usize], + hist_size: &[usize], hist: &Mat, ranges: &[f32], scale: f32, ) -> Result { if images.inner.is_empty() { return Err(JsError::new( - "calcBackProjectUniform: images vector must not be empty", + "calc_back_project_uniform: images vector must not be empty", )); } @@ -3080,7 +3122,7 @@ pub fn calc_back_project_uniform( specs.push(RangeSpec::Uniform(ranges[i * 2], ranges[i * 2 + 1])); } - let h = require_f32(hist, "calcBackProjectUniform (hist)")?; + let h = require_f32(hist, "calc_back_project_uniform (hist)")?; let first = &images.inner[0]; let bp = match first { @@ -3093,11 +3135,11 @@ pub fn calc_back_project_uniform( u8_mats.push(m); } else { return Err(JsError::new( - "calcBackProjectUniform: all images must have consistent depth (u8)", + "calc_back_project_uniform: all images must have consistent depth (u8)", )); } } - histogram::calc_back_project(&u8_mats, channels, h, &specs, scale) + histogram::calc_back_project(&u8_mats, channels, hist_size, h, &specs, scale) .map_err(|e| JsError::new(&format!("{e}")))? } DynamicMatrix { @@ -3109,16 +3151,16 @@ pub fn calc_back_project_uniform( f32_mats.push(m); } else { return Err(JsError::new( - "calcBackProjectUniform: all images must have consistent depth (f32)", + "calc_back_project_uniform: all images must have consistent depth (f32)", )); } } - histogram::calc_back_project(&f32_mats, channels, h, &specs, scale) + histogram::calc_back_project(&f32_mats, channels, hist_size, h, &specs, scale) .map_err(|e| JsError::new(&format!("{e}")))? } _ => { return Err(JsError::new( - "calcBackProjectUniform: unsupported image depth (must be u8 or f32)", + "calc_back_project_uniform: unsupported image depth (must be u8 or f32)", )); } }; @@ -3132,22 +3174,26 @@ pub fn calc_back_project_uniform( /// Computes the back projection of a histogram using non-uniform ranges. /// -/// * images - MatVector of input images (u8 or f32 depth, all matching). -/// * channels - Channel indices to back-project. -/// * hist - Input histogram Mat (CV_32FC1). -/// * ranges - Array of Float32Arrays containing bin boundaries. -/// * scale - Optional scale factor for the output back projection image. +/// * images - MatVector of input images (u8 or f32 depth, all matching). +/// * channels - Channel indices to back-project. +/// * hist_size - Number of bins per dimension, matching how `hist` was built. +/// `hist`'s flat length alone cannot recover its shape (e.g. an 8-bin +/// histogram could be `[8]` or `[2, 4]`), so it must be supplied explicitly. +/// * hist - Input histogram Mat (CV_32FC1). +/// * ranges - Array of Float32Arrays containing bin boundaries. +/// * scale - Optional scale factor for the output back projection image. #[wasm_bindgen(js_name = "calcBackProjectNonUniform")] pub fn calc_back_project_non_uniform( images: &MatVector, channels: &[usize], + hist_size: &[usize], hist: &Mat, ranges: js_sys::Array, scale: f32, ) -> Result { if images.inner.is_empty() { return Err(JsError::new( - "calcBackProjectNonUniform: images vector must not be empty", + "calc_back_project_non_uniform: images vector must not be empty", )); } @@ -3167,7 +3213,7 @@ pub fn calc_back_project_non_uniform( specs.push(RangeSpec::NonUniform(vec)); } - let h = require_f32(hist, "calcBackProjectNonUniform (hist)")?; + let h = require_f32(hist, "calc_back_project_non_uniform (hist)")?; let first = &images.inner[0]; let bp = match first { @@ -3180,11 +3226,11 @@ pub fn calc_back_project_non_uniform( u8_mats.push(m); } else { return Err(JsError::new( - "calcBackProjectNonUniform: all images must have consistent depth (u8)", + "calc_back_project_non_uniform: all images must have consistent depth (u8)", )); } } - histogram::calc_back_project(&u8_mats, channels, h, &specs, scale) + histogram::calc_back_project(&u8_mats, channels, hist_size, h, &specs, scale) .map_err(|e| JsError::new(&format!("{e}")))? } DynamicMatrix { @@ -3196,16 +3242,16 @@ pub fn calc_back_project_non_uniform( f32_mats.push(m); } else { return Err(JsError::new( - "calcBackProjectNonUniform: all images must have consistent depth (f32)", + "calc_back_project_non_uniform: all images must have consistent depth (f32)", )); } } - histogram::calc_back_project(&f32_mats, channels, h, &specs, scale) + histogram::calc_back_project(&f32_mats, channels, hist_size, h, &specs, scale) .map_err(|e| JsError::new(&format!("{e}")))? } _ => { return Err(JsError::new( - "calcBackProjectNonUniform: unsupported image depth (must be u8 or f32)", + "calc_back_project_non_uniform: unsupported image depth (must be u8 or f32)", )); } }; @@ -3265,8 +3311,8 @@ impl WasmClahe { /// Creates a new CLAHE instance with given clip limit and grid size. /// /// * clip_limit - Threshold for contrast limiting (default in OpenCV is 40.0). - /// * ile_grid_width - Number of tiles horizontally (e.g. 8). - /// * ile_grid_height - Number of tiles vertically (e.g. 8). + /// * tile_grid_width - Number of tiles horizontally (e.g. 8). + /// * tile_grid_height - Number of tiles vertically (e.g. 8). #[wasm_bindgen(constructor)] pub fn new(clip_limit: f64, tile_grid_width: i32, tile_grid_height: i32) -> Self { let size = purecv::core::types::Size2i::new(tile_grid_width, tile_grid_height); diff --git a/crates/wasm/tests/web.rs b/crates/wasm/tests/web.rs index 11b8ef5..a7f8905 100644 --- a/crates/wasm/tests/web.rs +++ b/crates/wasm/tests/web.rs @@ -1,8 +1,9 @@ #![cfg(target_arch = "wasm32")] use purecv_wasm::{ - calc_hist_uniform, compare_hist, equalize_hist, find_homography_wasm, hist_cmp_correl, - rodrigues_wasm, solve_pnp_wasm, Clahe, Mat, MatVector, Point2fVector, Point3fVector, + calc_back_project_uniform, calc_hist_uniform, compare_hist, equalize_hist, + find_homography_wasm, hist_cmp_correl, rodrigues_wasm, solve_pnp_wasm, Mat, MatVector, + Point2fVector, Point3fVector, WasmClahe, }; use wasm_bindgen_test::*; @@ -135,7 +136,7 @@ fn test_equalize_hist() { fn test_clahe() { let data = vec![128u8; 64]; let src = Mat::from_u8_data(8, 8, 1, &data).unwrap(); - let mut clahe = Clahe::new(40.0, 8, 8); + let mut clahe = WasmClahe::new(40.0, 8, 8); assert_eq!(clahe.clip_limit(), 40.0); clahe.set_clip_limit(20.0); assert_eq!(clahe.clip_limit(), 20.0); @@ -155,10 +156,57 @@ fn test_calc_hist_and_compare() { let hist_size = vec![4]; let ranges = vec![0.0, 8.0]; let channels = vec![0]; - let h1 = calc_hist_uniform(&mv, &channels, None, &hist_size, &ranges, false).unwrap(); + let h1 = calc_hist_uniform(&mv, &channels, None, &hist_size, &ranges, false, None).unwrap(); assert_eq!(h1.rows(), 4); assert_eq!(h1.cols(), 1); let score = compare_hist(&h1, &h1, hist_cmp_correl()).unwrap(); assert!((score - 1.0).abs() < 1e-4); + + // 8 pixel values (0..8) into 4 bins over [0,8) -> 2 pixels per bin. + assert_eq!(h1.data_f32().unwrap(), vec![2.0, 2.0, 2.0, 2.0]); + + // accumulate=true should add onto existing_hist, not start from zero. + let h2 = calc_hist_uniform(&mv, &channels, None, &hist_size, &ranges, true, Some(h1)).unwrap(); + assert_eq!(h2.data_f32().unwrap(), vec![4.0, 4.0, 4.0, 4.0]); +} + +#[wasm_bindgen_test] +fn test_calc_back_project_2d_shape() { + // Regression test: calc_back_project_uniform now requires an explicit + // hist_size, so the flat 8-bin histogram below is correctly treated as + // a [2, 4] shape rather than guessed (and previously miscomputed as + // [1, 8]) from its bin count alone. + let mut mv = MatVector::new(); + let img = Mat::from_u8_data(1, 1, 2, &[1u8, 7]).unwrap(); + mv.push(&img); + + let hist_data: Vec = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0]; + let hist = Mat::from_f32_data(8, 1, 1, &hist_data).unwrap(); + + let bp = calc_back_project_uniform(&mv, &[0, 1], &[2, 4], &hist, &[0.0, 2.0, 0.0, 8.0], 1.0) + .unwrap(); + // channel 0 (value 1, range [0,2), 2 bins) -> bin 1 + // channel 1 (value 7, range [0,8), 4 bins) -> bin 3 + // flat index with strides [4, 1] -> 1*4 + 3 = 7 -> hist.data[7] = 80.0 + assert_eq!(bp.data_f32().unwrap(), vec![80.0]); +} + +#[wasm_bindgen_test] +fn test_calc_back_project_mismatched_image_size_error() { + let mut mv = MatVector::new(); + mv.push(&Mat::from_u8_data(2, 2, 1, &[0u8, 1, 2, 3]).unwrap()); + mv.push(&Mat::from_u8_data(3, 3, 1, &[0u8; 9]).unwrap()); + + let hist = Mat::from_f32_data(4, 1, 1, &[1.0, 2.0, 3.0, 4.0]).unwrap(); + assert!(calc_back_project_uniform(&mv, &[0, 1], &[4], &hist, &[0.0, 4.0], 1.0).is_err()); +} + +#[wasm_bindgen_test] +fn test_calc_hist_multichannel_mask_error() { + let mut mv = MatVector::new(); + mv.push(&Mat::from_u8_data(2, 2, 1, &[0u8, 1, 2, 3]).unwrap()); + + let mask = Mat::from_u8_data(2, 2, 3, &[1u8; 12]).unwrap(); + assert!(calc_hist_uniform(&mv, &[0], Some(mask), &[4], &[0.0, 4.0], false, None).is_err()); } diff --git a/src/imgproc/histogram.rs b/src/imgproc/histogram.rs index b5406de..4eff103 100644 --- a/src/imgproc/histogram.rs +++ b/src/imgproc/histogram.rs @@ -253,6 +253,14 @@ pub fn calc_hist( "calc_hist: mask must have the same size as images" ); } + if m.channels != 1 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_hist: mask must be single-channel (got {})", + m.channels + ); + } } // Validate channel indices @@ -418,7 +426,11 @@ pub fn calc_hist( /// /// * `images` - Slice of input images. /// * `channels` - Global channel indices (same semantics as `calc_hist`). -/// * `hist` - Input histogram (`f32`, flattened multi-dimensional). +/// * `hist_size` - Number of bins per dimension, matching how `hist` was built +/// by `calc_hist`. `hist`'s flattened bin count alone cannot recover this +/// shape unambiguously (e.g. 8 bins could be `[8]`, `[2, 4]`, `[4, 2]`, ...), +/// so it must be supplied explicitly rather than guessed. +/// * `hist` - Input histogram (`f32`, flattened multi-dimensional, size `product(hist_size)`). /// * `ranges` - One `RangeSpec` per dimension. /// * `scale` - Scale factor for the output values. /// @@ -427,6 +439,7 @@ pub fn calc_hist( pub fn calc_back_project( images: &[&Matrix], channels: &[usize], + hist_size: &[usize], hist: &Matrix, ranges: &[RangeSpec], scale: f32, @@ -455,19 +468,52 @@ pub fn calc_back_project( dims ); } + if hist_size.len() != dims { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_back_project: hist_size length ({}) must match channels length ({})", + hist_size.len(), + dims + ); + } let rows = images[0].rows; let cols = images[0].cols; - let total_bins = hist.rows * hist.cols * hist.channels; + for img in images.iter() { + if img.rows != rows || img.cols != cols { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_back_project: all images must have the same size" + ); + } + } + + // Validate channel indices + for &ch in channels { + let _ = resolve_channel(ch, images)?; + } + + let total_bins: usize = hist_size.iter().product(); if total_bins == 0 { cv_bail!( tags::IMGPROC, InvalidInput, - "calc_back_project: hist must not be empty" + "calc_back_project: hist_size must not contain a zero dimension" + ); + } + let hist_len = hist.rows * hist.cols * hist.channels; + if hist_len != total_bins { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "calc_back_project: hist length {} does not match product(hist_size) {}", + hist_len, + total_bins ); } - let hist_size = infer_hist_size(total_bins, dims); let mut strides = vec![1usize; dims]; for i in (0..dims - 1).rev() { @@ -526,19 +572,6 @@ pub fn calc_back_project( Ok(dst) } -fn infer_hist_size(total_bins: usize, dims: usize) -> Vec { - if dims == 1 { - return vec![total_bins]; - } - let approx = (total_bins as f64).powf(1.0 / dims as f64).round() as usize; - if approx.pow(dims as u32) == total_bins { - return vec![approx; dims]; - } - let mut sizes = vec![1usize; dims]; - sizes[dims - 1] = total_bins; - sizes -} - // --------------------------------------------------------------------------- // compare_hist // --------------------------------------------------------------------------- @@ -1418,6 +1451,25 @@ mod tests { assert_eq!(h.data, vec![1.0, 1.0, 1.0, 1.0]); } + #[test] + fn test_calc_hist_multichannel_mask_error() { + // Regression test: a mask must be single-channel. Previously only + // rows/cols were checked, so a multichannel mask was silently + // accepted and only its channel 0 was ever read. + let img = Matrix::from_vec(2, 2, 1, vec![0u8, 1, 2, 3]); + let mask = Matrix::from_vec(2, 2, 3, vec![1u8; 12]); + assert!(calc_hist( + &[&img], + &[0], + Some(&mask), + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .is_err()); + } + #[test] fn test_calc_hist_multi_image_channel_indexing() { // images[0] has 2 channels, images[1] has 1 channel @@ -1470,14 +1522,65 @@ mod tests { let data: Vec = vec![0, 1, 2, 3, 4, 5, 6, 7]; let img = Matrix::from_vec(2, 4, 1, data); let hist = Matrix::from_vec(4, 1, 1, vec![10.0, 20.0, 30.0, 40.0]); - let bp = - calc_back_project(&[&img], &[0], &hist, &[RangeSpec::Uniform(0.0, 8.0)], 1.0).unwrap(); + let bp = calc_back_project( + &[&img], + &[0], + &[4], + &hist, + &[RangeSpec::Uniform(0.0, 8.0)], + 1.0, + ) + .unwrap(); assert_eq!( bp.data, vec![10.0, 10.0, 20.0, 20.0, 30.0, 30.0, 40.0, 40.0] ); } + #[test] + fn test_calc_back_project_2d_shape() { + // Regression test: without an explicit hist_size, a flat 8-bin + // histogram used to have its shape *guessed* from its length alone + // (infer_hist_size), which silently produced the wrong shape for any + // non-perfect-power dims (e.g. [2, 4] was inferred as [1, 8]) and + // corrupted the bin math. hist_size is now required explicitly. + let img = Matrix::from_vec(1, 1, 2, vec![1u8, 7]); + let hist = Matrix::from_vec( + 8, + 1, + 1, + vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0], + ); + let bp = calc_back_project( + &[&img], + &[0, 1], + &[2, 4], + &hist, + &[RangeSpec::Uniform(0.0, 2.0), RangeSpec::Uniform(0.0, 8.0)], + 1.0, + ) + .unwrap(); + // channel 0 (value 1, range [0,2), 2 bins) -> bin 1 + // channel 1 (value 7, range [0,8), 4 bins) -> bin 3 + // flat index with strides [4, 1] -> 1*4 + 3 = 7 -> hist.data[7] = 80.0 + assert_eq!(bp.data, vec![80.0]); + } + + #[test] + fn test_calc_back_project_hist_size_mismatch_error() { + let img = Matrix::from_vec(1, 4, 1, vec![0u8, 1, 2, 3]); + let hist = Matrix::from_vec(4, 1, 1, vec![10.0, 20.0, 30.0, 40.0]); + assert!(calc_back_project( + &[&img], + &[0], + &[3], // doesn't match hist's 4 bins + &hist, + &[RangeSpec::Uniform(0.0, 4.0)], + 1.0, + ) + .is_err()); + } + #[test] fn test_compare_hist_correl_identical() { let h1 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); @@ -1630,10 +1733,15 @@ mod tests { fn test_calc_back_project_empty_channels_error() { let img = Matrix::from_vec(2, 2, 1, vec![0u8; 4]); let hist = Matrix::from_vec(4, 1, 1, vec![0.0; 4]); - assert!( - calc_back_project::(&[&img], &[], &hist, &[RangeSpec::Uniform(0.0, 4.0)], 1.0,) - .is_err() - ); + assert!(calc_back_project::( + &[&img], + &[], + &[], + &hist, + &[RangeSpec::Uniform(0.0, 4.0)], + 1.0, + ) + .is_err()); } #[test] @@ -1643,8 +1751,15 @@ mod tests { // reach the row-chunking dispatch. let img = Matrix::::from_vec(3, 0, 1, vec![]); let hist = Matrix::from_vec(4, 1, 1, vec![0.0; 4]); - let dst = - calc_back_project(&[&img], &[0], &hist, &[RangeSpec::Uniform(0.0, 4.0)], 1.0).unwrap(); + let dst = calc_back_project( + &[&img], + &[0], + &[4], + &hist, + &[RangeSpec::Uniform(0.0, 4.0)], + 1.0, + ) + .unwrap(); assert_eq!(dst.rows, 3); assert_eq!(dst.cols, 0); assert_eq!(dst.data.len(), 0); @@ -1787,8 +1902,15 @@ mod tests { fn test_calc_back_project_scale() { let img = Matrix::from_vec(1, 4, 1, vec![0u8, 1, 2, 3]); let hist = Matrix::from_vec(4, 1, 1, vec![10.0, 20.0, 30.0, 40.0]); - let bp = - calc_back_project(&[&img], &[0], &hist, &[RangeSpec::Uniform(0.0, 4.0)], 2.0).unwrap(); + let bp = calc_back_project( + &[&img], + &[0], + &[4], + &hist, + &[RangeSpec::Uniform(0.0, 4.0)], + 2.0, + ) + .unwrap(); assert_eq!(bp.data, vec![20.0, 40.0, 60.0, 80.0]); } @@ -1827,10 +1949,15 @@ mod tests { ) .is_err()); let hist = Matrix::from_vec(4, 1, 1, vec![0.0; 4]); - assert!( - calc_back_project::(&[&img], &[], &hist, &[RangeSpec::Uniform(0.0, 4.0)], 1.0,) - .is_err() - ); + assert!(calc_back_project::( + &[&img], + &[], + &[], + &hist, + &[RangeSpec::Uniform(0.0, 4.0)], + 1.0, + ) + .is_err()); } #[test] From a2f3b1fd1c7f8ac6497ec7e4a2f85a8f8094eaf7 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Tue, 1 Sep 2026 18:54:55 +0200 Subject: [PATCH 14/20] doc: add histogram/CLAHE examples (Rust + WASM) and update READMEs - examples/histogram.rs: calc_hist (uniform and non-uniform bins), calc_back_project, compare_hist across all 6 HistCompMethods, equalize_hist, and Clahe::apply_u8 at two tile-grid sizes. Loads examples/data/butterfly.jpg (matching filters.rs's convention) and saves output PNGs under examples/data/out/. The back-projection scale is derived from the histogram's own peak bin (255 / max_bin) rather than a fixed guess, matching OpenCV's typical demo pattern - visually verified each output image before settling on this. - crates/wasm/www/example_histogram.html + .js: interactive demo matching the existing per-feature example_*.html/.js convention (example_pyramid, example_hough_circles, ...) and reusing cv_demo_utils.js. Shows grayscale/equalize_hist/CLAHE side by side, a live calc_hist bar chart, and compare_hist scores against a CLAHE-equalized histogram, with clip-limit and tile-grid sliders. Linked from crates/wasm/www/index.html's gallery. Verified end-to-end in a real browser (served crates/wasm/ over HTTP, built the actual wasm-pack output) including interactive slider updates re-triggering calc_hist/equalize_hist/Clahe/compare_hist and re-rendering correctly. - Root README.md: new purecv-imgproc bullet for histograms/CLAHE, added `cargo run --example histogram` to the Running Examples list, and updated the Imgproc module test-coverage bullet (308 -> 342 unit tests, current count, plus a mention of the histogram/CLAHE tests). Closes #108 --- README.md | 8 +- crates/wasm/www/example_histogram.html | 223 +++++++++++++++++++++++++ crates/wasm/www/example_histogram.js | 196 ++++++++++++++++++++++ crates/wasm/www/index.html | 5 + examples/histogram.rs | 203 ++++++++++++++++++++++ 5 files changed, 633 insertions(+), 2 deletions(-) create mode 100644 crates/wasm/www/example_histogram.html create mode 100644 crates/wasm/www/example_histogram.js create mode 100644 examples/histogram.rs diff --git a/README.md b/README.md index 86befb2..0feb0df 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Unlike existing wrappers, **PureCV** is a native rewrite. It aims to provide: - **Hough Transform:** Standard (`hough_lines`) and Probabilistic (`hough_lines_p`) line detection, plus Hough Circle Transform (`hough_circles`) using internally computed Sobel gradients. Fully parallelized via the `parallel` feature. - **Resizing:** `resize` function utilizing high-performance bilinear interpolation, fully compatible with `parallel` Rayon multi-threading. - **Geometric Transformations:** `remap` (with bilinear and nearest-neighbor interpolation) and `warp_perspective` (perspective transformations) fully parallelized and SIMD-accelerated. +- **Histograms & Contrast:** `calc_hist` (multi-dimensional, uniform or non-uniform bins, optional mask and accumulation) and `calc_back_project` for histogram back-projection; `compare_hist` with all 6 OpenCV comparison methods (`Correl`, `ChiSqr`, `ChiSqrAlt`, `Intersection`, `Bhattacharyya`, `KullbackLeibler`), SIMD-accelerated via the `simd` feature; `equalize_hist` for global histogram equalization; and `Clahe` (Contrast Limited Adaptive Histogram Equalization) for `u8`/`u16` images. `calc_hist`, `calc_back_project`, `equalize_hist`, and `Clahe` are all parallelized via the `parallel` feature. ### `purecv-features2d` - **FAST Feature Detector:** Real-time corner detector (`FastFeatureDetector`) supporting Type 5_8, 7_12, and 9_16 neighborhood configurations, plus optional non-maximum suppression. @@ -312,6 +313,9 @@ cargo run --example morphology # Gaussian pyramids (pyr_down, pyr_up) cargo run --example pyramids +# Histograms & contrast (calc_hist, calc_back_project, compare_hist, equalize_hist, CLAHE) +cargo run --example histogram + # Hough Transform (Lines and Circles detection) cargo run --example hough_transform @@ -344,10 +348,10 @@ cargo run --example rectification ## 🧪 Testing & Benchmarking ### Running Tests -PureCV uses a comprehensive suite of unit tests to ensure correctness and parity with OpenCV. The test suite currently includes **308 unit tests** (plus **40 doc-tests**) covering: +PureCV uses a comprehensive suite of unit tests to ensure correctness and parity with OpenCV. The test suite currently includes **342 unit tests** (plus **40 doc-tests**) covering: - **Core module:** Matrix factories, scalar arithmetic variants, bitwise scalar ops, min/max, comparison ops (`compare`, `in_range`), reduction (`reduce`, `count_non_zero`), polar/cartesian conversions, linear algebra (`determinant`, `invert`, `solve`), channel ops (`extract_channel`, `insert_channel`), `DynamicMatrix`, transforms, sorting, clustering, and RNG. -- **Imgproc module:** Filters, derivatives, edge detection, color conversions (including gray-to-RGB/BGR/RGBA/BGRA), thresholding, morphology (`erode`, `dilate`), pyramids (`pyr_down`, `pyr_up`), and kernel helpers (`get_gaussian_kernel`, `get_sobel_kernels`). +- **Imgproc module:** Filters, derivatives, edge detection, color conversions (including gray-to-RGB/BGR/RGBA/BGRA), thresholding, morphology (`erode`, `dilate`), pyramids (`pyr_down`, `pyr_up`), kernel helpers (`get_gaussian_kernel`, `get_sobel_kernels`), and histograms/CLAHE (`calc_hist`, `calc_back_project`, `compare_hist`, `equalize_hist`, `Clahe`). - **Features2d module:** Keypoint structures (`KeyPoint`), FAST corner detection (`FastFeatureDetector`), scale pyramids, and ORB feature extraction & BRIEF descriptor extraction (`Orb`). - **Video module:** Tracking and optical flow capabilities including `calc_optical_flow_pyr_lk` and `build_optical_flow_pyramid` implementations. - **Calib3d module:** SVD, homography estimation, pose estimation (`solve_pnp`), and `rodrigues`. diff --git a/crates/wasm/www/example_histogram.html b/crates/wasm/www/example_histogram.html new file mode 100644 index 0000000..8552267 --- /dev/null +++ b/crates/wasm/www/example_histogram.html @@ -0,0 +1,223 @@ + + + + + + + PureCV - Histogram & CLAHE + + + +
+

Initializing WASM...

+
+ +
+
+

Histogram & CLAHE

+

calc_hist, compare_hist, equalize_hist & Contrast Limited Adaptive Histogram Equalization

+
+ +
+
+

Click or drag image here

+ +
+ +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
256-bin uniform histogram of the grayscale source (calc_hist)
+ +
+ +
+
compare_hist: source histogram vs. CLAHE-equalized histogram
+
+ +
+
+
+ + + + diff --git a/crates/wasm/www/example_histogram.js b/crates/wasm/www/example_histogram.js new file mode 100644 index 0000000..c60fb2a --- /dev/null +++ b/crates/wasm/www/example_histogram.js @@ -0,0 +1,196 @@ +/* + * example_histogram.js + * purecv + * + * This file is part of purecv - WebARKit. + * + * purecv is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * purecv is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with purecv. If not, see . + * + * As a special exception, the copyright holders of this library give you + * permission to link this library with independent modules to produce an + * executable, regardless of the license terms of these independent modules, and to + * copy and distribute the resulting executable under terms of your choice, + * provided that you also meet, for each linked independent module, the terms and + * conditions of the license of that module. An independent module is a module + * which is neither derived from nor based on this library. If you modify this + * library, you may extend this exception to your version of the library, but you + * are not obligated to do so. If you do not wish to do so, delete this exception + * statement from your version. + * + * Copyright 2026 WebARKit. + * + * Author(s): Walter Perdan @kalwalt https://github.com/kalwalt + * + */ + +import { initWasm, loadImage, getScaledDimensions, canvasToMat, matToCanvas } from './cv_demo_utils.js'; + +let sourceImage = null; +let cv = null; +const compareRow = document.getElementById('compare-row'); +const histCanvas = document.getElementById('histogram-canvas'); +const scoresEl = document.getElementById('scores'); +const clipSlider = document.getElementById('clip-limit'); +const clipDisplay = document.getElementById('val-clip'); +const tileSlider = document.getElementById('tile-grid'); +const tilesDisplay = document.getElementById('val-tiles'); +const tilesDisplay2 = document.getElementById('val-tiles-2'); + +const COMPARE_METHODS = [ + { name: 'Correl', ctor: (cv) => cv.HIST_CMP_CORREL() }, + { name: 'ChiSqr', ctor: (cv) => cv.HIST_CMP_CHISQR() }, + { name: 'ChiSqrAlt', ctor: (cv) => cv.HIST_CMP_CHISQR_ALT() }, + { name: 'Intersect', ctor: (cv) => cv.HIST_CMP_INTERSECT() }, + { name: 'Bhattacharyya', ctor: (cv) => cv.HIST_CMP_BHATTACHARYYA() }, + { name: 'KL Divergence', ctor: (cv) => cv.HIST_CMP_KL_DIV() }, +]; + +async function start() { + try { + cv = await initWasm(); + document.getElementById('loader').classList.add('hidden'); + + sourceImage = await loadImage('https://raw.githubusercontent.com/opencv/opencv/master/samples/data/butterfly.jpg'); + processImage(); + } catch (err) { + console.error("WASM Initialization failed:", err); + document.getElementById('loader').innerHTML = `

Error loading WASM: ${err.message}

`; + } +} + +function addCanvasBox(label) { + const box = document.createElement('div'); + box.className = 'level-box'; + + const canvas = document.createElement('canvas'); + const text = document.createElement('span'); + text.className = 'level-label'; + text.innerText = label; + + box.appendChild(canvas); + box.appendChild(text); + compareRow.appendChild(box); + return canvas; +} + +function drawHistogram(canvas, histData, color) { + const w = canvas.clientWidth || 512; + const h = 160; + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, w, h); + + const max = Math.max(...histData, 1); + const binWidth = w / histData.length; + + ctx.fillStyle = color; + for (let i = 0; i < histData.length; i++) { + const barHeight = (histData[i] / max) * (h - 4); + ctx.fillRect(i * binWidth, h - barHeight, Math.max(binWidth, 1), barHeight); + } +} + +function processImage() { + if (!sourceImage || !cv) return; + + compareRow.innerHTML = ''; + scoresEl.innerHTML = ''; + + const { width, height } = getScaledDimensions(sourceImage, 512); + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = width; + tempCanvas.height = height; + const tempCtx = tempCanvas.getContext('2d', "willReadFrequently: true"); + tempCtx.drawImage(sourceImage, 0, 0, width, height); + + const rgbaMat = canvasToMat(cv, tempCanvas, tempCtx); + const gray = cv.cvtColor(rgbaMat, cv.COLOR_RGBA2GRAY()); + rgbaMat.free(); + + const clipLimit = parseFloat(clipSlider.value); + const tileGrid = parseInt(tileSlider.value); + + try { + // --- calc_hist: 256-bin uniform histogram of the grayscale source --- + const images = new cv.MatVector(); + images.push(gray); + const histSize = [256]; + const ranges = [0.0, 256.0]; + const hist = cv.calcHistUniform(images, [0], undefined, histSize, ranges, false, undefined); + drawHistogram(histCanvas, hist.dataF32(), '#43e97b'); + + // --- equalize_hist: global histogram equalization --- + const equalized = cv.equalizeHist(gray); + + // --- CLAHE: contrast-limited adaptive histogram equalization --- + const clahe = new cv.Clahe(clipLimit, tileGrid, tileGrid); + const claheOut = clahe.apply(gray); + + // --- compare_hist: source vs. CLAHE-equalized histograms --- + const claheImages = new cv.MatVector(); + claheImages.push(claheOut); + const claheHist = cv.calcHistUniform(claheImages, [0], undefined, histSize, ranges, false, undefined); + + for (const method of COMPARE_METHODS) { + const score = cv.compareHist(hist, claheHist, method.ctor(cv)); + const item = document.createElement('div'); + item.className = 'score-item'; + item.innerHTML = `${method.name}${score.toFixed(4)}`; + scoresEl.appendChild(item); + } + + // --- render the three grayscale variants side by side --- + matToCanvas(gray, addCanvasBox(`Grayscale: ${gray.cols}x${gray.rows}`)); + matToCanvas(equalized, addCanvasBox('equalize_hist')); + matToCanvas(claheOut, addCanvasBox(`CLAHE (clip=${clipLimit}, ${tileGrid}x${tileGrid})`)); + + hist.free(); + equalized.free(); + clahe.free(); + claheOut.free(); + claheHist.free(); + images.free(); + claheImages.free(); + } catch (e) { + console.error("Histogram/CLAHE error:", e); + } + + gray.free(); +} + +clipSlider.oninput = () => { + clipDisplay.innerText = clipSlider.value; + processImage(); +}; + +tileSlider.oninput = () => { + tilesDisplay.innerText = tileSlider.value; + tilesDisplay2.innerText = tileSlider.value; + processImage(); +}; + +const fileInput = document.getElementById('file-input'); +const dropZone = document.getElementById('drop-zone'); + +dropZone.onclick = () => fileInput.click(); +fileInput.onchange = async (e) => { + if (e.target.files.length > 0) { + const url = URL.createObjectURL(e.target.files[0]); + sourceImage = await loadImage(url); + processImage(); + } +}; + +start(); diff --git a/crates/wasm/www/index.html b/crates/wasm/www/index.html index b52b2c8..db72be8 100644 --- a/crates/wasm/www/index.html +++ b/crates/wasm/www/index.html @@ -124,6 +124,11 @@

ORB Keypoints & BRIEF

Image Pyramids

Construct and visualize a Gaussian pyramid stack at multiple scales.

+ + Imgproc +

Histogram & CLAHE

+

Compute histograms, compare distributions, and enhance contrast with global and adaptive (CLAHE) equalization.

+
Hough

Line Detection

diff --git a/examples/histogram.rs b/examples/histogram.rs new file mode 100644 index 0000000..0a3da02 --- /dev/null +++ b/examples/histogram.rs @@ -0,0 +1,203 @@ +/* + * histogram.rs + * purecv + * + * This file is part of purecv - WebARKit. + * + * purecv is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * purecv is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with purecv. If not, see . + * + * As a special exception, the copyright holders of this library give you + * permission to link this library with independent modules to produce an + * executable, regardless of the license terms of these independent modules, and to + * copy and distribute the resulting executable under terms of your choice, + * provided that you also meet, for each linked independent module, the terms and + * conditions of the license of that module. An independent module is a module + * which is neither derived from nor based on this library. If you modify this + * library, you may extend this exception to your version of the library, but you + * are not obligated to do so. If you do not wish to do so, delete this exception + * statement from your version. + * + * Copyright 2026 WebARKit. + * + * Author(s): Walter Perdan @kalwalt https://github.com/kalwalt + * + */ + +use image::{DynamicImage, GenericImageView, ImageBuffer, Luma}; +use purecv::core::logging::tags; +use purecv::core::types::Size2i; +use purecv::core::Matrix; +use purecv::imgproc::{ + calc_back_project, calc_hist, compare_hist, create_clahe, cvt_color_rgb_to_gray, equalize_hist, + HistCompMethods, RangeSpec, +}; +use purecv::version; +use purecv::{cv_log_error, cv_log_info}; +use std::path::Path; + +fn main() -> Result<(), Box> { + purecv::core::logging::init_basic_logger()?; + + cv_log_info!(tags::PURECV, "--- Histogram & CLAHE Example ---"); + version::print_version(); + + // 1. Load the image + let img_path = "examples/data/butterfly.jpg"; + if !Path::new(img_path).exists() { + cv_log_error!( + tags::IMGPROC, + "{} not found. Run from the project root.", + img_path + ); + return Ok(()); + } + + let img = image::open(img_path)?; + let (width, height) = img.dimensions(); + cv_log_info!( + tags::IMGPROC, + "loaded image: {} ({}x{})", + img_path, + width, + height + ); + + let rgb_img = img.to_rgb8(); + let mat_rgb = Matrix::from_vec(height as usize, width as usize, 3, rgb_img.into_raw()); + let mat_gray = cvt_color_rgb_to_gray(&mat_rgb)?; + + // --- calc_hist: uniform bins --- + + cv_log_info!(tags::IMGPROC, "computing a 256-bin uniform histogram..."); + let hist_size = [256usize]; + let ranges = [RangeSpec::Uniform(0.0, 256.0)]; + let hist = calc_hist(&[&mat_gray], &[0], None, &hist_size, &ranges, false, None)?; + print_histogram_summary(&hist); + + // --- calc_hist: non-uniform bins --- + // + // Four unevenly-spaced bins: a wide shadow bucket, two mid-tone buckets, + // and a narrow highlight bucket. + cv_log_info!(tags::IMGPROC, "computing a 4-bin non-uniform histogram..."); + let non_uniform_size = [4usize]; + let boundaries = vec![0.0, 96.0, 160.0, 224.0, 256.0]; + let non_uniform_ranges = [RangeSpec::NonUniform(boundaries)]; + let non_uniform_hist = calc_hist( + &[&mat_gray], + &[0], + None, + &non_uniform_size, + &non_uniform_ranges, + false, + None, + )?; + println!( + " non-uniform bins [0,96) [96,160) [160,224) [224,256): {:?}", + non_uniform_hist.data + ); + + // --- calc_back_project --- + // + // Projects the histogram back onto the source image: each pixel is + // replaced by its own bin's count, scaled for visibility. Bright + // regions in the output correspond to common intensities in the image. + // The scale is derived from the histogram's own peak so the output uses + // the full 0-255 display range, matching OpenCV's typical demo pattern. + cv_log_info!(tags::IMGPROC, "back-projecting the histogram..."); + let max_bin = hist.data.iter().cloned().fold(0.0f32, f32::max); + let bp_scale = if max_bin > 0.0 { 255.0 / max_bin } else { 1.0 }; + let back_projected = + calc_back_project(&[&mat_gray], &[0], &hist_size, &hist, &ranges, bp_scale)?; + let mut bp_u8 = Matrix::::new(back_projected.rows, back_projected.cols, 1); + for (dst, &src) in bp_u8.data.iter_mut().zip(back_projected.data.iter()) { + *dst = src.clamp(0.0, 255.0) as u8; + } + save_matrix_gray(&bp_u8, "examples/data/out/output_back_project.png")?; + + // --- compare_hist --- + // + // Compare the source histogram against the histogram of a brightened + // copy of the same image, across every HistCompMethods variant. + cv_log_info!(tags::IMGPROC, "comparing histograms..."); + let mut brightened = Matrix::::new(mat_gray.rows, mat_gray.cols, 1); + for (dst, &src) in brightened.data.iter_mut().zip(mat_gray.data.iter()) { + *dst = src.saturating_add(40); + } + let brightened_hist = calc_hist(&[&brightened], &[0], None, &hist_size, &ranges, false, None)?; + + for method in [ + HistCompMethods::Correl, + HistCompMethods::ChiSqr, + HistCompMethods::ChiSqrAlt, + HistCompMethods::Intersection, + HistCompMethods::Bhattacharyya, + HistCompMethods::KullbackLeibler, + ] { + let score = compare_hist(&hist, &brightened_hist, method)?; + println!(" {:?}: {:.4}", method, score); + } + + // --- equalize_hist --- + + cv_log_info!(tags::IMGPROC, "equalizing histogram..."); + let equalized = equalize_hist(&mat_gray)?; + save_matrix_gray(&equalized, "examples/data/out/output_equalize_hist.png")?; + + // --- CLAHE --- + // + // Contrast Limited Adaptive Histogram Equalization avoids over-amplifying + // noise in flat regions, unlike global equalize_hist above. Two tile + // grid sizes are compared: a coarser 4x4 grid and a finer 8x8 grid. + cv_log_info!(tags::IMGPROC, "applying CLAHE (4x4 tiles)..."); + let clahe_coarse = create_clahe(40.0, Size2i::new(4, 4)); + let clahe_coarse_out = clahe_coarse.apply_u8(&mat_gray)?; + save_matrix_gray(&clahe_coarse_out, "examples/data/out/output_clahe_4x4.png")?; + + cv_log_info!(tags::IMGPROC, "applying CLAHE (8x8 tiles)..."); + let clahe_fine = create_clahe(40.0, Size2i::new(8, 8)); + let clahe_fine_out = clahe_fine.apply_u8(&mat_gray)?; + save_matrix_gray(&clahe_fine_out, "examples/data/out/output_clahe_8x8.png")?; + + cv_log_info!( + tags::IMGPROC, + "done! Check the output_*.png files under examples/data/out/." + ); + Ok(()) +} + +/// Prints the min/max/mean bin count and the 3 most populated bins of a +/// flattened `f32` histogram (as returned by `calc_hist`). +fn print_histogram_summary(hist: &Matrix) { + let data = &hist.data; + let total_bins = data.len(); + let sum: f32 = data.iter().sum(); + let mean = sum / total_bins as f32; + let max_bin = data + .iter() + .enumerate() + .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) + .map(|(idx, &v)| (idx, v)) + .unwrap_or((0, 0.0)); + println!( + " {total_bins} bins, total count {sum:.0}, mean {mean:.2}, most populated bin {} (count {:.0})", + max_bin.0, max_bin.1 + ); +} + +fn save_matrix_gray(mat: &Matrix, filename: &str) -> image::ImageResult<()> { + let img: ImageBuffer, Vec> = + ImageBuffer::from_raw(mat.cols as u32, mat.rows as u32, mat.data.clone()) + .expect("Failed to create image buffer"); + DynamicImage::ImageLuma8(img).save(filename) +} From 861f2ea4495e5c405af3be6e3b56f0f58a5473ca Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Tue, 1 Sep 2026 23:35:37 +0200 Subject: [PATCH 15/20] fix: address Qodo review findings on histogram examples Verified each of the 5 findings against the code before acting: - Missing output directory (real, confirmed): the example wrote into examples/data/out/ without creating it first. It's gitignored, so a fresh checkout doesn't have it - the first save would fail with an IO error. corner_detection.rs already handles this correctly (std::fs::create_dir_all before saving); filters.rs, which this example was copied from, has the same latent bug. Reproduced by deleting examples/data/out/ and re-running - confirmed the fix. - Deep imports (split verdict): `Size2i` is already re-exported at purecv::core::Size2i (one existing example, corner_detection.rs, already uses that path) - switched to it, harmless simplification. NOT adding a `tags` re-export or changing that import: `use purecv::core::logging::tags;` is the exhaustive, deliberate convention used in 14+ files across this crate, including 3 other pre-existing examples and the official usage examples in logging.rs's own doc comments. Changing it would make this example the only inconsistent one in the whole codebase. - WASM object leak on error path, blob URL never revoked, and drag-and-drop advertised but not implemented (all real, confirmed): none of the other 9 example_*.js files in crates/wasm/www/ handle any of these three either, so these aren't regressions from an established good pattern - genuine bugs worth fixing in this file regardless. Allocated WASM objects are now tracked in variables declared outside the try block and freed in a finally block; the file-upload blob URL is now revoked once loadImage resolves (success or failure); dragover/drop handlers now share the same load path as the click-to-upload flow. Verified: cargo fmt --check and clippy -D warnings clean; cargo test --workspace 342 lib tests + 40 doc-tests passing; re-ran the example against a deleted examples/data/out/ to confirm the directory-creation fix; and re-verified the WASM demo end-to-end in a real browser - normal rendering and slider interaction still work after the try/finally refactor (no console errors, no double-free), and a synthetic drop event (fetched the demo image, dispatched a real DragEvent at the drop zone) confirmed the new drag-and-drop path loads and processes the image correctly. Co-Authored-By: Claude Sonnet 5 --- crates/wasm/www/example_histogram.html | 3 +- crates/wasm/www/example_histogram.js | 67 ++++++++++++++++++-------- examples/histogram.rs | 6 ++- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/crates/wasm/www/example_histogram.html b/crates/wasm/www/example_histogram.html index 8552267..b82391d 100644 --- a/crates/wasm/www/example_histogram.html +++ b/crates/wasm/www/example_histogram.html @@ -159,7 +159,8 @@ transition: all 0.2s; margin-bottom: 1rem; } - .uploader:hover { border-color: var(--primary); background: rgba(67, 233, 123, 0.05); } + .uploader:hover, + .uploader.drag-over { border-color: var(--primary); background: rgba(67, 233, 123, 0.05); } .uploader p { margin: 0; color: #94a3b8; } .loading { position: fixed; diff --git a/crates/wasm/www/example_histogram.js b/crates/wasm/www/example_histogram.js index c60fb2a..956984a 100644 --- a/crates/wasm/www/example_histogram.js +++ b/crates/wasm/www/example_histogram.js @@ -122,26 +122,36 @@ function processImage() { const clipLimit = parseFloat(clipSlider.value); const tileGrid = parseInt(tileSlider.value); + // Declared outside the try so a mid-way failure can still free whatever + // was already allocated (WASM objects are not garbage-collected). + let images = null; + let hist = null; + let equalized = null; + let clahe = null; + let claheOut = null; + let claheImages = null; + let claheHist = null; + try { // --- calc_hist: 256-bin uniform histogram of the grayscale source --- - const images = new cv.MatVector(); + images = new cv.MatVector(); images.push(gray); const histSize = [256]; const ranges = [0.0, 256.0]; - const hist = cv.calcHistUniform(images, [0], undefined, histSize, ranges, false, undefined); + hist = cv.calcHistUniform(images, [0], undefined, histSize, ranges, false, undefined); drawHistogram(histCanvas, hist.dataF32(), '#43e97b'); // --- equalize_hist: global histogram equalization --- - const equalized = cv.equalizeHist(gray); + equalized = cv.equalizeHist(gray); // --- CLAHE: contrast-limited adaptive histogram equalization --- - const clahe = new cv.Clahe(clipLimit, tileGrid, tileGrid); - const claheOut = clahe.apply(gray); + clahe = new cv.Clahe(clipLimit, tileGrid, tileGrid); + claheOut = clahe.apply(gray); // --- compare_hist: source vs. CLAHE-equalized histograms --- - const claheImages = new cv.MatVector(); + claheImages = new cv.MatVector(); claheImages.push(claheOut); - const claheHist = cv.calcHistUniform(claheImages, [0], undefined, histSize, ranges, false, undefined); + claheHist = cv.calcHistUniform(claheImages, [0], undefined, histSize, ranges, false, undefined); for (const method of COMPARE_METHODS) { const score = cv.compareHist(hist, claheHist, method.ctor(cv)); @@ -155,19 +165,18 @@ function processImage() { matToCanvas(gray, addCanvasBox(`Grayscale: ${gray.cols}x${gray.rows}`)); matToCanvas(equalized, addCanvasBox('equalize_hist')); matToCanvas(claheOut, addCanvasBox(`CLAHE (clip=${clipLimit}, ${tileGrid}x${tileGrid})`)); - - hist.free(); - equalized.free(); - clahe.free(); - claheOut.free(); - claheHist.free(); - images.free(); - claheImages.free(); } catch (e) { console.error("Histogram/CLAHE error:", e); + } finally { + gray.free(); + images?.free(); + hist?.free(); + equalized?.free(); + clahe?.free(); + claheOut?.free(); + claheImages?.free(); + claheHist?.free(); } - - gray.free(); } clipSlider.oninput = () => { @@ -184,13 +193,29 @@ tileSlider.oninput = () => { const fileInput = document.getElementById('file-input'); const dropZone = document.getElementById('drop-zone'); -dropZone.onclick = () => fileInput.click(); -fileInput.onchange = async (e) => { - if (e.target.files.length > 0) { - const url = URL.createObjectURL(e.target.files[0]); +async function loadFile(file) { + if (!file) return; + const url = URL.createObjectURL(file); + try { sourceImage = await loadImage(url); processImage(); + } finally { + URL.revokeObjectURL(url); } +} + +dropZone.onclick = () => fileInput.click(); +fileInput.onchange = (e) => loadFile(e.target.files[0]); + +dropZone.ondragover = (e) => { + e.preventDefault(); + dropZone.classList.add('drag-over'); +}; +dropZone.ondragleave = () => dropZone.classList.remove('drag-over'); +dropZone.ondrop = (e) => { + e.preventDefault(); + dropZone.classList.remove('drag-over'); + loadFile(e.dataTransfer.files[0]); }; start(); diff --git a/examples/histogram.rs b/examples/histogram.rs index 0a3da02..fdc9f28 100644 --- a/examples/histogram.rs +++ b/examples/histogram.rs @@ -36,8 +36,7 @@ use image::{DynamicImage, GenericImageView, ImageBuffer, Luma}; use purecv::core::logging::tags; -use purecv::core::types::Size2i; -use purecv::core::Matrix; +use purecv::core::{Matrix, Size2i}; use purecv::imgproc::{ calc_back_project, calc_hist, compare_hist, create_clahe, cvt_color_rgb_to_gray, equalize_hist, HistCompMethods, RangeSpec, @@ -77,6 +76,9 @@ fn main() -> Result<(), Box> { let mat_rgb = Matrix::from_vec(height as usize, width as usize, 3, rgb_img.into_raw()); let mat_gray = cvt_color_rgb_to_gray(&mat_rgb)?; + // Create output directory if it doesn't exist. + std::fs::create_dir_all("examples/data/out")?; + // --- calc_hist: uniform bins --- cv_log_info!(tags::IMGPROC, "computing a 256-bin uniform histogram..."); From 1aff3b0930b6e660fe00dfe067813cd5a3f3781f Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Wed, 2 Sep 2026 11:37:46 +0200 Subject: [PATCH 16/20] perf(ci): run the sequential (non-parallel) test suite in CI CI never actually executed the #[cfg(not(feature = "parallel"))] code path for any module - it was compiled (via the no_std job's cargo build/clippy) but never run under cargo test. cargo test --workspace uses the crate's default = ["std", "parallel"] feature set, so the sequential fallback in every module was untested at runtime. Add a step running cargo test --workspace --no-default-features --features std alongside the existing parallel/simd/ndarray feature runs. Verified locally first: 342 lib tests + 40 doc-tests pass under this config with no latent sequential/parallel divergence. Closes #112 Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c628c1f..d64d94a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: - name: Run tests run: cargo test --workspace + - name: Run tests (sequential, parallel disabled) + run: cargo test --workspace --no-default-features --features std + - name: Run tests (with parallel feature) run: cargo test --workspace --features parallel From 991cdafdff4e51718486ad0934ec686e231cb0d3 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Wed, 2 Sep 2026 11:43:20 +0200 Subject: [PATCH 17/20] test(imgproc): move histogram module tests into src/imgproc/tests.rs Every other imgproc submodule (color.rs, edge.rs, filter.rs, morph.rs, pyramid.rs, threshold.rs, hough.rs, geometric.rs, resize.rs, derivatives.rs, feature.rs) keeps its tests solely in the shared src/imgproc/tests.rs, per the module structure convention in CLAUDE.md. histogram.rs was the only one with its own embedded #[cfg(test)] mod tests block, left over from how #99 originally wrote it. Mechanical move, no behavior change: all 33 histogram tests already went through the public API (calc_hist, calc_back_project, compare_hist, equalize_hist, Clahe/create_clahe, RangeSpec, HistCompMethods) with no dependency on private helpers, so they drop straight into tests.rs's existing imgproc_tests module (which already glob-imports crate::core::* and crate::imgproc::*) with no import changes needed. Verified: same 342 lib test count before and after (nothing lost or duplicated), passing under both default and --no-default-features --features std, fmt/clippy clean under --all-features and --no-default-features. Closes #111 Co-Authored-By: Claude Sonnet 5 --- src/imgproc/histogram.rs | 639 --------------------------------------- src/imgproc/tests.rs | 630 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 630 insertions(+), 639 deletions(-) diff --git a/src/imgproc/histogram.rs b/src/imgproc/histogram.rs index 4eff103..49bff93 100644 --- a/src/imgproc/histogram.rs +++ b/src/imgproc/histogram.rs @@ -1355,642 +1355,3 @@ fn pad_reflect101( pub fn create_clahe(clip_limit: f64, tile_grid_size: Size2i) -> Clahe { Clahe::new(clip_limit, tile_grid_size) } - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_calc_hist_uniform_1d() { - let data: Vec = (0..16).collect(); - let img = Matrix::from_vec(4, 4, 1, data); - let hist = calc_hist( - &[&img], - &[0], - None, - &[16], - &[RangeSpec::Uniform(0.0, 16.0)], - false, - None, - ) - .unwrap(); - assert_eq!(hist.data.len(), 16); - for &v in hist.data.iter() { - assert_eq!(v, 1.0); - } - } - - #[test] - fn test_calc_hist_uniform_1d_fewer_bins() { - let data: Vec = (0..16).collect(); - let img = Matrix::from_vec(4, 4, 1, data); - let hist = calc_hist( - &[&img], - &[0], - None, - &[4], - &[RangeSpec::Uniform(0.0, 16.0)], - false, - None, - ) - .unwrap(); - assert_eq!(hist.data.len(), 4); - for &v in hist.data.iter() { - assert_eq!(v, 4.0); - } - } - - #[test] - fn test_calc_hist_mask() { - let img = Matrix::from_vec(3, 4, 1, vec![0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); - // Partial mask: checkerboard - let mask = Matrix::from_vec(3, 4, 1, vec![0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]); - let h = calc_hist( - &[&img], - &[0], - Some(&mask), - &[4], - &[RangeSpec::Uniform(0.0, 12.0)], - false, - None, - ) - .unwrap(); - assert_eq!(h.data, vec![1.0, 2.0, 1.0, 2.0]); - - // All-zero mask: nothing counted - let img2 = Matrix::from_vec(2, 2, 1, vec![0u8, 1, 2, 3]); - let mask_zero = Matrix::from_vec(2, 2, 1, vec![0u8; 4]); - let h = calc_hist( - &[&img2], - &[0], - Some(&mask_zero), - &[4], - &[RangeSpec::Uniform(0.0, 4.0)], - false, - None, - ) - .unwrap(); - assert_eq!(h.data, vec![0.0, 0.0, 0.0, 0.0]); - - // All-one mask: all counted - let mask_one = Matrix::from_vec(2, 2, 1, vec![1u8; 4]); - let h = calc_hist( - &[&img2], - &[0], - Some(&mask_one), - &[4], - &[RangeSpec::Uniform(0.0, 4.0)], - false, - None, - ) - .unwrap(); - assert_eq!(h.data, vec![1.0, 1.0, 1.0, 1.0]); - } - - #[test] - fn test_calc_hist_multichannel_mask_error() { - // Regression test: a mask must be single-channel. Previously only - // rows/cols were checked, so a multichannel mask was silently - // accepted and only its channel 0 was ever read. - let img = Matrix::from_vec(2, 2, 1, vec![0u8, 1, 2, 3]); - let mask = Matrix::from_vec(2, 2, 3, vec![1u8; 12]); - assert!(calc_hist( - &[&img], - &[0], - Some(&mask), - &[4], - &[RangeSpec::Uniform(0.0, 4.0)], - false, - None, - ) - .is_err()); - } - - #[test] - fn test_calc_hist_multi_image_channel_indexing() { - // images[0] has 2 channels, images[1] has 1 channel - let img0 = Matrix::from_vec(2, 1, 2, vec![10, 20, 30, 40]); - let img1 = Matrix::from_vec(2, 1, 1, vec![100, 200]); - - let hist = calc_hist( - &[&img0, &img1], - &[0, 2], - None, - &[2, 2], - &[ - RangeSpec::Uniform(0.0, 50.0), - RangeSpec::Uniform(0.0, 250.0), - ], - false, - None, - ) - .unwrap(); - - // pixel(0,0): ch0=10->bin0, ch2=100->bin0 -> idx=0 - // pixel(1,0): ch0=30->bin1, ch2=200->bin1 -> idx=3 - assert_eq!(hist.data[0], 1.0); - assert_eq!(hist.data[1], 0.0); - assert_eq!(hist.data[2], 0.0); - assert_eq!(hist.data[3], 1.0); - } - - #[test] - fn test_calc_hist_nonuniform() { - let data: Vec = (0..20).collect(); - let img = Matrix::from_vec(4, 5, 1, data); - // Non-uniform: boundaries [0, 5, 20] -> 2 bins: [0,5) and [5,20) - let hist = calc_hist( - &[&img], - &[0], - None, - &[2], - &[RangeSpec::NonUniform(vec![0.0, 5.0, 20.0])], - false, - None, - ) - .unwrap(); - assert_eq!(hist.data[0], 5.0); // values 0..4 - assert_eq!(hist.data[1], 15.0); // values 5..19 - } - - #[test] - fn test_calc_back_project() { - let data: Vec = vec![0, 1, 2, 3, 4, 5, 6, 7]; - let img = Matrix::from_vec(2, 4, 1, data); - let hist = Matrix::from_vec(4, 1, 1, vec![10.0, 20.0, 30.0, 40.0]); - let bp = calc_back_project( - &[&img], - &[0], - &[4], - &hist, - &[RangeSpec::Uniform(0.0, 8.0)], - 1.0, - ) - .unwrap(); - assert_eq!( - bp.data, - vec![10.0, 10.0, 20.0, 20.0, 30.0, 30.0, 40.0, 40.0] - ); - } - - #[test] - fn test_calc_back_project_2d_shape() { - // Regression test: without an explicit hist_size, a flat 8-bin - // histogram used to have its shape *guessed* from its length alone - // (infer_hist_size), which silently produced the wrong shape for any - // non-perfect-power dims (e.g. [2, 4] was inferred as [1, 8]) and - // corrupted the bin math. hist_size is now required explicitly. - let img = Matrix::from_vec(1, 1, 2, vec![1u8, 7]); - let hist = Matrix::from_vec( - 8, - 1, - 1, - vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0], - ); - let bp = calc_back_project( - &[&img], - &[0, 1], - &[2, 4], - &hist, - &[RangeSpec::Uniform(0.0, 2.0), RangeSpec::Uniform(0.0, 8.0)], - 1.0, - ) - .unwrap(); - // channel 0 (value 1, range [0,2), 2 bins) -> bin 1 - // channel 1 (value 7, range [0,8), 4 bins) -> bin 3 - // flat index with strides [4, 1] -> 1*4 + 3 = 7 -> hist.data[7] = 80.0 - assert_eq!(bp.data, vec![80.0]); - } - - #[test] - fn test_calc_back_project_hist_size_mismatch_error() { - let img = Matrix::from_vec(1, 4, 1, vec![0u8, 1, 2, 3]); - let hist = Matrix::from_vec(4, 1, 1, vec![10.0, 20.0, 30.0, 40.0]); - assert!(calc_back_project( - &[&img], - &[0], - &[3], // doesn't match hist's 4 bins - &hist, - &[RangeSpec::Uniform(0.0, 4.0)], - 1.0, - ) - .is_err()); - } - - #[test] - fn test_compare_hist_correl_identical() { - let h1 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); - let h2 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); - let corr = compare_hist(&h1, &h2, HistCompMethods::Correl).unwrap(); - assert!((corr - 1.0).abs() < 1e-10); - } - - #[test] - fn test_compare_hist_correl_opposite() { - let h1 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); - let h2 = Matrix::from_vec(4, 1, 1, vec![4.0, 3.0, 2.0, 1.0]); - let corr = compare_hist(&h1, &h2, HistCompMethods::Correl).unwrap(); - assert!((corr - (-1.0)).abs() < 1e-10); - } - - #[test] - fn test_compare_hist_intersect() { - let h1 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); - let h2 = Matrix::from_vec(4, 1, 1, vec![4.0, 3.0, 2.0, 1.0]); - let inter = compare_hist(&h1, &h2, HistCompMethods::Intersection).unwrap(); - assert!((inter - 6.0).abs() < 1e-10); - } - - #[test] - fn test_compare_hist_chi_sqr() { - let h1 = Matrix::from_vec(3, 1, 1, vec![1.0, 2.0, 3.0]); - let h2 = Matrix::from_vec(3, 1, 1, vec![1.0, 2.0, 3.0]); - let chi = compare_hist(&h1, &h2, HistCompMethods::ChiSqr).unwrap(); - assert!((chi - 0.0).abs() < 1e-10); - } - - #[test] - fn test_compare_hist_bhattacharyya_identical() { - let h1 = Matrix::from_vec(4, 1, 1, vec![0.25, 0.25, 0.25, 0.25]); - let h2 = Matrix::from_vec(4, 1, 1, vec![0.25, 0.25, 0.25, 0.25]); - let bc = compare_hist(&h1, &h2, HistCompMethods::Bhattacharyya).unwrap(); - assert!(bc.abs() < 1e-10); - } - - #[test] - fn test_compare_hist_kl_divergence() { - let h1 = Matrix::from_vec(3, 1, 1, vec![0.5, 0.3, 0.2]); - let h2 = Matrix::from_vec(3, 1, 1, vec![0.5, 0.3, 0.2]); - let kl = compare_hist(&h1, &h2, HistCompMethods::KullbackLeibler).unwrap(); - assert!(kl.abs() < 1e-10); - } - - #[test] - fn test_equalize_hist_uniform() { - let img = Matrix::from_vec(4, 4, 1, vec![128u8; 16]); - let dst = equalize_hist(&img).unwrap(); - for &v in dst.data.iter() { - assert_eq!(v, 128); - } - } - - #[test] - fn test_equalize_hist_gradient() { - let data: Vec = (0..=255).collect(); - let img = Matrix::from_vec(16, 16, 1, data); - let dst = equalize_hist(&img).unwrap(); - assert_eq!(dst.rows, 16); - assert_eq!(dst.cols, 16); - assert_eq!(dst.channels, 1); - let min_val = *dst.data.iter().min().unwrap(); - let max_val = *dst.data.iter().max().unwrap(); - assert_eq!(min_val, 0); - assert_eq!(max_val, 255); - } - - #[test] - fn test_clahe_basic() { - let data: Vec = (0..=255).collect(); - let img = Matrix::from_vec(16, 16, 1, data); - let clahe = create_clahe(40.0, Size2i::new(4, 4)); - let dst = clahe.apply_u8(&img).unwrap(); - assert_eq!(dst.rows, 16); - assert_eq!(dst.cols, 16); - assert_eq!(dst.channels, 1); - assert_eq!(dst.data.len(), 256); - } - - #[test] - fn test_clahe_u16() { - let data: Vec = (0..=1023).collect(); - let img = Matrix::from_vec(32, 32, 1, data); - let clahe = create_clahe(40.0, Size2i::new(4, 4)); - let dst = clahe.apply_u16(&img).unwrap(); - assert_eq!(dst.rows, 32); - assert_eq!(dst.cols, 32); - assert_eq!(dst.channels, 1); - } - - #[test] - fn test_calc_hist_2d() { - let img = Matrix::from_vec(3, 3, 1, vec![0, 1, 2, 3, 4, 5, 6, 7, 8]); - let hist = calc_hist( - &[&img, &img], - &[0, 0], - None, - &[3, 3], - &[RangeSpec::Uniform(0.0, 9.0), RangeSpec::Uniform(0.0, 9.0)], - false, - None, - ) - .unwrap(); - assert_eq!(hist.data.len(), 9); - assert_eq!(hist.data[0], 3.0); - assert_eq!(hist.data[1], 0.0); - assert_eq!(hist.data[2], 0.0); - assert_eq!(hist.data[3], 0.0); - assert_eq!(hist.data[4], 3.0); - assert_eq!(hist.data[5], 0.0); - assert_eq!(hist.data[6], 0.0); - assert_eq!(hist.data[7], 0.0); - assert_eq!(hist.data[8], 3.0); - } - - #[test] - fn test_calc_hist_empty_images_error() { - assert!(calc_hist::( - &[], - &[0], - None, - &[4], - &[RangeSpec::Uniform(0.0, 4.0)], - false, - None, - ) - .is_err()); - } - - #[test] - fn test_calc_hist_mismatched_channels_error() { - let img = Matrix::from_vec(2, 2, 1, vec![0u8; 4]); - assert!(calc_hist( - &[&img], - &[0, 1], - None, - &[4], - &[RangeSpec::Uniform(0.0, 4.0)], - false, - None, - ) - .is_err()); - } - - #[test] - fn test_calc_back_project_empty_channels_error() { - let img = Matrix::from_vec(2, 2, 1, vec![0u8; 4]); - let hist = Matrix::from_vec(4, 1, 1, vec![0.0; 4]); - assert!(calc_back_project::( - &[&img], - &[], - &[], - &hist, - &[RangeSpec::Uniform(0.0, 4.0)], - 1.0, - ) - .is_err()); - } - - #[test] - fn test_calc_back_project_zero_width() { - // Regression test: chunks_mut/par_chunks_mut panic on a zero chunk - // size regardless of slice length, so a zero-width image must not - // reach the row-chunking dispatch. - let img = Matrix::::from_vec(3, 0, 1, vec![]); - let hist = Matrix::from_vec(4, 1, 1, vec![0.0; 4]); - let dst = calc_back_project( - &[&img], - &[0], - &[4], - &hist, - &[RangeSpec::Uniform(0.0, 4.0)], - 1.0, - ) - .unwrap(); - assert_eq!(dst.rows, 3); - assert_eq!(dst.cols, 0); - assert_eq!(dst.data.len(), 0); - } - - #[test] - fn test_calc_hist_u16_input() { - let data: Vec = vec![0, 100, 200, 300, 400, 500]; - let img = Matrix::from_vec(2, 3, 1, data); - let hist = calc_hist( - &[&img], - &[0], - None, - &[3], - &[RangeSpec::Uniform(0.0, 600.0)], - false, - None, - ) - .unwrap(); - assert_eq!(hist.data, vec![2.0, 2.0, 2.0]); - } - - #[test] - fn test_calc_hist_f32_input() { - let data: Vec = vec![0.5, 1.5, 2.5, 3.5]; - let img = Matrix::from_vec(2, 2, 1, data); - let hist = calc_hist( - &[&img], - &[0], - None, - &[4], - &[RangeSpec::Uniform(0.0, 4.0)], - false, - None, - ) - .unwrap(); - assert_eq!(hist.data, vec![1.0, 1.0, 1.0, 1.0]); - } - - #[test] - fn test_calc_hist_boundary_exclusion() { - let img = Matrix::from_vec(1, 4, 1, vec![0u8, 4, 8, 12]); - let hist = calc_hist( - &[&img], - &[0], - None, - &[4], - &[RangeSpec::Uniform(0.0, 16.0)], - false, - None, - ) - .unwrap(); - assert_eq!(hist.data, vec![1.0, 1.0, 1.0, 1.0]); - - // Value at exact hi boundary should be excluded - let img2 = Matrix::from_vec(1, 2, 1, vec![4u8, 4]); - let hist2 = calc_hist( - &[&img2], - &[0], - None, - &[2], - &[RangeSpec::Uniform(0.0, 4.0)], - false, - None, - ) - .unwrap(); - assert_eq!(hist2.data, vec![0.0, 0.0]); - - // Value just below hi should be included - let img3 = Matrix::from_vec(1, 1, 1, vec![3u8]); - let hist3 = calc_hist( - &[&img3], - &[0], - None, - &[4], - &[RangeSpec::Uniform(0.0, 4.0)], - false, - None, - ) - .unwrap(); - assert_eq!(hist3.data[3], 1.0); - } - - #[test] - fn test_calc_hist_accumulate() { - let img = Matrix::from_vec(1, 4, 1, vec![0u8, 1, 2, 3]); - let h1 = calc_hist( - &[&img], - &[0], - None, - &[4], - &[RangeSpec::Uniform(0.0, 4.0)], - false, - None, - ) - .unwrap(); - assert_eq!(h1.data, vec![1.0, 1.0, 1.0, 1.0]); - - let h2 = calc_hist( - &[&img], - &[0], - None, - &[4], - &[RangeSpec::Uniform(0.0, 4.0)], - true, - Some(&h1), - ) - .unwrap(); - assert_eq!(h2.data, vec![2.0, 2.0, 2.0, 2.0]); - - let h3 = calc_hist( - &[&img], - &[0], - None, - &[4], - &[RangeSpec::Uniform(0.0, 4.0)], - true, - Some(&h2), - ) - .unwrap(); - assert_eq!(h3.data, vec![3.0, 3.0, 3.0, 3.0]); - } - - #[test] - fn test_compare_hist_edge_cases() { - let z = Matrix::from_vec(3, 1, 1, vec![0.0, 0.0, 0.0]); - assert!((compare_hist(&z, &z, HistCompMethods::Correl).unwrap() - 1.0).abs() < 1e-10); - assert!((compare_hist(&z, &z, HistCompMethods::Intersection).unwrap()).abs() < 1e-10); - assert!( - (compare_hist(&z, &z, HistCompMethods::Bhattacharyya).unwrap() - 1.0).abs() < 1e-10 - ); - - let p1 = Matrix::from_vec(4, 1, 1, vec![0.5, 0.25, 0.125, 0.125]); - let p2 = Matrix::from_vec(4, 1, 1, vec![0.5, 0.25, 0.125, 0.125]); - assert!((compare_hist(&p1, &p2, HistCompMethods::Correl).unwrap() - 1.0).abs() < 1e-10); - assert!((compare_hist(&p1, &p2, HistCompMethods::ChiSqr).unwrap()).abs() < 1e-10); - } - - #[test] - fn test_calc_back_project_scale() { - let img = Matrix::from_vec(1, 4, 1, vec![0u8, 1, 2, 3]); - let hist = Matrix::from_vec(4, 1, 1, vec![10.0, 20.0, 30.0, 40.0]); - let bp = calc_back_project( - &[&img], - &[0], - &[4], - &hist, - &[RangeSpec::Uniform(0.0, 4.0)], - 2.0, - ) - .unwrap(); - assert_eq!(bp.data, vec![20.0, 40.0, 60.0, 80.0]); - } - - #[test] - fn test_calc_hist_errors() { - let img = Matrix::from_vec(2, 2, 1, vec![0u8; 4]); - assert!(calc_hist::( - &[], - &[0], - None, - &[4], - &[RangeSpec::Uniform(0.0, 4.0)], - false, - None, - ) - .is_err()); - assert!(calc_hist( - &[&img], - &[0, 1], - None, - &[4], - &[RangeSpec::Uniform(0.0, 4.0)], - false, - None, - ) - .is_err()); - let img2 = Matrix::from_vec(3, 3, 1, vec![0u8; 9]); - assert!(calc_hist( - &[&img, &img2], - &[0, 0], - None, - &[2, 2], - &[RangeSpec::Uniform(0.0, 2.0), RangeSpec::Uniform(0.0, 2.0)], - false, - None, - ) - .is_err()); - let hist = Matrix::from_vec(4, 1, 1, vec![0.0; 4]); - assert!(calc_back_project::( - &[&img], - &[], - &[], - &hist, - &[RangeSpec::Uniform(0.0, 4.0)], - 1.0, - ) - .is_err()); - } - - #[test] - fn test_calc_hist_multichannel_select() { - let img = Matrix::from_vec( - 1, - 3, - 3, - vec![ - 10, 100, 200, // pixel 0 - 20, 150, 250, // pixel 1 - 30, 50, 100, // pixel 2 - ], - ); - let hist = calc_hist( - &[&img], - &[1], - None, - &[3], - &[RangeSpec::Uniform(0.0, 200.0)], - false, - None, - ) - .unwrap(); - assert_eq!(hist.data, vec![1.0, 1.0, 1.0]); - } - - #[test] - fn test_clahe_no_clip() { - let data: Vec = (0..=255).collect(); - let img = Matrix::from_vec(16, 16, 1, data); - let clahe = create_clahe(0.0, Size2i::new(4, 4)); - let dst = clahe.apply_u8(&img).unwrap(); - assert_eq!(dst.data.len(), 256); - } -} diff --git a/src/imgproc/tests.rs b/src/imgproc/tests.rs index 0ecdbfd..6c97aaa 100644 --- a/src/imgproc/tests.rs +++ b/src/imgproc/tests.rs @@ -1338,4 +1338,634 @@ mod imgproc_tests { let c = compare_hist(&h1, &h2, HistCompMethods::Bhattacharyya).unwrap(); assert!(c >= 0.0); } + + #[test] + fn test_calc_hist_uniform_1d() { + let data: Vec = (0..16).collect(); + let img = Matrix::from_vec(4, 4, 1, data); + let hist = calc_hist( + &[&img], + &[0], + None, + &[16], + &[RangeSpec::Uniform(0.0, 16.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data.len(), 16); + for &v in hist.data.iter() { + assert_eq!(v, 1.0); + } + } + + #[test] + fn test_calc_hist_uniform_1d_fewer_bins() { + let data: Vec = (0..16).collect(); + let img = Matrix::from_vec(4, 4, 1, data); + let hist = calc_hist( + &[&img], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 16.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data.len(), 4); + for &v in hist.data.iter() { + assert_eq!(v, 4.0); + } + } + + #[test] + fn test_calc_hist_mask() { + let img = Matrix::from_vec(3, 4, 1, vec![0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + // Partial mask: checkerboard + let mask = Matrix::from_vec(3, 4, 1, vec![0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]); + let h = calc_hist( + &[&img], + &[0], + Some(&mask), + &[4], + &[RangeSpec::Uniform(0.0, 12.0)], + false, + None, + ) + .unwrap(); + assert_eq!(h.data, vec![1.0, 2.0, 1.0, 2.0]); + + // All-zero mask: nothing counted + let img2 = Matrix::from_vec(2, 2, 1, vec![0u8, 1, 2, 3]); + let mask_zero = Matrix::from_vec(2, 2, 1, vec![0u8; 4]); + let h = calc_hist( + &[&img2], + &[0], + Some(&mask_zero), + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .unwrap(); + assert_eq!(h.data, vec![0.0, 0.0, 0.0, 0.0]); + + // All-one mask: all counted + let mask_one = Matrix::from_vec(2, 2, 1, vec![1u8; 4]); + let h = calc_hist( + &[&img2], + &[0], + Some(&mask_one), + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .unwrap(); + assert_eq!(h.data, vec![1.0, 1.0, 1.0, 1.0]); + } + + #[test] + fn test_calc_hist_multichannel_mask_error() { + // Regression test: a mask must be single-channel. Previously only + // rows/cols were checked, so a multichannel mask was silently + // accepted and only its channel 0 was ever read. + let img = Matrix::from_vec(2, 2, 1, vec![0u8, 1, 2, 3]); + let mask = Matrix::from_vec(2, 2, 3, vec![1u8; 12]); + assert!(calc_hist( + &[&img], + &[0], + Some(&mask), + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .is_err()); + } + + #[test] + fn test_calc_hist_multi_image_channel_indexing() { + // images[0] has 2 channels, images[1] has 1 channel + let img0 = Matrix::from_vec(2, 1, 2, vec![10, 20, 30, 40]); + let img1 = Matrix::from_vec(2, 1, 1, vec![100, 200]); + + let hist = calc_hist( + &[&img0, &img1], + &[0, 2], + None, + &[2, 2], + &[ + RangeSpec::Uniform(0.0, 50.0), + RangeSpec::Uniform(0.0, 250.0), + ], + false, + None, + ) + .unwrap(); + + // pixel(0,0): ch0=10->bin0, ch2=100->bin0 -> idx=0 + // pixel(1,0): ch0=30->bin1, ch2=200->bin1 -> idx=3 + assert_eq!(hist.data[0], 1.0); + assert_eq!(hist.data[1], 0.0); + assert_eq!(hist.data[2], 0.0); + assert_eq!(hist.data[3], 1.0); + } + + #[test] + fn test_calc_hist_nonuniform() { + let data: Vec = (0..20).collect(); + let img = Matrix::from_vec(4, 5, 1, data); + // Non-uniform: boundaries [0, 5, 20] -> 2 bins: [0,5) and [5,20) + let hist = calc_hist( + &[&img], + &[0], + None, + &[2], + &[RangeSpec::NonUniform(vec![0.0, 5.0, 20.0])], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data[0], 5.0); // values 0..4 + assert_eq!(hist.data[1], 15.0); // values 5..19 + } + + #[test] + fn test_calc_back_project() { + let data: Vec = vec![0, 1, 2, 3, 4, 5, 6, 7]; + let img = Matrix::from_vec(2, 4, 1, data); + let hist = Matrix::from_vec(4, 1, 1, vec![10.0, 20.0, 30.0, 40.0]); + let bp = calc_back_project( + &[&img], + &[0], + &[4], + &hist, + &[RangeSpec::Uniform(0.0, 8.0)], + 1.0, + ) + .unwrap(); + assert_eq!( + bp.data, + vec![10.0, 10.0, 20.0, 20.0, 30.0, 30.0, 40.0, 40.0] + ); + } + + #[test] + fn test_calc_back_project_2d_shape() { + // Regression test: without an explicit hist_size, a flat 8-bin + // histogram used to have its shape *guessed* from its length alone + // (infer_hist_size), which silently produced the wrong shape for any + // non-perfect-power dims (e.g. [2, 4] was inferred as [1, 8]) and + // corrupted the bin math. hist_size is now required explicitly. + let img = Matrix::from_vec(1, 1, 2, vec![1u8, 7]); + let hist = Matrix::from_vec( + 8, + 1, + 1, + vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0], + ); + let bp = calc_back_project( + &[&img], + &[0, 1], + &[2, 4], + &hist, + &[RangeSpec::Uniform(0.0, 2.0), RangeSpec::Uniform(0.0, 8.0)], + 1.0, + ) + .unwrap(); + // channel 0 (value 1, range [0,2), 2 bins) -> bin 1 + // channel 1 (value 7, range [0,8), 4 bins) -> bin 3 + // flat index with strides [4, 1] -> 1*4 + 3 = 7 -> hist.data[7] = 80.0 + assert_eq!(bp.data, vec![80.0]); + } + + #[test] + fn test_calc_back_project_hist_size_mismatch_error() { + let img = Matrix::from_vec(1, 4, 1, vec![0u8, 1, 2, 3]); + let hist = Matrix::from_vec(4, 1, 1, vec![10.0, 20.0, 30.0, 40.0]); + assert!(calc_back_project( + &[&img], + &[0], + &[3], // doesn't match hist's 4 bins + &hist, + &[RangeSpec::Uniform(0.0, 4.0)], + 1.0, + ) + .is_err()); + } + + #[test] + fn test_compare_hist_correl_identical() { + let h1 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); + let h2 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); + let corr = compare_hist(&h1, &h2, HistCompMethods::Correl).unwrap(); + assert!((corr - 1.0).abs() < 1e-10); + } + + #[test] + fn test_compare_hist_correl_opposite() { + let h1 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); + let h2 = Matrix::from_vec(4, 1, 1, vec![4.0, 3.0, 2.0, 1.0]); + let corr = compare_hist(&h1, &h2, HistCompMethods::Correl).unwrap(); + assert!((corr - (-1.0)).abs() < 1e-10); + } + + #[test] + fn test_compare_hist_intersect() { + let h1 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); + let h2 = Matrix::from_vec(4, 1, 1, vec![4.0, 3.0, 2.0, 1.0]); + let inter = compare_hist(&h1, &h2, HistCompMethods::Intersection).unwrap(); + assert!((inter - 6.0).abs() < 1e-10); + } + + #[test] + fn test_compare_hist_chi_sqr() { + let h1 = Matrix::from_vec(3, 1, 1, vec![1.0, 2.0, 3.0]); + let h2 = Matrix::from_vec(3, 1, 1, vec![1.0, 2.0, 3.0]); + let chi = compare_hist(&h1, &h2, HistCompMethods::ChiSqr).unwrap(); + assert!((chi - 0.0).abs() < 1e-10); + } + + #[test] + fn test_compare_hist_bhattacharyya_identical() { + let h1 = Matrix::from_vec(4, 1, 1, vec![0.25, 0.25, 0.25, 0.25]); + let h2 = Matrix::from_vec(4, 1, 1, vec![0.25, 0.25, 0.25, 0.25]); + let bc = compare_hist(&h1, &h2, HistCompMethods::Bhattacharyya).unwrap(); + assert!(bc.abs() < 1e-10); + } + + #[test] + fn test_compare_hist_kl_divergence() { + let h1 = Matrix::from_vec(3, 1, 1, vec![0.5, 0.3, 0.2]); + let h2 = Matrix::from_vec(3, 1, 1, vec![0.5, 0.3, 0.2]); + let kl = compare_hist(&h1, &h2, HistCompMethods::KullbackLeibler).unwrap(); + assert!(kl.abs() < 1e-10); + } + + #[test] + fn test_equalize_hist_uniform() { + let img = Matrix::from_vec(4, 4, 1, vec![128u8; 16]); + let dst = equalize_hist(&img).unwrap(); + for &v in dst.data.iter() { + assert_eq!(v, 128); + } + } + + #[test] + fn test_equalize_hist_gradient() { + let data: Vec = (0..=255).collect(); + let img = Matrix::from_vec(16, 16, 1, data); + let dst = equalize_hist(&img).unwrap(); + assert_eq!(dst.rows, 16); + assert_eq!(dst.cols, 16); + assert_eq!(dst.channels, 1); + let min_val = *dst.data.iter().min().unwrap(); + let max_val = *dst.data.iter().max().unwrap(); + assert_eq!(min_val, 0); + assert_eq!(max_val, 255); + } + + #[test] + fn test_clahe_basic() { + let data: Vec = (0..=255).collect(); + let img = Matrix::from_vec(16, 16, 1, data); + let clahe = create_clahe(40.0, Size2i::new(4, 4)); + let dst = clahe.apply_u8(&img).unwrap(); + assert_eq!(dst.rows, 16); + assert_eq!(dst.cols, 16); + assert_eq!(dst.channels, 1); + assert_eq!(dst.data.len(), 256); + } + + #[test] + fn test_clahe_u16() { + let data: Vec = (0..=1023).collect(); + let img = Matrix::from_vec(32, 32, 1, data); + let clahe = create_clahe(40.0, Size2i::new(4, 4)); + let dst = clahe.apply_u16(&img).unwrap(); + assert_eq!(dst.rows, 32); + assert_eq!(dst.cols, 32); + assert_eq!(dst.channels, 1); + } + + #[test] + fn test_calc_hist_2d() { + let img = Matrix::from_vec(3, 3, 1, vec![0, 1, 2, 3, 4, 5, 6, 7, 8]); + let hist = calc_hist( + &[&img, &img], + &[0, 0], + None, + &[3, 3], + &[RangeSpec::Uniform(0.0, 9.0), RangeSpec::Uniform(0.0, 9.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data.len(), 9); + assert_eq!(hist.data[0], 3.0); + assert_eq!(hist.data[1], 0.0); + assert_eq!(hist.data[2], 0.0); + assert_eq!(hist.data[3], 0.0); + assert_eq!(hist.data[4], 3.0); + assert_eq!(hist.data[5], 0.0); + assert_eq!(hist.data[6], 0.0); + assert_eq!(hist.data[7], 0.0); + assert_eq!(hist.data[8], 3.0); + } + + #[test] + fn test_calc_hist_empty_images_error() { + assert!(calc_hist::( + &[], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .is_err()); + } + + #[test] + fn test_calc_hist_mismatched_channels_error() { + let img = Matrix::from_vec(2, 2, 1, vec![0u8; 4]); + assert!(calc_hist( + &[&img], + &[0, 1], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .is_err()); + } + + #[test] + fn test_calc_back_project_empty_channels_error() { + let img = Matrix::from_vec(2, 2, 1, vec![0u8; 4]); + let hist = Matrix::from_vec(4, 1, 1, vec![0.0; 4]); + assert!(calc_back_project::( + &[&img], + &[], + &[], + &hist, + &[RangeSpec::Uniform(0.0, 4.0)], + 1.0, + ) + .is_err()); + } + + #[test] + fn test_calc_back_project_zero_width() { + // Regression test: chunks_mut/par_chunks_mut panic on a zero chunk + // size regardless of slice length, so a zero-width image must not + // reach the row-chunking dispatch. + let img = Matrix::::from_vec(3, 0, 1, vec![]); + let hist = Matrix::from_vec(4, 1, 1, vec![0.0; 4]); + let dst = calc_back_project( + &[&img], + &[0], + &[4], + &hist, + &[RangeSpec::Uniform(0.0, 4.0)], + 1.0, + ) + .unwrap(); + assert_eq!(dst.rows, 3); + assert_eq!(dst.cols, 0); + assert_eq!(dst.data.len(), 0); + } + + #[test] + fn test_calc_hist_u16_input() { + let data: Vec = vec![0, 100, 200, 300, 400, 500]; + let img = Matrix::from_vec(2, 3, 1, data); + let hist = calc_hist( + &[&img], + &[0], + None, + &[3], + &[RangeSpec::Uniform(0.0, 600.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data, vec![2.0, 2.0, 2.0]); + } + + #[test] + fn test_calc_hist_f32_input() { + let data: Vec = vec![0.5, 1.5, 2.5, 3.5]; + let img = Matrix::from_vec(2, 2, 1, data); + let hist = calc_hist( + &[&img], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data, vec![1.0, 1.0, 1.0, 1.0]); + } + + #[test] + fn test_calc_hist_boundary_exclusion() { + let img = Matrix::from_vec(1, 4, 1, vec![0u8, 4, 8, 12]); + let hist = calc_hist( + &[&img], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 16.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data, vec![1.0, 1.0, 1.0, 1.0]); + + // Value at exact hi boundary should be excluded + let img2 = Matrix::from_vec(1, 2, 1, vec![4u8, 4]); + let hist2 = calc_hist( + &[&img2], + &[0], + None, + &[2], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist2.data, vec![0.0, 0.0]); + + // Value just below hi should be included + let img3 = Matrix::from_vec(1, 1, 1, vec![3u8]); + let hist3 = calc_hist( + &[&img3], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist3.data[3], 1.0); + } + + #[test] + fn test_calc_hist_accumulate() { + let img = Matrix::from_vec(1, 4, 1, vec![0u8, 1, 2, 3]); + let h1 = calc_hist( + &[&img], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .unwrap(); + assert_eq!(h1.data, vec![1.0, 1.0, 1.0, 1.0]); + + let h2 = calc_hist( + &[&img], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + true, + Some(&h1), + ) + .unwrap(); + assert_eq!(h2.data, vec![2.0, 2.0, 2.0, 2.0]); + + let h3 = calc_hist( + &[&img], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + true, + Some(&h2), + ) + .unwrap(); + assert_eq!(h3.data, vec![3.0, 3.0, 3.0, 3.0]); + } + + #[test] + fn test_compare_hist_edge_cases() { + let z = Matrix::from_vec(3, 1, 1, vec![0.0, 0.0, 0.0]); + assert!((compare_hist(&z, &z, HistCompMethods::Correl).unwrap() - 1.0).abs() < 1e-10); + assert!((compare_hist(&z, &z, HistCompMethods::Intersection).unwrap()).abs() < 1e-10); + assert!( + (compare_hist(&z, &z, HistCompMethods::Bhattacharyya).unwrap() - 1.0).abs() < 1e-10 + ); + + let p1 = Matrix::from_vec(4, 1, 1, vec![0.5, 0.25, 0.125, 0.125]); + let p2 = Matrix::from_vec(4, 1, 1, vec![0.5, 0.25, 0.125, 0.125]); + assert!((compare_hist(&p1, &p2, HistCompMethods::Correl).unwrap() - 1.0).abs() < 1e-10); + assert!((compare_hist(&p1, &p2, HistCompMethods::ChiSqr).unwrap()).abs() < 1e-10); + } + + #[test] + fn test_calc_back_project_scale() { + let img = Matrix::from_vec(1, 4, 1, vec![0u8, 1, 2, 3]); + let hist = Matrix::from_vec(4, 1, 1, vec![10.0, 20.0, 30.0, 40.0]); + let bp = calc_back_project( + &[&img], + &[0], + &[4], + &hist, + &[RangeSpec::Uniform(0.0, 4.0)], + 2.0, + ) + .unwrap(); + assert_eq!(bp.data, vec![20.0, 40.0, 60.0, 80.0]); + } + + #[test] + fn test_calc_hist_errors() { + let img = Matrix::from_vec(2, 2, 1, vec![0u8; 4]); + assert!(calc_hist::( + &[], + &[0], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .is_err()); + assert!(calc_hist( + &[&img], + &[0, 1], + None, + &[4], + &[RangeSpec::Uniform(0.0, 4.0)], + false, + None, + ) + .is_err()); + let img2 = Matrix::from_vec(3, 3, 1, vec![0u8; 9]); + assert!(calc_hist( + &[&img, &img2], + &[0, 0], + None, + &[2, 2], + &[RangeSpec::Uniform(0.0, 2.0), RangeSpec::Uniform(0.0, 2.0)], + false, + None, + ) + .is_err()); + let hist = Matrix::from_vec(4, 1, 1, vec![0.0; 4]); + assert!(calc_back_project::( + &[&img], + &[], + &[], + &hist, + &[RangeSpec::Uniform(0.0, 4.0)], + 1.0, + ) + .is_err()); + } + + #[test] + fn test_calc_hist_multichannel_select() { + let img = Matrix::from_vec( + 1, + 3, + 3, + vec![ + 10, 100, 200, // pixel 0 + 20, 150, 250, // pixel 1 + 30, 50, 100, // pixel 2 + ], + ); + let hist = calc_hist( + &[&img], + &[1], + None, + &[3], + &[RangeSpec::Uniform(0.0, 200.0)], + false, + None, + ) + .unwrap(); + assert_eq!(hist.data, vec![1.0, 1.0, 1.0]); + } + + #[test] + fn test_clahe_no_clip() { + let data: Vec = (0..=255).collect(); + let img = Matrix::from_vec(16, 16, 1, data); + let clahe = create_clahe(0.0, Size2i::new(4, 4)); + let dst = clahe.apply_u8(&img).unwrap(); + assert_eq!(dst.data.len(), 256); + } } From e03c9388b72f126c46707b03bd9269b2da97b451 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Wed, 2 Sep 2026 16:46:03 +0200 Subject: [PATCH 18/20] chore(release): prepare for v0.8.0 - Bump version 0.7.1 -> 0.8.0 in Cargo.toml ([package] and [workspace.package]) and root package.json. crates/wasm/Cargo.toml inherits via version.workspace = true, no separate edit needed. - Regenerate crates/wasm/pkg/package.json via npm run build. - Fix README.md's hardcoded install-snippet version strings (still said "0.6" in five places, e.g. `purecv = "0.6"` - these don't update automatically with the version bump). crates/wasm/README.md had none. - Add a note to MAINTAINERS.md's release Step 2 about checking both READMEs for hardcoded version strings, so this doesn't drift again. - Generate the v0.8.0 changelog entry via git-cliff, plus the blank line before the previous version heading that --prepend omits. Minor: this is a minor version bump (0.7.1 -> 0.8.0) rather than a patch, since this release adds a whole new feature area (the histogram/CLAHE module: calc_hist, calc_back_project, compare_hist, equalize_hist, Clahe, with parallel + SIMD support and WASM bindings) with no breaking changes to previously-released APIs, matching this project's own precedent (0.6 -> 0.7.0 was the last feature-sized bump; 0.7.0 -> 0.7.1 was CI/Miri-only). Verified: cargo build --workspace and cargo test --workspace clean (342 lib tests + 40 doc-tests) with the new version. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 4 ++-- MAINTAINERS.md | 6 +++++- README.md | 10 ++++----- crates/wasm/pkg/package.json | 2 +- package.json | 2 +- 6 files changed, 56 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce645a4..a06f71d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ All notable changes to this project will be documented in this file. +## [0.8.0] - 2026-09-02 + +### ⚙️ Miscellaneous Tasks + +- *(ci)* Pin stable toolchain and allow the new chunks_exact_to_as_chunks lint + +### ⚡ Performance + +- *(imgproc)* SIMD-accelerate compare_hist +- *(imgproc)* SIMD-accelerate compare_hist +- *(ci)* Run the sequential (non-parallel) test suite in CI + +### 🐛 Bug Fixes + +- *(imgproc)* Address review comments for histogram module +- *(imgproc)* Guard calc_back_project against zero-width images +- *(imgproc)* Address Qodo review comments for SIMD compare_hist +- *(wasm)* Address Qodo review findings on histogram bindings +- Address Qodo review findings on histogram examples + +### 📚 Documentation + +- Record the release workflow and merge policy in CLAUDE.md +- Add histogram/CLAHE examples (Rust + WASM) and update READMEs + +### 🕸️ WebAssembly & Emscripten + +- *(wasm)* Expose histogram module bindings + +### 🚀 Features + +- *(imgproc)* Add histogram module +- *(imgproc)* Add parallel support to histogram module + +### 🚜 Refactor + +- *(imgproc)* Use as_chunks instead of allowing the new clippy lint + +### 🧪 Testing + +- *(imgproc)* Move histogram module tests into src/imgproc/tests.rs + ## [0.7.1] - 2026-08-10 ### ⚙️ Miscellaneous Tasks diff --git a/Cargo.toml b/Cargo.toml index 5a1b509..e35cce0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "purecv" -version = "0.7.1" +version = "0.8.0" authors = ["Walter Perdan "] edition = "2021" rust-version = "1.88" @@ -86,7 +86,7 @@ members = ["crates/wasm"] exclude = ["crates/no-std-smoke"] [workspace.package] -version = "0.7.1" +version = "0.8.0" authors = ["Walter Perdan "] edition = "2021" description = "A pure Rust, high-performance computer vision library focused on safety and portability." diff --git a/MAINTAINERS.md b/MAINTAINERS.md index f8ba2d9..a07207f 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -28,7 +28,11 @@ Publishing a new version requires a mix of manual changelog curation and automat 2. Verify that all CI checks (Formatting, Clippy, Tests for `parallel` and `simd`) are passing on the latest commit. ### Step 2: Bump the Version -Update the version number in the `Cargo.toml` file of the workspace in [package] and [workspace.package] sections. +Update the version number in the `Cargo.toml` file of the workspace in [package] and [workspace.package] sections, and in the root `package.json`. + +Also check `README.md` and `crates/wasm/README.md` for hardcoded version strings in +installation snippets (e.g. `purecv = "0.6"`) — these don't update automatically and +are easy to miss. Search for the old version number across both files before moving on. ### Step 3: Generate the Local Changelog We use `git-cliff` to parse the conventional commits and update the historical changelog. Run the following command in the root directory: diff --git a/README.md b/README.md index 0feb0df..ddf2178 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Add the following to your `Cargo.toml`: ```toml [dependencies] -purecv = "0.6" +purecv = "0.8" ``` PureCV's minimum supported Rust version (MSRV) is **1.88**. @@ -105,7 +105,7 @@ PureCV's minimum supported Rust version (MSRV) is **1.88**. ### `no_std` / embedded support Build with `--no-default-features` to run on bare-metal targets such as the -ESP32 (`purecv = { version = "0.6", default-features = false }`). Only `core` +ESP32 (`purecv = { version = "0.8", default-features = false }`). Only `core` and `alloc` are required (an allocator must be provided by the target). | Module | `no_std` | Notes | @@ -121,7 +121,7 @@ features gives the scalar, single-threaded code paths. ```toml [dependencies] -purecv = { version = "0.6", default-features = false } +purecv = { version = "0.8", default-features = false } ``` ```rust @@ -150,14 +150,14 @@ To enable the `ndarray` feature: ```toml [dependencies] -purecv = { version = "0.6", features = ["ndarray"] } +purecv = { version = "0.8", features = ["ndarray"] } ``` To enable SIMD + Parallel for maximum performance: ```toml [dependencies] -purecv = { version = "0.6", features = ["parallel", "simd"] } +purecv = { version = "0.8", features = ["parallel", "simd"] } ``` ### Usage Example diff --git a/crates/wasm/pkg/package.json b/crates/wasm/pkg/package.json index 923a255..989b496 100644 --- a/crates/wasm/pkg/package.json +++ b/crates/wasm/pkg/package.json @@ -5,7 +5,7 @@ "Walter Perdan \u003chttps://github.com/kalwalt\u003e" ], "description": "A pure Rust, high-performance computer vision library focused on safety and portability.", - "version": "0.7.1", + "version": "0.8.0", "license": "LGPL-2.1-or-later", "repository": { "type": "git", diff --git a/package.json b/package.json index dada5ed..a9f0e00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purecv", - "version": "0.7.1", + "version": "0.8.0", "description": "A pure Rust, high-performance computer vision library focused on safety and portability.", "private": true, "scripts": { From e3f9bb664c8608efdbdd22ffe4f74e3aa28ef7d2 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Wed, 2 Sep 2026 18:46:38 +0200 Subject: [PATCH 19/20] fix(imgproc): CLAHE edge-pixel extrapolation and calc_back_project range validation Two real bugs found by Qodo's review on #121, verified before fixing (a third finding on the same review - missing FromPrimitive bound - is a false positive, same recurring rule-2966076 pattern already identified on #110: the functions only ever read pixels via ToPrimitive::to_f32(), never construct T from a primitive). - CLAHE interpolation clamped the tile index *before* computing the blend fraction. At y=0 (tile_rows=4), tyf=-0.5 -> floor=-1 -> clamped to 0 -> ya = tyf - 0 = -0.5, ya1 = 1.5. Those aren't interpolation weights (they don't lie in [0,1]) - they extrapolate beyond the edge tile's LUT instead of blending within it, on the top row and left column of both the u8 and u16 paths. Fixed by deriving the fraction from the *unclamped* floor (always in [0,1) by construction) and clamping only the LUT-lookup indices separately. Extracted the duplicated u8/u16 weight math into a single tile_interp_weights() helper so both paths share one implementation and a direct unit test can check the invariant (weights in [0,1], sum to 1) across the actual coordinate range, including the exact y=0 case from the bug report. Confirmed the old formula fails that assertion (ya=-0.5). - calc_back_project checked only ranges.len() == dims, never the contents - unlike calc_hist, which already validates Uniform(lo, hi) satisfies lo < hi (rejecting NaN via partial_cmp) and NonUniform boundaries have the right length and strict ordering. A malformed range (NaN bounds, wrong-length or unsorted boundaries) silently produced a plausible-but-wrong back-projection instead of an error. Extracted calc_hist's validation into a shared validate_hist_ranges() helper (parameterized by the caller's name for error messages) so calc_hist and calc_back_project can't drift apart again, per Qodo's own suggestion. Verified: cargo fmt --check and clippy -D warnings clean under --all-features and --no-default-features; cargo test --workspace 345 lib tests + 40 doc-tests (342 + 3 new regression tests) passing under both the default (parallel) and --no-default-features --features std (sequential) configs, identical results. Co-Authored-By: Claude Sonnet 5 --- src/imgproc/histogram.rs | 176 ++++++++++++++++++++++----------------- src/imgproc/tests.rs | 98 ++++++++++++++++++++++ 2 files changed, 198 insertions(+), 76 deletions(-) diff --git a/src/imgproc/histogram.rs b/src/imgproc/histogram.rs index 49bff93..cfdda4e 100644 --- a/src/imgproc/histogram.rs +++ b/src/imgproc/histogram.rs @@ -171,6 +171,76 @@ fn map_bin(val: f32, range: &RangeSpec, hist_size: usize) -> Option { } } +/// Validates that `hist_size` and `ranges` describe a well-formed set of +/// histogram dimensions, shared by `calc_hist` and `calc_back_project` so +/// the two can't drift: `hist_size[d] > 0`, `Uniform(lo, hi)` satisfies +/// `lo < hi` (also rejects NaN, since `partial_cmp` returns `None` for it), +/// and `NonUniform(boundaries)` has exactly `hist_size[d] + 1` strictly +/// increasing entries. +/// +/// `fn_name` is used as the error-message prefix so callers keep their own +/// identity in the message (e.g. `"calc_hist: ..."` vs +/// `"calc_back_project: ..."`). +fn validate_hist_ranges(fn_name: &str, hist_size: &[usize], ranges: &[RangeSpec]) -> Result<()> { + for (d, &sz) in hist_size.iter().enumerate() { + if sz == 0 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "{}: hist_size[{}] must be > 0 (got 0)", + fn_name, + d + ); + } + match &ranges[d] { + RangeSpec::Uniform(lo, hi) => { + if lo.partial_cmp(hi) != Some(Ordering::Less) { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "{}: Uniform range [{}] must satisfy lo < hi (got {} >= {})", + fn_name, + d, + lo, + hi + ); + } + } + RangeSpec::NonUniform(boundaries) => { + if boundaries.len() != sz + 1 { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "{}: NonUniform boundaries[{}] length {} must be hist_size[{}]+1 ({})", + fn_name, + d, + boundaries.len(), + d, + sz + 1 + ); + } + for k in 0..boundaries.len() - 1 { + if boundaries[k].partial_cmp(&boundaries[k + 1]) != Some(Ordering::Less) { + cv_bail!( + tags::IMGPROC, + InvalidInput, + "{}: NonUniform boundaries[{}][{}] ({}) must be < boundaries[{}][{}] ({})", + fn_name, + d, + k, + boundaries[k], + d, + k + 1, + boundaries[k + 1] + ); + } + } + } + } + } + Ok(()) +} + // --------------------------------------------------------------------------- // calc_hist // --------------------------------------------------------------------------- @@ -269,58 +339,7 @@ pub fn calc_hist( } // Validate hist_size and ranges (prevents panics in bin mapping) - for (d, &sz) in hist_size.iter().enumerate() { - if sz == 0 { - cv_bail!( - tags::IMGPROC, - InvalidInput, - "calc_hist: hist_size[{}] must be > 0 (got 0)", - d - ); - } - match &ranges[d] { - RangeSpec::Uniform(lo, hi) => { - if lo.partial_cmp(hi) != Some(Ordering::Less) { - cv_bail!( - tags::IMGPROC, - InvalidInput, - "calc_hist: Uniform range [{}] must satisfy lo < hi (got {} >= {})", - d, - lo, - hi - ); - } - } - RangeSpec::NonUniform(boundaries) => { - if boundaries.len() != sz + 1 { - cv_bail!( - tags::IMGPROC, - InvalidInput, - "calc_hist: NonUniform boundaries[{}] length {} must be hist_size[{}]+1 ({})", - d, - boundaries.len(), - d, - sz + 1 - ); - } - for k in 0..boundaries.len() - 1 { - if boundaries[k].partial_cmp(&boundaries[k + 1]) != Some(Ordering::Less) { - cv_bail!( - tags::IMGPROC, - InvalidInput, - "calc_hist: NonUniform boundaries[{}][{}] ({}) must be < boundaries[{}][{}] ({})", - d, - k, - boundaries[k], - d, - k + 1, - boundaries[k + 1] - ); - } - } - } - } - } + validate_hist_ranges("calc_hist", hist_size, ranges)?; let total_bins: usize = hist_size.iter().product(); @@ -477,6 +496,7 @@ pub fn calc_back_project( dims ); } + validate_hist_ranges("calc_back_project", hist_size, ranges)?; let rows = images[0].rows; let cols = images[0].cols; @@ -1199,6 +1219,30 @@ fn clip_and_redistribute(tile_hist: &mut [i32], clip_limit: i32, hist_size: usiz } } +/// Computes the pair of tile indices and blend weight for CLAHE's bilinear +/// tile interpolation along one axis. +/// +/// `coord_f` is the pixel's tile-space coordinate, already offset by -0.5 so +/// tile centers land on integers. Returns `(idx1, idx2, weight1, weight2)` +/// where `weight1 + weight2 == 1.0` and both weights are in `[0, 1]`. +/// +/// The fraction is derived from the *unclamped* floor of `coord_f` so it +/// always lands in `[0, 1)` - a genuine convex blend. Only the indices used +/// for LUT lookup are clamped to the valid tile range; clamping the index +/// *before* computing the fraction (as an earlier version of this code did) +/// produces fractions outside `[0, 1]` at the first/last tile, extrapolating +/// beyond that tile's LUT instead of blending within it. +#[inline(always)] +pub(crate) fn tile_interp_weights(coord_f: f64, num_tiles: usize) -> (usize, usize, f64, f64) { + let idx1_raw = coord_f.floor() as i32; + let weight2 = coord_f - idx1_raw as f64; + let weight1 = 1.0 - weight2; + let max_idx = num_tiles as i32 - 1; + let idx1 = idx1_raw.clamp(0, max_idx) as usize; + let idx2 = (idx1_raw + 1).clamp(0, max_idx) as usize; + (idx1, idx2, weight1, weight2) +} + #[allow(clippy::too_many_arguments)] fn interpolate_tiles_u8( src: &Matrix, @@ -1219,21 +1263,11 @@ fn interpolate_tiles_u8( let process_row = |y: usize, dst_row: &mut [u8]| { let tyf = y as f64 * inv_th - 0.5; - let ty1 = (tyf.floor() as i32).max(0); - let ty2 = (ty1 + 1).min(tiles_y as i32 - 1); - let ya = tyf - ty1 as f64; - let ya1 = 1.0 - ya; - let ty1 = ty1 as usize; - let ty2 = ty2 as usize; + let (ty1, ty2, ya1, ya) = tile_interp_weights(tyf, tiles_y); for (x, out_pixel) in dst_row.iter_mut().enumerate() { let txf = x as f64 * inv_tw - 0.5; - let tx1 = (txf.floor() as i32).max(0); - let tx2 = (tx1 + 1).min(tiles_x as i32 - 1); - let xa = txf - tx1 as f64; - let xa1 = 1.0 - xa; - let tx1 = tx1 as usize; - let tx2 = tx2 as usize; + let (tx1, tx2, xa1, xa) = tile_interp_weights(txf, tiles_x); let src_val = *src.get(y, x, 0).unwrap_or(&0) as usize; let bin = (src_val >> bit_shift).min(hist_size - 1); @@ -1286,21 +1320,11 @@ fn interpolate_tiles_u16( let process_row = |y: usize, dst_row: &mut [u16]| { let tyf = y as f64 * inv_th - 0.5; - let ty1 = (tyf.floor() as i32).max(0); - let ty2 = (ty1 + 1).min(tiles_y as i32 - 1); - let ya = tyf - ty1 as f64; - let ya1 = 1.0 - ya; - let ty1 = ty1 as usize; - let ty2 = ty2 as usize; + let (ty1, ty2, ya1, ya) = tile_interp_weights(tyf, tiles_y); for (x, out_pixel) in dst_row.iter_mut().enumerate() { let txf = x as f64 * inv_tw - 0.5; - let tx1 = (txf.floor() as i32).max(0); - let tx2 = (tx1 + 1).min(tiles_x as i32 - 1); - let xa = txf - tx1 as f64; - let xa1 = 1.0 - xa; - let tx1 = tx1 as usize; - let tx2 = tx2 as usize; + let (tx1, tx2, xa1, xa) = tile_interp_weights(txf, tiles_x); let src_val = *src.get(y, x, 0).unwrap_or(&0) as usize; let bin = (src_val >> bit_shift).min(hist_size - 1); diff --git a/src/imgproc/tests.rs b/src/imgproc/tests.rs index 6c97aaa..65ed16b 100644 --- a/src/imgproc/tests.rs +++ b/src/imgproc/tests.rs @@ -1556,6 +1556,66 @@ mod imgproc_tests { .is_err()); } + #[test] + fn test_calc_back_project_invalid_uniform_range_error() { + // Regression test: calc_back_project used to skip the range + // validation calc_hist already had (lo < hi, NaN rejection, + // non-uniform boundary shape/ordering), silently producing a + // plausible-but-wrong projection instead of an error. + let img = Matrix::from_vec(1, 4, 1, vec![0u8, 1, 2, 3]); + let hist = Matrix::from_vec(4, 1, 1, vec![10.0, 20.0, 30.0, 40.0]); + + // NaN bounds: lo.partial_cmp(hi) is None, must be rejected. + assert!(calc_back_project( + &[&img], + &[0], + &[4], + &hist, + &[RangeSpec::Uniform(f32::NAN, f32::NAN)], + 1.0, + ) + .is_err()); + + // lo >= hi. + assert!(calc_back_project( + &[&img], + &[0], + &[4], + &hist, + &[RangeSpec::Uniform(4.0, 0.0)], + 1.0, + ) + .is_err()); + } + + #[test] + fn test_calc_back_project_invalid_nonuniform_boundaries_error() { + let img = Matrix::from_vec(1, 4, 1, vec![0u8, 1, 2, 3]); + let hist = Matrix::from_vec(4, 1, 1, vec![10.0, 20.0, 30.0, 40.0]); + + // Wrong length: needs hist_size[0] + 1 = 5 boundaries, only 3 given. + assert!(calc_back_project( + &[&img], + &[0], + &[4], + &hist, + &[RangeSpec::NonUniform(vec![0.0, 2.0, 4.0])], + 1.0, + ) + .is_err()); + + // Not strictly increasing. + assert!(calc_back_project( + &[&img], + &[0], + &[4], + &hist, + &[RangeSpec::NonUniform(vec![0.0, 2.0, 1.0, 3.0, 4.0])], + 1.0, + ) + .is_err()); + } + #[test] fn test_compare_hist_correl_identical() { let h1 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]); @@ -1968,4 +2028,42 @@ mod imgproc_tests { let dst = clahe.apply_u8(&img).unwrap(); assert_eq!(dst.data.len(), 256); } + + #[test] + fn test_clahe_tile_interp_weights_are_bounded() { + // Regression test: the interpolation fraction used to be derived + // *after* clamping the tile index, which produced weights outside + // [0, 1] (extrapolation) at the top/left edges - e.g. at y=0 with + // tile_rows=4, tyf=-0.5 used to yield ya=-0.5, ya1=1.5 instead of a + // convex blend. Check the invariant holds across the coordinate + // range that actually occurs (coord_f = pixel*inv_tile_extent - 0.5, + // for pixel in 0..src_extent), for several tile counts. + use crate::imgproc::histogram::tile_interp_weights; + + for num_tiles in [1usize, 2, 3, 5] { + let tile_extent = 4usize; // pixels per tile + let inv = 1.0 / tile_extent as f64; + let src_extent = num_tiles * tile_extent; + + for pixel in 0..src_extent { + let coord_f = pixel as f64 * inv - 0.5; + let (idx1, idx2, w1, w2) = tile_interp_weights(coord_f, num_tiles); + + assert!( + (0.0..=1.0).contains(&w1) && (0.0..=1.0).contains(&w2), + "weights out of [0,1] at pixel={pixel}, num_tiles={num_tiles}: w1={w1}, w2={w2}" + ); + assert!( + (w1 + w2 - 1.0).abs() < 1e-12, + "weights don't sum to 1 at pixel={pixel}: w1={w1}, w2={w2}" + ); + assert!(idx1 < num_tiles && idx2 < num_tiles); + } + } + + // The exact case from the bug report: y=0, tile_rows=4 -> tyf=-0.5. + let (idx1, idx2, w1, w2) = tile_interp_weights(-0.5, 2); + assert_eq!((idx1, idx2), (0, 0)); // both clamp to the first tile + assert!((w1 - 0.5).abs() < 1e-12 && (w2 - 0.5).abs() < 1e-12); + } } From e3e361322462dca3221c1180fd79f23b24780522 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Wed, 2 Sep 2026 18:54:55 +0200 Subject: [PATCH 20/20] fix(imgproc): guard validate_hist_ranges against usize overflow Qodo's review on #122 found that validate_hist_ranges computed hist_size[d] + 1 unchecked, so hist_size[d] == usize::MAX panicked with "attempt to add with overflow" (in debug/test builds) instead of returning the InvalidInput error the function exists to produce - violating the project's own "never panic! in library code" rule. Confirmed by reproducing the panic directly (std::panic::catch_unwind) before applying the fix. The empty-boundaries underflow Qodo also flagged (boundaries.len() - 1 on an empty Vec) is a consequence of the same root cause: it's only reachable when sz + 1 has already wrapped to 0 in a release build without overflow checks, matching an empty boundaries.len(). Guarding the addition with checked_add and returning InvalidInput on overflow closes both paths at once - for any hist_size that doesn't overflow, sz + 1 >= 1, so boundaries.len() == sz + 1 can never be 0. Verified: cargo fmt --check and clippy -D warnings clean under --all-features and --no-default-features; new regression test (hist_size = usize::MAX, empty non-uniform boundaries) confirms calc_back_project now returns Err instead of panicking; cargo test --workspace 346 lib tests + 40 doc-tests passing under both the default (parallel) and --no-default-features --features std (sequential) configs, identical results. Co-Authored-By: Claude Sonnet 5 --- src/imgproc/histogram.rs | 16 ++++++++++++++-- src/imgproc/tests.rs | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/imgproc/histogram.rs b/src/imgproc/histogram.rs index cfdda4e..0fc2e7a 100644 --- a/src/imgproc/histogram.rs +++ b/src/imgproc/histogram.rs @@ -47,6 +47,7 @@ use crate::core::types::{BorderTypes, Size2i}; use crate::core::utils::border_interpolate; use crate::core::Matrix; use crate::cv_bail; +use crate::cv_err; #[cfg(feature = "parallel")] use rayon::prelude::*; @@ -207,7 +208,18 @@ fn validate_hist_ranges(fn_name: &str, hist_size: &[usize], ranges: &[RangeSpec] } } RangeSpec::NonUniform(boundaries) => { - if boundaries.len() != sz + 1 { + let expected_len = sz.checked_add(1).ok_or_else(|| { + cv_err!( + tags::IMGPROC, + InvalidInput, + "{}: hist_size[{}] ({}) is too large (hist_size[{}]+1 overflows)", + fn_name, + d, + sz, + d + ) + })?; + if boundaries.len() != expected_len { cv_bail!( tags::IMGPROC, InvalidInput, @@ -216,7 +228,7 @@ fn validate_hist_ranges(fn_name: &str, hist_size: &[usize], ranges: &[RangeSpec] d, boundaries.len(), d, - sz + 1 + expected_len ); } for k in 0..boundaries.len() - 1 { diff --git a/src/imgproc/tests.rs b/src/imgproc/tests.rs index 65ed16b..58fc9da 100644 --- a/src/imgproc/tests.rs +++ b/src/imgproc/tests.rs @@ -1616,6 +1616,25 @@ mod imgproc_tests { .is_err()); } + #[test] + fn test_calc_back_project_hist_size_overflow_error() { + // Regression test: validate_hist_ranges computed hist_size[d] + 1 + // unchecked, so hist_size[d] == usize::MAX panicked with "attempt to + // add with overflow" instead of returning an InvalidInput error. + // Confirmed the pre-fix code panics here via std::panic::catch_unwind. + let img = Matrix::from_vec(1, 1, 1, vec![0u8]); + let hist = Matrix::from_vec(1, 1, 1, vec![1.0f32]); + let result = calc_back_project( + &[&img], + &[0], + &[usize::MAX], + &hist, + &[RangeSpec::NonUniform(vec![])], + 1.0, + ); + assert!(result.is_err()); + } + #[test] fn test_compare_hist_correl_identical() { let h1 = Matrix::from_vec(4, 1, 1, vec![1.0, 2.0, 3.0, 4.0]);