Skip to main content

cumulus3d_features/
gray.rs

1/*
2 * gray.rs
3 *
4 * Copyright (c) 2026 ParkSangWoo
5 *
6 * Author(s):
7 *
8 *      ParkSangWoo <dev.parksangwoo@gmail.com>
9 *
10 *
11 * This file is part of cumulus3d.
12 *
13 * Licensed under the Apache License, Version 2.0 (the "License");
14 * you may not use this file except in compliance with the License.
15 * You may obtain a copy of the License at
16 *
17 *      http://www.apache.org/licenses/LICENSE-2.0
18 *
19 * Unless required by applicable law or agreed to in writing, software
20 * distributed under the License is distributed on an "AS IS" BASIS,
21 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22 * See the License for the specific language governing permissions and
23 * limitations under the License.
24 *
25 * Third-party notices: parts of the algorithms, default parameters and data
26 * formats in this file follow other open-source projects. Their copyright
27 * notices and licenses are reproduced in THIRD_PARTY_NOTICES.md.
28 *
29 * SPDX-License-Identifier: Apache-2.0
30 */
31
32//! 8-bit grayscale image: decoding, grayscale conversion, downscaling, 90° rotation.
33
34use cumulus3d_core::{Error, Keypoint, Result};
35use std::path::Path;
36
37/// Row-major 8-bit grayscale image.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct GrayImage {
40    /// Width (pixels).
41    pub width: usize,
42    /// Height (pixels).
43    pub height: usize,
44    /// Row-major pixel values (length width x height).
45    pub data: Vec<u8>,
46}
47
48impl GrayImage {
49    /// Builds from a buffer. Error if the length is not width x height.
50    pub fn new(width: usize, height: usize, data: Vec<u8>) -> Result<Self> {
51        if data.len() != width * height {
52            return Err(Error::InvalidArgument(format!("회색 영상 크기 {width}x{height} 와 자료 길이 {} 불일치", data.len())));
53        }
54        Ok(Self { width, height, data })
55    }
56
57    /// Image whose pixels are all `v`.
58    pub fn filled(width: usize, height: usize, v: u8) -> Self {
59        Self { width, height, data: vec![v; width * height] }
60    }
61
62    /// Builds from a `[0,1]` real-valued image (rounding and saturation).
63    pub fn from_f32(width: usize, height: usize, f: impl Fn(usize, usize) -> f32) -> Self {
64        let mut data = Vec::with_capacity(width * height);
65        for y in 0..height {
66            for x in 0..width {
67                data.push((f(x, y) * 255.0).round().clamp(0.0, 255.0) as u8);
68            }
69        }
70        Self { width, height, data }
71    }
72
73    #[inline]
74    /// Value of pixel (x, y).
75    pub fn get(&self, x: usize, y: usize) -> u8 {
76        self.data[y * self.width + x]
77    }
78
79    /// Counter-clockwise 90°xk rotation (matches the keypoint rotation transform: (x,y) -> (y, w-x)).
80    pub fn rotate_ccw(&self, k: u32) -> GrayImage {
81        let mut img = self.clone();
82        for _ in 0..(k % 4) {
83            img = img.rotate_ccw_once();
84        }
85        img
86    }
87
88    fn rotate_ccw_once(&self) -> GrayImage {
89        let (w, h) = (self.width, self.height);
90        // New image (w'=h, h'=w): new(X, Y) = old(col = w-1-Y, row = X).
91        let mut data = vec![0u8; w * h];
92        for ny in 0..w {
93            let oc = w - 1 - ny;
94            let row = &mut data[ny * h..(ny + 1) * h];
95            for (nx, v) in row.iter_mut().enumerate() {
96                *v = self.data[nx * w + oc];
97            }
98        }
99        GrayImage { width: h, height: w, data }
100    }
101
102    /// Resamples this image to `(width, height)` (Lanczos3, with anti-aliasing).
103    // Design decision: the downscale filter is Lanczos3.
104    pub fn resized(&self, width: usize, height: usize) -> GrayImage {
105        if width == self.width && height == self.height {
106            return self.clone();
107        }
108        let buf = image::GrayImage::from_raw(self.width as u32, self.height as u32, self.data.clone()).expect("크기 불변식");
109        let out = image::imageops::resize(&buf, width as u32, height as u32, image::imageops::FilterType::Lanczos3);
110        GrayImage { width, height, data: out.into_raw() }
111    }
112}
113
114/// RGB -> gray: `round(0.2126 R + 0.7152 G + 0.0722 B)` (no gamma linearization).
115#[inline]
116pub fn rgb_to_gray(r: u8, g: u8, b: u8) -> u8 {
117    (0.2126f32 * r as f32 + 0.7152f32 * g as f32 + 0.0722f32 * b as f32).round().clamp(0.0, 255.0) as u8
118}
119
120/// Converts a decoded image to grayscale (alpha dropped, 16-bit reduced to 8-bit).
121pub fn to_gray(img: &image::DynamicImage) -> Result<GrayImage> {
122    use image::DynamicImage as D;
123    let (w, h) = (img.width() as usize, img.height() as usize);
124    let data = match img {
125        D::ImageLuma8(b) => b.as_raw().clone(),
126        D::ImageLumaA8(_) | D::ImageLuma16(_) | D::ImageLumaA16(_) => img.to_luma8().into_raw(),
127        D::ImageRgb8(b) => b.as_raw().chunks(3).map(|p| rgb_to_gray(p[0], p[1], p[2])).collect(),
128        _ => {
129            let c = img.color();
130            if c.channel_count() == 3 || c.channel_count() == 4 {
131                img.to_rgb8().as_raw().chunks(3).map(|p| rgb_to_gray(p[0], p[1], p[2])).collect()
132            } else {
133                return Err(Error::Unsupported(format!("채널 구성 {c:?}")));
134            }
135        }
136    };
137    GrayImage::new(w, h, data)
138}
139
140/// Decodes a file and reads it as a grayscale image (no automatic EXIF orientation rotation).
141pub fn read_gray(path: &Path) -> Result<GrayImage> {
142    let reader = image::ImageReader::open(path)?.with_guessed_format().map_err(Error::Io)?;
143    let img = reader.decode().map_err(|e| Error::Format(format!("{}: 디코딩 실패: {e}", path.display())))?;
144    to_gray(&img)
145}
146
147/// Maximum size limit: unchanged if both are <= M, otherwise s = M/max(w,h), (round(w s), round(h s)).
148pub fn limited_size(width: usize, height: usize, max_size: usize) -> (usize, usize) {
149    if width <= max_size && height <= max_size {
150        return (width, height);
151    }
152    let s = max_size as f64 / width.max(height) as f64;
153    (((width as f64 * s).round() as usize).max(1), ((height as f64 * s).round() as usize).max(1))
154}
155
156/// Rotates keypoints counter-clockwise by 90°xk in a (w, h) image. The size changes with each rotation, so apply one step at a time.
157pub fn rotate_keypoint_ccw(kp: &Keypoint, k: u32, width: f32, height: f32) -> Keypoint {
158    let mut p = *kp;
159    let (mut w, mut h) = (width, height);
160    for _ in 0..(k % 4) {
161        p = Keypoint { x: p.y, y: w - p.x, a11: p.a21, a12: p.a22, a21: -p.a11, a22: -p.a12 };
162        std::mem::swap(&mut w, &mut h);
163    }
164    p
165}
166
167/// EXIF Orientation -> number of counter-clockwise rotations k. 1->0, 3->2, 6->3, 8->1; otherwise (mirrored or absent) 0.
168pub fn orientation_to_rotation(orientation: Option<u32>) -> u32 {
169    let g = orientation_gravity(orientation);
170    match g {
171        Some((gx, gy)) => {
172            let k = ((gy.atan2(gx) - std::f64::consts::FRAC_PI_2) / std::f64::consts::FRAC_PI_2).round() as i64;
173            k.rem_euclid(4) as u32
174        }
175        None => 0,
176    }
177}
178
179/// EXIF Orientation -> gravity direction (g_x, g_y) in image coordinates. 1:(0,1) 3:(0,-1) 6:(1,0) 8:(-1,0).
180pub fn orientation_gravity(orientation: Option<u32>) -> Option<(f64, f64)> {
181    match orientation? {
182        1 => Some((0.0, 1.0)),
183        3 => Some((0.0, -1.0)),
184        6 => Some((1.0, 0.0)),
185        8 => Some((-1.0, 0.0)),
186        _ => None,
187    }
188}