Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
dfe5343
refactor
sharkAndshark May 28, 2025
723eee7
move get_index to image struct
sharkAndshark May 28, 2025
b6bbdf8
Merge branch 'main' into cog_web_4
sharkAndshark May 29, 2025
6b22c9b
fix a bug introduced in this refactor
sharkAndshark May 29, 2025
a6b30ff
revmoe #[allow(dead_code)] as aleady being used
sharkAndshark May 29, 2025
fdc881d
move nodata bach to meta
sharkAndshark May 29, 2025
0d4e341
split meta fields to source
sharkAndshark May 29, 2025
3c2d8ab
add #[allow(dead_code)]
sharkAndshark May 29, 2025
d3cde3e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 29, 2025
5520c18
add doc
sharkAndshark May 29, 2025
4fed5c0
remove uncessary allow too many lines rule
sharkAndshark May 29, 2025
b348116
fmt
sharkAndshark May 29, 2025
ad5867e
fix typo
sharkAndshark May 29, 2025
a31465f
improve doc
sharkAndshark May 29, 2025
02f69e8
add doc
sharkAndshark May 29, 2025
a1d3862
refactor rgb_to_png
sharkAndshark May 30, 2025
7154dab
Merge branch 'main' into cog_web_4
sharkAndshark May 31, 2025
953cdb1
better doc
sharkAndshark Jun 3, 2025
641d510
improve image.rs
sharkAndshark Jun 3, 2025
e47071d
better doc
sharkAndshark Jun 3, 2025
8f7d59e
rename ifd
sharkAndshark Jun 4, 2025
08fbc71
better comment
sharkAndshark Jun 4, 2025
ece0b09
improve doc
sharkAndshark Jun 4, 2025
3662bc0
Merge remote-tracking branch 'maplibre/main' into cog_web_4
sharkAndshark Jun 4, 2025
b56a6a0
improve func rgb_to_png
sharkAndshark Jun 4, 2025
6e5b369
better doc
sharkAndshark Jun 4, 2025
4c45ae8
improve rgb_to_png
sharkAndshark Jun 4, 2025
846adc3
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 4, 2025
b907079
Merge remote-tracking branch 'maplibre/main' into cog_web_4
sharkAndshark Jun 6, 2025
1ece931
refactor rgb_to_png
sharkAndshark Jun 6, 2025
0cbb55e
add doc
sharkAndshark Jun 6, 2025
2505648
remove redundant check in get_tile_idx
sharkAndshark Jun 6, 2025
31f3dfa
split rgb_to_png to three funcs
sharkAndshark Jun 6, 2025
00fa2a9
Merge branch 'main' into cog_web_4
sharkAndshark Jun 6, 2025
6675872
Aditional cleanups
nyurik Jun 6, 2025
3e9b7b0
remove unnecessary Clippy expectations
sharkAndshark Jun 7, 2025
5d67546
clean up
sharkAndshark Jun 7, 2025
7fd12c7
fix bug
sharkAndshark Jun 7, 2025
deb23b3
use ifd_index() method
sharkAndshark Jun 7, 2025
8e161fb
remove redundant documentation comments
sharkAndshark Jun 7, 2025
dc7510a
remove unnecessary cloning
sharkAndshark Jun 8, 2025
c42be1b
add doc about tilegrid in COG
sharkAndshark Jun 8, 2025
e888abc
ignore syntax highlighting in doc
sharkAndshark Jun 8, 2025
e214dca
Merge branch 'main' into cog_web_4
nyurik Jun 10, 2025
60dd188
move docs to readme
nyurik Jun 10, 2025
4f5279e
do not change cargo files
nyurik Jun 10, 2025
b051009
more cleanup
nyurik Jun 10, 2025
799a068
sync to main
nyurik Jun 10, 2025
8b5ec1c
update readme.md
sharkAndshark Jun 12, 2025
1ccf0f8
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 12, 2025
a624661
format readme.md
sharkAndshark Jun 12, 2025
a0c4803
Merge branch 'main' into cog_web_4
sharkAndshark Jun 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions martin/src/cog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
## COG Image Representation

* COG file is an image container representing a tile grid
* A COG may have multiple images, also called subfiles, each indexed with an Image File Directory number - [`IFD`](https://download.osgeo.org/libtiff/doc/TIFF6.pdf#[{"num":209,"gen":0},{"name":"FitB"}]#[{"num":76,"gen":0},{"name":"FitB"}]#[{"num":76,"gen":0},{"name":"FitB"}]])
* A COG must have at least one image.
* The first image (IFD=0) must be a full resolution image, e.g., the one with the highest resolution.
* [Each image may also have a mask](https://docs.ogc.org/is/21-026/21-026.html#_requirement_reduced_resolution_subfiles), which is also indexed with an IFD. The mask is used to [defines a transparency mask](https://www.verypdf.com/document/tiff6/pg_0036.htm). We do not support masks yet.
* While uncommon, COG tile grid might be different from the common ones like Web Mercator.

### COG structure example

Here is an example of a tile grid for a COG file with five images and five masks.

| ifd | image index | resolution | zoom |
| --- | ----------- | ---------- | ---- |
| 0 | 0 | 20 | 4 |
| 2 | 1 | 40 | 3 |
| 4 | 2 | 80 | 2 |
| 6 | 3 | 160 | 1 |
| 8 | 4 | 320 | 0 |

### Tile grid code representation

```rust, ignore
let images = vec![ image_0, image_1, image_2, image_3, image_4 ];
let minzoom = 0;
let zoom_of_image = image_count - 1 - idx_in_vec; # TODO: what is this?
let maxzoom = images.len() - 1;
```
9 changes: 4 additions & 5 deletions martin/src/cog/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,16 @@ impl ConfigExtras for CogConfig {
}

impl SourceConfigExtras for CogConfig {
fn parse_urls() -> bool {
false
}

async fn new_sources(&self, id: String, path: PathBuf) -> FileResult<Box<dyn Source>> {
let cog = CogSource::new(id, path)?;
Ok(Box::new(cog))
}

#[allow(clippy::no_effect_underscore_binding)]
async fn new_sources_url(&self, _id: String, _url: Url) -> FileResult<Box<dyn Source>> {
unreachable!()
}

fn parse_urls() -> bool {
false
}
}
291 changes: 291 additions & 0 deletions martin/src/cog/image.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
use std::fs::File;
use std::io::BufWriter;
use std::path::Path;

use martin_tile_utils::TileCoord;
use tiff::decoder::{Decoder, DecodingResult};

use super::CogError;
use crate::{MartinResult, TileData};

/// Image represents a single image in a COG file. A tiff file may contain many images.
/// This struct contains information and methods for taking tiles from the image.
#[derive(Clone, Debug)]
pub struct Image {
/// The Image File Directory index represents IDF entry with the image pointers to the actual image data.
ifd_index: usize,
/// Number of tiles in a row of this image
tiles_across: u32,
/// Number of tiles in a column of this image
tiles_down: u32,
}

impl Image {
Comment thread
sharkAndshark marked this conversation as resolved.
pub fn new(ifd_index: usize, tiles_across: u32, tiles_down: u32) -> Self {
Self {
ifd_index,
tiles_across,
tiles_down,
}
}

/// Retrieves a tile from the image, decodes it, and converts it to PNG format.
#[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
pub fn get_tile(
&self,
decoder: &mut Decoder<File>,
xyz: TileCoord,
nodata: Option<f64>,
path: &Path,
) -> MartinResult<TileData> {
decoder
.seek_to_image(self.ifd_index())
.map_err(|e| CogError::IfdSeekFailed(e, self.ifd_index(), path.to_path_buf()))?;

let tile_idx;
if let Some(idx) = self.get_tile_index(xyz) {
tile_idx = idx;
} else {
return Ok(Vec::new());
}
let decode_result = decoder.read_chunk(tile_idx).map_err(|e| {
CogError::ReadChunkFailed(e, tile_idx, self.ifd_index(), path.to_path_buf())
})?;
let color_type = decoder
.colortype()
.map_err(|e| CogError::InvalidTiffFile(e, path.to_path_buf()))?;

let (tile_width, tile_height) = decoder.chunk_dimensions();
let (data_width, data_height) = decoder.chunk_data_dimensions(tile_idx);

//FIXME: do more research on the not u8 case, is this the right way to do it?
let png_file_bytes = match (decode_result, color_type) {
(DecodingResult::U8(vec), tiff::ColorType::RGB(_)) => rgb_to_png(
vec,
(tile_width, tile_height),
(data_width, data_height),
3,
nodata.map(|v| v as u8),
path,
),
(DecodingResult::U8(vec), tiff::ColorType::RGBA(_)) => rgb_to_png(
vec,
(tile_width, tile_height),
(data_width, data_height),
4,
nodata.map(|v| v as u8),
path,
),
(_, _) => Err(CogError::NotSupportedColorTypeAndBitDepth(
color_type,
path.to_path_buf(),
)),
//todo do others in next PRs, a lot of discussion would be needed
}?;
Ok(png_file_bytes)
}

pub fn ifd_index(&self) -> usize {
self.ifd_index
}

fn get_tile_index(&self, xyz: TileCoord) -> Option<u32> {
if xyz.y >= self.tiles_down || xyz.x >= self.tiles_across {
return None;
}

Some(xyz.y * self.tiles_across + xyz.x)
}
}

/// Converts RGB/RGBA tile data to PNG format.
fn rgb_to_png(
data: Vec<u8>,
(tile_width, tile_height): (u32, u32),
(data_width, data_height): (u32, u32),
components_count: u32,
nodata: Option<u8>,
path: &Path,
) -> Result<Vec<u8>, CogError> {
let pixels = ensure_pixels_valid(
data,
(tile_width, tile_height),
(data_width, data_height),
components_count,
nodata,
);
encode_rgba_as_png(tile_width, tile_height, &pixels, path)
}

/// Ensures pixel data is valid for PNG encoding by handling padding, alpha channel, and nodata values.
fn ensure_pixels_valid(
data: Vec<u8>,
(tile_width, tile_height): (u32, u32),
(data_width, data_height): (u32, u32),
components_count: u32,
nodata: Option<u8>,
) -> Vec<u8> {
let is_padded = data_width != tile_width || data_height != tile_height;
// FIXME: why not `== 3`?
let add_alpha = components_count != 4;
// 1. Check if the tile is padded. If so, we need to add padding part back.
// The decoded value might be smaller than the tile size.
// TIFF crate always cut off the padding part, so we would need to add the padding part back.
// 2. If the components count is 3 (RGB), we need to add the alpha channel to convert it to RGBA.
// 3. Check if nodata is provided. We need to make the pixels with nodata value transparent
// See https://gdal.org/en/stable/drivers/raster/gtiff.html#nodata-value
if nodata.is_some() || add_alpha || is_padded {
let mut result_vec = vec![0; (tile_width * tile_height * 4) as usize];
for row in 0..data_height {
'outer: for col in 0..data_width {
let idx_chunk = row * data_width * components_count + col * components_count;
let idx_result = row * tile_width * 4 + col * 4;

// Copy component values one by one
for component_idx in 0..components_count {
// Before copying, check if this component == nodata. If so, skip because it's transparent.
// FIXME: Should we copy the RGB values anyway and just set alpha to 0?
// The visual result is the same (transparent), but the component values would differ.
// But it might be a little slower as we don't skip the copy.
// Source pixel: [4, 1, 2, 3] nodata: Some(1)
// Skip:
// result pixel: [4, 0, 0, 0]
// Do not skip:
// result pixel: [4, 1, 2, 0]
// So the visual result is the same, but the component values are different.

let value = data[(idx_chunk + component_idx) as usize];
if let Some(v) = nodata {
if value == v {
continue 'outer;
}
}
// Copy this component to the result vector
result_vec[(idx_result + component_idx) as usize] = value;
}
if add_alpha {
result_vec[(idx_result + 3) as usize] = 255; // opaque
}
}
}
result_vec
} else {
data
Comment thread
sharkAndshark marked this conversation as resolved.
}
}

/// Encodes RGBA pixel data to PNG format.
fn encode_rgba_as_png(
tile_width: u32,
tile_height: u32,
pixels: &[u8],
path: &Path,
) -> Result<Vec<u8>, CogError> {
let mut result_file_buffer = Vec::new();
{
let mut encoder = png::Encoder::new(
BufWriter::new(&mut result_file_buffer),
tile_width,
tile_height,
);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder
.write_header()
.map_err(|e| CogError::WritePngHeaderFailed(path.to_path_buf(), e))?;
writer
.write_image_data(pixels)
.map_err(|e| CogError::WriteToPngFailed(path.to_path_buf(), e))?;
}
Ok(result_file_buffer)
}

#[cfg(test)]
mod tests {
use std::path::PathBuf;

use martin_tile_utils::TileCoord;
use rstest::rstest;

use crate::cog::image::Image;

#[test]
fn can_calc_tile_idx() {
let image = Image {
ifd_index: 0,
tiles_across: 3,
tiles_down: 3,
};
assert_eq!(
Some(0),
image.get_tile_index(TileCoord { z: 0, x: 0, y: 0 })
);
assert_eq!(
Some(8),
image.get_tile_index(TileCoord { z: 0, x: 2, y: 2 })
);
assert_eq!(None, image.get_tile_index(TileCoord { z: 0, x: 3, y: 0 }));
assert_eq!(None, image.get_tile_index(TileCoord { z: 0, x: 1, y: 9 }));
}
#[rstest]
// the right half should be transparent
#[case(
"../tests/fixtures/cog/expected/right_padded.png",
(0, 0, 0, None), None, (128, 256), (256, 256)
)]
// the down half should be transparent
#[case(
"../tests/fixtures/cog/expected/down_padded.png",
(0, 0, 0, None), None, (256, 128), (256, 256)
)]
// the up half should be half-transparent and down half should be transparent
#[case(
"../tests/fixtures/cog/expected/down_padded_with_alpha.png",
(0, 0, 0, Some(128)), None, (256, 128), (256, 256)
)]
// the left half should be half-transparent and the right half should be transparent
#[case(
"../tests/fixtures/cog/expected/right_padded_with_alpha.png",
(0, 0, 0, Some(128)), None, (128, 256), (256, 256)
)]
// should be all half transparent
#[case(
"../tests/fixtures/cog/expected/not_padded.png",
(0, 0, 0, Some(128)), None, (256, 256), (256, 256)
)]
// all padded and with a no_data whose value is 128, and all the component is 128
// so that should be all transparent
#[case(
"../tests/fixtures/cog/expected/all_transparent.png",
(128, 128, 128, Some(128)), Some(128), (128, 128), (256, 256)
)]
fn test_padded_cases(
#[case] expected_file_path: &str,
#[case] components: (u8, u8, u8, Option<u8>),
#[case] no_value: Option<u8>,
#[case] (data_width, data_height): (u32, u32),
#[case] (tile_width, tile_height): (u32, u32),
) {
let mut pixels = Vec::new();
for _ in 0..(data_width * data_height) {
pixels.push(components.0);
pixels.push(components.1);
pixels.push(components.2);
if let Some(alpha) = components.3 {
pixels.push(alpha);
}
}
let components_count = if components.3.is_some() { 4 } else { 3 };
let png_bytes = super::rgb_to_png(
pixels,
(tile_width, tile_height),
(data_width, data_height),
components_count,
no_value,
&PathBuf::from("not_exist.tif"),
)
.unwrap();
let expected = std::fs::read(expected_file_path).unwrap();
assert_eq!(png_bytes, expected);
}
}
3 changes: 3 additions & 0 deletions martin/src/cog/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#![doc = include_str!("README.md")]

mod config;
mod errors;
mod image;
mod model;
mod source;

Expand Down
9 changes: 5 additions & 4 deletions martin/src/cog/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ use tiff::tags::Tag;

use super::CogError;

/// These are tags to be used for defining the relationship between raster space and model space. See [ogc doc](https://docs.ogc.org/is/19-008r4/19-008r4.html#_coordinate_transformations) for more details.
/// These tags define the relationship between raster space and model space.
/// See [ogc doc](https://docs.ogc.org/is/19-008r4/19-008r4.html#_coordinate_transformations) for details.
///
/// The the relationship may be diagrammed as:
/// The relationship may be diagrammed as:
/// ```raw
/// ModelPixelScaleTag
/// ModelTiepointTag
Expand All @@ -17,7 +18,7 @@ use super::CogError;
/// ```
#[derive(Clone, Debug)]
pub struct ModelInfo {
/// `ModelPixelScaleTag`, may be used to specify the size of raster pixel spacing in the model space units, when the raster space can be embedded in the model space coordinate reference system without rotation.
/// `ModelPixelScaleTag` may be used to specify the size of raster pixel spacing in the model space units, when the raster space can be embedded in the model space coordinate reference system without rotation.
/// Consists of the following 3 values: `(ScaleX, ScaleY, ScaleZ)`.
///
/// ```raw
Expand All @@ -37,7 +38,7 @@ pub struct ModelInfo {
/// ModelTiepointTag:
/// Tag = 33922 (8482.H)
/// Type = DOUBLE (IEEE Double precision)
/// N = 6*K, K = number of tiepoints
/// N = 6*K, K = number of tie-points
/// Alias: GeoreferenceTag
/// ```
///
Expand Down
Loading
Loading