Skip to main content

geonative_image/
dataset.rs

1//! [`ImageRaster`] — the public reader. Decodes a JPG or PNG, parses the
2//! adjacent world file, exposes the whole image as a single `RasterTile`
3//! via the `RasterLayer` trait.
4
5use std::path::Path;
6
7use geonative_core::raster::{
8    Band, BandDescriptor, PixelType, RasterLayer, RasterProfile, RasterTile,
9};
10use geonative_core::{Crs, Result as CoreResult};
11
12use crate::error::{ImageError, Result};
13use crate::worldfile;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ImageKind {
17    Jpeg,
18    Png,
19}
20
21impl ImageKind {
22    pub fn from_extension(ext: &str) -> Result<Self> {
23        match ext.to_ascii_lowercase().as_str() {
24            "jpg" | "jpeg" => Ok(Self::Jpeg),
25            "png" => Ok(Self::Png),
26            other => Err(ImageError::unsupported(format!(
27                "extension .{other} (v0.1 supports .jpg / .jpeg / .png)"
28            ))),
29        }
30    }
31}
32
33/// An opened raster image with georeferencing from a world file.
34///
35/// The entire image is decoded into memory on `open`. Subsequent
36/// `read_tile` calls slice from the in-memory buffer.
37#[derive(Debug)]
38pub struct ImageRaster {
39    /// Owned pixel data, interleaved chunky per `bands_meta`.
40    pixels: Vec<u8>,
41    /// Schema-like description.
42    profile: RasterProfile,
43}
44
45impl ImageRaster {
46    /// Open the image at `image_path`. Looks for a sidecar world file
47    /// (`.jgw` / `.pgw` / `.wld`) next to it; errors with
48    /// [`ImageError::MissingWorldFile`] if none found.
49    ///
50    /// CRS defaults to `Crs::Unknown`; use [`Self::open_with_crs`] to set
51    /// one explicitly (world files don't carry CRS info).
52    pub fn open(image_path: impl AsRef<Path>) -> Result<Self> {
53        Self::open_with_crs(image_path, Crs::Unknown)
54    }
55
56    /// Same as [`Self::open`] but with an explicit CRS — the typical
57    /// pattern is the caller specifying it via UI / config.
58    pub fn open_with_crs(image_path: impl AsRef<Path>, crs: Crs) -> Result<Self> {
59        let path = image_path.as_ref();
60        let ext = path.extension().and_then(|s| s.to_str()).ok_or_else(|| {
61            ImageError::malformed(format!("image path has no extension: {}", path.display()))
62        })?;
63        let kind = ImageKind::from_extension(ext)?;
64
65        // 1) Decode the image bytes.
66        let image_bytes = std::fs::read(path)?;
67        let decoded = match kind {
68            ImageKind::Jpeg => decode_jpeg(&image_bytes)?,
69            ImageKind::Png => decode_png(&image_bytes)?,
70        };
71
72        // 2) Locate + parse the world file.
73        let wf_path = worldfile::find_sidecar(path)?;
74        let wf_text = std::fs::read_to_string(&wf_path)?;
75        let geo_transform = worldfile::parse(&wf_text)?;
76
77        // 3) Build the profile + return.
78        let bands: Vec<BandDescriptor> = decoded
79            .band_names()
80            .into_iter()
81            .map(|name| BandDescriptor::new(Some(name.into()), PixelType::U8))
82            .collect();
83        let profile = RasterProfile {
84            width: decoded.width,
85            height: decoded.height,
86            bands,
87            geo_transform,
88            crs,
89            tile_size: [decoded.width, decoded.height], // single-tile, whole-image
90            pyramid_levels: 1,
91        };
92
93        Ok(Self {
94            pixels: decoded.pixels,
95            profile,
96        })
97    }
98
99    /// Hand back the raw pixel buffer (chunky interleaved). Useful for
100    /// downstream code that wants to encode the same pixels into another
101    /// format without going through `RasterTile` (which would copy).
102    pub fn pixels(&self) -> &[u8] {
103        &self.pixels
104    }
105}
106
107impl RasterLayer for ImageRaster {
108    fn profile(&self) -> &RasterProfile {
109        &self.profile
110    }
111
112    fn read_tile(&self, level: u8, x: u32, y: u32) -> CoreResult<RasterTile> {
113        if level != 0 {
114            return Err(ImageError::Unsupported(format!(
115                "single-image raster has only level 0; got {level}"
116            ))
117            .into());
118        }
119        if x != 0 || y != 0 {
120            return Err(ImageError::Unsupported(format!(
121                "single-image raster has only tile (0, 0); got ({x}, {y})"
122            ))
123            .into());
124        }
125        // Split interleaved bytes into per-band buffers.
126        let nbands = self.profile.bands.len();
127        let pixels = (self.profile.width as usize) * (self.profile.height as usize);
128        let stride = nbands; // all bands are U8 in v0.1
129        let mut bands = Vec::with_capacity(nbands);
130        for bi in 0..nbands {
131            let mut data = Vec::with_capacity(pixels);
132            for p in 0..pixels {
133                data.push(self.pixels[p * stride + bi]);
134            }
135            bands.push(Band::new(self.profile.bands[bi].clone(), data));
136        }
137        Ok(RasterTile {
138            width: self.profile.width,
139            height: self.profile.height,
140            bands,
141            geo_transform: self.profile.geo_transform,
142            crs: self.profile.crs.clone(),
143        })
144    }
145}
146
147/// Internal type used while decoding. Stays out of the public API.
148struct Decoded {
149    width: u32,
150    height: u32,
151    /// Chunky-interleaved pixels.
152    pixels: Vec<u8>,
153    /// 1 (Greyscale), 3 (Rgb), or 4 (Rgba)
154    nbands: u8,
155}
156
157impl Decoded {
158    fn band_names(&self) -> Vec<&'static str> {
159        match self.nbands {
160            1 => vec!["grey"],
161            3 => vec!["red", "green", "blue"],
162            4 => vec!["red", "green", "blue", "alpha"],
163            _ => Vec::new(),
164        }
165    }
166}
167
168fn decode_jpeg(bytes: &[u8]) -> Result<Decoded> {
169    let mut dec = jpeg_decoder::Decoder::new(bytes);
170    let pixels = dec
171        .decode()
172        .map_err(|e| ImageError::Jpeg(format!("{e:?}")))?;
173    let info = dec
174        .info()
175        .ok_or_else(|| ImageError::Jpeg("missing JPEG info after decode".into()))?;
176    let nbands = match info.pixel_format {
177        jpeg_decoder::PixelFormat::L8 => 1,
178        jpeg_decoder::PixelFormat::RGB24 => 3,
179        jpeg_decoder::PixelFormat::CMYK32 => {
180            return Err(ImageError::unsupported(
181                "CMYK JPEGs (use RGB output instead)",
182            ))
183        }
184        // L16 / RGB48 etc are 16-bit; v0.1 is U8-only
185        _ => {
186            return Err(ImageError::unsupported(format!(
187                "JPEG pixel format {:?} (v0.1 supports L8 + RGB24)",
188                info.pixel_format
189            )))
190        }
191    };
192    Ok(Decoded {
193        width: info.width as u32,
194        height: info.height as u32,
195        pixels,
196        nbands,
197    })
198}
199
200fn decode_png(bytes: &[u8]) -> Result<Decoded> {
201    let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
202    let mut reader = decoder
203        .read_info()
204        .map_err(|e| ImageError::Png(e.to_string()))?;
205    let info = reader.info().clone();
206
207    // We need 8-bit channel depth for v0.1.
208    if info.bit_depth != png::BitDepth::Eight {
209        return Err(ImageError::unsupported(format!(
210            "PNG bit depth {:?} (v0.1 supports 8-bit)",
211            info.bit_depth
212        )));
213    }
214
215    let mut buf = vec![0u8; reader.output_buffer_size()];
216    let frame = reader
217        .next_frame(&mut buf)
218        .map_err(|e| ImageError::Png(e.to_string()))?;
219    buf.truncate(frame.buffer_size());
220
221    let nbands = match info.color_type {
222        png::ColorType::Grayscale => 1,
223        png::ColorType::Rgb => 3,
224        png::ColorType::Rgba => 4,
225        png::ColorType::GrayscaleAlpha => 2,
226        png::ColorType::Indexed => {
227            return Err(ImageError::unsupported(
228                "indexed (palette) PNGs — decode to RGB before upload",
229            ))
230        }
231    };
232
233    Ok(Decoded {
234        width: info.width,
235        height: info.height,
236        pixels: buf,
237        nbands,
238    })
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    fn workdir(name: &str) -> std::path::PathBuf {
246        let dir =
247            std::env::temp_dir().join(format!("imgraster_test_{}_{}", std::process::id(), name));
248        let _ = std::fs::remove_dir_all(&dir);
249        std::fs::create_dir_all(&dir).unwrap();
250        dir
251    }
252
253    /// Encode a tiny RGB PNG using the `png` crate so we have a real
254    /// fixture to test against (jpeg-decoder is decode-only — JPEG paths
255    /// are exercised only via documented `decode_jpeg` shape, not via a
256    /// round-trip).
257    fn write_test_png(path: &Path, width: u32, height: u32, fill: [u8; 3]) {
258        let file = std::fs::File::create(path).unwrap();
259        let buf = std::io::BufWriter::new(file);
260        let mut enc = png::Encoder::new(buf, width, height);
261        enc.set_color(png::ColorType::Rgb);
262        enc.set_depth(png::BitDepth::Eight);
263        let mut writer = enc.write_header().unwrap();
264        let pixels: Vec<u8> = (0..(width * height))
265            .flat_map(|_| fill.iter().copied())
266            .collect();
267        writer.write_image_data(&pixels).unwrap();
268    }
269
270    #[test]
271    fn opens_png_with_world_file() {
272        let dir = workdir("png_with_wf");
273        let img = dir.join("ortho.png");
274        write_test_png(&img, 4, 4, [10, 20, 30]);
275        let wld = dir.join("ortho.pgw");
276        std::fs::write(&wld, "0.5\n0\n0\n-0.5\n144\n-37\n").unwrap();
277
278        let r = ImageRaster::open_with_crs(&img, Crs::Epsg(4326)).unwrap();
279        let p = r.profile();
280        assert_eq!(p.width, 4);
281        assert_eq!(p.height, 4);
282        assert_eq!(p.bands.len(), 3);
283        assert_eq!(p.crs, Crs::Epsg(4326));
284        // Half-pixel shift: centre (144, -37) + pixel (0.5, -0.5) → upper-left (143.75, -36.75)
285        assert!((p.geo_transform.origin[0] - 143.75).abs() < 1e-9);
286        assert!((p.geo_transform.origin[1] - (-36.75)).abs() < 1e-9);
287    }
288
289    #[test]
290    fn read_tile_returns_full_image_as_one_tile() {
291        let dir = workdir("read_tile");
292        let img = dir.join("ortho.png");
293        write_test_png(&img, 4, 4, [10, 20, 30]);
294        let wld = dir.join("ortho.pgw");
295        std::fs::write(&wld, "0.5\n0\n0\n-0.5\n144\n-37\n").unwrap();
296
297        let r = ImageRaster::open_with_crs(&img, Crs::Epsg(4326)).unwrap();
298        let t = r.read_tile(0, 0, 0).unwrap();
299        assert_eq!(t.width, 4);
300        assert_eq!(t.height, 4);
301        assert_eq!(t.bands.len(), 3);
302        // Each band should be all the same value (since fill was constant)
303        assert!(t.bands[0].data.iter().all(|&v| v == 10));
304        assert!(t.bands[1].data.iter().all(|&v| v == 20));
305        assert!(t.bands[2].data.iter().all(|&v| v == 30));
306    }
307
308    #[test]
309    fn missing_world_file_errors_clearly() {
310        let dir = workdir("missing_wf");
311        let img = dir.join("noworld.png");
312        write_test_png(&img, 2, 2, [0, 0, 0]);
313
314        let err = ImageRaster::open(&img).unwrap_err();
315        assert!(matches!(err, ImageError::MissingWorldFile { .. }));
316    }
317
318    #[test]
319    fn unsupported_extension_errors() {
320        let dir = workdir("bad_ext");
321        let img = dir.join("file.gif");
322        std::fs::write(&img, b"").unwrap();
323        let err = ImageRaster::open(&img).unwrap_err();
324        assert!(matches!(err, ImageError::Unsupported(_)));
325    }
326
327    #[test]
328    fn read_tile_rejects_non_zero_tile() {
329        let dir = workdir("bad_tile");
330        let img = dir.join("ortho.png");
331        write_test_png(&img, 2, 2, [0, 0, 0]);
332        std::fs::write(dir.join("ortho.pgw"), "0.5\n0\n0\n-0.5\n0\n0\n").unwrap();
333        let r = ImageRaster::open(&img).unwrap();
334        assert!(r.read_tile(0, 1, 0).is_err());
335        assert!(r.read_tile(1, 0, 0).is_err());
336    }
337}