martin_core/tiles/cog/
source.rs

1use std::collections::HashMap;
2use std::fmt::Debug;
3use std::fs::File;
4use std::path::{Path, PathBuf};
5use std::vec;
6
7use async_trait::async_trait;
8use log::warn;
9use martin_tile_utils::{Format, TileCoord, TileData, TileInfo};
10use tiff::decoder::{ChunkType, Decoder};
11use tiff::tags::Tag::{self, GdalNodata};
12use tilejson::{TileJSON, tilejson};
13
14use crate::tiles::cog::CogError;
15use crate::tiles::cog::image::Image;
16use crate::tiles::cog::model::ModelInfo;
17use crate::tiles::{MartinCoreResult, Source, UrlQuery};
18
19/// Tile source that reads from `Cloud Optimized GeoTIFF` files.
20#[derive(Clone, Debug)]
21pub struct CogSource {
22    id: String,
23    path: PathBuf,
24    min_zoom: u8,
25    max_zoom: u8,
26    _model: ModelInfo,
27    // The geo coords of pixel(0, 0, 0) ordering in [x, y, z]
28    _origin: [f64; 3],
29    // [minx, miny, maxx, maxy] in its model space coordinate system
30    _extent: [f64; 4],
31    images: HashMap<u8, Image>,
32    nodata: Option<f64>,
33    tilejson: TileJSON,
34    tileinfo: TileInfo,
35}
36
37impl CogSource {
38    #[expect(clippy::cast_possible_truncation)]
39    /// Creates a new COG tile source from a file path.
40    pub fn new(id: String, path: PathBuf) -> Result<Self, CogError> {
41        let tileinfo = TileInfo::new(Format::Png, martin_tile_utils::Encoding::Uncompressed);
42        let tif_file =
43            File::open(&path).map_err(|e: std::io::Error| CogError::IoError(e, path.clone()))?;
44        let mut decoder = Decoder::new(tif_file)
45            .map_err(|e| CogError::InvalidTiffFile(e, path.clone()))?
46            .with_limits(tiff::decoder::Limits::unlimited());
47        let model = ModelInfo::decode(&mut decoder, &path);
48        verify_requirements(&mut decoder, &model, &path.clone())?;
49        let nodata: Option<f64> = if let Ok(no_data) = decoder.get_tag_ascii_string(GdalNodata) {
50            no_data.parse().ok()
51        } else {
52            None
53        };
54        let origin = get_origin(
55            model.tie_points.as_deref(),
56            model.transformation.as_deref(),
57            &path,
58        )?;
59        let (full_width_pixel, full_length_pixel) = dimensions_in_pixel(&mut decoder, &path, 0)?;
60        let (full_width, full_length) = dimensions_in_model(
61            &mut decoder,
62            &path,
63            0,
64            model.pixel_scale.as_deref(),
65            model.transformation.as_deref(),
66        )?;
67        let extent = get_extent(
68            &origin,
69            model.transformation.as_deref(),
70            (full_width_pixel, full_length_pixel),
71            (full_width, full_length),
72        );
73
74        let mut images = vec![];
75        let mut ifd_index = 0;
76
77        loop {
78            let is_image = decoder
79                .get_tag_u32(Tag::NewSubfileType)
80                .map_or_else(|_| true, |v| v & 4 != 4); // see https://www.verypdf.com/document/tiff6/pg_0036.htm
81            if is_image {
82                // TODO: We should not ignore mask in the next PRs
83                images.push(get_image(&mut decoder, &path, ifd_index)?);
84            } else {
85                warn!(
86                    "A subfile of {} is ignored in the tiff file as Martin currently does not support mask subfile in tiff. IFD={ifd_index}",
87                    path.display(),
88                );
89            }
90
91            ifd_index += 1;
92
93            let next_res = decoder.seek_to_image(ifd_index);
94            if next_res.is_err() {
95                // TODO: add warn!() here
96                break;
97            }
98        }
99        let min_zoom = 0;
100        let max_zoom = (images.len() - 1) as u8;
101        let images: HashMap<u8, Image> = images
102            .into_iter()
103            .enumerate()
104            .map(|(idx, image)| {
105                let zoom = max_zoom.saturating_sub(idx as u8);
106                (zoom, image)
107            })
108            .collect();
109        let tilejson = tilejson! {
110            tiles: vec![],
111            minzoom: min_zoom,
112            maxzoom: max_zoom
113        };
114        Ok(CogSource {
115            id,
116            path,
117            min_zoom,
118            max_zoom,
119            // FIXME: these are not yet used
120            _model: model,
121            _origin: origin,
122            _extent: extent,
123            images,
124            nodata,
125            tilejson,
126            tileinfo,
127        })
128    }
129}
130
131#[async_trait]
132impl Source for CogSource {
133    fn get_id(&self) -> &str {
134        &self.id
135    }
136
137    fn get_tilejson(&self) -> &TileJSON {
138        &self.tilejson
139    }
140
141    fn get_tile_info(&self) -> TileInfo {
142        self.tileinfo
143    }
144
145    fn clone_source(&self) -> Box<dyn Source> {
146        Box::new(self.clone())
147    }
148
149    /// Whether this [`Source`] benefits from concurrency when being scraped via `martin-cp`.
150    ///
151    /// If this returns `true`, martin-cp will suggest concurrent scraping.
152    fn benefits_from_concurrent_scraping(&self) -> bool {
153        // if we copy from one local file to another, we are likely not bottlenecked by CPU
154        // TODO: benchmark this assumption, decoding might be a bottleneck
155        false
156    }
157
158    async fn get_tile(
159        &self,
160        xyz: TileCoord,
161        _url_query: Option<&UrlQuery>,
162    ) -> MartinCoreResult<TileData> {
163        if xyz.z < self.min_zoom || xyz.z > self.max_zoom {
164            return Ok(Vec::new());
165        }
166        let tif_file =
167            File::open(&self.path).map_err(|e| CogError::IoError(e, self.path.clone()))?;
168        let mut decoder =
169            Decoder::new(tif_file).map_err(|e| CogError::InvalidTiffFile(e, self.path.clone()))?;
170        decoder = decoder.with_limits(tiff::decoder::Limits::unlimited());
171
172        let image = self.images.get(&(xyz.z)).ok_or_else(|| {
173            CogError::ZoomOutOfRange(xyz.z, self.path.clone(), self.min_zoom, self.max_zoom)
174        })?;
175
176        let bytes = image.get_tile(&mut decoder, xyz, self.nodata, &self.path)?;
177        Ok(bytes)
178    }
179}
180
181fn verify_requirements(
182    decoder: &mut Decoder<File>,
183    model: &ModelInfo,
184    path: &Path,
185) -> Result<(), CogError> {
186    let chunk_type = decoder.get_chunk_type();
187    // see requirement 2 in https://docs.ogc.org/is/21-026/21-026.html#_tiles
188    if chunk_type != ChunkType::Tile {
189        Err(CogError::NotSupportedChunkType(path.to_path_buf()))?;
190    }
191
192    // see https://docs.ogc.org/is/21-026/21-026.html#_planar_configuration_considerations and https://www.verypdf.com/document/tiff6/pg_0038.htm
193    // we might support planar configuration 2 in the future
194    decoder
195        .get_tag_unsigned(Tag::PlanarConfiguration)
196        .map_err(|e| {
197            CogError::TagsNotFound(
198                e,
199                vec![Tag::PlanarConfiguration.to_u16()],
200                0,
201                path.to_path_buf(),
202            )
203        })
204        .and_then(|config| {
205            if config == 1 {
206                Ok(())
207            } else {
208                Err(CogError::PlanarConfigurationNotSupported(
209                    path.to_path_buf(),
210                    0,
211                    config,
212                ))
213            }
214        })?;
215
216    let color_type = decoder
217        .colortype()
218        .map_err(|e| CogError::InvalidTiffFile(e, path.to_path_buf()))?;
219
220    if !matches!(
221        color_type,
222        tiff::ColorType::RGB(8) | tiff::ColorType::RGBA(8)
223    ) {
224        Err(CogError::NotSupportedColorTypeAndBitDepth(
225            color_type,
226            path.to_path_buf(),
227        ))?;
228    }
229
230    match (&model.pixel_scale, &model.tie_points, &model.transformation) {
231        (Some(pixel_scale), Some(tie_points), _)
232             =>
233        {
234            if pixel_scale.len() != 3 {
235                Err(CogError::InvalidGeoInformation(path.to_path_buf(), "The count of pixel scale should be 3".to_string()))
236            }
237            else if (pixel_scale[0].abs() - pixel_scale[1].abs()).abs() > 0.01{
238                Err(CogError::NonSquaredImage(path.to_path_buf(), pixel_scale[0], pixel_scale[1]))
239            }
240            else if tie_points.len() % 6 != 0 {
241                Err(CogError::InvalidGeoInformation(path.to_path_buf(), "The count of tie points should be a multiple of 6".to_string()))
242            }else{
243                Ok(())
244            }
245       }
246        (_, _, Some(matrix))
247        => {
248            if matrix.len() == 16 {
249                Ok(())
250            } else {
251                Err(CogError::InvalidGeoInformation(path.to_path_buf(), "The length of matrix should be 16".to_string()))
252            }
253        },
254            _ => Err(CogError::InvalidGeoInformation(path.to_path_buf(), "Either a valid transformation (tag 34264) or both pixel scale (tag 33550) and tie points (tag 33922) must be provided".to_string())),
255    }?;
256
257    Ok(())
258}
259
260fn get_image(
261    decoder: &mut Decoder<File>,
262    path: &Path,
263    ifd_index: usize,
264) -> Result<Image, CogError> {
265    let (tile_width, tile_height) = (decoder.chunk_dimensions().0, decoder.chunk_dimensions().1);
266    let (image_width, image_length) = dimensions_in_pixel(decoder, path, ifd_index)?;
267    let tiles_across = image_width.div_ceil(tile_width);
268    let tiles_down = image_length.div_ceil(tile_height);
269
270    Ok(Image::new(ifd_index, tiles_across, tiles_down))
271}
272
273/// Gets image pixel dimensions from TIFF decoder
274fn dimensions_in_pixel(
275    decoder: &mut Decoder<File>,
276    path: &Path,
277    ifd_index: usize,
278) -> Result<(u32, u32), CogError> {
279    let (image_width, image_length) = decoder.dimensions().map_err(|e| {
280        CogError::TagsNotFound(
281            e,
282            vec![Tag::ImageWidth.to_u16(), Tag::ImageLength.to_u16()],
283            ifd_index,
284            path.to_path_buf(),
285        )
286    })?;
287
288    Ok((image_width, image_length))
289}
290
291/// Converts pixel dimensions to model space dimensions using resolution values
292fn dimensions_in_model(
293    decoder: &mut Decoder<File>,
294    path: &Path,
295    ifd_index: usize,
296    pixel_scale: Option<&[f64]>,
297    transformation: Option<&[f64]>,
298) -> Result<(f64, f64), CogError> {
299    let (image_width_pixel, image_length_pixel) = dimensions_in_pixel(decoder, path, ifd_index)?;
300
301    let full_resolution = get_full_resolution(pixel_scale, transformation, path)?;
302
303    let width_in_model = f64::from(image_width_pixel) * full_resolution[0].abs();
304    let length_in_model = f64::from(image_length_pixel) * full_resolution[1].abs();
305
306    Ok((width_in_model, length_in_model))
307}
308
309fn get_origin(
310    tie_points: Option<&[f64]>,
311    transformation: Option<&[f64]>,
312    path: &Path,
313) -> Result<[f64; 3], CogError> {
314    // From geotiff spec: "This matrix tag should not be used if the ModelTiepointTag and the ModelPixelScaleTag are already defined"
315    // See more in https://docs.ogc.org/is/19-008r4/19-008r4.html#_geotiff_tags_for_coordinate_transformations
316    match (tie_points, transformation) {
317        // From geotiff spec: "If possible, the first tiepoint placed in this tag shall be the one establishing the location of the point (0,0) in raster space"
318        (Some(points), _) if points.len() >= 6 => Ok([points[3], points[4], points[5]]),
319
320        // coords =     matrix  * coords
321        // |- -|     |-       -|  |- -|
322        // | X |     | a b c d |  | I |
323        // | | |     |         |  |   |
324        // | Y |     | e f g h |  | J |
325        // |   |  =  |         |  |   |
326        // | Z |     | i j k l |  | K |
327        // | | |     |         |  |   |
328        // | 1 |     | m n o p |  | 1 |
329        // |- -|     |-       -|  |- -|
330
331        // The (I,J,K) of origin is (0,0,0), so:
332        //
333        //    x = I*a + J*b + K*c + 1*d => d => matrix[3]
334        //    y = I*e + J*f + k*g + 1*h => h => matrix[7]
335        //    z = I*i + J*j + K*k + 1*l => l => matrix[11]
336        (_, Some(matrix)) if matrix.len() >= 12 => Ok([matrix[3], matrix[7], matrix[11]]),
337        _ => Err(CogError::GetOriginFailed(path.to_path_buf())),
338    }
339}
340
341fn get_full_resolution(
342    pixel_scale: Option<&[f64]>,
343    transformation: Option<&[f64]>,
344    path: &Path,
345) -> Result<[f64; 2], CogError> {
346    match (pixel_scale, transformation) {
347        // ModelPixelScaleTag = (ScaleX, ScaleY, ScaleZ)
348        (Some(scale), _) => Ok([scale[0], scale[1]]),
349        // here we adopted the 2-d matrix form based on the geotiff spec, the z-axis is dropped intentionally, see https://docs.ogc.org/is/19-008r4/19-008r4.html#_geotiff_tags_for_coordinate_transformations
350        // It looks like this:
351        /*
352           |- -|   |-       -| |- -|
353           | X |   | a b 0 d | | I |
354           | | |   |         | |   |
355           | Y |   | e f 0 h | | J |
356           |   | = |         | |   |
357           | Z |   | 0 0 0 0 | | K |
358           | | |   |         | |   |
359           | 1 |   | 0 0 0 1 | | 1 |
360           |- -|   |-       -| |- -|
361        */
362        (_, Some(matrix)) => {
363            let mut x_res = (matrix[0] * matrix[0] + matrix[4] * matrix[4]).sqrt();
364            x_res = x_res.copysign(matrix[0]);
365            let mut y_res = (matrix[1] * matrix[1] + matrix[5] * matrix[5]).sqrt();
366            // A positive y_res indicates that model space Y coordinates decrease as raster space J indices increase. This is the standard vertical relationship between raster space and model space
367            y_res = y_res.copysign(-matrix[5]);
368            Ok([x_res, y_res]) // drop the z scale directly as we don't use it
369        }
370        (None, None) => Err(CogError::GetFullResolutionFailed(path.to_path_buf())),
371    }
372}
373
374fn raster2model(i: u32, j: u32, matrix: &[f64]) -> (f64, f64) {
375    let i = f64::from(i);
376    let j = f64::from(j);
377    let x = matrix[3] + (matrix[0] * i) + (matrix[1] * j);
378    let y = matrix[7] + (matrix[4] * i) + (matrix[5] * j);
379    (x, y)
380}
381
382/// Computes the bounding box (`[min_x, min_y, max_x, max_y]`) based on the transformation matrix, origin, width, and height.
383fn get_extent(
384    origin: &[f64; 3],
385    transformation: Option<&[f64]>,
386    (full_width_pixel, full_height_pixel): (u32, u32),
387    (full_width, full_height): (f64, f64),
388) -> [f64; 4] {
389    if let Some(matrix) = transformation {
390        let corner_pixels = [
391            (0, 0),                                // Top-left
392            (0, full_height_pixel),                // Bottom-left
393            (full_width_pixel, 0),                 // Top-right
394            (full_width_pixel, full_height_pixel), // Bottom-right
395        ];
396
397        // Transform the first corner to initialize min/max values
398        let (mut min_x, mut min_y) = raster2model(corner_pixels[0].0, corner_pixels[0].1, matrix);
399        let mut max_x = min_x;
400        let mut max_y = min_y;
401
402        // Iterate over the rest of the corners
403        for &(i, j) in corner_pixels.iter().skip(1) {
404            let (x, y) = raster2model(i, j, matrix);
405            min_x = min_x.min(x);
406            min_y = min_y.min(y);
407            max_x = max_x.max(x);
408            max_y = max_y.max(y);
409        }
410        return [min_x, min_y, max_x, max_y];
411    }
412    let [x1, y1, _] = origin;
413    let x2 = x1 + full_width;
414    let y2 = y1 - full_height;
415
416    [x1.min(x2), y1.min(y2), x1.max(x2), y1.max(y2)]
417}
418
419#[cfg(test)]
420mod tests {
421    use std::fs::File;
422    use std::path::Path;
423
424    use insta::assert_yaml_snapshot;
425    use rstest::rstest;
426    use tiff::decoder::Decoder;
427
428    use crate::tiles::cog::model::ModelInfo;
429
430    #[test]
431    fn can_get_model_info() {
432        let path = Path::new("../tests/fixtures/cog/rgb_u8.tif");
433        let tif_file = File::open(path).unwrap();
434        let mut decoder = Decoder::new(tif_file).unwrap();
435        let model = ModelInfo::decode(&mut decoder, path);
436
437        assert_yaml_snapshot!(model.pixel_scale, @r"
438        - 10
439        - 10
440        - 0
441        ");
442        assert_yaml_snapshot!(model.tie_points, @r"
443        - 0
444        - 0
445        - 0
446        - 1620750.2508
447        - 4277012.7153
448        - 0
449        ");
450        assert_yaml_snapshot!(model.transformation, @"~");
451    }
452
453    #[rstest]
454    #[case(
455        Some(vec![0.0, 0.0, 0.0, 1_620_750.250_8, 4_277_012.715_3, 0.0]),None,
456        Some([1_620_750.250_8, 4_277_012.715_3, 0.0])
457    )]
458    #[case(
459        None,Some(vec![
460            0.0, 100.0, 0.0, 400_000.0, 100.0, 0.0, 0.0, 500_000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
461            0.0, 1.0,
462        ]),
463        Some([400_000.0, 500_000.0, 0.0])
464    )]
465    #[case(None, None, None)]
466    fn can_get_origin(
467        #[case] tie_point: Option<Vec<f64>>,
468        #[case] matrix: Option<Vec<f64>>,
469        #[case] expected: Option<[f64; 3]>,
470    ) {
471        use approx::assert_abs_diff_eq;
472
473        let origin = super::get_origin(
474            tie_point.as_deref(),
475            matrix.as_deref(),
476            Path::new("not_exist.tif"),
477        )
478        .ok();
479        match (origin, expected) {
480            (Some(o), Some(e)) => {
481                assert_abs_diff_eq!(o[0], e[0]);
482                assert_abs_diff_eq!(o[1], e[1]);
483                assert_abs_diff_eq!(o[2], e[2]);
484            }
485            (None, None) => {
486                // Both are None, which is expected
487            }
488            _ => {
489                panic!("Origin {origin:?} does not match expected {expected:?}");
490            }
491        }
492    }
493
494    #[rstest]
495    #[case(
496        None,Some(vec![10.0, 10.0,0.0]),Some(vec![0.0, 0.0, 0.0, 1_620_750.250_8, 4_277_012.715_3, 0.0]),(512,512),
497        [1_620_750.250_8, 4_271_892.715_3, 1_625_870.250_8, 4_277_012.715_3]
498    )]
499    #[case(
500        Some(vec![
501            10.0,0.0,0.0,1_620_750.250_8,
502            0.0,-10.0,0.0,4_277_012.715_3,
503            0.0,0.0,0.0,0.0,
504            0.0,0.0,0.0,1.0
505        ]),None,None,(512,512),
506        [1_620_750.250_8, 4_271_892.715_3, 1_625_870.250_8, 4_277_012.715_3]
507    )]
508    #[case(
509        Some(vec![
510            0.010_005_529_647_693, 0.0, 0.0, -7.583_906_932_854_38,
511            0.0, -0.009_986_188_755_447_6, 0.0, 38.750_354_738_325_9,
512            0.0, 0.0, 0.0, 0.0,
513            0.0, 0.0, 0.0, 1.0
514        ]), None, None, (598, 279),
515        [-7.583_906_9, 35.964_208_1, -1.600_600_2, 38.750_354_7]
516    )]
517    fn can_get_extent(
518        #[case] matrix: Option<Vec<f64>>,
519        #[case] pixel_scale: Option<Vec<f64>>,
520        #[case] tie_point: Option<Vec<f64>>,
521        #[case] (full_width_pixel, full_length_pixel): (u32, u32),
522        #[case] expected_extent: [f64; 4],
523    ) {
524        use approx::assert_abs_diff_eq;
525
526        use crate::tiles::cog::source::{get_extent, get_full_resolution, get_origin};
527
528        let origin = get_origin(
529            tie_point.as_deref(),
530            matrix.as_deref(),
531            Path::new("not_exist.tif"),
532        )
533        .unwrap();
534        let full_resolution = get_full_resolution(
535            pixel_scale.as_deref(),
536            matrix.as_deref(),
537            Path::new("not_exist.tif"),
538        )
539        .unwrap();
540
541        let full_width = full_resolution[0] * f64::from(full_width_pixel);
542        let full_length = full_resolution[1] * f64::from(full_length_pixel);
543
544        let extent = get_extent(
545            &origin,
546            matrix.as_deref(),
547            (full_width_pixel, full_length_pixel),
548            (full_width, full_length),
549        );
550
551        assert_abs_diff_eq!(extent[0], expected_extent[0], epsilon = 0.00001);
552        assert_abs_diff_eq!(extent[1], expected_extent[1], epsilon = 0.00001);
553        assert_abs_diff_eq!(extent[2], expected_extent[2], epsilon = 0.00001);
554        assert_abs_diff_eq!(extent[3], expected_extent[3], epsilon = 0.00001);
555    }
556
557    #[rstest]
558    #[case(
559        None,Some(vec![118.450_587_6, 118.450_587_6, 0.0]), [118.450_587_6, 118.450_587_6]
560    )]
561    #[case(
562        None,Some(vec![100.00, -100.0]), [100.0, -100.0]
563    )]
564    #[
565        case(
566            Some(vec![
567                0.010_005_529_647_693_3, 0.0, 0.0, -7.583_906_932_854_38, 0.0, -0.009_986_188_755_447_63, 0.0, 38.750_354_738_325_9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]),
568            None, [0.010_005_529_647_693, 0.009_986_188_755_448])
569    ]
570    fn can_get_full_resolution(
571        #[case] matrix: Option<Vec<f64>>,
572        #[case] pixel_scale: Option<Vec<f64>>,
573        #[case] expected: [f64; 2],
574    ) {
575        use approx::assert_abs_diff_eq;
576
577        use crate::tiles::cog::source::get_full_resolution;
578
579        let full_resolution = get_full_resolution(
580            pixel_scale.as_deref(),
581            matrix.as_deref(),
582            Path::new("not_exist.tif"),
583        )
584        .unwrap();
585        assert_abs_diff_eq!(full_resolution[0], expected[0], epsilon = 0.00001);
586        assert_abs_diff_eq!(full_resolution[1], expected[1], epsilon = 0.00001);
587    }
588}