├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── examples ├── basic.rs ├── edges.rs └── transforms.rs ├── images ├── cameraman.ppm └── peppers.png └── src ├── core ├── colour_models.rs ├── image.rs ├── mod.rs ├── padding.rs ├── traits.rs └── util.rs ├── enhancement ├── histogram_equalisation.rs └── mod.rs ├── format ├── mod.rs └── netpbm.rs ├── lib.rs ├── morphology └── mod.rs ├── processing ├── canny.rs ├── conv.rs ├── filter.rs ├── kernels.rs ├── mod.rs ├── sobel.rs └── threshold.rs └── transform ├── affine.rs └── mod.rs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: xd009642 4 | patreon: xd009642 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | target-branch: develop 9 | ignore: 10 | - dependency-name: ndarray-rand 11 | versions: 12 | - 0.13.0 13 | - 0.14.0 14 | - dependency-name: ndarray-linalg 15 | versions: 16 | - 0.13.0 17 | - dependency-name: rand 18 | versions: 19 | - 0.8.0 20 | - dependency-name: ndarray-stats 21 | versions: 22 | - 0.4.0 23 | - dependency-name: ndarray 24 | versions: 25 | - 0.14.0 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - "*" 6 | pull_request: 7 | jobs: 8 | linux: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | version: 13 | - stable 14 | - beta 15 | - nightly 16 | target: 17 | - x86_64-unknown-linux-gnu 18 | - x86_64-unknown-linux-musl 19 | fail-fast: false 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: actions-rs/toolchain@v1 23 | with: 24 | toolchain: ${{ matrix.version }} 25 | override: true 26 | components: rustfmt 27 | - name: build 28 | run: | 29 | cargo check --no-default-features 30 | - name: test 31 | run: | 32 | cargo test --features=intel-mkl 33 | - name: check formatting 34 | run: cargo fmt -- --check 35 | - name: code-coverage 36 | run: | 37 | cargo install cargo-tarpaulin --force --git https://github.com/xd009642/tarpaulin --branch develop 38 | cargo tarpaulin --features=intel-mkl --force-clean --coveralls ${{ secrets.COVERALLS_TOKEN }} 39 | if: matrix.target == 'x86_64-unknown-linux-gnu' && matrix.version == 'nightly' 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | *.swp 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [0.5.1] 2023-09-04 4 | ### Changed 5 | - Updated dependencies and attempt to fix doc.rs rendering 6 | 7 | ## [0.5.0] 2023-07-23 8 | ### Added 9 | - The `ThresholdApplyExt` trait to apply user-defined threshold 10 | - The `threshold_apply` method to the `ArrayBase` and `Image` types 11 | 12 | ### Changed 13 | - Completely revamped transform module adding a new `Transform` and `ComposedTransform` 14 | trait and fixing implementation issues 15 | 16 | ## [0.4.0] 2022-02-17 17 | ### Changed 18 | - Remove discrete levels - this overflowed with the 64 and 128 bit types 19 | 20 | ## [0.3.0] 2021-11-24 21 | ### Changed 22 | - Fixed orientation of sobel filters 23 | - Fixed remove limit on magnitude in sobel magnitude calculation 24 | 25 | ## [0.2.0] 2020-06-06 26 | ### Added 27 | - Padding strategies (`NoPadding`, `ConstantPadding`, `ZeroPadding`) 28 | - Threshold module with Otsu and Mean threshold algorithms 29 | - Image transformations and functions to create affine transform matrices 30 | - Type alias `Image` for `ImageBase, _>` replicated old `Image` type 31 | - Type alias `ImageView` for `ImageBase, _>` 32 | - Morphology module with dilation, erosion, union and intersection of binary images 33 | 34 | ### Changed 35 | - Integrated Padding strategies into convolutions 36 | - Updated `ndarray-stats` to 0.2.0 adding `noisy_float` for median change 37 | - [INTERNAL] Disabled code coverage due to issues with tarpaulin and native libraries 38 | - Renamed `Image` to `ImageBase` which can take any implementor of the ndaray `Data` trait 39 | - Made images have `NoPadding` by default 40 | - No pad behaviour now keeps pixels near the edges the same as source value instead of making them black 41 | - Various performance enhancements in convolution and canny functions 42 | 43 | ## [0.1.1] - 2019-07-31 44 | ### Changed 45 | - Applied zero padding by default in convolutions 46 | 47 | ## [0.1.0] - 2019-03-24 48 | ### Added 49 | - Image type 50 | - Colour Models (RGB, Gray, HSV, CIEXYZ, Channel-less) 51 | - Histogram equalisation 52 | - Image convolutions 53 | - `PixelBound` type to aid in rescaling images 54 | - Canny edge detector 55 | - `KernelBuilder` and `FixedDimensionKernelBuilder` to create kernels 56 | - Builder implementations for Sobel, Gaussian, Box Linear filter, Laplace 57 | - Median filter 58 | - Sobel Operator 59 | - PPM encoding and decoding for images 60 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ndarray-vision" 3 | version = "0.5.1" 4 | authors = ["xd009642 "] 5 | description = "A computer vision library built on top of ndarray" 6 | repository = "https://github.com/xd009642/ndarray-vision" 7 | readme = "README.md" 8 | license = "MIT/Apache-2.0" 9 | keywords = ["image", "vision", "image-processing"] 10 | categories = ["science", "science::robotics", "multimedia", "multimedia::images", "graphics"] 11 | edition = "2018" 12 | 13 | [features] 14 | default = ["enhancement", "format", "morphology", "processing", "transform" ] 15 | enhancement = [] 16 | format = [] 17 | morphology = [] 18 | processing = [] 19 | netlib = ["ndarray-linalg/netlib"] 20 | openblas = ["ndarray-linalg/openblas"] 21 | intel-mkl = ["ndarray-linalg/intel-mkl"] 22 | transform = ["ndarray-linalg"] 23 | 24 | [dependencies] 25 | ndarray = { version = "0.15", default-features = false } 26 | ndarray-stats = { version = "0.5", default-features = false } 27 | ndarray-linalg = { version = "0.16", default-features = false, optional = true } 28 | noisy_float = { version = "0.2", default-features = false } 29 | num-traits = { version = "0.2", default-features = false } 30 | 31 | [dev-dependencies] 32 | # Note: building with `cargo test` requires a linalg backend specified 33 | # CI uses `cargo test --features=intel-mkl` 34 | # See ndarray-linagl's README for more information 35 | ndarray = { version = "0.15", features = ["approx"] } 36 | ndarray-rand = "0.14.0" 37 | rand = "0.8" 38 | assert_approx_eq = "1.1.0" 39 | approx = "0.4" 40 | noisy_float = "0.2" 41 | png = "0.17" 42 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 xd009642 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ndarray-vision 2 | 3 | [![Build Status](https://travis-ci.org/xd009642/ndarray-vision.svg?branch=master)](https://travis-ci.org/xd009642/ndarray-vision) 4 | [![License:MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | [![Coverage Status](https://coveralls.io/repos/github/xd009642/ndarray-vision/badge.svg?branch=master)](https://coveralls.io/github/xd009642/ndarray-vision?branch=master) 6 | 7 | This project is a computer vision library built on top of ndarray. This project 8 | is a work in progress. Basic image encoding/decoding and processing are 9 | currently implemented. 10 | 11 | See the examples and tests for basic usage. 12 | 13 | # Features 14 | 15 | * Conversions between Grayscale, RGB, HSV and CIEXYZ 16 | * Image convolutions and common kernels (box linear, gaussian, laplace) 17 | * Median filtering 18 | * Sobel operator 19 | * Canny Edge Detection 20 | * Histogram Equalisation 21 | * Thresholding (basic, mean, Otsu) 22 | * Encoding and decoding PPM (binary or plaintext) 23 | 24 | # Performance 25 | 26 | Not a lot of work has been put towards performance yet but a rudimentary 27 | benchmarking project exists [here](https://github.com/rust-cv/ndarray-vision-benchmarking) 28 | for comparative benchmarks against other image processing libraries in rust. 29 | -------------------------------------------------------------------------------- /examples/basic.rs: -------------------------------------------------------------------------------- 1 | use ndarray::{Array3, Ix3}; 2 | use ndarray_vision::core::*; 3 | use ndarray_vision::format::netpbm::*; 4 | use ndarray_vision::format::*; 5 | use ndarray_vision::processing::*; 6 | 7 | use std::path::{Path, PathBuf}; 8 | 9 | fn main() { 10 | let root = Path::new(env!("CARGO_MANIFEST_DIR")); 11 | let cameraman = root.clone().join("images/cameraman.ppm"); 12 | println!("{:?}", cameraman); 13 | 14 | let decoder = PpmDecoder::default(); 15 | let image: Image = decoder 16 | .decode_file(cameraman) 17 | .expect("Couldn't open cameraman.ppm"); 18 | 19 | let boxkern: Array3 = 20 | BoxLinearFilter::build(Ix3(3, 3, 3)).expect("Was unable to construct filter"); 21 | 22 | let mut image: Image = image.into_type(); 23 | 24 | image 25 | .conv2d_inplace(boxkern.view()) 26 | .expect("Poorly sized kernel"); 27 | // There's no u8: From so I've done this to hack things 28 | 29 | let mut cameraman = PathBuf::from(&root); 30 | cameraman.push("images/cameramanblur.ppm"); 31 | 32 | let ppm = PpmEncoder::new_plaintext_encoder(); 33 | ppm.encode_file(&image, cameraman) 34 | .expect("Unable to encode ppm"); 35 | } 36 | -------------------------------------------------------------------------------- /examples/edges.rs: -------------------------------------------------------------------------------- 1 | use ndarray_vision::core::*; 2 | use ndarray_vision::format::netpbm::*; 3 | use ndarray_vision::format::*; 4 | use ndarray_vision::processing::*; 5 | use std::env::current_exe; 6 | use std::path::PathBuf; 7 | 8 | fn canny_edges(img: &Image) -> Image { 9 | let x = CannyBuilder::::new() 10 | .lower_threshold(0.3) 11 | .upper_threshold(0.5) 12 | .blur((5, 5), [0.4, 0.4]) 13 | .build(); 14 | let res = img.canny_edge_detector(x).expect("Failed to run canny"); 15 | 16 | Image::from_data(res.data.mapv(|x| if x { 1.0 } else { 0.0 })) 17 | } 18 | 19 | fn main() { 20 | if let Ok(mut root) = current_exe() { 21 | root.pop(); 22 | root.pop(); 23 | root.pop(); 24 | root.pop(); 25 | let mut cameraman = PathBuf::from(&root); 26 | cameraman.push("images/cameraman.ppm"); 27 | 28 | let decoder = PpmDecoder::default(); 29 | let image: Image = decoder 30 | .decode_file(cameraman) 31 | .expect("Couldn't open cameraman.ppm"); 32 | 33 | let image: Image = image.into_type(); 34 | let image: Image<_, Gray> = image.into(); 35 | 36 | let canny = canny_edges(&image); 37 | 38 | let image = image.apply_sobel().expect("Error in sobel"); 39 | // back to RGB 40 | let image: Image<_, RGB> = image.into(); 41 | let mut cameraman = PathBuf::from(&root); 42 | cameraman.push("images/cameraman-sobel.ppm"); 43 | 44 | let ppm = PpmEncoder::new_plaintext_encoder(); 45 | ppm.encode_file(&image, cameraman) 46 | .expect("Unable to encode ppm"); 47 | 48 | let mut cameraman = PathBuf::from(&root); 49 | cameraman.push("images/cameraman-canny.ppm"); 50 | ppm.encode_file(&canny.into(), cameraman) 51 | .expect("Unable to encode ppm"); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/transforms.rs: -------------------------------------------------------------------------------- 1 | use ndarray_vision::core::*; 2 | use ndarray_vision::format::netpbm::*; 3 | use ndarray_vision::format::*; 4 | use ndarray_vision::transform::affine::*; 5 | use ndarray_vision::transform::*; 6 | use std::env::current_exe; 7 | use std::f64::consts::FRAC_PI_4; 8 | use std::fs::File; 9 | use std::io::BufWriter; 10 | use std::path::{Path, PathBuf}; 11 | 12 | fn get_cameraman() -> Option> { 13 | if let Ok(mut root) = current_exe() { 14 | root.pop(); 15 | root.pop(); 16 | root.pop(); 17 | root.pop(); 18 | let mut cameraman = PathBuf::from(&root); 19 | cameraman.push("images/cameraman.ppm"); 20 | 21 | let decoder = PpmDecoder::default(); 22 | let image: Image = decoder 23 | .decode_file(cameraman) 24 | .expect("Couldn't open cameraman.ppm"); 25 | Some(image) 26 | } else { 27 | None 28 | } 29 | } 30 | 31 | fn main() { 32 | let cameraman = get_cameraman().expect("Couldn't load cameraman"); 33 | 34 | // Create transformation matrix 35 | let x = 0.5 * (cameraman.cols() as f64) - 0.5; 36 | let y = 0.5 * (cameraman.rows() as f64) - 0.5; 37 | let trans = 38 | transform_from_2dmatrix(rotate_around_centre(FRAC_PI_4, (x, y)).dot(&scale(0.7, 0.7))); 39 | 40 | let transformed = cameraman.transform(&trans, None).expect("Transform failed"); 41 | 42 | // save 43 | let path = Path::new("transformed_cameraman.png"); 44 | let file = File::create(path).expect("Couldn't create output file"); 45 | let w = &mut BufWriter::new(file); 46 | 47 | let mut encoder = png::Encoder::new(w, transformed.cols() as u32, transformed.rows() as u32); 48 | encoder.set_color(png::ColorType::Rgb); 49 | encoder.set_depth(png::BitDepth::Eight); 50 | 51 | println!( 52 | "Writing image with resolution {}x{}", 53 | transformed.cols(), 54 | transformed.rows() 55 | ); 56 | 57 | let mut writer = encoder.write_header().expect("Failed to write file header"); 58 | if let Some(data) = transformed.data.view().to_slice() { 59 | writer 60 | .write_image_data(data) 61 | .expect("Failed to write image data"); 62 | } else { 63 | println!("Failed to get image slice"); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /images/cameraman.ppm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-cv/ndarray-vision/de424b53dc9de87041855fb8436b246539ecef68/images/cameraman.ppm -------------------------------------------------------------------------------- /images/peppers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-cv/ndarray-vision/de424b53dc9de87041855fb8436b246539ecef68/images/peppers.png -------------------------------------------------------------------------------- /src/core/colour_models.rs: -------------------------------------------------------------------------------- 1 | use crate::core::traits::*; 2 | use crate::core::*; 3 | use ndarray::{prelude::*, s, Data, Zip}; 4 | use num_traits::cast::{FromPrimitive, NumCast}; 5 | use num_traits::{Num, NumAssignOps}; 6 | use std::convert::From; 7 | use std::fmt::Display; 8 | 9 | /// Grayscale image 10 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 11 | pub struct Gray; 12 | /// RGB colour as intended by sRGB and standardised in IEC 61966-2-1:1999 13 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 14 | pub struct RGB; 15 | /// RGB colour similar to `RGB` type but with an additional channel for alpha 16 | /// transparency 17 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 18 | pub struct RGBA; 19 | /// Hue Saturation Value image 20 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 21 | pub struct HSV; 22 | /// Hue Saturation Intensity image 23 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 24 | pub struct HSI; 25 | /// Hue Saturation Lightness image 26 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 27 | pub struct HSL; 28 | /// YCrCb represents an image as luma, red-difference chroma and blue-difference 29 | /// chroma. 30 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 31 | pub struct YCrCb; 32 | /// CIE XYZ standard - assuming a D50 reference white 33 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 34 | pub struct CIEXYZ; 35 | /// CIE LAB (also known as CIE L*a*b* or Lab) a colour model that represents 36 | /// colour as lightness, and a* and b* as the green-red and blue-yellow colour 37 | /// differences respectively. It is designed to be representative of human 38 | /// perception of colour 39 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 40 | pub struct CIELAB; 41 | /// Similar to `CIELAB` but has a different representation of colour. 42 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 43 | pub struct CIELUV; 44 | /// A single channel image with no colour model specified 45 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 46 | pub struct Generic1; 47 | /// A two channel image with no colour model specified 48 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 49 | pub struct Generic2; 50 | /// A three channel image with no colour model specified 51 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 52 | pub struct Generic3; 53 | /// A four channel image with no colour model specified 54 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 55 | pub struct Generic4; 56 | /// A five channel image with no colour model specified 57 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 58 | pub struct Generic5; 59 | 60 | /// ColourModel trait, this trait reports base parameters for different colour 61 | /// models 62 | pub trait ColourModel { 63 | /// Number of colour channels for a type. 64 | fn channels() -> usize { 65 | 3 66 | } 67 | } 68 | 69 | impl RGB { 70 | /// Remove the gamma from a normalised channel 71 | pub fn remove_gamma(v: f64) -> f64 { 72 | if v < 0.04045 { 73 | v / 12.92 74 | } else { 75 | ((v + 0.055) / 1.055).powf(2.4) 76 | } 77 | } 78 | 79 | /// Apply the gamma to a normalised channel 80 | pub fn apply_gamma(v: f64) -> f64 { 81 | if v < 0.0031308 { 82 | v * 12.92 83 | } else { 84 | 1.055 * v.powf(1.0 / 2.4) - 0.055 85 | } 86 | } 87 | } 88 | 89 | fn rescale_pixel(x: f64) -> T 90 | where 91 | T: FromPrimitive + Num + NumCast + PixelBound + Display, 92 | { 93 | let tmax = T::max_pixel().to_f64().unwrap_or(0.0f64); 94 | let tmin = T::min_pixel().to_f64().unwrap_or(0.0f64); 95 | 96 | let x = x * (tmax - tmin) + tmin; 97 | 98 | T::from_f64(x).unwrap_or_else(T::zero) 99 | } 100 | 101 | /// Converts an RGB pixel to a HSV pixel 102 | pub fn rgb_to_hsv(r: T, g: T, b: T) -> (T, T, T) 103 | where 104 | T: Copy 105 | + Clone 106 | + FromPrimitive 107 | + Num 108 | + NumAssignOps 109 | + NumCast 110 | + PartialOrd 111 | + Display 112 | + PixelBound, 113 | { 114 | let r_norm = normalise_pixel_value(r); 115 | let g_norm = normalise_pixel_value(g); 116 | let b_norm = normalise_pixel_value(b); 117 | let cmax = r_norm.max(g_norm.max(b_norm)); 118 | let cmin = r_norm.min(g_norm.min(b_norm)); 119 | let delta = cmax - cmin; 120 | 121 | let sat = if cmax > 0.0f64 { delta / cmax } else { 0.0f64 }; 122 | 123 | let hue = if delta < std::f64::EPSILON { 124 | 0.0 // hue is undefined for full black full white 125 | } else if cmax <= r_norm { 126 | 60.0 * (((g_norm - b_norm) / delta) % 6.0) 127 | } else if cmax <= g_norm { 128 | 60.0 * ((b_norm - r_norm) / delta + 2.0) 129 | } else { 130 | 60.0 * ((r_norm - g_norm) / delta + 4.0) 131 | }; 132 | let hue = hue / 360.0f64; 133 | 134 | let hue = rescale_pixel(hue); 135 | let sat = rescale_pixel(sat); 136 | let val = rescale_pixel(cmax); 137 | 138 | (hue, sat, val) 139 | } 140 | 141 | /// Converts a HSV pixel to a RGB pixel 142 | pub fn hsv_to_rgb(h: T, s: T, v: T) -> (T, T, T) 143 | where 144 | T: Copy 145 | + Clone 146 | + FromPrimitive 147 | + Num 148 | + NumAssignOps 149 | + NumCast 150 | + PartialOrd 151 | + Display 152 | + PixelBound, 153 | { 154 | let h_deg = normalise_pixel_value(h) * 360.0f64; 155 | let s_norm = normalise_pixel_value(s); 156 | let v_norm = normalise_pixel_value(v); 157 | 158 | let c = v_norm * s_norm; 159 | let x = c * (1.0f64 - ((h_deg / 60.0f64) % 2.0f64 - 1.0f64).abs()); 160 | let m = v_norm - c; 161 | 162 | let rgb = if (0.0f64..60.0f64).contains(&h_deg) { 163 | (c, x, 0.0f64) 164 | } else if (60.0f64..120.0f64).contains(&h_deg) { 165 | (x, c, 0.0f64) 166 | } else if (120.0f64..180.0f64).contains(&h_deg) { 167 | (0.0f64, c, x) 168 | } else if (180.0f64..240.0f64).contains(&h_deg) { 169 | (0.0f64, x, c) 170 | } else if (240.0f64..300.0f64).contains(&h_deg) { 171 | (x, 0.0f64, c) 172 | } else if (300.0f64..360.0f64).contains(&h_deg) { 173 | (c, 0.0f64, x) 174 | } else { 175 | (0.0f64, 0.0f64, 0.0f64) 176 | }; 177 | 178 | let red = rescale_pixel(rgb.0 + m); 179 | let green = rescale_pixel(rgb.1 + m); 180 | let blue = rescale_pixel(rgb.2 + m); 181 | 182 | (red, green, blue) 183 | } 184 | 185 | impl From> for Image 186 | where 187 | U: Data, 188 | T: Copy 189 | + Clone 190 | + FromPrimitive 191 | + Num 192 | + NumAssignOps 193 | + NumCast 194 | + PartialOrd 195 | + Display 196 | + PixelBound, 197 | { 198 | fn from(image: ImageBase) -> Self { 199 | let mut res = Array3::<_>::zeros((image.rows(), image.cols(), HSV::channels())); 200 | let window = image.data.windows((1, 1, image.channels())); 201 | 202 | Zip::indexed(window).for_each(|(i, j, _), pix| { 203 | let red = pix[[0, 0, 0]]; 204 | let green = pix[[0, 0, 1]]; 205 | let blue = pix[[0, 0, 2]]; 206 | 207 | let (hue, sat, val) = rgb_to_hsv(red, green, blue); 208 | res.slice_mut(s![i, j, ..]).assign(&arr1(&[hue, sat, val])); 209 | }); 210 | Self::from_data(res) 211 | } 212 | } 213 | 214 | impl From> for Image 215 | where 216 | U: Data, 217 | T: Copy 218 | + Clone 219 | + FromPrimitive 220 | + Num 221 | + NumAssignOps 222 | + NumCast 223 | + PartialOrd 224 | + Display 225 | + PixelBound, 226 | { 227 | fn from(image: ImageBase) -> Self { 228 | let mut res = Array3::::zeros((image.rows(), image.cols(), RGB::channels())); 229 | let window = image.data.windows((1, 1, image.channels())); 230 | 231 | Zip::indexed(window).for_each(|(i, j, _), pix| { 232 | let h = pix[[0, 0, 0]]; 233 | let s = pix[[0, 0, 1]]; 234 | let v = pix[[0, 0, 2]]; 235 | 236 | let (r, g, b) = hsv_to_rgb(h, s, v); 237 | res.slice_mut(s![i, j, ..]).assign(&arr1(&[r, g, b])); 238 | }); 239 | Self::from_data(res) 240 | } 241 | } 242 | 243 | impl From> for Image 244 | where 245 | U: Data, 246 | T: Copy 247 | + Clone 248 | + FromPrimitive 249 | + Num 250 | + NumAssignOps 251 | + NumCast 252 | + PartialOrd 253 | + Display 254 | + PixelBound, 255 | { 256 | fn from(image: ImageBase) -> Self { 257 | let mut res = Array3::::zeros((image.rows(), image.cols(), Gray::channels())); 258 | let window = image.data.windows((1, 1, image.channels())); 259 | 260 | Zip::indexed(window).for_each(|(i, j, _), pix| { 261 | let r = normalise_pixel_value(pix[[0, 0, 0]]); 262 | let g = normalise_pixel_value(pix[[0, 0, 1]]); 263 | let b = normalise_pixel_value(pix[[0, 0, 2]]); 264 | 265 | let gray = (0.3 * r) + (0.59 * g) + (0.11 * b); 266 | let gray = rescale_pixel(gray); 267 | 268 | res.slice_mut(s![i, j, ..]).assign(&arr1(&[gray])); 269 | }); 270 | Self::from_data(res) 271 | } 272 | } 273 | 274 | impl From> for Image 275 | where 276 | U: Data, 277 | T: Copy 278 | + Clone 279 | + FromPrimitive 280 | + Num 281 | + NumAssignOps 282 | + NumCast 283 | + PartialOrd 284 | + Display 285 | + PixelBound, 286 | { 287 | fn from(image: ImageBase) -> Self { 288 | let mut res = Array3::::zeros((image.rows(), image.cols(), RGB::channels())); 289 | let window = image.data.windows((1, 1, image.channels())); 290 | 291 | Zip::indexed(window).for_each(|(i, j, _), pix| { 292 | let gray = pix[[0, 0, 0]]; 293 | 294 | res.slice_mut(s![i, j, ..]) 295 | .assign(&arr1(&[gray, gray, gray])); 296 | }); 297 | Self::from_data(res) 298 | } 299 | } 300 | 301 | impl From> for Image 302 | where 303 | U: Data, 304 | T: Copy 305 | + Clone 306 | + FromPrimitive 307 | + Num 308 | + NumAssignOps 309 | + NumCast 310 | + PartialOrd 311 | + Display 312 | + PixelBound, 313 | { 314 | fn from(image: ImageBase) -> Self { 315 | let mut res = Array3::::zeros((image.rows(), image.cols(), CIEXYZ::channels())); 316 | let window = image.data.windows((1, 1, image.channels())); 317 | 318 | let m = arr2(&[ 319 | [0.4360747, 0.3850649, 0.1430804], 320 | [0.2225045, 0.7168786, 0.0606169], 321 | [0.0139322, 0.0971045, 0.7141733], 322 | ]); 323 | 324 | Zip::indexed(window).for_each(|(i, j, _), pix| { 325 | let pixel = pix 326 | .index_axis(Axis(0), 0) 327 | .index_axis(Axis(0), 0) 328 | .mapv(normalise_pixel_value) 329 | .mapv(RGB::remove_gamma); 330 | 331 | let pixel = m.dot(&pixel); 332 | let pixel = pixel.mapv(rescale_pixel); 333 | 334 | res.slice_mut(s![i, j, ..]).assign(&pixel); 335 | }); 336 | Self::from_data(res) 337 | } 338 | } 339 | 340 | impl From> for Image 341 | where 342 | U: Data, 343 | T: Copy 344 | + Clone 345 | + FromPrimitive 346 | + Num 347 | + NumAssignOps 348 | + NumCast 349 | + PartialOrd 350 | + Display 351 | + PixelBound, 352 | { 353 | fn from(image: ImageBase) -> Self { 354 | let mut res = Array3::::zeros((image.rows(), image.cols(), RGB::channels())); 355 | let window = image.data.windows((1, 1, image.channels())); 356 | 357 | let m = arr2(&[ 358 | [3.1338561, -1.6168667, -0.4906146], 359 | [-0.9787684, 1.9161415, 0.0334540], 360 | [0.0719453, -0.2289914, 1.4052427], 361 | ]); 362 | 363 | Zip::indexed(window).for_each(|(i, j, _), pix| { 364 | let pixel = pix 365 | .index_axis(Axis(0), 0) 366 | .index_axis(Axis(0), 0) 367 | .mapv(normalise_pixel_value); 368 | 369 | let pixel = m.dot(&pixel); 370 | 371 | let pixel = pixel.mapv(RGB::apply_gamma).mapv(rescale_pixel); 372 | 373 | res.slice_mut(s![i, j, ..]).assign(&pixel); 374 | }); 375 | Self::from_data(res) 376 | } 377 | } 378 | 379 | impl From> for ImageBase 380 | where 381 | T: Data, 382 | { 383 | fn from(image: ImageBase) -> Self { 384 | image.into_type_raw() 385 | } 386 | } 387 | 388 | impl From> for ImageBase 389 | where 390 | T: Data, 391 | { 392 | fn from(image: ImageBase) -> Self { 393 | Self::from_data(image.data) 394 | } 395 | } 396 | 397 | impl From> for ImageBase 398 | where 399 | T: Data, 400 | { 401 | fn from(image: ImageBase) -> Self { 402 | Self::from_data(image.data) 403 | } 404 | } 405 | 406 | impl From> for ImageBase 407 | where 408 | T: Data, 409 | { 410 | fn from(image: ImageBase) -> Self { 411 | Self::from_data(image.data) 412 | } 413 | } 414 | 415 | impl From> for ImageBase 416 | where 417 | T: Data, 418 | { 419 | fn from(image: ImageBase) -> Self { 420 | Self::from_data(image.data) 421 | } 422 | } 423 | 424 | impl From> for ImageBase 425 | where 426 | T: Data, 427 | { 428 | fn from(image: ImageBase) -> Self { 429 | Self::from_data(image.data) 430 | } 431 | } 432 | 433 | impl From> for ImageBase 434 | where 435 | T: Data, 436 | { 437 | fn from(image: ImageBase) -> Self { 438 | Self::from_data(image.data) 439 | } 440 | } 441 | 442 | impl From> for ImageBase 443 | where 444 | T: Data, 445 | { 446 | fn from(image: ImageBase) -> Self { 447 | Self::from_data(image.data) 448 | } 449 | } 450 | 451 | impl From> for ImageBase 452 | where 453 | T: Data, 454 | { 455 | fn from(image: ImageBase) -> Self { 456 | Self::from_data(image.data) 457 | } 458 | } 459 | 460 | impl From> for ImageBase 461 | where 462 | T: Data, 463 | { 464 | fn from(image: ImageBase) -> Self { 465 | Self::from_data(image.data) 466 | } 467 | } 468 | 469 | impl From> for ImageBase 470 | where 471 | T: Data, 472 | { 473 | fn from(image: ImageBase) -> Self { 474 | Self::from_data(image.data) 475 | } 476 | } 477 | 478 | impl From> for ImageBase 479 | where 480 | T: Data, 481 | { 482 | fn from(image: ImageBase) -> Self { 483 | Self::from_data(image.data) 484 | } 485 | } 486 | 487 | impl From> for ImageBase 488 | where 489 | T: Data, 490 | { 491 | fn from(image: ImageBase) -> Self { 492 | Self::from_data(image.data) 493 | } 494 | } 495 | 496 | impl From> for ImageBase 497 | where 498 | T: Data, 499 | { 500 | fn from(image: ImageBase) -> Self { 501 | Self::from_data(image.data) 502 | } 503 | } 504 | 505 | impl From> for ImageBase 506 | where 507 | T: Data, 508 | { 509 | fn from(image: ImageBase) -> Self { 510 | Self::from_data(image.data) 511 | } 512 | } 513 | 514 | impl From> for ImageBase 515 | where 516 | T: Data, 517 | { 518 | fn from(image: ImageBase) -> Self { 519 | Self::from_data(image.data) 520 | } 521 | } 522 | 523 | impl From> for ImageBase 524 | where 525 | T: Data, 526 | { 527 | fn from(image: ImageBase) -> Self { 528 | Self::from_data(image.data) 529 | } 530 | } 531 | 532 | impl From> for ImageBase 533 | where 534 | T: Data, 535 | { 536 | fn from(image: ImageBase) -> Self { 537 | Self::from_data(image.data) 538 | } 539 | } 540 | 541 | impl From> for ImageBase 542 | where 543 | T: Data, 544 | { 545 | fn from(image: ImageBase) -> Self { 546 | Self::from_data(image.data) 547 | } 548 | } 549 | 550 | impl From> for ImageBase 551 | where 552 | T: Data, 553 | { 554 | fn from(image: ImageBase) -> Self { 555 | Self::from_data(image.data) 556 | } 557 | } 558 | 559 | impl From> for Image 560 | where 561 | U: Data, 562 | T: Copy, 563 | { 564 | fn from(image: ImageBase) -> Self { 565 | let shape = (image.rows(), image.cols(), Generic4::channels()); 566 | let data = Array3::from_shape_fn(shape, |(i, j, k)| image.data[[i, j, k]]); 567 | Self::from_data(data) 568 | } 569 | } 570 | 571 | impl From> for Image 572 | where 573 | U: Data, 574 | T: Copy, 575 | { 576 | fn from(image: ImageBase) -> Self { 577 | let shape = (image.rows(), image.cols(), Generic3::channels()); 578 | let data = Array3::from_shape_fn(shape, |(i, j, k)| image.data[[i, j, k]]); 579 | Self::from_data(data) 580 | } 581 | } 582 | 583 | impl From> for Image 584 | where 585 | U: Data, 586 | T: Copy, 587 | { 588 | fn from(image: ImageBase) -> Self { 589 | let shape = (image.rows(), image.cols(), Generic2::channels()); 590 | let data = Array3::from_shape_fn(shape, |(i, j, k)| image.data[[i, j, k]]); 591 | Self::from_data(data) 592 | } 593 | } 594 | 595 | impl From> for Image 596 | where 597 | U: Data, 598 | T: Copy, 599 | { 600 | fn from(image: ImageBase) -> Self { 601 | let shape = (image.rows(), image.cols(), Generic1::channels()); 602 | let data = Array3::from_shape_fn(shape, |(i, j, k)| image.data[[i, j, k]]); 603 | Self::from_data(data) 604 | } 605 | } 606 | 607 | impl From> for Image 608 | where 609 | U: Data, 610 | T: Copy, 611 | { 612 | fn from(image: ImageBase) -> Self { 613 | let shape = (image.rows(), image.cols(), Generic3::channels()); 614 | let data = Array3::from_shape_fn(shape, |(i, j, k)| image.data[[i, j, k]]); 615 | Self::from_data(data) 616 | } 617 | } 618 | 619 | impl From> for Image 620 | where 621 | U: Data, 622 | T: Copy, 623 | { 624 | fn from(image: ImageBase) -> Self { 625 | let shape = (image.rows(), image.cols(), Generic2::channels()); 626 | let data = Array3::from_shape_fn(shape, |(i, j, k)| image.data[[i, j, k]]); 627 | Self::from_data(data) 628 | } 629 | } 630 | 631 | impl From> for Image 632 | where 633 | U: Data, 634 | T: Copy, 635 | { 636 | fn from(image: ImageBase) -> Self { 637 | let shape = (image.rows(), image.cols(), Generic1::channels()); 638 | let data = Array3::from_shape_fn(shape, |(i, j, k)| image.data[[i, j, k]]); 639 | Self::from_data(data) 640 | } 641 | } 642 | 643 | impl From> for Image 644 | where 645 | U: Data, 646 | T: Copy, 647 | { 648 | fn from(image: ImageBase) -> Self { 649 | let shape = (image.rows(), image.cols(), Generic2::channels()); 650 | let data = Array3::from_shape_fn(shape, |(i, j, k)| image.data[[i, j, k]]); 651 | Self::from_data(data) 652 | } 653 | } 654 | 655 | impl From> for Image 656 | where 657 | U: Data, 658 | T: Copy, 659 | { 660 | fn from(image: ImageBase) -> Self { 661 | let shape = (image.rows(), image.cols(), Generic1::channels()); 662 | let data = Array3::from_shape_fn(shape, |(i, j, k)| image.data[[i, j, k]]); 663 | Self::from_data(data) 664 | } 665 | } 666 | 667 | impl From> for Image 668 | where 669 | U: Data, 670 | T: Copy, 671 | { 672 | fn from(image: ImageBase) -> Self { 673 | let shape = (image.rows(), image.cols(), Generic1::channels()); 674 | let data = Array3::from_shape_fn(shape, |(i, j, k)| image.data[[i, j, k]]); 675 | Self::from_data(data) 676 | } 677 | } 678 | 679 | impl From> for Image 680 | where 681 | U: Data, 682 | T: Copy + Num, 683 | { 684 | fn from(image: ImageBase) -> Self { 685 | let shape = (image.rows(), image.cols(), Generic5::channels()); 686 | let mut data = Array3::zeros(shape); 687 | data.slice_mut(s![.., .., 0..Generic1::channels()]) 688 | .assign(&image.data); 689 | Self::from_data(data) 690 | } 691 | } 692 | 693 | impl From> for Image 694 | where 695 | U: Data, 696 | T: Copy + Num, 697 | { 698 | fn from(image: ImageBase) -> Self { 699 | let shape = (image.rows(), image.cols(), Generic5::channels()); 700 | let mut data = Array3::zeros(shape); 701 | data.slice_mut(s![.., .., 0..Generic2::channels()]) 702 | .assign(&image.data); 703 | Self::from_data(data) 704 | } 705 | } 706 | 707 | impl From> for Image 708 | where 709 | U: Data, 710 | T: Copy + Num, 711 | { 712 | fn from(image: ImageBase) -> Self { 713 | let shape = (image.rows(), image.cols(), Generic5::channels()); 714 | let mut data = Array3::zeros(shape); 715 | data.slice_mut(s![.., .., 0..Generic3::channels()]) 716 | .assign(&image.data); 717 | Self::from_data(data) 718 | } 719 | } 720 | 721 | impl From> for Image 722 | where 723 | U: Data, 724 | T: Copy + Num, 725 | { 726 | fn from(image: ImageBase) -> Self { 727 | let shape = (image.rows(), image.cols(), Generic5::channels()); 728 | let mut data = Array3::zeros(shape); 729 | data.slice_mut(s![.., .., 0..Generic4::channels()]) 730 | .assign(&image.data); 731 | Self::from_data(data) 732 | } 733 | } 734 | 735 | impl From> for Image 736 | where 737 | U: Data, 738 | T: Copy + Num, 739 | { 740 | fn from(image: ImageBase) -> Self { 741 | let shape = (image.rows(), image.cols(), Generic4::channels()); 742 | let mut data = Array3::zeros(shape); 743 | data.slice_mut(s![.., .., 0..Generic1::channels()]) 744 | .assign(&image.data); 745 | Self::from_data(data) 746 | } 747 | } 748 | 749 | impl From> for Image 750 | where 751 | U: Data, 752 | T: Copy + Num, 753 | { 754 | fn from(image: ImageBase) -> Self { 755 | let shape = (image.rows(), image.cols(), Generic4::channels()); 756 | let mut data = Array3::zeros(shape); 757 | data.slice_mut(s![.., .., 0..Generic2::channels()]) 758 | .assign(&image.data); 759 | Self::from_data(data) 760 | } 761 | } 762 | 763 | impl From> for Image 764 | where 765 | U: Data, 766 | T: Copy + Num, 767 | { 768 | fn from(image: ImageBase) -> Self { 769 | let shape = (image.rows(), image.cols(), Generic4::channels()); 770 | let mut data = Array3::zeros(shape); 771 | data.slice_mut(s![.., .., 0..Generic3::channels()]) 772 | .assign(&image.data); 773 | Self::from_data(data) 774 | } 775 | } 776 | 777 | impl From> for Image 778 | where 779 | U: Data, 780 | T: Copy + Num, 781 | { 782 | fn from(image: ImageBase) -> Self { 783 | let shape = (image.rows(), image.cols(), Generic3::channels()); 784 | let mut data = Array3::zeros(shape); 785 | data.slice_mut(s![.., .., 0..Generic1::channels()]) 786 | .assign(&image.data); 787 | Self::from_data(data) 788 | } 789 | } 790 | 791 | impl From> for Image 792 | where 793 | U: Data, 794 | T: Copy + Num, 795 | { 796 | fn from(image: ImageBase) -> Self { 797 | let shape = (image.rows(), image.cols(), Generic3::channels()); 798 | let mut data = Array3::zeros(shape); 799 | data.slice_mut(s![.., .., 0..Generic2::channels()]) 800 | .assign(&image.data); 801 | Self::from_data(data) 802 | } 803 | } 804 | 805 | impl From> for Image 806 | where 807 | U: Data, 808 | T: Copy + Num, 809 | { 810 | fn from(image: ImageBase) -> Self { 811 | let shape = (image.rows(), image.cols(), Generic2::channels()); 812 | let mut data = Array3::zeros(shape); 813 | data.slice_mut(s![.., .., 0..Generic1::channels()]) 814 | .assign(&image.data); 815 | Self::from_data(data) 816 | } 817 | } 818 | 819 | impl ColourModel for RGB {} 820 | impl ColourModel for HSV {} 821 | impl ColourModel for HSI {} 822 | impl ColourModel for HSL {} 823 | impl ColourModel for YCrCb {} 824 | impl ColourModel for CIEXYZ {} 825 | impl ColourModel for CIELAB {} 826 | impl ColourModel for CIELUV {} 827 | 828 | impl ColourModel for Gray { 829 | fn channels() -> usize { 830 | 1 831 | } 832 | } 833 | 834 | impl ColourModel for Generic1 { 835 | fn channels() -> usize { 836 | 1 837 | } 838 | } 839 | impl ColourModel for Generic2 { 840 | fn channels() -> usize { 841 | 2 842 | } 843 | } 844 | impl ColourModel for Generic3 { 845 | fn channels() -> usize { 846 | 3 847 | } 848 | } 849 | impl ColourModel for Generic4 { 850 | fn channels() -> usize { 851 | 4 852 | } 853 | } 854 | impl ColourModel for Generic5 { 855 | fn channels() -> usize { 856 | 5 857 | } 858 | } 859 | 860 | impl ColourModel for RGBA { 861 | fn channels() -> usize { 862 | 4 863 | } 864 | } 865 | 866 | #[cfg(test)] 867 | mod tests { 868 | use super::*; 869 | use ndarray::s; 870 | use ndarray_rand::RandomExt; 871 | use ndarray_stats::QuantileExt; 872 | use rand::distributions::Uniform; 873 | 874 | #[test] 875 | fn basic_rgb_hsv_check() { 876 | let mut i = Image::::new(1, 2); 877 | i.pixel_mut(0, 0).assign(&arr1(&[0, 0, 0])); 878 | i.pixel_mut(0, 1).assign(&arr1(&[255, 255, 255])); 879 | 880 | let hsv = Image::::from(i.clone()); 881 | 882 | assert_eq!(hsv.pixel(0, 0)[[2]], 0); 883 | assert_eq!(hsv.pixel(0, 1)[[2]], 255); 884 | 885 | let rgb = Image::::from(hsv); 886 | assert_eq!(i, rgb); 887 | } 888 | 889 | #[test] 890 | fn gray_to_rgb_test() { 891 | let mut image = Image::::new(480, 640); 892 | let new_data = Array3::::random(image.data.dim(), Uniform::new(0, 255)); 893 | image.data = new_data; 894 | 895 | let rgb = Image::::from(image.clone()); 896 | let slice_2d = image.data.slice(s![.., .., 0]); 897 | 898 | assert_eq!(slice_2d, rgb.data.slice(s![.., .., 0])); 899 | assert_eq!(slice_2d, rgb.data.slice(s![.., .., 1])); 900 | assert_eq!(slice_2d, rgb.data.slice(s![.., .., 2])); 901 | } 902 | 903 | #[test] 904 | fn rgb_to_gray_basic() { 905 | // Check white, black, red, green, blue 906 | let data = vec![255, 255, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255]; 907 | let image = Image::::from_shape_data(1, 5, data); 908 | 909 | let gray = Image::::from(image); 910 | 911 | // take standard 0.3 0.59 0.11 values and assume truncation 912 | let expected = vec![255, 0, 77, 150, 28]; 913 | 914 | for (act, exp) in gray.data.iter().zip(expected.iter()) { 915 | let delta = (*act as i16 - *exp as i16).abs(); 916 | assert!(delta < 2); 917 | } 918 | } 919 | 920 | #[test] 921 | fn basic_xyz_rgb_checks() { 922 | let mut image = Image::::new(100, 100); 923 | let new_data = Array3::::random(image.data.dim(), Uniform::new(0.0, 1.0)); 924 | image.data = new_data; 925 | 926 | let xyz = Image::::from(image.clone()); 927 | 928 | let rgb_restored = Image::::from(xyz); 929 | 930 | let mut delta = image.data - rgb_restored.data; 931 | delta.mapv_inplace(|x| x.abs()); 932 | 933 | // 0.5% error in RGB -> XYZ -> RGB 934 | assert!(*delta.max().unwrap() * 100.0 < 0.5); 935 | } 936 | 937 | #[test] 938 | fn generic3_checks() { 939 | let mut image = Image::::new(100, 100); 940 | let new_data = Array3::::random(image.data.dim(), Uniform::new(0.0, 1.0)); 941 | image.data = new_data; 942 | let gen = Image::<_, Generic3>::from(image.clone()); 943 | // Normally don't check floats with equality but data shouldn't have 944 | // changed 945 | assert_eq!(gen.data, image.data); 946 | 947 | let hsv = Image::<_, HSV>::from(gen); 948 | assert_eq!(image.data, hsv.data); 949 | 950 | let gen = Image::<_, Generic3>::from(hsv); 951 | assert_eq!(image.data, gen.data); 952 | 953 | let hsi = Image::<_, HSI>::from(gen); 954 | assert_eq!(image.data, hsi.data); 955 | 956 | let gen = Image::<_, Generic3>::from(hsi); 957 | assert_eq!(image.data, gen.data); 958 | 959 | let hsl = Image::<_, HSL>::from(gen); 960 | assert_eq!(image.data, hsl.data); 961 | 962 | let gen = Image::<_, Generic3>::from(hsl); 963 | assert_eq!(image.data, gen.data); 964 | 965 | let ycrcb = Image::<_, YCrCb>::from(gen); 966 | assert_eq!(image.data, ycrcb.data); 967 | 968 | let gen = Image::<_, Generic3>::from(ycrcb); 969 | assert_eq!(image.data, gen.data); 970 | 971 | let ciexyz = Image::<_, CIEXYZ>::from(gen); 972 | assert_eq!(image.data, ciexyz.data); 973 | 974 | let gen = Image::<_, Generic3>::from(ciexyz); 975 | assert_eq!(image.data, gen.data); 976 | 977 | let cieluv = Image::<_, CIELUV>::from(gen); 978 | assert_eq!(image.data, cieluv.data); 979 | 980 | let gen = Image::<_, Generic3>::from(cieluv); 981 | assert_eq!(image.data, gen.data); 982 | 983 | let cielab = Image::<_, CIELAB>::from(gen); 984 | assert_eq!(image.data, cielab.data); 985 | } 986 | 987 | #[test] 988 | fn generic_model_expand() { 989 | let mut image = Image::::new(100, 100); 990 | let new_data = Array3::::random(image.data.dim(), Uniform::new(0.0, 1.0)); 991 | image.data = new_data; 992 | 993 | let large = Image::<_, Generic5>::from(image.clone()); 994 | 995 | assert_eq!(large.channels(), Generic5::channels()); 996 | 997 | assert_eq!( 998 | large.data.slice(s![.., .., 0]), 999 | image.data.slice(s![.., .., 0]) 1000 | ); 1001 | let zeros = Array2::::zeros((100, 100)); 1002 | for i in 1..Generic5::channels() { 1003 | assert_eq!(large.data.slice(s![.., .., i]), zeros); 1004 | } 1005 | } 1006 | } 1007 | -------------------------------------------------------------------------------- /src/core/image.rs: -------------------------------------------------------------------------------- 1 | use crate::core::colour_models::*; 2 | use crate::core::traits::PixelBound; 3 | use ndarray::prelude::*; 4 | use ndarray::{s, Data, DataMut, OwnedRepr, RawDataClone, ViewRepr}; 5 | use num_traits::cast::{FromPrimitive, NumCast}; 6 | use num_traits::Num; 7 | use std::{fmt, hash, marker::PhantomData}; 8 | 9 | pub type Image = ImageBase, C>; 10 | pub type ImageView<'a, T, C> = ImageBase, C>; 11 | 12 | /// Basic structure containing an image. 13 | pub struct ImageBase 14 | where 15 | C: ColourModel, 16 | T: Data, 17 | { 18 | /// Images are always going to be 3D to handle rows, columns and colour 19 | /// channels 20 | /// 21 | /// This should allow for max compatibility with maths ops in ndarray. 22 | /// Caution should be taken if performing any operations that change the 23 | /// number of channels in an image as this may cause other functionality to 24 | /// perform incorrectly. Use conversions to one of the `Generic` colour models 25 | /// instead. 26 | pub data: ArrayBase, 27 | /// Representation of how colour is encoded in the image 28 | pub(crate) model: PhantomData, 29 | } 30 | 31 | impl ImageBase 32 | where 33 | U: Data, 34 | T: Copy + Clone + FromPrimitive + Num + NumCast + PixelBound, 35 | C: ColourModel, 36 | { 37 | /// Converts image into a different type - doesn't scale to new pixel bounds 38 | pub fn into_type(self) -> Image 39 | where 40 | T2: Copy + Clone + FromPrimitive + Num + NumCast + PixelBound, 41 | { 42 | let rescale = |x: &T| { 43 | let scaled = normalise_pixel_value(*x) 44 | * (T2::max_pixel() - T2::min_pixel()) 45 | .to_f64() 46 | .unwrap_or(0.0f64); 47 | T2::from_f64(scaled).unwrap_or_else(T2::zero) + T2::min_pixel() 48 | }; 49 | let data = self.data.map(rescale); 50 | Image::<_, C>::from_data(data) 51 | } 52 | } 53 | 54 | impl ImageBase 55 | where 56 | S: Data, 57 | T: Clone, 58 | C: ColourModel, 59 | { 60 | pub fn to_owned(&self) -> Image { 61 | Image { 62 | data: self.data.to_owned(), 63 | model: PhantomData, 64 | } 65 | } 66 | 67 | /// Given the shape of the image and a data vector create an image. If 68 | /// the data sizes don't match a zero filled image will be returned instead 69 | /// of panicking 70 | pub fn from_shape_data(rows: usize, cols: usize, data: Vec) -> Image { 71 | let data = Array3::from_shape_vec((rows, cols, C::channels()), data).unwrap(); 72 | 73 | Image { 74 | data, 75 | model: PhantomData, 76 | } 77 | } 78 | } 79 | 80 | impl Image 81 | where 82 | T: Clone + Num, 83 | C: ColourModel, 84 | { 85 | /// Construct a new image filled with zeros using the given dimensions and 86 | /// a colour model 87 | pub fn new(rows: usize, columns: usize) -> Self { 88 | Image { 89 | data: Array3::zeros((rows, columns, C::channels())), 90 | model: PhantomData, 91 | } 92 | } 93 | } 94 | 95 | impl ImageBase 96 | where 97 | T: Data, 98 | C: ColourModel, 99 | { 100 | /// Construct the image from a given Array3 101 | pub fn from_data(data: ArrayBase) -> Self { 102 | Self { 103 | data, 104 | model: PhantomData, 105 | } 106 | } 107 | /// Returns the number of rows in an image 108 | pub fn rows(&self) -> usize { 109 | self.data.shape()[0] 110 | } 111 | /// Returns the number of channels in an image 112 | pub fn cols(&self) -> usize { 113 | self.data.shape()[1] 114 | } 115 | 116 | /// Convenience method to get number of channels 117 | pub fn channels(&self) -> usize { 118 | C::channels() 119 | } 120 | 121 | /// Get a view of all colour channels at a pixels location 122 | pub fn pixel(&self, row: usize, col: usize) -> ArrayView { 123 | self.data.slice(s![row, col, ..]) 124 | } 125 | 126 | pub fn into_type_raw(self) -> ImageBase 127 | where 128 | C2: ColourModel, 129 | { 130 | assert_eq!(C2::channels(), C::channels()); 131 | ImageBase::::from_data(self.data) 132 | } 133 | } 134 | 135 | impl ImageBase 136 | where 137 | T: DataMut, 138 | C: ColourModel, 139 | { 140 | /// Get a mutable view of a pixels colour channels given a location 141 | pub fn pixel_mut(&mut self, row: usize, col: usize) -> ArrayViewMut { 142 | self.data.slice_mut(s![row, col, ..]) 143 | } 144 | } 145 | 146 | impl fmt::Debug for ImageBase 147 | where 148 | U: Data, 149 | T: fmt::Debug, 150 | C: ColourModel, 151 | { 152 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 153 | write!(f, "ColourModel={:?} Data={:?}", self.model, self.data)?; 154 | Ok(()) 155 | } 156 | } 157 | 158 | impl PartialEq> for ImageBase 159 | where 160 | U: Data, 161 | T: PartialEq, 162 | C: ColourModel, 163 | { 164 | fn eq(&self, other: &Self) -> bool { 165 | self.model == other.model && self.data == other.data 166 | } 167 | } 168 | 169 | impl Clone for ImageBase 170 | where 171 | S: RawDataClone + Data, 172 | C: ColourModel, 173 | { 174 | fn clone(&self) -> Self { 175 | Self { 176 | data: self.data.clone(), 177 | model: PhantomData, 178 | } 179 | } 180 | 181 | fn clone_from(&mut self, other: &Self) { 182 | self.data.clone_from(&other.data) 183 | } 184 | } 185 | 186 | impl<'a, S, C> hash::Hash for ImageBase 187 | where 188 | S: Data, 189 | S::Elem: hash::Hash, 190 | C: ColourModel, 191 | { 192 | fn hash(&self, state: &mut H) { 193 | self.model.hash(state); 194 | self.data.hash(state); 195 | } 196 | } 197 | 198 | /// Returns a normalised pixel value or 0 if it can't convert the types. 199 | /// This should never fail if your types are good. 200 | pub fn normalise_pixel_value(t: T) -> f64 201 | where 202 | T: PixelBound + Num + NumCast, 203 | { 204 | let numerator = (t + T::min_pixel()).to_f64(); 205 | let denominator = (T::max_pixel() - T::min_pixel()).to_f64(); 206 | 207 | let numerator = numerator.unwrap_or(0.0f64); 208 | let denominator = denominator.unwrap_or(1.0f64); 209 | 210 | numerator / denominator 211 | } 212 | 213 | #[cfg(test)] 214 | mod tests { 215 | use super::*; 216 | use ndarray::arr1; 217 | 218 | #[test] 219 | fn image_consistency_checks() { 220 | let i = Image::::new(1, 2); 221 | assert_eq!(i.rows(), 1); 222 | assert_eq!(i.cols(), 2); 223 | assert_eq!(i.channels(), 3); 224 | assert_eq!(i.channels(), i.data.shape()[2]); 225 | } 226 | 227 | #[test] 228 | fn image_type_conversion() { 229 | let mut i = Image::::new(1, 1); 230 | i.pixel_mut(0, 0) 231 | .assign(&arr1(&[u8::max_value(), 0, u8::max_value() / 3])); 232 | let t: Image = i.into_type(); 233 | assert_eq!( 234 | t.pixel(0, 0), 235 | arr1(&[u16::max_value(), 0, u16::max_value() / 3]) 236 | ); 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/core/mod.rs: -------------------------------------------------------------------------------- 1 | /// This module deals with different colour models and conversions between 2 | /// colour models. 3 | pub mod colour_models; 4 | /// Core image type and simple operations on it 5 | pub mod image; 6 | /// Image padding operations to increase the image size 7 | pub mod padding; 8 | /// Essential traits for the functionality of `ndarray-vision` 9 | pub mod traits; 10 | /// Some utility functions required in different modules 11 | pub mod util; 12 | 13 | pub use colour_models::*; 14 | pub use image::*; 15 | pub use padding::*; 16 | pub use traits::*; 17 | pub use util::*; 18 | -------------------------------------------------------------------------------- /src/core/padding.rs: -------------------------------------------------------------------------------- 1 | use crate::core::{ColourModel, Image, ImageBase}; 2 | use ndarray::{prelude::*, s, Data, OwnedRepr}; 3 | use num_traits::identities::Zero; 4 | use std::marker::PhantomData; 5 | 6 | /// Defines a method for padding the data of an image applied directly to the 7 | /// ndarray type internally. Padding is symmetric 8 | pub trait PaddingStrategy 9 | where 10 | T: Copy, 11 | { 12 | /// Taking in the image data and the margin to apply to rows and columns 13 | /// returns a padded image 14 | fn pad( 15 | &self, 16 | image: ArrayView, 17 | padding: (usize, usize), 18 | ) -> ArrayBase, Ix3>; 19 | 20 | /// Taking in the image data and row and column return the pixel value 21 | /// if the coordinates are within the image bounds this should probably not 22 | /// be used in the name of performance 23 | fn get_pixel(&self, image: ArrayView, index: (isize, isize)) -> Option>; 24 | 25 | /// Gets a value for a channel rows and columns can exceed bounds but the channel index must be 26 | /// present 27 | fn get_value(&self, image: ArrayView, index: (isize, isize, usize)) -> Option; 28 | 29 | /// Returns true if the padder will return a value for (row, col) or if None if it can pad 30 | /// an image at all. `NoPadding` is a special instance which will always be false 31 | fn will_pad(&self, _coord: Option<(isize, isize)>) -> bool { 32 | true 33 | } 34 | } 35 | 36 | /// Doesn't apply any padding to the image returning it unaltered regardless 37 | /// of padding value 38 | #[derive(Clone, Copy, Eq, PartialEq, Hash, Debug)] 39 | pub struct NoPadding; 40 | 41 | /// Pad the image with a constant value 42 | #[derive(Clone, Eq, PartialEq, Hash, Debug)] 43 | pub struct ConstantPadding(T) 44 | where 45 | T: Copy; 46 | 47 | /// Pad the image with zeros. Uses ConstantPadding internally 48 | #[derive(Clone, Eq, PartialEq, Hash, Debug)] 49 | pub struct ZeroPadding; 50 | 51 | #[inline] 52 | fn is_out_of_bounds(dim: (usize, usize, usize), index: (isize, isize, usize)) -> bool { 53 | index.0 < 0 54 | || index.1 < 0 55 | || index.0 >= dim.0 as isize 56 | || index.1 >= dim.1 as isize 57 | || index.2 >= dim.2 58 | } 59 | 60 | impl PaddingStrategy for NoPadding 61 | where 62 | T: Copy, 63 | { 64 | fn pad( 65 | &self, 66 | image: ArrayView, 67 | _padding: (usize, usize), 68 | ) -> ArrayBase, Ix3> { 69 | image.to_owned() 70 | } 71 | 72 | fn get_pixel(&self, image: ArrayView, index: (isize, isize)) -> Option> { 73 | let index = (index.0, index.1, 0); 74 | if is_out_of_bounds(image.dim(), index) { 75 | None 76 | } else { 77 | Some(image.slice(s![index.0, index.1, ..]).to_owned()) 78 | } 79 | } 80 | 81 | fn get_value(&self, image: ArrayView, index: (isize, isize, usize)) -> Option { 82 | if is_out_of_bounds(image.dim(), index) { 83 | None 84 | } else { 85 | image 86 | .get((index.0 as usize, index.1 as usize, index.2)) 87 | .copied() 88 | } 89 | } 90 | 91 | fn will_pad(&self, _coord: Option<(isize, isize)>) -> bool { 92 | false 93 | } 94 | } 95 | 96 | impl PaddingStrategy for ConstantPadding 97 | where 98 | T: Copy, 99 | { 100 | fn pad( 101 | &self, 102 | image: ArrayView, 103 | padding: (usize, usize), 104 | ) -> ArrayBase, Ix3> { 105 | let shape = ( 106 | image.shape()[0] + padding.0 * 2, 107 | image.shape()[1] + padding.1 * 2, 108 | image.shape()[2], 109 | ); 110 | 111 | let mut result = Array::from_elem(shape, self.0); 112 | result 113 | .slice_mut(s![ 114 | padding.0..shape.0 - padding.0, 115 | padding.1..shape.1 - padding.1, 116 | .. 117 | ]) 118 | .assign(&image); 119 | 120 | result 121 | } 122 | 123 | fn get_pixel(&self, image: ArrayView, index: (isize, isize)) -> Option> { 124 | let index = (index.0, index.1, 0); 125 | if is_out_of_bounds(image.dim(), index) { 126 | let v = vec![self.0; image.dim().2]; 127 | Some(Array1::from(v)) 128 | } else { 129 | Some(image.slice(s![index.0, index.1, ..]).to_owned()) 130 | } 131 | } 132 | 133 | fn get_value(&self, image: ArrayView, index: (isize, isize, usize)) -> Option { 134 | if is_out_of_bounds(image.dim(), index) { 135 | Some(self.0) 136 | } else { 137 | image 138 | .get((index.0 as usize, index.1 as usize, index.2)) 139 | .copied() 140 | } 141 | } 142 | } 143 | 144 | impl PaddingStrategy for ZeroPadding 145 | where 146 | T: Copy + Zero, 147 | { 148 | fn pad( 149 | &self, 150 | image: ArrayView, 151 | padding: (usize, usize), 152 | ) -> ArrayBase, Ix3> { 153 | let padder = ConstantPadding(T::zero()); 154 | padder.pad(image, padding) 155 | } 156 | 157 | fn get_pixel(&self, image: ArrayView, index: (isize, isize)) -> Option> { 158 | let padder = ConstantPadding(T::zero()); 159 | padder.get_pixel(image, index) 160 | } 161 | 162 | fn get_value(&self, image: ArrayView, index: (isize, isize, usize)) -> Option { 163 | let padder = ConstantPadding(T::zero()); 164 | padder.get_value(image, index) 165 | } 166 | } 167 | 168 | /// Padding extension for images 169 | pub trait PaddingExt { 170 | /// Type of the output image 171 | type Output; 172 | /// Pad the object with the given padding and strategy 173 | fn pad(&self, padding: (usize, usize), strategy: &dyn PaddingStrategy) -> Self::Output; 174 | } 175 | 176 | impl PaddingExt for ArrayBase 177 | where 178 | U: Data, 179 | T: Copy, 180 | { 181 | type Output = ArrayBase, Ix3>; 182 | 183 | fn pad(&self, padding: (usize, usize), strategy: &dyn PaddingStrategy) -> Self::Output { 184 | strategy.pad(self.view(), padding) 185 | } 186 | } 187 | 188 | impl PaddingExt for ImageBase 189 | where 190 | U: Data, 191 | T: Copy, 192 | C: ColourModel, 193 | { 194 | type Output = Image; 195 | 196 | fn pad(&self, padding: (usize, usize), strategy: &dyn PaddingStrategy) -> Self::Output { 197 | Self::Output { 198 | data: strategy.pad(self.data.view(), padding), 199 | model: PhantomData, 200 | } 201 | } 202 | } 203 | 204 | #[cfg(test)] 205 | mod tests { 206 | use super::*; 207 | use crate::core::colour_models::{Gray, RGB}; 208 | 209 | #[test] 210 | fn constant_padding() { 211 | let i = Image::::from_shape_data(3, 3, vec![1, 2, 3, 4, 5, 6, 7, 8, 9]); 212 | 213 | let p = i.pad((1, 1), &ConstantPadding(0)); 214 | 215 | let exp = Image::::from_shape_data( 216 | 5, 217 | 5, 218 | vec![ 219 | 0, 0, 0, 0, 0, 0, 1, 2, 3, 0, 0, 4, 5, 6, 0, 0, 7, 8, 9, 0, 0, 0, 0, 0, 0, 220 | ], 221 | ); 222 | assert_eq!(p, exp); 223 | 224 | let p = i.pad((1, 1), &ConstantPadding(2)); 225 | 226 | let exp = Image::::from_shape_data( 227 | 5, 228 | 5, 229 | vec![ 230 | 2, 2, 2, 2, 2, 2, 1, 2, 3, 2, 2, 4, 5, 6, 2, 2, 7, 8, 9, 2, 2, 2, 2, 2, 2, 231 | ], 232 | ); 233 | assert_eq!(p, exp); 234 | 235 | let p = i.pad((2, 0), &ConstantPadding(0)); 236 | let z = i.pad((2, 0), &ZeroPadding {}); 237 | assert_eq!(p, z); 238 | 239 | let exp = Image::::from_shape_data( 240 | 7, 241 | 3, 242 | vec![ 243 | 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 0, 0, 0, 0, 0, 244 | ], 245 | ); 246 | assert_eq!(p, exp); 247 | } 248 | 249 | #[test] 250 | fn no_padding() { 251 | let i = Image::::new(5, 5); 252 | let p = i.pad((10, 10), &NoPadding {}); 253 | 254 | assert_eq!(i, p); 255 | 256 | let p = i.pad((0, 0), &NoPadding {}); 257 | assert_eq!(i, p); 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/core/traits.rs: -------------------------------------------------------------------------------- 1 | /// When working with pixel data types may have odd bitdepths or not use the 2 | /// full range of the value. We can't assume every image with `u8` ranges from 3 | /// [0..255]. Additionally, floating point representations of pixels normally 4 | /// range from [0.0..1.0]. `PixelBound` is an attempt to solve this issue. 5 | /// 6 | /// Unfortunately, type aliases don't really create new types so if you wanted 7 | /// to create a pixel with a reduced bound you'd have to create something like: 8 | /// 9 | /// ```Rust 10 | /// struct LimitedU8(u8); 11 | /// impl PixelBound for LimitedU8 { 12 | /// fn min_pixel() -> Self { 13 | /// LimitedU8(16u8) 14 | /// } 15 | /// 16 | /// fn max_pixel() -> Self { 17 | /// LimitedU8(160u8) 18 | /// } 19 | /// } 20 | /// 21 | /// And then implement the required numerical traits just calling the 22 | /// corresponding methods in `u8` 23 | /// ``` 24 | pub trait PixelBound { 25 | /// The minimum value a pixel can take 26 | fn min_pixel() -> Self; 27 | /// The maximum value a pixel can take 28 | fn max_pixel() -> Self; 29 | /// If this is a non-floating point value return true 30 | fn is_integral() -> bool { 31 | true 32 | } 33 | } 34 | 35 | impl PixelBound for f64 { 36 | fn min_pixel() -> Self { 37 | 0.0f64 38 | } 39 | 40 | fn max_pixel() -> Self { 41 | 1.0f64 42 | } 43 | 44 | fn is_integral() -> bool { 45 | false 46 | } 47 | } 48 | 49 | impl PixelBound for f32 { 50 | fn min_pixel() -> Self { 51 | 0.0f32 52 | } 53 | 54 | fn max_pixel() -> Self { 55 | 1.0f32 56 | } 57 | 58 | fn is_integral() -> bool { 59 | false 60 | } 61 | } 62 | 63 | impl PixelBound for u8 { 64 | fn min_pixel() -> Self { 65 | Self::min_value() 66 | } 67 | 68 | fn max_pixel() -> Self { 69 | Self::max_value() 70 | } 71 | } 72 | 73 | impl PixelBound for u16 { 74 | fn min_pixel() -> Self { 75 | Self::min_value() 76 | } 77 | 78 | fn max_pixel() -> Self { 79 | Self::max_value() 80 | } 81 | } 82 | 83 | impl PixelBound for u32 { 84 | fn min_pixel() -> Self { 85 | Self::min_value() 86 | } 87 | 88 | fn max_pixel() -> Self { 89 | Self::max_value() 90 | } 91 | } 92 | 93 | impl PixelBound for u64 { 94 | fn min_pixel() -> Self { 95 | Self::min_value() 96 | } 97 | 98 | fn max_pixel() -> Self { 99 | Self::max_value() 100 | } 101 | } 102 | 103 | impl PixelBound for u128 { 104 | fn min_pixel() -> Self { 105 | Self::min_value() 106 | } 107 | 108 | fn max_pixel() -> Self { 109 | Self::max_value() 110 | } 111 | } 112 | 113 | impl PixelBound for i8 { 114 | fn min_pixel() -> Self { 115 | Self::min_value() 116 | } 117 | 118 | fn max_pixel() -> Self { 119 | Self::max_value() 120 | } 121 | } 122 | 123 | impl PixelBound for i16 { 124 | fn min_pixel() -> Self { 125 | Self::min_value() 126 | } 127 | 128 | fn max_pixel() -> Self { 129 | Self::max_value() 130 | } 131 | } 132 | 133 | impl PixelBound for i32 { 134 | fn min_pixel() -> Self { 135 | Self::min_value() 136 | } 137 | 138 | fn max_pixel() -> Self { 139 | Self::max_value() 140 | } 141 | } 142 | 143 | impl PixelBound for i64 { 144 | fn min_pixel() -> Self { 145 | Self::min_value() 146 | } 147 | 148 | fn max_pixel() -> Self { 149 | Self::max_value() 150 | } 151 | } 152 | 153 | impl PixelBound for i128 { 154 | fn min_pixel() -> Self { 155 | Self::min_value() 156 | } 157 | 158 | fn max_pixel() -> Self { 159 | Self::max_value() 160 | } 161 | } 162 | 163 | #[cfg(test)] 164 | mod tests { 165 | use super::*; 166 | 167 | #[test] 168 | fn integral_correct() { 169 | assert!(!f64::is_integral()); 170 | assert!(!f32::is_integral()); 171 | assert!(u128::is_integral()); 172 | assert!(u64::is_integral()); 173 | assert!(u32::is_integral()); 174 | assert!(u16::is_integral()); 175 | assert!(u8::is_integral()); 176 | assert!(i128::is_integral()); 177 | assert!(i64::is_integral()); 178 | assert!(i32::is_integral()); 179 | assert!(i16::is_integral()); 180 | assert!(i8::is_integral()); 181 | } 182 | 183 | #[test] 184 | fn max_more_than_min() { 185 | assert!(f64::max_pixel() > f64::min_pixel()); 186 | assert!(f32::max_pixel() > f32::min_pixel()); 187 | assert!(u8::max_pixel() > u8::min_pixel()); 188 | assert!(u16::max_pixel() > u16::min_pixel()); 189 | assert!(u32::max_pixel() > u32::min_pixel()); 190 | assert!(u64::max_pixel() > u64::min_pixel()); 191 | assert!(u128::max_pixel() > u128::min_pixel()); 192 | assert!(i8::max_pixel() > i8::min_pixel()); 193 | assert!(i16::max_pixel() > i16::min_pixel()); 194 | assert!(i32::max_pixel() > i32::min_pixel()); 195 | assert!(i64::max_pixel() > i64::min_pixel()); 196 | assert!(i128::max_pixel() > i128::min_pixel()); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/core/util.rs: -------------------------------------------------------------------------------- 1 | /// Get the centre of a kernel. Determines the pixel to be 2 | /// modified as a window is moved over an image 3 | pub fn kernel_centre(rows: usize, cols: usize) -> (usize, usize) { 4 | let row_offset = rows / 2 - ((rows % 2 == 0) as usize); 5 | let col_offset = cols / 2 - ((cols % 2 == 0) as usize); 6 | (row_offset, col_offset) 7 | } 8 | -------------------------------------------------------------------------------- /src/enhancement/histogram_equalisation.rs: -------------------------------------------------------------------------------- 1 | use crate::core::*; 2 | use ndarray::{prelude::*, DataMut}; 3 | use ndarray_stats::{histogram::Grid, HistogramExt}; 4 | use num_traits::cast::{FromPrimitive, ToPrimitive}; 5 | use num_traits::{Num, NumAssignOps}; 6 | 7 | /// Extension trait to implement histogram equalisation on other types 8 | pub trait HistogramEqExt 9 | where 10 | A: Ord, 11 | { 12 | type Output; 13 | /// Equalises an image histogram returning a new image. 14 | /// Grids should be for a 1xN image as the image is flattened during processing 15 | fn equalise_hist(&self, grid: Grid) -> Self::Output; 16 | 17 | /// Equalises an image histogram inplace 18 | /// Grids should be for a 1xN image as the image is flattened during processing 19 | fn equalise_hist_inplace(&mut self, grid: Grid); 20 | } 21 | 22 | impl HistogramEqExt for ArrayBase 23 | where 24 | U: DataMut, 25 | T: Copy + Clone + Ord + Num + NumAssignOps + ToPrimitive + FromPrimitive + PixelBound, 26 | { 27 | type Output = Array; 28 | 29 | fn equalise_hist(&self, grid: Grid) -> Self::Output { 30 | let mut result = self.to_owned(); 31 | result.equalise_hist_inplace(grid); 32 | result 33 | } 34 | 35 | fn equalise_hist_inplace(&mut self, grid: Grid) { 36 | for mut c in self.axis_iter_mut(Axis(2)) { 37 | // get the histogram 38 | let flat = Array::from_iter(c.iter()).mapv(|x| *x).insert_axis(Axis(1)); 39 | let hist = flat.histogram(grid.clone()); 40 | // get cdf 41 | let mut running_total = 0; 42 | let mut min = 0.0; 43 | let cdf = hist.counts().mapv(|x| { 44 | running_total += x; 45 | if min == 0.0 && running_total > 0 { 46 | min = running_total as f32; 47 | } 48 | running_total as f32 49 | }); 50 | 51 | // Rescale cdf writing back new values 52 | let scale = (T::max_pixel() - T::min_pixel()) 53 | .to_f32() 54 | .unwrap_or_default(); 55 | let denominator = flat.len() as f32 - min; 56 | c.mapv_inplace(|x| { 57 | let index = match grid.index_of(&arr1(&[x])) { 58 | Some(i) => { 59 | if i.is_empty() { 60 | 0 61 | } else { 62 | i[0] 63 | } 64 | } 65 | None => 0, 66 | }; 67 | let mut f_res = ((cdf[index] - min) / denominator) * scale; 68 | if T::is_integral() { 69 | f_res = f_res.round(); 70 | } 71 | T::from_f32(f_res).unwrap_or_else(T::zero) + T::min_pixel() 72 | }); 73 | } 74 | } 75 | } 76 | 77 | impl HistogramEqExt for ImageBase 78 | where 79 | U: DataMut, 80 | T: Copy + Clone + Ord + Num + NumAssignOps + ToPrimitive + FromPrimitive + PixelBound, 81 | C: ColourModel, 82 | { 83 | type Output = Image; 84 | 85 | fn equalise_hist(&self, grid: Grid) -> Self::Output { 86 | let mut result = self.to_owned(); 87 | result.equalise_hist_inplace(grid); 88 | result 89 | } 90 | 91 | fn equalise_hist_inplace(&mut self, grid: Grid) { 92 | self.data.equalise_hist_inplace(grid); 93 | } 94 | } 95 | 96 | #[cfg(test)] 97 | mod tests { 98 | use super::*; 99 | use crate::core::Gray; 100 | use ndarray_stats::histogram::{Bins, Edges}; 101 | 102 | #[test] 103 | fn hist_eq_test() { 104 | // test data from wikipedia 105 | let input_pixels = vec![ 106 | 52, 55, 61, 59, 70, 61, 76, 61, 62, 59, 55, 104, 94, 85, 59, 71, 63, 65, 66, 113, 144, 107 | 104, 63, 72, 64, 70, 70, 126, 154, 109, 71, 69, 67, 73, 68, 106, 122, 88, 68, 68, 68, 108 | 79, 60, 79, 77, 66, 58, 75, 69, 85, 64, 58, 55, 61, 65, 83, 70, 87, 69, 68, 65, 73, 78, 109 | 90, 110 | ]; 111 | 112 | let output_pixels = vec![ 113 | 0, 12, 53, 32, 146, 53, 174, 53, 57, 32, 12, 227, 219, 202, 32, 154, 65, 85, 93, 239, 114 | 251, 227, 65, 158, 73, 146, 146, 247, 255, 235, 154, 130, 97, 166, 117, 231, 243, 210, 115 | 117, 117, 117, 190, 36, 190, 178, 93, 20, 170, 130, 202, 73, 20, 12, 53, 85, 194, 146, 116 | 206, 130, 117, 85, 166, 182, 215, 117 | ]; 118 | 119 | let input = Image::::from_shape_data(8, 8, input_pixels); 120 | 121 | let expected = Image::::from_shape_data(8, 8, output_pixels); 122 | 123 | let edges_vec: Vec = (0..255).collect(); 124 | let grid = Grid::from(vec![Bins::new(Edges::from(edges_vec))]); 125 | 126 | let equalised = input.equalise_hist(grid); 127 | 128 | assert_eq!(expected, equalised); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/enhancement/mod.rs: -------------------------------------------------------------------------------- 1 | /// Implementation of histogram equalisation for images 2 | pub mod histogram_equalisation; 3 | 4 | pub use histogram_equalisation::*; 5 | -------------------------------------------------------------------------------- /src/format/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::core::traits::PixelBound; 2 | use crate::core::*; 3 | use ndarray::Data; 4 | use num_traits::cast::{FromPrimitive, NumCast}; 5 | use num_traits::{Num, NumAssignOps}; 6 | use std::fmt::Display; 7 | use std::fs::{read, File}; 8 | use std::io::prelude::*; 9 | use std::path::Path; 10 | 11 | /// Trait for an image encoder 12 | pub trait Encoder 13 | where 14 | U: Data, 15 | T: Copy + Clone + Num + NumAssignOps + NumCast + PartialOrd + Display + PixelBound, 16 | C: ColourModel, 17 | { 18 | /// Encode an image into a sequence of bytes for the given format 19 | fn encode(&self, image: &ImageBase) -> Vec; 20 | 21 | /// Encode an image saving it to the file at filename. This function shouldn't 22 | /// add an extension preferring the user to do that instead. 23 | fn encode_file>( 24 | &self, 25 | image: &ImageBase, 26 | filename: P, 27 | ) -> std::io::Result<()> { 28 | let mut file = File::create(filename)?; 29 | file.write_all(&self.encode(image))?; 30 | Ok(()) 31 | } 32 | } 33 | 34 | /// Trait for an image decoder, use this to get an image from a byte stream 35 | pub trait Decoder 36 | where 37 | T: Copy 38 | + Clone 39 | + FromPrimitive 40 | + Num 41 | + NumAssignOps 42 | + NumCast 43 | + PartialOrd 44 | + Display 45 | + PixelBound, 46 | C: ColourModel, 47 | { 48 | /// From the bytes decode an image, will perform any scaling or conversions 49 | /// required to represent elements with type T. 50 | fn decode(&self, bytes: &[u8]) -> std::io::Result>; 51 | /// Given a filename decode an image performing any necessary conversions. 52 | fn decode_file>(&self, filename: P) -> std::io::Result> { 53 | let bytes = read(filename)?; 54 | self.decode(&bytes) 55 | } 56 | } 57 | 58 | /// Netpbm refers to a collection of image formats used and defined by the Netpbm 59 | /// project. These include the portable pixmap format (PPM), portable graymap 60 | /// format (PGM), and portable bitmap format (PBM) 61 | pub mod netpbm; 62 | -------------------------------------------------------------------------------- /src/format/netpbm.rs: -------------------------------------------------------------------------------- 1 | use crate::core::{normalise_pixel_value, Image, ImageBase, PixelBound, RGB}; 2 | use crate::format::{Decoder, Encoder}; 3 | use ndarray::Data; 4 | use num_traits::cast::{FromPrimitive, NumCast}; 5 | use num_traits::{Num, NumAssignOps}; 6 | use std::fmt::Display; 7 | use std::io::{Error, ErrorKind}; 8 | 9 | #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] 10 | enum EncodingType { 11 | Binary, 12 | Plaintext, 13 | } 14 | 15 | /// Encoder type for a PPM image. 16 | #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] 17 | pub struct PpmEncoder { 18 | encoding: EncodingType, 19 | } 20 | 21 | /// Decoder type for a PPM image. 22 | #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default)] 23 | pub struct PpmDecoder; 24 | 25 | impl Default for PpmEncoder { 26 | fn default() -> Self { 27 | Self::new() 28 | } 29 | } 30 | 31 | /// Implements the encoder trait for the PpmEncoder. 32 | /// 33 | /// The ColourModel type argument is locked to RGB - this prevents calling 34 | /// RGB::into::() unnecessarily which is unavoidable until trait specialisation is 35 | /// stabilised. 36 | impl Encoder for PpmEncoder 37 | where 38 | U: Data, 39 | T: Copy 40 | + Clone 41 | + Num 42 | + NumAssignOps 43 | + NumCast 44 | + PartialOrd 45 | + Display 46 | + PixelBound 47 | + FromPrimitive, 48 | { 49 | fn encode(&self, image: &ImageBase) -> Vec { 50 | use EncodingType::*; 51 | match self.encoding { 52 | Plaintext => self.encode_plaintext(image), 53 | Binary => self.encode_binary(image), 54 | } 55 | } 56 | } 57 | 58 | impl PpmEncoder { 59 | /// Create a new PPM encoder or decoder 60 | pub fn new() -> Self { 61 | PpmEncoder { 62 | encoding: EncodingType::Binary, 63 | } 64 | } 65 | 66 | /// Creates a new PPM format to encode plain-text. This results in very large 67 | /// file sizes so isn't recommended in general use 68 | pub fn new_plaintext_encoder() -> Self { 69 | PpmEncoder { 70 | encoding: EncodingType::Plaintext, 71 | } 72 | } 73 | 74 | /// Gets the maximum pixel value in the image across all channels. This is 75 | /// used in the PPM header 76 | fn get_max_value(image: &ImageBase) -> Option 77 | where 78 | U: Data, 79 | T: Copy + Clone + Num + NumAssignOps + NumCast + PartialOrd + Display + PixelBound, 80 | { 81 | image 82 | .data 83 | .iter() 84 | .fold(T::zero(), |ref acc, x| if x > acc { *x } else { *acc }) 85 | .to_u8() 86 | } 87 | 88 | ///! Generate the header string for the image 89 | fn generate_header(self, rows: usize, cols: usize, max_value: u8) -> String { 90 | use EncodingType::*; 91 | match self.encoding { 92 | Plaintext => format!("P3\n{} {} {}\n", rows, cols, max_value), 93 | Binary => format!("P6\n{} {} {}\n", rows, cols, max_value), 94 | } 95 | } 96 | 97 | /// Encode the image into the binary PPM format (P6) returning the bytes 98 | fn encode_binary(self, image: &ImageBase) -> Vec 99 | where 100 | U: Data, 101 | T: Copy + Clone + Num + NumAssignOps + NumCast + PartialOrd + Display + PixelBound, 102 | { 103 | let max_val = Self::get_max_value(image).unwrap_or(255); 104 | 105 | let mut result = self 106 | .generate_header(image.rows(), image.cols(), max_val) 107 | .into_bytes(); 108 | 109 | result.reserve(result.len() + (image.rows() * image.cols() * 3)); 110 | 111 | for data in image.data.iter() { 112 | let value = (normalise_pixel_value(*data) * 255.0f64) as u8; 113 | result.push(value); 114 | } 115 | result 116 | } 117 | 118 | /// Encode the image into the plaintext PPM format (P3) returning the text as 119 | /// an array of bytes 120 | fn encode_plaintext(self, image: &ImageBase) -> Vec 121 | where 122 | U: Data, 123 | T: Copy + Clone + Num + NumAssignOps + NumCast + PartialOrd + Display + PixelBound, 124 | { 125 | let max_val = 255; 126 | 127 | let mut result = self.generate_header(image.rows(), image.cols(), max_val); 128 | // Not very accurate as a reserve, doesn't factor in max storage for 129 | // a pixel or spaces. But somewhere between best and worst case 130 | result.reserve(image.rows() * image.cols() * 5); 131 | 132 | // There is a 70 character line length in PPM using another string to keep track 133 | let mut temp = String::new(); 134 | let max_margin = 70 - 12; 135 | temp.reserve(max_margin); 136 | 137 | for data in image.data.iter() { 138 | let value = (normalise_pixel_value(*data) * 255.0f64) as u8; 139 | temp.push_str(&format!("{} ", value)); 140 | if temp.len() > max_margin { 141 | result.push_str(&temp); 142 | result.push('\n'); 143 | temp.clear(); 144 | } 145 | } 146 | if !temp.is_empty() { 147 | result.push_str(&temp); 148 | } 149 | result.into_bytes() 150 | } 151 | } 152 | 153 | /// Implements the decoder trait for the PpmDecoder. 154 | /// 155 | /// The ColourModel type argument is locked to RGB - this prevents calling 156 | /// RGB::into::() unnecessarily which is unavoidable until trait specialisation is 157 | /// stabilised. 158 | impl Decoder for PpmDecoder 159 | where 160 | T: Copy 161 | + Clone 162 | + Num 163 | + NumAssignOps 164 | + NumCast 165 | + PartialOrd 166 | + Display 167 | + PixelBound 168 | + FromPrimitive, 169 | { 170 | fn decode(&self, bytes: &[u8]) -> std::io::Result> { 171 | if bytes.len() < 9 { 172 | Err(Error::new( 173 | ErrorKind::InvalidData, 174 | "File is below minimum size of ppm", 175 | )) 176 | } else if bytes.starts_with(b"P3") { 177 | Self::decode_plaintext(&bytes[2..]) 178 | } else if bytes.starts_with(b"P6") { 179 | Self::decode_binary(&bytes[2..]) 180 | } else { 181 | Err(Error::new( 182 | ErrorKind::InvalidData, 183 | "File is below minimum size of ppm", 184 | )) 185 | } 186 | } 187 | } 188 | 189 | impl PpmDecoder { 190 | /// Decodes a PPM header getting (rows, cols, maximum value) or returning 191 | /// an io::Error if the header is malformed 192 | fn decode_header(bytes: &[u8]) -> std::io::Result<(usize, usize, usize)> { 193 | let err = || Error::new(ErrorKind::InvalidData, "Error in file header"); 194 | let mut keep = true; 195 | let bytes = bytes 196 | .iter() 197 | .filter(|x| { 198 | if *x == &b'#' { 199 | keep = false; 200 | false 201 | } else if !keep { 202 | if *x == &b'\n' || *x == &b'\r' { 203 | keep = true; 204 | } 205 | false 206 | } else { 207 | true 208 | } 209 | }) 210 | .cloned() 211 | .collect::>(); 212 | 213 | if let Ok(s) = String::from_utf8(bytes) { 214 | let res = s 215 | .split_whitespace() 216 | .map(|x| x.parse::().unwrap_or(0)) 217 | .collect::>(); 218 | if res.len() == 3 { 219 | Ok((res[0], res[1], res[2])) 220 | } else { 221 | Err(err()) 222 | } 223 | } else { 224 | Err(err()) 225 | } 226 | } 227 | 228 | fn decode_binary(bytes: &[u8]) -> std::io::Result> 229 | where 230 | T: Copy 231 | + Clone 232 | + Num 233 | + NumAssignOps 234 | + NumCast 235 | + PartialOrd 236 | + Display 237 | + PixelBound 238 | + FromPrimitive, 239 | { 240 | let err = || Error::new(ErrorKind::InvalidData, "Error in file encoding"); 241 | const WHITESPACE: &[u8] = b" \t\n\r"; 242 | 243 | let mut image_bytes = Vec::::new(); 244 | 245 | let mut last_saw_whitespace = false; 246 | let mut is_comment = false; 247 | let mut val_count = 0; 248 | let header_end = bytes 249 | .iter() 250 | .position(|&b| { 251 | if b == b'#' { 252 | is_comment = true; 253 | } else if is_comment { 254 | if b == b'\r' || b == b'\n' { 255 | is_comment = false; 256 | } 257 | } else if last_saw_whitespace && !WHITESPACE.contains(&b) { 258 | val_count += 1; 259 | last_saw_whitespace = false; 260 | } else if WHITESPACE.contains(&b) { 261 | last_saw_whitespace = true; 262 | } 263 | val_count == 3 && WHITESPACE.contains(&b) 264 | }) 265 | .ok_or_else(err)?; 266 | 267 | let (rows, cols, max_val) = Self::decode_header(&bytes[0..header_end])?; 268 | for b in bytes.iter().skip(header_end + 1) { 269 | let real_pixel = (*b as f64) * (255.0f64 / (max_val as f64)); 270 | image_bytes.push(T::from_u8(real_pixel as u8).unwrap_or_else(T::zero)); 271 | } 272 | 273 | if image_bytes.is_empty() || image_bytes.len() != (rows * cols * 3) { 274 | Err(err()) 275 | } else { 276 | let image = Image::::from_shape_data(rows, cols, image_bytes); 277 | Ok(image) 278 | } 279 | } 280 | 281 | fn decode_plaintext(bytes: &[u8]) -> std::io::Result> 282 | where 283 | T: Copy 284 | + Clone 285 | + Num 286 | + NumAssignOps 287 | + NumCast 288 | + PartialOrd 289 | + Display 290 | + PixelBound 291 | + FromPrimitive, 292 | { 293 | let err = || Error::new(ErrorKind::InvalidData, "Error in file encoding"); 294 | // plaintext is easier than binary because the whole thing is a string 295 | let data = String::from_utf8(bytes.to_vec()).map_err(|_| err())?; 296 | 297 | let mut rows = -1; 298 | let mut cols = -1; 299 | let mut max_val = -1; 300 | let mut image_bytes = Vec::::new(); 301 | for line in data.lines().filter(|l| !l.starts_with('#')) { 302 | for value in line.split_whitespace().take_while(|x| !x.starts_with('#')) { 303 | let temp = value.parse::().map_err(|_| err())?; 304 | if rows < 0 { 305 | rows = temp; 306 | } else if cols < 0 { 307 | cols = temp; 308 | image_bytes.reserve((rows * cols * 3) as usize); 309 | } else if max_val < 0 { 310 | max_val = temp; 311 | } else { 312 | let real_pixel = (temp as f64) * (255.0f64 / (max_val as f64)); 313 | image_bytes.push(T::from_f64(real_pixel).unwrap_or_else(T::zero)); 314 | } 315 | } 316 | } 317 | if image_bytes.is_empty() || image_bytes.len() != ((rows * cols * 3) as usize) { 318 | Err(err()) 319 | } else { 320 | let image = Image::::from_shape_data(rows as usize, cols as usize, image_bytes); 321 | Ok(image) 322 | } 323 | } 324 | } 325 | 326 | #[cfg(test)] 327 | mod tests { 328 | use super::*; 329 | use crate::core::colour_models::*; 330 | use ndarray::prelude::*; 331 | use ndarray_rand::RandomExt; 332 | use rand::distributions::Uniform; 333 | use std::fs::remove_file; 334 | 335 | #[test] 336 | fn max_value_test() { 337 | let full_range = "P3 1 1 255 0 255 0"; 338 | let clamped = "P3 1 1 1 0 1 0"; 339 | 340 | let decoder = PpmDecoder::default(); 341 | let full_image: Image = decoder.decode(full_range.as_bytes()).unwrap(); 342 | let clamp_image: Image = decoder.decode(clamped.as_bytes()).unwrap(); 343 | 344 | assert_eq!(full_image, clamp_image); 345 | assert_eq!(full_image.pixel(0, 0), arr1(&[0, 255, 0])); 346 | } 347 | 348 | #[test] 349 | fn encoding_consistency() { 350 | let image_str = "P3 351 | 3 3 255 352 | 255 255 255 0 0 0 255 0 0 353 | 0 255 0 0 0 255 255 255 0 354 | 0 255 255 127 127 127 0 0 0"; 355 | 356 | let decoder = PpmDecoder::default(); 357 | let image: Image = decoder.decode(image_str.as_bytes()).unwrap(); 358 | 359 | let encoder = PpmEncoder::new(); 360 | let image_bytes = encoder.encode(&image); 361 | 362 | let restored: Image = decoder.decode(&image_bytes).unwrap(); 363 | 364 | assert_eq!(image, restored); 365 | 366 | let encoder = PpmEncoder::new_plaintext_encoder(); 367 | let image_bytes = encoder.encode(&image); 368 | let restored: Image = decoder.decode(&image_bytes).unwrap(); 369 | 370 | assert_eq!(image, restored); 371 | } 372 | 373 | #[test] 374 | fn binary_comments() { 375 | let image_str = "P3 376 | 3 3 255 377 | 255 255 255 0 0 0 255 0 0 378 | 0 255 0 0 0 255 255 255 0 379 | 0 255 255 127 127 127 0 0 0"; 380 | 381 | let decoder = PpmDecoder::default(); 382 | let image: Image = decoder.decode(image_str.as_bytes()).unwrap(); 383 | 384 | let encoder = PpmEncoder::new(); 385 | let mut image_bytes = encoder.encode(&image); 386 | let comment = b"# This is a comment\n"; 387 | for i in 0..comment.len() { 388 | image_bytes.insert(2 + i, comment[i]); 389 | } 390 | let restored: Image = decoder.decode(&image_bytes).unwrap(); 391 | 392 | assert_eq!(image, restored); 393 | } 394 | 395 | #[test] 396 | fn binary_file_save() { 397 | let mut image = Image::::new(480, 640); 398 | let new_data = Array3::::random(image.data.dim(), Uniform::new(0, 255)); 399 | image.data = new_data; 400 | 401 | let bin_encoder = PpmEncoder::new(); 402 | 403 | let filename = "bintest.ppm"; 404 | 405 | bin_encoder.encode_file(&image, filename).unwrap(); 406 | 407 | let decoder = PpmDecoder::default(); 408 | let new_image = decoder.decode_file(filename).unwrap(); 409 | let _ = remove_file(filename); 410 | 411 | image_compare(&new_image, &image); 412 | } 413 | 414 | #[test] 415 | fn plaintext_file_save() { 416 | let mut image = Image::::new(480, 640); 417 | let new_data = Array3::::random(image.data.dim(), Uniform::new(0, 255)); 418 | image.data = new_data; 419 | 420 | let bin_encoder = PpmEncoder::new_plaintext_encoder(); 421 | let filename = "texttest.ppm"; 422 | 423 | bin_encoder.encode_file(&image, filename).unwrap(); 424 | 425 | let decoder = PpmDecoder::default(); 426 | let new_image = decoder.decode_file(filename).unwrap(); 427 | let _ = remove_file(filename); 428 | 429 | image_compare(&new_image, &image); 430 | } 431 | 432 | fn image_compare(actual: &Image, expected: &Image) 433 | where 434 | C: ColourModel, 435 | { 436 | assert_eq!(actual.data.shape(), expected.data.shape()); 437 | 438 | for (act, exp) in actual.data.iter().zip(expected.data.iter()) { 439 | let delta = (*act as i16 - *exp as i16).abs(); 440 | // An error of 1 is acceptable on any value due to rounding 441 | assert!(delta < 2); 442 | } 443 | } 444 | } 445 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate is a computer vision and image analysis crate built on `ndarray`. 2 | //! 3 | //! By using `ndarray`, this project aims to make full use of other crates in 4 | //! the ecosystem like `ndarray_stats`. This should also allow users to easily 5 | //! integrate other `ndarray` crates with `ndarray-vision` and avoid this crate 6 | //! becoming a monolith by fulfilling every potential usecase in a rather large 7 | //! field. Instead the main focus of this crate will be as follows: 8 | //! 9 | //! * An `Image` type which makes use of Rust to ensure proper and safe use 10 | //! * Conversions between different colour models 11 | //! * Encoding and decoding images 12 | //! * Image processing intrinsics like convolution and a selection of common filters 13 | //! * Common image enhancement algorithms 14 | //! * Geometric image transformations 15 | //! * Intrinsics required for feature detection and matching 16 | //! * Camera Calibration 17 | //! * Frequency domain image processing 18 | //! 19 | //! This may seem like a lot but is still a lot less than OpenCV offers. Also, 20 | //! where possible algorithms will be used from other crates in the ecosystem 21 | //! when those operations aren't Computer Vision specific. For example, 22 | //! `ndarray-stats` has histogram calculation. 23 | //! 24 | //! This crate is a work in progress and as such most of these features aren't 25 | //! yet present and those that are may not be stable. Although, there will be 26 | //! some effort to ensure things don't break. 27 | 28 | /// The core of `ndarray-vision` contains the `Image` type and colour models 29 | pub mod core; 30 | /// Image enhancement intrinsics and algorithms 31 | #[cfg(feature = "enhancement")] 32 | pub mod enhancement; 33 | /// Image formats - encoding and decoding images from bytes for saving and 34 | /// loading 35 | #[cfg(feature = "format")] 36 | pub mod format; 37 | /// Operations relating to morphological image processing 38 | #[cfg(feature = "morphology")] 39 | pub mod morphology; 40 | /// Image processing intrinsics and common filters/algorithms. 41 | #[cfg(feature = "processing")] 42 | pub mod processing; 43 | /// Image transforms and warping 44 | #[cfg(feature = "transform")] 45 | pub mod transform; 46 | -------------------------------------------------------------------------------- /src/morphology/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::core::*; 2 | use ndarray::{prelude::*, DataMut, Zip}; 3 | 4 | pub trait MorphologyExt { 5 | type Output; 6 | 7 | fn erode(&self, kernel: ArrayView2) -> Self::Output; 8 | 9 | fn erode_inplace(&mut self, kernel: ArrayView2); 10 | 11 | fn dilate(&self, kernel: ArrayView2) -> Self::Output; 12 | 13 | fn dilate_inplace(&mut self, kernel: ArrayView2); 14 | 15 | fn union(&self, other: &Self) -> Self::Output; 16 | 17 | fn union_inplace(&mut self, other: &Self); 18 | 19 | fn intersection(&self, other: &Self) -> Self::Output; 20 | 21 | fn intersection_inplace(&mut self, other: &Self); 22 | } 23 | 24 | impl MorphologyExt for ArrayBase 25 | where 26 | U: DataMut, 27 | { 28 | type Output = Array; 29 | 30 | fn erode(&self, kernel: ArrayView2) -> Self::Output { 31 | let sh = kernel.shape(); 32 | let (ro, co) = kernel_centre(sh[0], sh[1]); 33 | let mut result = Self::Output::from_elem(self.dim(), false); 34 | if self.shape()[0] >= sh[0] && self.shape()[1] >= sh[1] { 35 | Zip::indexed(self.slice(s![.., .., 0]).windows(kernel.dim())).for_each( 36 | |(i, j), window| { 37 | result[[i + ro, j + co, 0]] = (&kernel & &window) == kernel; 38 | }, 39 | ); 40 | } 41 | result 42 | } 43 | 44 | fn erode_inplace(&mut self, kernel: ArrayView2) { 45 | self.assign(&self.erode(kernel)); 46 | } 47 | 48 | fn dilate(&self, kernel: ArrayView2) -> Self::Output { 49 | let sh = kernel.shape(); 50 | let (ro, co) = kernel_centre(sh[0], sh[1]); 51 | let mut result = Self::Output::from_elem(self.dim(), false); 52 | if self.shape()[0] >= sh[0] && self.shape()[1] >= sh[1] { 53 | Zip::indexed(self.slice(s![.., .., 0]).windows(kernel.dim())).for_each( 54 | |(i, j), window| { 55 | result[[i + ro, j + co, 0]] = (&kernel & &window).iter().any(|x| *x); 56 | }, 57 | ); 58 | } 59 | result 60 | } 61 | 62 | fn dilate_inplace(&mut self, kernel: ArrayView2) { 63 | self.assign(&self.dilate(kernel)); 64 | } 65 | 66 | fn union(&self, other: &Self) -> Self::Output { 67 | self | other 68 | } 69 | 70 | fn union_inplace(&mut self, other: &Self) { 71 | *self |= other; 72 | } 73 | 74 | fn intersection(&self, other: &Self) -> Self::Output { 75 | self & other 76 | } 77 | 78 | fn intersection_inplace(&mut self, other: &Self) { 79 | *self &= other; 80 | } 81 | } 82 | 83 | impl MorphologyExt for ImageBase 84 | where 85 | U: DataMut, 86 | C: ColourModel, 87 | { 88 | type Output = Image; 89 | 90 | fn erode(&self, kernel: ArrayView2) -> Self::Output { 91 | Self::Output::from_data(self.data.erode(kernel)) 92 | } 93 | 94 | fn erode_inplace(&mut self, kernel: ArrayView2) { 95 | self.data.erode_inplace(kernel); 96 | } 97 | 98 | fn dilate(&self, kernel: ArrayView2) -> Self::Output { 99 | Self::Output::from_data(self.data.dilate(kernel)) 100 | } 101 | 102 | fn dilate_inplace(&mut self, kernel: ArrayView2) { 103 | self.data.dilate_inplace(kernel); 104 | } 105 | 106 | fn union(&self, other: &Self) -> Self::Output { 107 | Self::Output::from_data(self.data.union(&other.data)) 108 | } 109 | 110 | fn union_inplace(&mut self, other: &Self) { 111 | self.data.union_inplace(&other.data); 112 | } 113 | 114 | fn intersection(&self, other: &Self) -> Self::Output { 115 | Self::Output::from_data(self.data.intersection(&other.data)) 116 | } 117 | 118 | fn intersection_inplace(&mut self, other: &Self) { 119 | self.data.intersection_inplace(&other.data); 120 | } 121 | } 122 | 123 | #[cfg(test)] 124 | mod tests { 125 | use super::*; 126 | use ndarray::arr2; 127 | 128 | #[test] 129 | fn simple_dilation() { 130 | let pix_in = vec![ 131 | false, false, false, false, false, false, false, false, false, false, false, false, 132 | true, false, false, false, false, false, false, false, false, false, false, false, 133 | false, 134 | ]; 135 | let pix_out = vec![ 136 | false, false, false, false, false, false, true, true, true, false, false, true, true, 137 | true, false, false, true, true, true, false, false, false, false, false, false, 138 | ]; 139 | 140 | let kern = arr2(&[[true, true, true], [true, true, true], [true, true, true]]); 141 | 142 | let mut input = Image::::from_shape_data(5, 5, pix_in); 143 | let expected = Image::::from_shape_data(5, 5, pix_out); 144 | let actual = input.dilate(kern.view()); 145 | assert_eq!(actual, expected); 146 | input.dilate_inplace(kern.view()); 147 | assert_eq!(input, expected); 148 | } 149 | 150 | #[test] 151 | fn simple_erosion() { 152 | let pix_out = vec![ 153 | false, false, false, false, false, false, false, false, false, false, false, false, 154 | true, false, false, false, false, false, false, false, false, false, false, false, 155 | false, 156 | ]; 157 | let pix_in = vec![ 158 | false, false, false, false, false, false, true, true, true, false, false, true, true, 159 | true, false, false, true, true, true, false, false, false, false, false, false, 160 | ]; 161 | 162 | let kern = arr2(&[[true, true, true], [true, true, true], [true, true, true]]); 163 | 164 | let mut input = Image::::from_shape_data(5, 5, pix_in); 165 | let expected = Image::::from_shape_data(5, 5, pix_out); 166 | let actual = input.erode(kern.view()); 167 | assert_eq!(actual, expected); 168 | input.erode_inplace(kern.view()); 169 | assert_eq!(input, expected); 170 | } 171 | 172 | #[test] 173 | fn simple_intersect() { 174 | let a = vec![false, false, false, true, true, true, false, false, false]; 175 | let b = vec![false, true, false, false, true, false, false, true, false]; 176 | let mut a = Image::::from_shape_data(3, 3, a); 177 | let b = Image::::from_shape_data(3, 3, b); 178 | 179 | let exp = vec![false, false, false, false, true, false, false, false, false]; 180 | let expected = Image::::from_shape_data(3, 3, exp); 181 | let c = a.intersection(&b); 182 | 183 | assert_eq!(c, expected); 184 | assert_eq!(a.intersection(&b), b.intersection(&a)); 185 | 186 | a.intersection_inplace(&b); 187 | assert_eq!(a, c); 188 | } 189 | 190 | #[test] 191 | fn simple_union() { 192 | let a = vec![false, false, false, true, true, true, false, false, false]; 193 | let b = vec![false, true, false, false, true, false, false, true, false]; 194 | let mut a = Image::::from_shape_data(3, 3, a); 195 | let b = Image::::from_shape_data(3, 3, b); 196 | 197 | let exp = vec![false, true, false, true, true, true, false, true, false]; 198 | let expected = Image::::from_shape_data(3, 3, exp); 199 | let c = a.union(&b); 200 | 201 | assert_eq!(c, expected); 202 | assert_eq!(a.union(&b), b.union(&a)); 203 | 204 | a.union_inplace(&b); 205 | assert_eq!(a, c); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/processing/canny.rs: -------------------------------------------------------------------------------- 1 | use crate::core::{ColourModel, Image, ImageBase}; 2 | use crate::processing::*; 3 | use ndarray::prelude::*; 4 | use ndarray::{DataMut, IntoDimension}; 5 | use num_traits::{cast::FromPrimitive, real::Real, Num, NumAssignOps}; 6 | use std::collections::HashSet; 7 | use std::marker::PhantomData; 8 | 9 | /// Runs the Canny Edge Detector algorithm on a type T 10 | pub trait CannyEdgeDetectorExt { 11 | /// Output type, this is different as canny outputs a binary image 12 | type Output; 13 | 14 | /// Run the edge detection algorithm with the given parameters. Due to Canny 15 | /// being specified as working on greyscale images all current implementations 16 | /// assume a single channel image returning an error otherwise. 17 | fn canny_edge_detector(&self, params: CannyParameters) -> Result; 18 | } 19 | 20 | /// Builder to construct the Canny parameters, if a parameter is not selected then 21 | /// a sensible default is chosen 22 | #[derive(Clone, Eq, PartialEq, Hash, Debug)] 23 | pub struct CannyBuilder { 24 | blur: Option>, 25 | t1: Option, 26 | t2: Option, 27 | } 28 | 29 | /// Parameters for the Canny Edge Detector 30 | #[derive(Clone, Eq, PartialEq, Hash, Debug)] 31 | pub struct CannyParameters { 32 | /// By default this library uses a Gaussian blur, although other kernels can 33 | /// be substituted 34 | pub blur: Array3, 35 | /// Lower threshold for weak edges used during the hystersis based edge linking 36 | pub t1: T, 37 | /// Upper threshold defining a strong edge 38 | pub t2: T, 39 | } 40 | 41 | impl CannyEdgeDetectorExt for ImageBase 42 | where 43 | U: DataMut, 44 | T: Copy + Clone + FromPrimitive + Real + Num + NumAssignOps, 45 | C: ColourModel, 46 | { 47 | type Output = Image; 48 | 49 | fn canny_edge_detector(&self, params: CannyParameters) -> Result { 50 | let data = self.data.canny_edge_detector(params)?; 51 | Ok(Self::Output { 52 | data, 53 | model: PhantomData, 54 | }) 55 | } 56 | } 57 | 58 | impl CannyEdgeDetectorExt for ArrayBase 59 | where 60 | U: DataMut, 61 | T: Copy + Clone + FromPrimitive + Real + Num + NumAssignOps, 62 | { 63 | type Output = Array3; 64 | 65 | fn canny_edge_detector(&self, params: CannyParameters) -> Result { 66 | if self.shape()[2] > 1 { 67 | Err(Error::ChannelDimensionMismatch) 68 | } else { 69 | // apply blur 70 | let blurred = self.conv2d(params.blur.view())?; 71 | let (mag, rot) = blurred.full_sobel()?; 72 | 73 | let mag = non_maxima_supression(mag, rot.view()); 74 | 75 | Ok(link_edges(mag, params.t1, params.t2)) 76 | } 77 | } 78 | } 79 | 80 | fn non_maxima_supression(magnitudes: Array3, rotations: ArrayView3) -> Array3 81 | where 82 | T: Copy + Clone + FromPrimitive + Real + Num + NumAssignOps, 83 | { 84 | let row_size = magnitudes.shape()[0] as isize; 85 | let column_size = magnitudes.shape()[1] as isize; 86 | 87 | let get_neighbours = |r, c, dr, dc| { 88 | if (r == 0 && dr < 0) || (r == (row_size - 1) && dr > 0) { 89 | T::zero() 90 | } else if (c == 0 && dc < 0) || (c == (column_size - 1) && dc > 0) { 91 | T::zero() 92 | } else { 93 | magnitudes[[(r + dr) as usize, (c + dc) as usize, 0]] 94 | } 95 | }; 96 | 97 | let mut result = magnitudes.clone(); 98 | 99 | for (i, mut row) in result.outer_iter_mut().enumerate() { 100 | let i = i as isize; 101 | for (j, mut col) in row.outer_iter_mut().enumerate() { 102 | let mut dir = rotations[[i as usize, j, 0]] 103 | .to_degrees() 104 | .to_f64() 105 | .unwrap_or(0.0); 106 | 107 | let j = j as isize; 108 | if dir >= 180.0 { 109 | dir -= 180.0; 110 | } else if dir < 0.0 { 111 | dir += 180.0; 112 | } 113 | // Now get neighbour values and suppress col if not a maxima 114 | let (a, b) = if dir < 45.0 { 115 | (get_neighbours(i, j, 0, -1), get_neighbours(i, j, 0, 1)) 116 | } else if dir < 90.0 { 117 | (get_neighbours(i, j, -1, -1), get_neighbours(i, j, 1, 1)) 118 | } else if dir < 135.0 { 119 | (get_neighbours(i, j, -1, 0), get_neighbours(i, j, 1, 0)) 120 | } else { 121 | (get_neighbours(i, j, -1, 1), get_neighbours(i, j, 1, -1)) 122 | }; 123 | 124 | if a > col[[0]] || b > col[[0]] { 125 | col.fill(T::zero()); 126 | } 127 | } 128 | } 129 | result 130 | } 131 | 132 | fn get_candidates( 133 | coord: (usize, usize), 134 | bounds: (usize, usize), 135 | closed_set: &HashSet<[usize; 2]>, 136 | ) -> Vec<[usize; 2]> { 137 | let mut result = Vec::new(); 138 | let (r, c) = coord; 139 | let (rows, cols) = bounds; 140 | 141 | if r > 0 { 142 | if c > 0 && !closed_set.contains(&[r - 1, c + 1]) { 143 | result.push([r - 1, c - 1]); 144 | } 145 | if c < cols - 1 && !closed_set.contains(&[r - 1, c + 1]) { 146 | result.push([r - 1, c + 1]); 147 | } 148 | if !closed_set.contains(&[r - 1, c]) { 149 | result.push([r - 1, c]); 150 | } 151 | } 152 | if r < rows - 1 { 153 | if c > 0 && !closed_set.contains(&[r + 1, c - 1]) { 154 | result.push([r + 1, c - 1]); 155 | } 156 | if c < cols - 1 && !closed_set.contains(&[r + 1, c + 1]) { 157 | result.push([r + 1, c + 1]); 158 | } 159 | if !closed_set.contains(&[r + 1, c]) { 160 | result.push([r + 1, c]); 161 | } 162 | } 163 | result 164 | } 165 | 166 | fn link_edges(magnitudes: Array3, lower: T, upper: T) -> Array3 167 | where 168 | T: Copy + Clone + FromPrimitive + Real + Num + NumAssignOps, 169 | { 170 | let magnitudes = magnitudes.mapv(|x| if x >= lower { x } else { T::zero() }); 171 | let mut result = magnitudes.mapv(|x| x >= upper); 172 | let mut visited = HashSet::new(); 173 | 174 | let rows = result.shape()[0]; 175 | let cols = result.shape()[1]; 176 | 177 | for r in 0..rows { 178 | for c in 0..cols { 179 | // If it is a strong edge check if neighbours are weak and add them 180 | if result[[r, c, 0]] { 181 | visited.insert([r, c]); 182 | let mut buffer = get_candidates((r, c), (rows, cols), &visited); 183 | 184 | while let Some(cand) = buffer.pop() { 185 | let coord3 = [cand[0], cand[1], 0]; 186 | if magnitudes[coord3] > lower { 187 | visited.insert(cand); 188 | result[coord3] = true; 189 | 190 | let temp = get_candidates((cand[0], cand[1]), (rows, cols), &visited); 191 | buffer.extend_from_slice(temp.as_slice()); 192 | } 193 | } 194 | } 195 | } 196 | } 197 | result 198 | } 199 | 200 | impl Default for CannyBuilder 201 | where 202 | T: Copy + Clone + FromPrimitive + Real + Num, 203 | { 204 | fn default() -> Self { 205 | Self::new() 206 | } 207 | } 208 | 209 | impl CannyBuilder 210 | where 211 | T: Copy + Clone + FromPrimitive + Real + Num, 212 | { 213 | /// Creates a new Builder with no parameters selected 214 | pub fn new() -> Self { 215 | Self { 216 | blur: None, 217 | t1: None, 218 | t2: None, 219 | } 220 | } 221 | 222 | /// Sets the lower threshold for the parameters returning a new builder 223 | pub fn lower_threshold(self, t1: T) -> Self { 224 | Self { 225 | blur: self.blur, 226 | t1: Some(t1), 227 | t2: self.t2, 228 | } 229 | } 230 | 231 | /// Sets the upper threshold for the parameters returning a new builder 232 | pub fn upper_threshold(self, t2: T) -> Self { 233 | Self { 234 | blur: self.blur, 235 | t1: self.t1, 236 | t2: Some(t2), 237 | } 238 | } 239 | 240 | /// Given the shape and covariance matrix constructs a Gaussian blur to be 241 | /// used with the Canny Edge Detector 242 | pub fn blur(self, shape: D, covariance: [f64; 2]) -> Self 243 | where 244 | D: Copy + IntoDimension, 245 | { 246 | let shape = shape.into_dimension(); 247 | let shape = (shape[0], shape[1], 1); 248 | if let Ok(blur) = GaussianFilter::build_with_params(shape, covariance) { 249 | Self { 250 | blur: Some(blur), 251 | t1: self.t1, 252 | t2: self.t2, 253 | } 254 | } else { 255 | self 256 | } 257 | } 258 | 259 | /// Creates the Canny parameters to be used with sensible defaults for unspecified 260 | /// parameters. This method also rearranges the upper and lower threshold to 261 | /// ensure that the relationship `t1 <= t2` is maintained. 262 | /// 263 | /// Defaults are: a lower threshold of 0.3, upper threshold of 0.7 and a 5x5 264 | /// Gaussian blur with a horizontal and vertical variances of 2.0. 265 | pub fn build(self) -> CannyParameters { 266 | let blur = match self.blur { 267 | Some(b) => b, 268 | None => GaussianFilter::build_with_params((5, 5, 1), [2.0, 2.0]).unwrap(), 269 | }; 270 | let mut t1 = match self.t1 { 271 | Some(t) => t, 272 | None => T::from_f64(0.3).unwrap(), 273 | }; 274 | let mut t2 = match self.t2 { 275 | Some(t) => t, 276 | None => T::from_f64(0.7).unwrap(), 277 | }; 278 | if t2 < t1 { 279 | std::mem::swap(&mut t1, &mut t2); 280 | } 281 | CannyParameters { blur, t1, t2 } 282 | } 283 | } 284 | 285 | #[cfg(test)] 286 | mod tests { 287 | use super::*; 288 | use ndarray::arr3; 289 | 290 | #[test] 291 | fn canny_builder() { 292 | let builder = CannyBuilder::::new() 293 | .lower_threshold(0.75) 294 | .upper_threshold(0.25); 295 | 296 | assert_eq!(builder.t1, Some(0.75)); 297 | assert_eq!(builder.t2, Some(0.25)); 298 | assert_eq!(builder.blur, None); 299 | 300 | let result = builder.clone().build(); 301 | 302 | assert_eq!(result.t1, 0.25); 303 | assert_eq!(result.t2, 0.75); 304 | 305 | let builder2 = builder.blur((3, 3), [0.2, 0.2]); 306 | 307 | assert_eq!(builder2.t1, Some(0.75)); 308 | assert_eq!(builder2.t2, Some(0.25)); 309 | assert!(builder2.blur.is_some()); 310 | let gauss = builder2.blur.unwrap(); 311 | assert_eq!(gauss.shape(), [3, 3, 1]); 312 | } 313 | 314 | #[test] 315 | fn canny_thresholding() { 316 | let magnitudes = arr3(&[ 317 | [[0.2], [0.4], [0.0]], 318 | [[0.7], [0.5], [0.8]], 319 | [[0.1], [0.6], [0.0]], 320 | ]); 321 | 322 | let expected = arr3(&[ 323 | [[false], [false], [false]], 324 | [[true], [true], [true]], 325 | [[false], [true], [false]], 326 | ]); 327 | 328 | let result = link_edges(magnitudes, 0.4, 0.69); 329 | 330 | assert_eq!(result, expected); 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /src/processing/conv.rs: -------------------------------------------------------------------------------- 1 | use crate::core::padding::*; 2 | use crate::core::{kernel_centre, ColourModel, Image, ImageBase}; 3 | use crate::processing::Error; 4 | use core::mem::MaybeUninit; 5 | use ndarray::prelude::*; 6 | use ndarray::{Data, DataMut, Zip}; 7 | use num_traits::{Num, NumAssignOps}; 8 | use std::marker::PhantomData; 9 | use std::marker::Sized; 10 | 11 | /// Perform image convolutions 12 | pub trait ConvolutionExt 13 | where 14 | Self: Sized, 15 | { 16 | /// Type for the output as data will have to be allocated 17 | type Output; 18 | 19 | /// Perform a convolution returning the resultant data 20 | /// applies the default padding of zero padding 21 | fn conv2d>(&self, kernel: ArrayBase) -> Result; 22 | /// Performs the convolution inplace mutating the containers data 23 | /// applies the default padding of zero padding 24 | fn conv2d_inplace>(&mut self, kernel: ArrayBase) 25 | -> Result<(), Error>; 26 | /// Perform a convolution returning the resultant data 27 | /// applies the default padding of zero padding 28 | fn conv2d_with_padding>( 29 | &self, 30 | kernel: ArrayBase, 31 | strategy: &impl PaddingStrategy, 32 | ) -> Result; 33 | /// Performs the convolution inplace mutating the containers data 34 | /// applies the default padding of zero padding 35 | fn conv2d_inplace_with_padding>( 36 | &mut self, 37 | kernel: ArrayBase, 38 | strategy: &impl PaddingStrategy, 39 | ) -> Result<(), Error>; 40 | } 41 | 42 | fn apply_edge_convolution( 43 | array: ArrayView3, 44 | kernel: ArrayView3, 45 | coord: (usize, usize), 46 | strategy: &impl PaddingStrategy, 47 | ) -> Vec 48 | where 49 | T: Copy + Num + NumAssignOps, 50 | { 51 | let out_of_bounds = 52 | |r, c| r < 0 || c < 0 || r >= array.dim().0 as isize || c >= array.dim().1 as isize; 53 | let (row_offset, col_offset) = kernel_centre(kernel.dim().0, kernel.dim().1); 54 | 55 | let top = coord.0 as isize - row_offset as isize; 56 | let bottom = (coord.0 + row_offset + 1) as isize; 57 | let left = coord.1 as isize - col_offset as isize; 58 | let right = (coord.1 + col_offset + 1) as isize; 59 | let channels = array.dim().2; 60 | let mut res = vec![T::zero(); channels]; 61 | 'processing: for (kr, r) in (top..bottom).enumerate() { 62 | for (kc, c) in (left..right).enumerate() { 63 | let oob = out_of_bounds(r, c); 64 | if oob && !strategy.will_pad(Some((r, c))) { 65 | for chan in 0..channels { 66 | res[chan] = array[[coord.0, coord.1, chan]]; 67 | } 68 | break 'processing; 69 | } 70 | for chan in 0..channels { 71 | // TODO this doesn't work on no padding 72 | if oob { 73 | if let Some(val) = strategy.get_value(array, (r, c, chan)) { 74 | res[chan] += kernel[[kr, kc, chan]] * val; 75 | } else { 76 | unreachable!() 77 | } 78 | } else { 79 | res[chan] += kernel[[kr, kc, chan]] * array[[r as usize, c as usize, chan]]; 80 | } 81 | } 82 | } 83 | } 84 | res 85 | } 86 | 87 | impl ConvolutionExt for ArrayBase 88 | where 89 | U: DataMut, 90 | T: Copy + Clone + Num + NumAssignOps, 91 | { 92 | type Output = Array; 93 | 94 | fn conv2d>(&self, kernel: ArrayBase) -> Result { 95 | self.conv2d_with_padding(kernel, &NoPadding {}) 96 | } 97 | 98 | fn conv2d_inplace>( 99 | &mut self, 100 | kernel: ArrayBase, 101 | ) -> Result<(), Error> { 102 | self.assign(&self.conv2d_with_padding(kernel, &NoPadding {})?); 103 | Ok(()) 104 | } 105 | 106 | #[inline] 107 | fn conv2d_with_padding>( 108 | &self, 109 | kernel: ArrayBase, 110 | strategy: &impl PaddingStrategy, 111 | ) -> Result { 112 | if self.shape()[2] != kernel.shape()[2] { 113 | Err(Error::ChannelDimensionMismatch) 114 | } else { 115 | let k_s = kernel.shape(); 116 | // Bit icky but handles fact that uncentred convolutions will cross the bounds 117 | // otherwise 118 | let (row_offset, col_offset) = kernel_centre(k_s[0], k_s[1]); 119 | let shape = (self.shape()[0], self.shape()[1], self.shape()[2]); 120 | 121 | if shape.0 > 0 && shape.1 > 0 { 122 | let mut result = Self::Output::uninit(shape); 123 | 124 | Zip::indexed(self.windows(kernel.dim())).for_each(|(i, j, _), window| { 125 | let mut temp; 126 | for channel in 0..k_s[2] { 127 | temp = T::zero(); 128 | for r in 0..k_s[0] { 129 | for c in 0..k_s[1] { 130 | temp += window[[r, c, channel]] * kernel[[r, c, channel]]; 131 | } 132 | } 133 | unsafe { 134 | *result.uget_mut([i + row_offset, j + col_offset, channel]) = 135 | MaybeUninit::new(temp); 136 | } 137 | } 138 | }); 139 | for c in 0..shape.1 { 140 | for r in 0..row_offset { 141 | let pixel = 142 | apply_edge_convolution(self.view(), kernel.view(), (r, c), strategy); 143 | for chan in 0..k_s[2] { 144 | unsafe { 145 | *result.uget_mut([r, c, chan]) = MaybeUninit::new(pixel[chan]); 146 | } 147 | } 148 | let bottom = shape.0 - r - 1; 149 | let pixel = apply_edge_convolution( 150 | self.view(), 151 | kernel.view(), 152 | (bottom, c), 153 | strategy, 154 | ); 155 | for chan in 0..k_s[2] { 156 | unsafe { 157 | *result.uget_mut([bottom, c, chan]) = MaybeUninit::new(pixel[chan]); 158 | } 159 | } 160 | } 161 | } 162 | for r in (row_offset)..(shape.0 - row_offset) { 163 | for c in 0..col_offset { 164 | let pixel = 165 | apply_edge_convolution(self.view(), kernel.view(), (r, c), strategy); 166 | for chan in 0..k_s[2] { 167 | unsafe { 168 | *result.uget_mut([r, c, chan]) = MaybeUninit::new(pixel[chan]); 169 | } 170 | } 171 | let right = shape.1 - c - 1; 172 | let pixel = apply_edge_convolution( 173 | self.view(), 174 | kernel.view(), 175 | (r, right), 176 | strategy, 177 | ); 178 | for chan in 0..k_s[2] { 179 | unsafe { 180 | *result.uget_mut([r, right, chan]) = MaybeUninit::new(pixel[chan]); 181 | } 182 | } 183 | } 184 | } 185 | Ok(unsafe { result.assume_init() }) 186 | } else { 187 | Err(Error::InvalidDimensions) 188 | } 189 | } 190 | } 191 | 192 | fn conv2d_inplace_with_padding>( 193 | &mut self, 194 | kernel: ArrayBase, 195 | strategy: &impl PaddingStrategy, 196 | ) -> Result<(), Error> { 197 | self.assign(&self.conv2d_with_padding(kernel, strategy)?); 198 | Ok(()) 199 | } 200 | } 201 | 202 | impl ConvolutionExt for ImageBase 203 | where 204 | U: DataMut, 205 | T: Copy + Clone + Num + NumAssignOps, 206 | C: ColourModel, 207 | { 208 | type Output = Image; 209 | 210 | fn conv2d>(&self, kernel: ArrayBase) -> Result { 211 | let data = self.data.conv2d(kernel)?; 212 | Ok(Self::Output { 213 | data, 214 | model: PhantomData, 215 | }) 216 | } 217 | 218 | fn conv2d_inplace>( 219 | &mut self, 220 | kernel: ArrayBase, 221 | ) -> Result<(), Error> { 222 | self.data.conv2d_inplace(kernel) 223 | } 224 | 225 | fn conv2d_with_padding>( 226 | &self, 227 | kernel: ArrayBase, 228 | strategy: &impl PaddingStrategy, 229 | ) -> Result { 230 | let data = self.data.conv2d_with_padding(kernel, strategy)?; 231 | Ok(Self::Output { 232 | data, 233 | model: PhantomData, 234 | }) 235 | } 236 | 237 | fn conv2d_inplace_with_padding>( 238 | &mut self, 239 | kernel: ArrayBase, 240 | strategy: &impl PaddingStrategy, 241 | ) -> Result<(), Error> { 242 | self.data.conv2d_inplace_with_padding(kernel, strategy) 243 | } 244 | } 245 | 246 | #[cfg(test)] 247 | mod tests { 248 | use super::*; 249 | use crate::core::colour_models::{Gray, RGB}; 250 | use ndarray::arr3; 251 | 252 | #[test] 253 | fn bad_dimensions() { 254 | let error = Err(Error::ChannelDimensionMismatch); 255 | let error2 = Err(Error::ChannelDimensionMismatch); 256 | 257 | let mut i = Image::::new(5, 5); 258 | let bad_kern = Array3::::zeros((2, 2, 2)); 259 | assert_eq!(i.conv2d(bad_kern.view()), error); 260 | 261 | let data_clone = i.data.clone(); 262 | let res = i.conv2d_inplace(bad_kern.view()); 263 | assert_eq!(res, error2); 264 | assert_eq!(i.data, data_clone); 265 | 266 | let good_kern = Array3::::zeros((2, 2, RGB::channels())); 267 | assert!(i.conv2d(good_kern.view()).is_ok()); 268 | assert!(i.conv2d_inplace(good_kern.view()).is_ok()); 269 | } 270 | 271 | #[test] 272 | #[rustfmt::skip] 273 | fn basic_conv() { 274 | let input_pixels = vec![ 275 | 1, 1, 1, 0, 0, 276 | 0, 1, 1, 1, 0, 277 | 0, 0, 1, 1, 1, 278 | 0, 0, 1, 1, 0, 279 | 0, 1, 1, 0, 0, 280 | ]; 281 | let output_pixels = vec![ 282 | 1, 1, 1, 0, 0, 283 | 0, 4, 3, 4, 0, 284 | 0, 2, 4, 3, 1, 285 | 0, 2, 3, 4, 0, 286 | 0, 1, 1, 0, 0, 287 | ]; 288 | 289 | let kern = arr3( 290 | &[ 291 | [[1], [0], [1]], 292 | [[0], [1], [0]], 293 | [[1], [0], [1]] 294 | ]); 295 | 296 | let input = Image::::from_shape_data(5, 5, input_pixels); 297 | let expected = Image::::from_shape_data(5, 5, output_pixels); 298 | 299 | assert_eq!(Ok(expected), input.conv2d(kern.view())); 300 | } 301 | 302 | #[test] 303 | #[rustfmt::skip] 304 | fn basic_conv_inplace() { 305 | let input_pixels = vec![ 306 | 1, 1, 1, 0, 0, 307 | 0, 1, 1, 1, 0, 308 | 0, 0, 1, 1, 1, 309 | 0, 0, 1, 1, 0, 310 | 0, 1, 1, 0, 0, 311 | ]; 312 | 313 | let output_pixels = vec![ 314 | 2, 2, 3, 1, 1, 315 | 1, 4, 3, 4, 1, 316 | 1, 2, 4, 3, 3, 317 | 1, 2, 3, 4, 1, 318 | 0, 2, 2, 1, 1, 319 | ]; 320 | 321 | let kern = arr3( 322 | &[ 323 | [[1], [0], [1]], 324 | [[0], [1], [0]], 325 | [[1], [0], [1]] 326 | ]); 327 | 328 | let mut input = Image::::from_shape_data(5, 5, input_pixels); 329 | let expected = Image::::from_shape_data(5, 5, output_pixels); 330 | let padding = ZeroPadding {}; 331 | input.conv2d_inplace_with_padding(kern.view(), &padding).unwrap(); 332 | 333 | assert_eq!(expected, input); 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /src/processing/filter.rs: -------------------------------------------------------------------------------- 1 | use crate::core::{ColourModel, Image, ImageBase}; 2 | use ndarray::prelude::*; 3 | use ndarray::{Data, IntoDimension, OwnedRepr, Zip}; 4 | use ndarray_stats::interpolate::*; 5 | use ndarray_stats::Quantile1dExt; 6 | use noisy_float::types::n64; 7 | use num_traits::{FromPrimitive, Num, ToPrimitive}; 8 | use std::marker::PhantomData; 9 | 10 | /// Median filter, given a region to move over the image, each pixel is given 11 | /// the median value of itself and it's neighbours 12 | pub trait MedianFilterExt { 13 | type Output; 14 | /// Run the median filter given the region. Median is assumed to be calculated 15 | /// independently for each channel. 16 | fn median_filter(&self, region: E) -> Self::Output 17 | where 18 | E: IntoDimension; 19 | } 20 | 21 | impl MedianFilterExt for ArrayBase 22 | where 23 | U: Data, 24 | T: Copy + Clone + FromPrimitive + ToPrimitive + Num + Ord, 25 | { 26 | type Output = ArrayBase, Ix3>; 27 | 28 | fn median_filter(&self, region: E) -> Self::Output 29 | where 30 | E: IntoDimension, 31 | { 32 | let shape = region.into_dimension(); 33 | let r_offset = shape[0] / 2; 34 | let c_offset = shape[1] / 2; 35 | let region = (shape[0], shape[1], 1); 36 | let mut result = Array3::::zeros(self.dim()); 37 | Zip::indexed(self.windows(region)).for_each(|(i, j, k), window| { 38 | let mut flat_window = Array::from_iter(window.iter()).mapv(|x| *x); 39 | if let Ok(v) = flat_window.quantile_mut(n64(0.5f64), &Linear {}) { 40 | if let Some(r) = result.get_mut([i + r_offset, j + c_offset, k]) { 41 | *r = v; 42 | } 43 | } 44 | }); 45 | result 46 | } 47 | } 48 | 49 | impl MedianFilterExt for ImageBase 50 | where 51 | U: Data, 52 | T: Copy + Clone + FromPrimitive + ToPrimitive + Num + Ord, 53 | C: ColourModel, 54 | { 55 | type Output = Image; 56 | 57 | fn median_filter(&self, region: E) -> Self::Output 58 | where 59 | E: IntoDimension, 60 | { 61 | let data = self.data.median_filter(region); 62 | Image { 63 | data, 64 | model: PhantomData, 65 | } 66 | } 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use super::*; 72 | use crate::core::colour_models::{Gray, RGB}; 73 | 74 | #[test] 75 | fn simple_median() { 76 | let mut pixels = Vec::::new(); 77 | for i in 0..9 { 78 | pixels.extend_from_slice(&[i, i + 1, i + 2]); 79 | } 80 | let image = Image::<_, RGB>::from_shape_data(3, 3, pixels); 81 | 82 | let image = image.median_filter((3, 3)); 83 | 84 | let mut expected = Image::::new(3, 3); 85 | expected.pixel_mut(1, 1).assign(&arr1(&[4, 5, 6])); 86 | 87 | assert_eq!(image, expected); 88 | } 89 | 90 | #[test] 91 | fn row_median() { 92 | let pixels = vec![1, 2, 3, 4, 5, 6, 7]; 93 | let image = Image::<_, Gray>::from_shape_data(7, 1, pixels); 94 | let image = image.median_filter((3, 1)); 95 | 96 | let expected_pixels = vec![0, 2, 3, 4, 5, 6, 0]; 97 | let expected = Image::<_, Gray>::from_shape_data(7, 1, expected_pixels); 98 | 99 | assert_eq!(image, expected); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/processing/kernels.rs: -------------------------------------------------------------------------------- 1 | use crate::processing::Error; 2 | use core::ops::Neg; 3 | use ndarray::prelude::*; 4 | use ndarray::IntoDimension; 5 | use num_traits::{cast::FromPrimitive, float::Float, sign::Signed, Num, NumAssignOps, NumOps}; 6 | 7 | /// Builds a convolutioon kernel given a shape and optional parameters 8 | pub trait KernelBuilder { 9 | /// Parameters used in construction of the kernel 10 | type Params; 11 | /// Build a kernel with a given dimension given sensible defaults for any 12 | /// parameters 13 | fn build(shape: D) -> Result, Error> 14 | where 15 | D: Copy + IntoDimension; 16 | /// For kernels with optional parameters use build with params otherwise 17 | /// appropriate default parameters will be chosen 18 | fn build_with_params(shape: D, _p: Self::Params) -> Result, Error> 19 | where 20 | D: Copy + IntoDimension, 21 | { 22 | Self::build(shape) 23 | } 24 | } 25 | 26 | /// Create a kernel with a fixed dimension 27 | pub trait FixedDimensionKernelBuilder { 28 | /// Parameters used in construction of the kernel 29 | type Params; 30 | /// Build a fixed size kernel 31 | fn build() -> Result, Error>; 32 | /// Build a fixed size kernel with the given parameters 33 | fn build_with_params(_p: Self::Params) -> Result, Error> { 34 | Self::build() 35 | } 36 | } 37 | 38 | /// Create a Laplacian filter, this provides the 2nd spatial derivative of an 39 | /// image. 40 | #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] 41 | pub struct LaplaceFilter; 42 | 43 | /// Specifies the type of Laplacian filter 44 | #[derive(Copy, Clone, Eq, PartialEq, Hash)] 45 | pub enum LaplaceType { 46 | /// Standard filter and the default parameter choice, for a 3x3x1 matrix it is: 47 | /// ```text 48 | /// [0, -1, 0] 49 | /// [-1, 4, -1] 50 | /// [0, -1, 0] 51 | /// ``` 52 | Standard, 53 | /// The diagonal filter also contains derivatives for diagonal lines and 54 | /// for a 3x3x1 matrix is given by: 55 | /// ```text 56 | /// [-1, -1, -1] 57 | /// [-1, 8, -1] 58 | /// [-1, -1, -1] 59 | /// ``` 60 | Diagonal, 61 | } 62 | 63 | impl FixedDimensionKernelBuilder for LaplaceFilter 64 | where 65 | T: Copy + Clone + Num + NumOps + Signed + FromPrimitive, 66 | { 67 | /// Type of Laplacian filter to construct 68 | type Params = LaplaceType; 69 | 70 | fn build() -> Result, Error> { 71 | Self::build_with_params(LaplaceType::Standard) 72 | } 73 | 74 | fn build_with_params(p: Self::Params) -> Result, Error> { 75 | let res = match p { 76 | LaplaceType::Standard => { 77 | let m_1 = -T::one(); 78 | let p_4 = T::from_u8(4).ok_or(Error::NumericError)?; 79 | let z = T::zero(); 80 | 81 | arr2(&[[z, m_1, z], [m_1, p_4, m_1], [z, m_1, z]]) 82 | } 83 | LaplaceType::Diagonal => { 84 | let m_1 = -T::one(); 85 | let p_8 = T::from_u8(8).ok_or(Error::NumericError)?; 86 | 87 | arr2(&[[m_1, m_1, m_1], [m_1, p_8, m_1], [m_1, m_1, m_1]]) 88 | } 89 | }; 90 | Ok(res.insert_axis(Axis(2))) 91 | } 92 | } 93 | 94 | /// Builds a Gaussian kernel taking the covariance as a parameter. Covariance 95 | /// is given as 2 values for the x and y variance. 96 | #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] 97 | pub struct GaussianFilter; 98 | 99 | impl KernelBuilder for GaussianFilter 100 | where 101 | T: Copy + Clone + FromPrimitive + Num, 102 | { 103 | /// The parameter for the Gaussian filter is the horizontal and vertical 104 | /// covariances to form the covariance matrix. 105 | /// ```text 106 | /// [ Params[0], 0] 107 | /// [ 0, Params[1]] 108 | /// ``` 109 | type Params = [f64; 2]; 110 | 111 | fn build(shape: D) -> Result, Error> 112 | where 113 | D: Copy + IntoDimension, 114 | { 115 | // This recommendation was taken from OpenCV 2.4 docs 116 | let s = shape.into_dimension(); 117 | let sig = 0.3 * (((std::cmp::max(s[0], 1) - 1) as f64) * 0.5 - 1.0) + 0.8; 118 | Self::build_with_params(shape, [sig, sig]) 119 | } 120 | 121 | fn build_with_params(shape: D, covar: Self::Params) -> Result, Error> 122 | where 123 | D: Copy + IntoDimension, 124 | { 125 | let is_even = |x| x & 1 == 0; 126 | let s = shape.into_dimension(); 127 | if is_even(s[0]) || is_even(s[1]) || s[0] != s[1] || s[2] == 0 { 128 | Err(Error::InvalidDimensions) 129 | } else if covar[0] <= 0.0f64 || covar[1] <= 0.0f64 { 130 | Err(Error::InvalidParameter) 131 | } else { 132 | let centre: isize = (s[0] as isize + 1) / 2 - 1; 133 | let gauss = |coord, covar| ((coord - centre) as f64).powi(2) / (2.0f64 * covar); 134 | 135 | let mut temp = Array2::from_shape_fn((s[0], s[1]), |(r, c)| { 136 | f64::exp(-(gauss(r as isize, covar[1]) + gauss(c as isize, covar[0]))) 137 | }); 138 | 139 | let sum = temp.sum(); 140 | 141 | temp *= 1.0f64 / sum; 142 | 143 | let temp = temp.mapv(T::from_f64); 144 | 145 | if temp.iter().any(|x| x.is_none()) { 146 | Err(Error::NumericError) 147 | } else { 148 | let temp = temp.mapv(|x| x.unwrap()); 149 | Ok(Array3::from_shape_fn(shape, |(r, c, _)| temp[[r, c]])) 150 | } 151 | } 152 | } 153 | } 154 | 155 | /// The box linear filter is roughly defined as `1/(R*C)*Array2::ones((R, C))` 156 | /// This filter will be a box linear for every colour channel provided 157 | #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] 158 | pub struct BoxLinearFilter; 159 | 160 | impl KernelBuilder for BoxLinearFilter 161 | where 162 | T: Float + Num + NumAssignOps + FromPrimitive, 163 | { 164 | /// If false the kernel will not be normalised - this means that pixel bounds 165 | /// may be exceeded and overflow may occur 166 | type Params = bool; 167 | 168 | fn build(shape: D) -> Result, Error> 169 | where 170 | D: Copy + IntoDimension, 171 | { 172 | Self::build_with_params(shape, true) 173 | } 174 | 175 | fn build_with_params(shape: D, normalise: Self::Params) -> Result, Error> 176 | where 177 | D: Copy + IntoDimension, 178 | { 179 | let shape = shape.into_dimension(); 180 | if shape[0] < 1 || shape[1] < 1 || shape[2] < 1 { 181 | Err(Error::InvalidDimensions) 182 | } else if normalise { 183 | let weight = 1.0f64 / ((shape[0] * shape[1]) as f64); 184 | match T::from_f64(weight) { 185 | Some(weight) => Ok(Array3::from_elem(shape, weight)), 186 | None => Err(Error::NumericError), 187 | } 188 | } else { 189 | Ok(Array3::ones(shape)) 190 | } 191 | } 192 | } 193 | 194 | /// Builder to create either a horizontal or vertical Sobel filter for the Sobel 195 | /// operator 196 | #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] 197 | pub struct SobelFilter; 198 | 199 | /// Orientation of the filter 200 | #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] 201 | pub enum Orientation { 202 | /// Obtain the vertical derivatives of an image 203 | Vertical, 204 | /// Obtain the horizontal derivatives of an image 205 | Horizontal, 206 | } 207 | 208 | impl FixedDimensionKernelBuilder for SobelFilter 209 | where 210 | T: Copy + Clone + Num + Neg + FromPrimitive, 211 | { 212 | /// Orientation of the filter. Default is vertical 213 | type Params = Orientation; 214 | /// Build a fixed size kernel 215 | fn build() -> Result, Error> { 216 | // Arbitary decision 217 | Self::build_with_params(Orientation::Vertical) 218 | } 219 | 220 | /// Build a fixed size kernel with the given parameters 221 | fn build_with_params(p: Self::Params) -> Result, Error> { 222 | let two = T::from_i8(2).ok_or(Error::NumericError)?; 223 | // Gets the gradient along the horizontal axis 224 | #[rustfmt::skip] 225 | let horz_sobel = arr2(&[ 226 | [T::one(), T::zero(), -T::one()], 227 | [two, T::zero(), -two], 228 | [T::one(), T::zero(), -T::one()], 229 | ]); 230 | let sobel = match p { 231 | Orientation::Vertical => horz_sobel.t().to_owned(), 232 | Orientation::Horizontal => horz_sobel, 233 | }; 234 | Ok(sobel.insert_axis(Axis(2))) 235 | } 236 | } 237 | 238 | #[cfg(test)] 239 | mod tests { 240 | use super::*; 241 | use ndarray::arr3; 242 | 243 | #[test] 244 | fn test_box_linear_filter() { 245 | let filter: Array3 = BoxLinearFilter::build(Ix3(2, 2, 3)).unwrap(); 246 | 247 | assert_eq!(filter, Array3::from_elem((2, 2, 3), 0.25f64)); 248 | 249 | let filter: Result, Error> = BoxLinearFilter::build(Ix3(0, 2, 3)); 250 | assert!(filter.is_err()); 251 | } 252 | 253 | #[test] 254 | fn test_sobel_filter() { 255 | // As sobel works with integer numbers I'm going to ignore the perils of 256 | // floating point comparisons... for now. 257 | let filter: Array3 = SobelFilter::build_with_params(Orientation::Horizontal).unwrap(); 258 | 259 | assert_eq!( 260 | filter, 261 | arr3(&[ 262 | [[1.0f32], [0.0f32], [-1.0f32]], 263 | [[2.0f32], [0.0f32], [-2.0f32]], 264 | [[1.0f32], [0.0f32], [-1.0f32]] 265 | ]) 266 | ); 267 | 268 | let filter: Array3 = SobelFilter::build_with_params(Orientation::Vertical).unwrap(); 269 | 270 | assert_eq!( 271 | filter, 272 | arr3(&[ 273 | [[1.0f32], [2.0f32], [1.0f32]], 274 | [[0.0f32], [0.0f32], [0.0f32]], 275 | [[-1.0f32], [-2.0f32], [-1.0f32]] 276 | ]) 277 | ) 278 | } 279 | 280 | #[test] 281 | fn test_gaussian_filter() { 282 | let bad_gauss: Result, _> = GaussianFilter::build(Ix3(3, 5, 2)); 283 | assert_eq!(bad_gauss, Err(Error::InvalidDimensions)); 284 | let bad_gauss: Result, _> = GaussianFilter::build(Ix3(4, 4, 2)); 285 | assert_eq!(bad_gauss, Err(Error::InvalidDimensions)); 286 | let bad_gauss: Result, _> = GaussianFilter::build(Ix3(4, 0, 2)); 287 | assert_eq!(bad_gauss, Err(Error::InvalidDimensions)); 288 | 289 | let channels = 2; 290 | let filter: Array3 = 291 | GaussianFilter::build_with_params(Ix3(3, 3, channels), [0.3, 0.3]).unwrap(); 292 | 293 | assert_eq!(filter.sum().round(), channels as f64); 294 | 295 | let filter: Array3 = 296 | GaussianFilter::build_with_params(Ix3(3, 3, 1), [0.05, 0.05]).unwrap(); 297 | 298 | let filter = filter.mapv(|x| x.round() as u8); 299 | // Need to do a proper test but this should cover enough 300 | assert_eq!( 301 | filter, 302 | arr3(&[[[0], [0], [0]], [[0], [1], [0]], [[0], [0], [0]]]) 303 | ); 304 | } 305 | 306 | #[test] 307 | fn test_laplace_filters() { 308 | let standard: Array3 = LaplaceFilter::build().unwrap(); 309 | assert_eq!( 310 | standard, 311 | arr3(&[[[0], [-1], [0]], [[-1], [4], [-1]], [[0], [-1], [0]]]) 312 | ); 313 | 314 | let standard: Array3 = 315 | LaplaceFilter::build_with_params(LaplaceType::Diagonal).unwrap(); 316 | assert_eq!( 317 | standard, 318 | arr3(&[[[-1], [-1], [-1]], [[-1], [8], [-1]], [[-1], [-1], [-1]]]) 319 | ); 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /src/processing/mod.rs: -------------------------------------------------------------------------------- 1 | /// Implementation of a Canny Edge Detector and associated types 2 | pub mod canny; 3 | /// Image convolutions in 2D 4 | pub mod conv; 5 | /// Not convolution based image filters 6 | pub mod filter; 7 | /// Common convolution kernels and traits to aid in the building of kernels 8 | pub mod kernels; 9 | /// Sobel operator for edge detection 10 | pub mod sobel; 11 | /// Thresholding functions 12 | pub mod threshold; 13 | 14 | pub use canny::*; 15 | pub use conv::*; 16 | pub use filter::*; 17 | pub use kernels::*; 18 | pub use sobel::*; 19 | pub use threshold::*; 20 | 21 | /// Common error type for image processing algorithms 22 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] 23 | pub enum Error { 24 | /// Indicates that an error was caused by an image having an unexpected number 25 | /// of channels. This could be caused by something such as an RGB image being 26 | /// input to an algorithm that only works on greyscale images 27 | ChannelDimensionMismatch, 28 | /// Invalid dimensions to an algorithm - this includes rows and columns and 29 | /// relationships between the two 30 | InvalidDimensions, 31 | /// An invalid parameter has been supplied to an algorithm. 32 | InvalidParameter, 33 | /// Numeric error such as an invalid conversion or issues in floating point 34 | /// math. As `ndarray` and `ndarray-vision` rely on `num_traits` for a lot 35 | /// of generic functionality this may indicate things such as failed typecasts 36 | NumericError, 37 | } 38 | -------------------------------------------------------------------------------- /src/processing/sobel.rs: -------------------------------------------------------------------------------- 1 | use crate::core::*; 2 | use crate::processing::*; 3 | use core::mem::MaybeUninit; 4 | use core::ops::Neg; 5 | use ndarray::{prelude::*, s, DataMut, OwnedRepr, Zip}; 6 | use num_traits::{cast::FromPrimitive, real::Real, Num, NumAssignOps}; 7 | use std::marker::Sized; 8 | 9 | /// Runs the sobel operator on an image 10 | pub trait SobelExt 11 | where 12 | Self: Sized, 13 | { 14 | /// Type to output 15 | type Output; 16 | /// Returns the magnitude output of the sobel - an image of only lines 17 | fn apply_sobel(&self) -> Result; 18 | 19 | /// Returns the magntitude and rotation outputs for use in other algorithms 20 | /// like the Canny edge detector. Rotation is in radians 21 | fn full_sobel(&self) -> Result<(Self::Output, Self::Output), Error>; 22 | } 23 | 24 | fn get_edge_images(mat: &ArrayBase) -> Result<(Array3, Array3), Error> 25 | where 26 | U: DataMut, 27 | T: Copy + Clone + Num + NumAssignOps + Neg + FromPrimitive + Real, 28 | { 29 | let v_temp: Array3 = SobelFilter::build_with_params(Orientation::Vertical).unwrap(); 30 | let h_temp: Array3 = SobelFilter::build_with_params(Orientation::Horizontal).unwrap(); 31 | let shape = (v_temp.shape()[0], v_temp.shape()[1], mat.shape()[2]); 32 | let mut h_kernel = Array3::::uninit(shape); 33 | let mut v_kernel = Array3::::uninit(shape); 34 | for i in 0..mat.dim().2 { 35 | h_temp 36 | .slice(s![.., .., 0]) 37 | .assign_to(h_kernel.slice_mut(s![.., .., i])); 38 | v_temp 39 | .slice(s![.., .., 0]) 40 | .assign_to(v_kernel.slice_mut(s![.., .., i])); 41 | } 42 | let h_kernel = unsafe { h_kernel.assume_init() }; 43 | let v_kernel = unsafe { v_kernel.assume_init() }; 44 | let h_deriv = mat.conv2d(h_kernel.view())?; 45 | let v_deriv = mat.conv2d(v_kernel.view())?; 46 | 47 | Ok((h_deriv, v_deriv)) 48 | } 49 | 50 | impl SobelExt for ArrayBase 51 | where 52 | U: DataMut, 53 | T: Copy + Clone + Num + NumAssignOps + Neg + FromPrimitive + Real, 54 | { 55 | type Output = ArrayBase, Ix3>; 56 | 57 | fn apply_sobel(&self) -> Result { 58 | let (h_deriv, v_deriv) = get_edge_images(self)?; 59 | let res_shape = h_deriv.dim(); 60 | let mut result = Self::Output::uninit(res_shape); 61 | for r in 0..res_shape.0 { 62 | for c in 0..res_shape.1 { 63 | for channel in 0..res_shape.2 { 64 | let temp = (h_deriv[[r, c, channel]].powi(2) 65 | + v_deriv[[r, c, channel]].powi(2)) 66 | .sqrt(); 67 | unsafe { 68 | *result.uget_mut([r, c, channel]) = MaybeUninit::new(temp); 69 | } 70 | } 71 | } 72 | } 73 | Ok(unsafe { result.assume_init() }) 74 | } 75 | 76 | fn full_sobel(&self) -> Result<(Self::Output, Self::Output), Error> { 77 | let (h_deriv, v_deriv) = get_edge_images(self)?; 78 | 79 | let mut magnitude = h_deriv.mapv(|x| x.powi(2)) + v_deriv.mapv(|x| x.powi(2)); 80 | magnitude.mapv_inplace(|x| x.sqrt()); 81 | 82 | let dim = h_deriv.dim(); 83 | let mut rotation = Array3::uninit((dim.0, dim.1, dim.2)); 84 | Zip::from(&mut rotation) 85 | .and(&h_deriv) 86 | .and(&v_deriv) 87 | .for_each(|r, &h, &v| *r = MaybeUninit::new(h.atan2(v))); 88 | 89 | let rotation = unsafe { rotation.assume_init() }; 90 | 91 | Ok((magnitude, rotation)) 92 | } 93 | } 94 | 95 | impl SobelExt for ImageBase 96 | where 97 | U: DataMut, 98 | T: Copy + Clone + Num + NumAssignOps + Neg + FromPrimitive + Real, 99 | C: ColourModel, 100 | { 101 | type Output = Image; 102 | 103 | fn apply_sobel(&self) -> Result { 104 | let data = self.data.apply_sobel()?; 105 | Ok(Image::from_data(data)) 106 | } 107 | 108 | fn full_sobel(&self) -> Result<(Self::Output, Self::Output), Error> { 109 | self.data 110 | .full_sobel() 111 | .map(|(m, r)| (Image::from_data(m), Image::from_data(r))) 112 | } 113 | } 114 | 115 | #[cfg(test)] 116 | mod tests { 117 | use super::*; 118 | use approx::*; 119 | 120 | #[test] 121 | fn simple() { 122 | let mut image: Image = ImageBase::new(11, 11); 123 | image.data.slice_mut(s![4..7, 4..7, ..]).fill(1.0); 124 | image.data.slice_mut(s![3..8, 5, ..]).fill(1.0); 125 | image.data.slice_mut(s![5, 3..8, ..]).fill(1.0); 126 | 127 | let sobel = image.full_sobel().unwrap(); 128 | 129 | // Did a calculation of sobel_mag[1..9, 1..9, ..] in a spreadsheet 130 | #[rustfmt::skip] 131 | let mag = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 132 | 0.0, 0.0, 0.0, 1.41421356237301, 2.0, 1.41421356237301, 0.0, 0.0, 0.0, 133 | 0.0, 0.0, 1.41421356237301, 4.24264068711929, 4.0, 4.24264068711929, 1.4142135623731, 0.0, 0.0, 134 | 0.0, 1.4142135623731, 4.24264068711929, 4.24264068711929, 2.0, 4.24264068711929, 4.24264068711929, 1.4142135623731, 0.0, 135 | 0.0, 2.0, 4.0, 2.0, 0.0, 2.0, 4.0, 2.0, 0.0, 136 | 0.0, 1.4142135623731, 4.24264068711929, 4.24264068711929, 2.0, 4.24264068711929, 4.24264068711929, 1.4142135623731, 0.0, 137 | 0.0, 0.0, 1.4142135623731, 4.24264068711929, 4.0, 4.24264068711929, 1.4142135623731, 0.0, 138 | 0.0, 0.0, 0.0, 0.0, 1.4142135623731, 2.0, 1.4142135623731, 0.0, 0.0, 139 | 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 140 | ]; 141 | 142 | let mag = Array::from_shape_vec((9, 9), mag).unwrap(); 143 | 144 | assert_abs_diff_eq!(sobel.0.data.slice(s![1..10, 1..10, 0]), mag, epsilon = 1e-5); 145 | 146 | let only_mag = image.apply_sobel().unwrap(); 147 | assert_abs_diff_eq!(sobel.0.data, only_mag.data); 148 | 149 | // Did a calculation of sobel_rot[1..9, 1..9, ..] in a spreadsheet 150 | #[rustfmt::skip] 151 | let rot = vec![0.00000000000000,0.00000000000000,0.00000000000000,0.00000000000000,0.00000000000000,0.00000000000000,0.00000000000000,0.00000000000000,0.00000000000000, 152 | 0.00000000000000,0.00000000000000,0.00000000000000,-2.35619449019234,3.14159265358979,2.35619449019234,0.00000000000000,0.00000000000000,0.00000000000000, 153 | 0.00000000000000,0.00000000000000,-2.35619449019234,-2.35619449019234,3.14159265358979,2.35619449019234,2.35619449019234,0.00000000000000,0.00000000000000, 154 | 0.00000000000000,-2.35619449019234,-2.35619449019234,-2.35619449019234,3.14159265358979,2.35619449019234,2.35619449019234,2.35619449019234,0.00000000000000, 155 | 0.00000000000000,-1.57079632679490,-1.57079632679490,-1.57079632679490,0.00000000000000,1.57079632679490,1.57079632679490,1.57079632679490,0.00000000000000, 156 | 0.00000000000000,-0.78539816339745,-0.78539816339745,-0.78539816339745,0.00000000000000,0.78539816339745,0.78539816339745,0.78539816339745,0.00000000000000, 157 | 0.00000000000000,0.00000000000000,-0.78539816339745,-0.78539816339745,0.00000000000000,0.78539816339745,0.78539816339745,0.00000000000000,0.00000000000000, 158 | 0.00000000000000,0.00000000000000,0.00000000000000,-0.78539816339745,0.00000000000000,0.78539816339745,0.00000000000000,0.00000000000000,0.00000000000000, 159 | 0.00000000000000,0.00000000000000,0.00000000000000,0.00000000000000,0.00000000000000,0.00000000000000,0.00000000000000,0.00000000000000,0.00000000000000]; 160 | let rot = Array::from_shape_vec((9, 9), rot).unwrap(); 161 | 162 | assert_abs_diff_eq!(sobel.1.data.slice(s![1..10, 1..10, 0]), rot, epsilon = 1e-5); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/processing/threshold.rs: -------------------------------------------------------------------------------- 1 | use crate::core::PixelBound; 2 | use crate::core::{ColourModel, Image, ImageBase}; 3 | use crate::processing::*; 4 | use ndarray::{prelude::*, Data}; 5 | use ndarray_stats::histogram::{Bins, Edges, Grid}; 6 | use ndarray_stats::HistogramExt; 7 | use ndarray_stats::QuantileExt; 8 | use num_traits::cast::FromPrimitive; 9 | use num_traits::cast::ToPrimitive; 10 | use num_traits::{Num, NumAssignOps}; 11 | use std::marker::PhantomData; 12 | 13 | /// Runs the Otsu thresholding algorithm on a type `T`. 14 | pub trait ThresholdOtsuExt { 15 | /// The Otsu thresholding output is a binary image. 16 | type Output; 17 | 18 | /// Run the Otsu threshold algorithm. 19 | /// 20 | /// Due to Otsu threshold algorithm specifying a greyscale image, all 21 | /// current implementations assume a single channel image; otherwise, an 22 | /// error is returned. 23 | /// 24 | /// # Errors 25 | /// 26 | /// Returns a `ChannelDimensionMismatch` error if more than one channel 27 | /// exists. 28 | fn threshold_otsu(&self) -> Result; 29 | } 30 | 31 | /// Runs the Mean thresholding algorithm on a type `T`. 32 | pub trait ThresholdMeanExt { 33 | /// The Mean thresholding output is a binary image. 34 | type Output; 35 | 36 | /// Run the Mean threshold algorithm. 37 | /// 38 | /// This assumes the image is a single channel image, i.e., a greyscale 39 | /// image; otherwise, an error is returned. 40 | /// 41 | /// # Errors 42 | /// 43 | /// Returns a `ChannelDimensionMismatch` error if more than one channel 44 | /// exists. 45 | fn threshold_mean(&self) -> Result; 46 | } 47 | 48 | /// Applies an upper and lower limit threshold on a type `T`. 49 | pub trait ThresholdApplyExt { 50 | /// The output is a binary image. 51 | type Output; 52 | 53 | /// Apply the threshold with the given limits. 54 | /// 55 | /// An image is segmented into background and foreground 56 | /// elements, where any pixel value within the limits are considered 57 | /// foreground elements and any pixels with a value outside the limits are 58 | /// considered part of the background. The upper and lower limits are 59 | /// inclusive. 60 | /// 61 | /// If only a lower limit threshold is to be applied, the `f64::INFINITY` 62 | /// value can be used for the upper limit. 63 | /// 64 | /// # Errors 65 | /// 66 | /// The current implementation assumes a single channel image, i.e., 67 | /// greyscale image. Thus, if more than one channel is present, then 68 | /// a `ChannelDimensionMismatch` error occurs. 69 | /// 70 | /// An `InvalidParameter` error occurs if the `lower` limit is greater than 71 | /// the `upper` limit. 72 | fn threshold_apply(&self, lower: f64, upper: f64) -> Result; 73 | } 74 | 75 | impl ThresholdOtsuExt for ImageBase 76 | where 77 | U: Data, 78 | Image: Clone, 79 | T: Copy + Clone + Ord + Num + NumAssignOps + ToPrimitive + FromPrimitive + PixelBound, 80 | C: ColourModel, 81 | { 82 | type Output = Image; 83 | 84 | fn threshold_otsu(&self) -> Result { 85 | let data = self.data.threshold_otsu()?; 86 | Ok(Self::Output { 87 | data, 88 | model: PhantomData, 89 | }) 90 | } 91 | } 92 | 93 | impl ThresholdOtsuExt for ArrayBase 94 | where 95 | U: Data, 96 | T: Copy + Clone + Ord + Num + NumAssignOps + ToPrimitive + FromPrimitive, 97 | { 98 | type Output = Array3; 99 | 100 | fn threshold_otsu(&self) -> Result { 101 | if self.shape()[2] > 1 { 102 | Err(Error::ChannelDimensionMismatch) 103 | } else { 104 | let value = calculate_threshold_otsu(self)?; 105 | self.threshold_apply(value, f64::INFINITY) 106 | } 107 | } 108 | } 109 | 110 | /// Calculates Otsu's threshold. 111 | /// 112 | /// Works per channel, but currently assumes greyscale. 113 | /// 114 | /// See the Errors section for the `ThresholdOtsuExt` trait if the number of 115 | /// channels is greater than one (1), i.e., single channel; otherwise, we would 116 | /// need to output all three threshold values. 117 | /// 118 | /// TODO: Add optional nbins 119 | fn calculate_threshold_otsu(mat: &ArrayBase) -> Result 120 | where 121 | U: Data, 122 | T: Copy + Clone + Ord + Num + NumAssignOps + ToPrimitive + FromPrimitive, 123 | { 124 | let mut threshold = 0.0; 125 | let n_bins = 255; 126 | for c in mat.axis_iter(Axis(2)) { 127 | let scale_factor = (n_bins) as f64 / (c.max().unwrap().to_f64().unwrap()); 128 | let edges_vec: Vec = (0..n_bins).collect(); 129 | let grid = Grid::from(vec![Bins::new(Edges::from(edges_vec))]); 130 | 131 | // get the histogram 132 | let flat = Array::from_iter(c.iter()).insert_axis(Axis(1)); 133 | let flat2 = flat.mapv(|x| ((*x).to_f64().unwrap() * scale_factor).to_u8().unwrap()); 134 | let hist = flat2.histogram(grid); 135 | // Straight out of wikipedia: 136 | let counts = hist.counts(); 137 | let total = counts.sum().to_f64().unwrap(); 138 | let counts = Array::from_iter(counts.iter()); 139 | // NOTE: Could use the cdf generation for skimage-esque implementation 140 | // which entails a cumulative sum of the standard histogram 141 | let mut sum_b = 0.0; 142 | let mut weight_b = 0.0; 143 | let mut maximum = 0.0; 144 | let mut level = 0.0; 145 | let mut sum_intensity = 0.0; 146 | for (index, count) in counts.indexed_iter() { 147 | sum_intensity += (index as f64) * (*count).to_f64().unwrap(); 148 | } 149 | for (index, count) in counts.indexed_iter() { 150 | weight_b += count.to_f64().unwrap(); 151 | sum_b += (index as f64) * count.to_f64().unwrap(); 152 | let weight_f = total - weight_b; 153 | if (weight_b > 0.0) && (weight_f > 0.0) { 154 | let mean_f = (sum_intensity - sum_b) / weight_f; 155 | let val = weight_b 156 | * weight_f 157 | * ((sum_b / weight_b) - mean_f) 158 | * ((sum_b / weight_b) - mean_f); 159 | if val > maximum { 160 | level = 1.0 + (index as f64); 161 | maximum = val; 162 | } 163 | } 164 | } 165 | threshold = level / scale_factor; 166 | } 167 | Ok(threshold) 168 | } 169 | 170 | impl ThresholdMeanExt for ImageBase 171 | where 172 | U: Data, 173 | Image: Clone, 174 | T: Copy + Clone + Ord + Num + NumAssignOps + ToPrimitive + FromPrimitive + PixelBound, 175 | C: ColourModel, 176 | { 177 | type Output = Image; 178 | 179 | fn threshold_mean(&self) -> Result { 180 | let data = self.data.threshold_mean()?; 181 | Ok(Self::Output { 182 | data, 183 | model: PhantomData, 184 | }) 185 | } 186 | } 187 | 188 | impl ThresholdMeanExt for ArrayBase 189 | where 190 | U: Data, 191 | T: Copy + Clone + Ord + Num + NumAssignOps + ToPrimitive + FromPrimitive, 192 | { 193 | type Output = Array3; 194 | 195 | fn threshold_mean(&self) -> Result { 196 | if self.shape()[2] > 1 { 197 | Err(Error::ChannelDimensionMismatch) 198 | } else { 199 | let value = calculate_threshold_mean(self)?; 200 | self.threshold_apply(value, f64::INFINITY) 201 | } 202 | } 203 | } 204 | 205 | fn calculate_threshold_mean(array: &ArrayBase) -> Result 206 | where 207 | U: Data, 208 | T: Copy + Clone + Num + NumAssignOps + ToPrimitive + FromPrimitive, 209 | { 210 | Ok(array.sum().to_f64().unwrap() / array.len() as f64) 211 | } 212 | 213 | impl ThresholdApplyExt for ImageBase 214 | where 215 | U: Data, 216 | Image: Clone, 217 | T: Copy + Clone + Ord + Num + NumAssignOps + ToPrimitive + FromPrimitive + PixelBound, 218 | C: ColourModel, 219 | { 220 | type Output = Image; 221 | 222 | fn threshold_apply(&self, lower: f64, upper: f64) -> Result { 223 | let data = self.data.threshold_apply(lower, upper)?; 224 | Ok(Self::Output { 225 | data, 226 | model: PhantomData, 227 | }) 228 | } 229 | } 230 | 231 | impl ThresholdApplyExt for ArrayBase 232 | where 233 | U: Data, 234 | T: Copy + Clone + Ord + Num + NumAssignOps + ToPrimitive + FromPrimitive, 235 | { 236 | type Output = Array3; 237 | 238 | fn threshold_apply(&self, lower: f64, upper: f64) -> Result { 239 | if self.shape()[2] > 1 { 240 | Err(Error::ChannelDimensionMismatch) 241 | } else if lower > upper { 242 | Err(Error::InvalidParameter) 243 | } else { 244 | Ok(apply_threshold(self, lower, upper)) 245 | } 246 | } 247 | } 248 | 249 | fn apply_threshold(data: &ArrayBase, lower: f64, upper: f64) -> Array3 250 | where 251 | U: Data, 252 | T: Copy + Clone + Num + NumAssignOps + ToPrimitive + FromPrimitive, 253 | { 254 | data.mapv(|x| x.to_f64().unwrap() >= lower && x.to_f64().unwrap() <= upper) 255 | } 256 | 257 | #[cfg(test)] 258 | mod tests { 259 | use super::*; 260 | use assert_approx_eq::assert_approx_eq; 261 | use ndarray::arr3; 262 | use noisy_float::types::n64; 263 | 264 | #[test] 265 | fn threshold_apply_threshold() { 266 | let data = arr3(&[ 267 | [[0.2], [0.4], [0.0]], 268 | [[0.7], [0.5], [0.8]], 269 | [[0.1], [0.6], [0.0]], 270 | ]); 271 | 272 | let expected = arr3(&[ 273 | [[false], [false], [false]], 274 | [[true], [true], [true]], 275 | [[false], [true], [false]], 276 | ]); 277 | 278 | let result = apply_threshold(&data, 0.5, f64::INFINITY); 279 | 280 | assert_eq!(result, expected); 281 | } 282 | 283 | #[test] 284 | fn threshold_apply_threshold_range() { 285 | let data = arr3(&[ 286 | [[0.2], [0.4], [0.0]], 287 | [[0.7], [0.5], [0.8]], 288 | [[0.1], [0.6], [0.0]], 289 | ]); 290 | let expected = arr3(&[ 291 | [[false], [true], [false]], 292 | [[true], [true], [false]], 293 | [[false], [true], [false]], 294 | ]); 295 | 296 | let result = apply_threshold(&data, 0.25, 0.75); 297 | 298 | assert_eq!(result, expected); 299 | } 300 | 301 | #[test] 302 | fn threshold_calculate_threshold_otsu_ints() { 303 | let data = arr3(&[[[2], [4], [0]], [[7], [5], [8]], [[1], [6], [0]]]); 304 | let result = calculate_threshold_otsu(&data).unwrap(); 305 | println!("Done {}", result); 306 | 307 | // Calculated using Python's skimage.filters.threshold_otsu 308 | // on int input array. Float array returns 2.0156... 309 | let expected = 2.0; 310 | 311 | assert_approx_eq!(result, expected, 5e-1); 312 | } 313 | 314 | #[test] 315 | fn threshold_calculate_threshold_otsu_floats() { 316 | let data = arr3(&[ 317 | [[n64(2.0)], [n64(4.0)], [n64(0.0)]], 318 | [[n64(7.0)], [n64(5.0)], [n64(8.0)]], 319 | [[n64(1.0)], [n64(6.0)], [n64(0.0)]], 320 | ]); 321 | 322 | let result = calculate_threshold_otsu(&data).unwrap(); 323 | 324 | // Calculated using Python's skimage.filters.threshold_otsu 325 | // on int input array. Float array returns 2.0156... 326 | let expected = 2.0156; 327 | 328 | assert_approx_eq!(result, expected, 5e-1); 329 | } 330 | 331 | #[test] 332 | fn threshold_calculate_threshold_mean_ints() { 333 | let data = arr3(&[[[4], [4], [4]], [[5], [5], [5]], [[6], [6], [6]]]); 334 | 335 | let result = calculate_threshold_mean(&data).unwrap(); 336 | let expected = 5.0; 337 | 338 | assert_approx_eq!(result, expected, 1e-16); 339 | } 340 | 341 | #[test] 342 | fn threshold_calculate_threshold_mean_floats() { 343 | let data = arr3(&[ 344 | [[4.0], [4.0], [4.0]], 345 | [[5.0], [5.0], [5.0]], 346 | [[6.0], [6.0], [6.0]], 347 | ]); 348 | 349 | let result = calculate_threshold_mean(&data).unwrap(); 350 | let expected = 5.0; 351 | 352 | assert_approx_eq!(result, expected, 1e-16); 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /src/transform/affine.rs: -------------------------------------------------------------------------------- 1 | use super::Transform; 2 | use ndarray::{array, prelude::*}; 3 | use ndarray_linalg::Inverse; 4 | 5 | /// converts a matrix into an equivalent `AffineTransform` 6 | pub fn transform_from_2dmatrix(in_array: Array2) -> AffineTransform { 7 | match in_array.inv() { 8 | Ok(inv) => AffineTransform { 9 | matrix2d_transform: in_array, 10 | matrix2d_transform_inverse: inv, 11 | inverse_exists: true, 12 | }, 13 | Err(_e) => AffineTransform { 14 | matrix2d_transform: in_array, 15 | matrix2d_transform_inverse: Array2::zeros((2, 2)), 16 | inverse_exists: false, 17 | }, 18 | } 19 | } 20 | 21 | /// a linear transform of an image represented by either size 2x2 22 | /// or 3x3 ( right column is a translation and projection ) matrix applied to the image index 23 | /// coordinates 24 | pub struct AffineTransform { 25 | matrix2d_transform: Array2, 26 | matrix2d_transform_inverse: Array2, 27 | inverse_exists: bool, 28 | } 29 | 30 | fn source_coordinate(p: (f64, f64), trans: ArrayView2) -> (f64, f64) { 31 | let p = match trans.shape()[0] { 32 | 2 => array![[p.0], [p.1]], 33 | 3 => array![[p.0], [p.1], [1.0]], 34 | _ => unreachable!(), 35 | }; 36 | 37 | let result = trans.dot(&p); 38 | let x = result[[0, 0]]; 39 | let y = result[[1, 0]]; 40 | let w = match trans.shape()[0] { 41 | 2 => 1.0, 42 | 3 => result[[2, 0]], 43 | _ => unreachable!(), 44 | }; 45 | if (w - 1.0).abs() > std::f64::EPSILON { 46 | (x / w, y / w) 47 | } else { 48 | (x, y) 49 | } 50 | } 51 | 52 | impl Transform for AffineTransform { 53 | fn apply(&self, p: (f64, f64)) -> (f64, f64) { 54 | return source_coordinate(p, self.matrix2d_transform.view()); 55 | } 56 | 57 | fn apply_inverse(&self, p: (f64, f64)) -> (f64, f64) { 58 | return source_coordinate(p, self.matrix2d_transform_inverse.view()); 59 | } 60 | 61 | fn inverse_exists(&self) -> bool { 62 | self.inverse_exists 63 | } 64 | } 65 | 66 | /// describes the Axes to use in rotation_3d 67 | /// X and Y correspond to the image index coordinates and 68 | /// Z is perpendicular out of the image plane 69 | pub enum Axes { 70 | X, 71 | Y, 72 | Z, 73 | } 74 | 75 | /// generates a 2d matrix describing a rotation around a 2d coordinate 76 | pub fn rotate_around_centre(radians: f64, centre: (f64, f64)) -> Array2 { 77 | translation(centre.0, centre.1) 78 | .dot(&rotation_3d(radians, Axes::Z)) 79 | .dot(&translation(-centre.0, -centre.1)) 80 | } 81 | 82 | /// generates a matrix describing 2d rotation around origin 83 | pub fn rotation_2d(radians: f64) -> Array2 { 84 | let s = radians.sin(); 85 | let c = radians.cos(); 86 | array![[c, -s], [s, c]] 87 | } 88 | 89 | /// generates a 3x3 matrix describing a rotation around either the index coordinate axes 90 | /// (X,Y) or in the perpendicular axes to the image (Z) 91 | pub fn rotation_3d(radians: f64, ax: Axes) -> Array2 { 92 | let s = radians.sin(); 93 | let c = radians.cos(); 94 | 95 | match ax { 96 | Axes::X => array![[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]], 97 | Axes::Y => array![[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]], 98 | Axes::Z => array![[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]], 99 | } 100 | } 101 | 102 | /// generates a matrix describing translation in the image index space 103 | pub fn translation(x: f64, y: f64) -> Array2 { 104 | array![[1.0, 0.0, x], [0.0, 1.0, y], [0.0, 0.0, 1.0]] 105 | } 106 | 107 | /// generates a matrix describing scaling in image index space 108 | pub fn scale(x: f64, y: f64) -> Array2 { 109 | array![[x, 0.0, 0.0], [0.0, y, 0.0], [0.0, 0.0, 1.0]] 110 | } 111 | 112 | /// generates a matrix describing shear in image index space 113 | pub fn shear(x: f64, y: f64) -> Array2 { 114 | array![[1.0, x, 0.0], [y, 1.0, 0.0], [0.0, 0.0, 1.0]] 115 | } 116 | -------------------------------------------------------------------------------- /src/transform/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::core::{ColourModel, Image, ImageBase}; 2 | use ndarray::{prelude::*, s, Data}; 3 | use num_traits::{Num, NumAssignOps}; 4 | use std::fmt::Display; 5 | 6 | pub mod affine; 7 | 8 | #[derive(Clone, Copy, Eq, PartialEq, Debug, Hash)] 9 | pub enum TransformError { 10 | InvalidTransform, 11 | NonInvertibleTransform, 12 | } 13 | 14 | impl std::error::Error for TransformError {} 15 | 16 | impl Display for TransformError { 17 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 18 | match self { 19 | TransformError::InvalidTransform => write!(f, "invalid transform"), 20 | TransformError::NonInvertibleTransform => { 21 | write!( 22 | f, 23 | "Non Invertible Transform, Forward transform not yet implemented " 24 | ) 25 | } 26 | } 27 | } 28 | } 29 | 30 | pub trait Transform { 31 | fn apply(&self, p: (f64, f64)) -> (f64, f64); 32 | fn apply_inverse(&self, p: (f64, f64)) -> (f64, f64); 33 | fn inverse_exists(&self) -> bool; 34 | } 35 | 36 | /// Composition of two transforms. Specifically, derives transform2(transform1(image)). 37 | /// this is not equivalent to running the transforms separately, since the composition of the 38 | /// transforms occurs before sampling. IE, running transforms separately incur a resample per 39 | /// transform, whereas composed Transforms only incur a single image resample. 40 | pub struct ComposedTransform { 41 | transform1: T, 42 | transform2: T, 43 | } 44 | 45 | impl Transform for ComposedTransform { 46 | fn apply(&self, p: (f64, f64)) -> (f64, f64) { 47 | self.transform2.apply(self.transform1.apply(p)) 48 | } 49 | 50 | fn apply_inverse(&self, p: (f64, f64)) -> (f64, f64) { 51 | self.transform1 52 | .apply_inverse(self.transform2.apply_inverse(p)) 53 | } 54 | 55 | fn inverse_exists(&self) -> bool { 56 | self.transform1.inverse_exists() && self.transform2.inverse_exists() 57 | } 58 | } 59 | 60 | pub trait TransformExt 61 | where 62 | Self: Sized, 63 | { 64 | /// Output type for the operation 65 | type Output; 66 | 67 | /// Transforms an image given the transformation matrix and output size. 68 | /// Uses the source index coordinate space 69 | /// Assume nearest-neighbour interpolation 70 | fn transform( 71 | &self, 72 | transform: &T, 73 | output_size: Option<(usize, usize)>, 74 | ) -> Result; 75 | } 76 | 77 | #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 78 | struct Rect { 79 | x: isize, 80 | y: isize, 81 | w: usize, 82 | h: usize, 83 | } 84 | 85 | impl TransformExt for ArrayBase 86 | where 87 | T: Copy + Clone + Num + NumAssignOps, 88 | U: Data, 89 | V: Transform, 90 | { 91 | type Output = Array; 92 | 93 | fn transform( 94 | &self, 95 | transform: &V, 96 | output_size: Option<(usize, usize)>, 97 | ) -> Result { 98 | let mut output = match output_size { 99 | Some((r, c)) => Self::Output::zeros((r, c, self.shape()[2])), 100 | None => Self::Output::zeros(self.raw_dim()), 101 | }; 102 | 103 | for r in 0..output.shape()[0] { 104 | for c in 0..output.shape()[1] { 105 | let (x, y) = transform.apply_inverse((c as f64, r as f64)); 106 | let x = x.round() as isize; 107 | let y = y.round() as isize; 108 | if x >= 0 109 | && y >= 0 110 | && (x as usize) < self.shape()[1] 111 | && (y as usize) < self.shape()[0] 112 | { 113 | output 114 | .slice_mut(s![r, c, ..]) 115 | .assign(&self.slice(s![y, x, ..])); 116 | } 117 | } 118 | } 119 | 120 | Ok(output) 121 | } 122 | } 123 | 124 | impl TransformExt for ImageBase 125 | where 126 | U: Data, 127 | T: Copy + Clone + Num + NumAssignOps, 128 | C: ColourModel, 129 | V: Transform, 130 | { 131 | type Output = Image; 132 | 133 | fn transform( 134 | &self, 135 | transform: &V, 136 | output_size: Option<(usize, usize)>, 137 | ) -> Result { 138 | let data = self.data.transform(transform, output_size)?; 139 | let result = Self::Output::from_data(data).to_owned(); 140 | Ok(result) 141 | } 142 | } 143 | 144 | #[cfg(test)] 145 | mod tests { 146 | use super::affine; 147 | use super::*; 148 | use crate::core::colour_models::Gray; 149 | use std::f64::consts::PI; 150 | 151 | #[test] 152 | fn translation() { 153 | let src_data = vec![2.0, 0.0, 1.0, 0.0, 5.0, 0.0, 1.0, 2.0, 3.0]; 154 | let src = Image::::from_shape_data(3, 3, src_data); 155 | 156 | let trans = affine::transform_from_2dmatrix(affine::translation(2.0, 1.0)); 157 | 158 | let res = src.transform(&trans, Some((3, 3))); 159 | assert!(res.is_ok()); 160 | let res = res.unwrap(); 161 | 162 | let expected = vec![0.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0]; 163 | let expected = Image::::from_shape_data(3, 3, expected); 164 | 165 | assert_eq!(expected, res) 166 | } 167 | 168 | #[test] 169 | fn rotate() { 170 | let src = Image::::from_shape_data(5, 5, (0..25).collect()); 171 | let trans = affine::transform_from_2dmatrix(affine::rotate_around_centre(PI, (2.0, 2.0))); 172 | let upside_down = src.transform(&trans, Some((5, 5))).unwrap(); 173 | 174 | let res = upside_down.transform(&trans, Some((5, 5))).unwrap(); 175 | 176 | assert_eq!(src, res); 177 | 178 | let trans_2 = 179 | affine::transform_from_2dmatrix(affine::rotate_around_centre(PI / 2.0, (2.0, 2.0))); 180 | let trans_3 = 181 | affine::transform_from_2dmatrix(affine::rotate_around_centre(-PI / 2.0, (2.0, 2.0))); 182 | 183 | let upside_down_sideways = upside_down.transform(&trans_2, Some((5, 5))).unwrap(); 184 | let src_sideways = src.transform(&trans_3, Some((5, 5))).unwrap(); 185 | 186 | assert_eq!(upside_down_sideways, src_sideways); 187 | } 188 | 189 | #[test] 190 | fn scale() { 191 | let src = Image::::from_shape_data(4, 4, (0..16).collect()); 192 | let trans = affine::transform_from_2dmatrix(affine::scale(0.5, 2.0)); 193 | let res = src.transform(&trans, None).unwrap(); 194 | 195 | assert_eq!(res.rows(), 4); 196 | assert_eq!(res.cols(), 4); 197 | } 198 | } 199 | --------------------------------------------------------------------------------