use std::collections::HashSet;
use std::f64::consts::PI;
use ahash::AHashMap;
use proj4rs::proj::Proj;
use proj4rs::transform::transform;
use crate::cog_reader::CogReader;
use crate::geometry::projection::{get_proj_string, is_geographic_crs};
use crate::tiff_utils::AnyResult;
const EPSG_WEB_MERCATOR: u32 = 3857;
const EPSG_WGS84: u32 = 4326;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ResamplingMethod {
#[default]
Nearest,
Bilinear,
Bicubic,
}
#[derive(Debug, Clone)]
pub struct TileData {
pub pixels: Vec<f32>,
pub bands: usize,
pub width: usize,
pub height: usize,
pub bytes_fetched: usize,
pub tiles_read: usize,
pub overview_used: Option<usize>,
}
#[derive(Debug, Clone, Copy)]
pub struct BoundingBox {
pub minx: f64,
pub miny: f64,
pub maxx: f64,
pub maxy: f64,
}
impl BoundingBox {
#[inline]
#[must_use]
pub fn new(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
Self { minx: min_x, miny: min_y, maxx: max_x, maxy: max_y }
}
#[inline]
#[must_use]
pub fn from_xyz(z: u32, x: u32, y: u32) -> Self {
let n = f64::from(2_u32.pow(z));
let tile_size = 40_075_016.685_578_49 / n;
let min_x = -20_037_508.342_789_244 + f64::from(x) * tile_size;
let max_x = min_x + tile_size;
let max_y = 20_037_508.342_789_244 - f64::from(y) * tile_size;
let min_y = max_y - tile_size;
Self { minx: min_x, miny: min_y, maxx: max_x, maxy: max_y }
}
}
const HALF_EARTH: f64 = 20_037_508.342_789_244;
#[inline(always)]
fn bicubic_weight(x: f64) -> f64 {
const A0: f64 = 7.0 / 6.0; const A1: f64 = -2.0; const A2: f64 = 16.0 / 18.0; const B0: f64 = -7.0 / 18.0; const B1: f64 = 2.0; const B2: f64 = -10.0 / 3.0; const B3: f64 = 16.0 / 9.0;
let x = x.abs();
if x < 1.0 {
let x2 = x * x;
A0 * x2 * x + A1 * x2 + A2
} else if x < 2.0 {
let x2 = x * x;
B0 * x2 * x + B1 * x2 + B2 * x + B3
} else {
0.0
}
}
#[inline]
fn merc_x_to_lon(x: f64) -> f64 {
x * 180.0 / HALF_EARTH
}
#[inline]
fn merc_y_to_lat(y: f64) -> f64 {
let y_rad = y * PI / HALF_EARTH;
(2.0 * y_rad.exp().atan() - PI / 2.0) * 180.0 / PI
}
enum TransformStrategy {
Identity,
FastMerc2Geo,
Proj4rs(Box<CoordTransformer>),
}
impl TransformStrategy {
fn new(output_crs: u32, source_crs: u32) -> Result<Self, String> {
if output_crs == source_crs {
return Ok(TransformStrategy::Identity);
}
if output_crs == EPSG_WEB_MERCATOR && source_crs == EPSG_WGS84 {
return Ok(TransformStrategy::FastMerc2Geo);
}
#[allow(clippy::cast_possible_wrap)]
let transformer = CoordTransformer::new(output_crs as i32, source_crs as i32)?;
Ok(TransformStrategy::Proj4rs(Box::new(transformer)))
}
#[inline]
fn transform(&self, x: f64, y: f64) -> Result<(f64, f64), String> {
match self {
TransformStrategy::Identity => Ok((x, y)),
TransformStrategy::FastMerc2Geo => Ok((merc_x_to_lon(x), merc_y_to_lat(y))),
TransformStrategy::Proj4rs(t) => t.transform(x, y),
}
}
}
pub struct CoordTransformer {
source_proj: Proj,
target_proj: Proj,
source_epsg: i32,
target_epsg: i32,
source_is_geographic: bool,
target_is_geographic: bool,
}
impl std::fmt::Debug for CoordTransformer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CoordTransformer")
.field("source_epsg", &self.source_epsg)
.field("target_epsg", &self.target_epsg)
.field("source_is_geographic", &self.source_is_geographic)
.field("target_is_geographic", &self.target_is_geographic)
.finish_non_exhaustive()
}
}
impl CoordTransformer {
pub fn new(source_epsg: i32, target_epsg: i32) -> Result<Self, String> {
let source_str = get_proj_string(source_epsg)
.ok_or_else(|| format!("EPSG:{source_epsg} not supported"))?;
let target_str = get_proj_string(target_epsg)
.ok_or_else(|| format!("EPSG:{target_epsg} not supported"))?;
let source_proj = Proj::from_proj_string(source_str)
.map_err(|e| format!("Invalid source projection EPSG:{source_epsg}: {e:?}"))?;
let target_proj = Proj::from_proj_string(target_str)
.map_err(|e| format!("Invalid target projection EPSG:{target_epsg}: {e:?}"))?;
Ok(Self {
source_proj,
target_proj,
source_epsg,
target_epsg,
source_is_geographic: is_geographic_crs(source_epsg),
target_is_geographic: is_geographic_crs(target_epsg),
})
}
pub fn from_3857_to(target_epsg: u32) -> Result<Self, String> {
#[allow(clippy::cast_possible_wrap)]
let target = target_epsg as i32;
#[allow(clippy::cast_possible_wrap)]
let source = EPSG_WEB_MERCATOR as i32;
Self::new(source, target)
}
pub fn from_lonlat_to(target_epsg: i32) -> Result<Self, String> {
#[allow(clippy::cast_possible_wrap)]
let source = EPSG_WGS84 as i32;
Self::new(source, target_epsg)
}
pub fn to_lonlat_from(source_epsg: i32) -> Result<Self, String> {
#[allow(clippy::cast_possible_wrap)]
let target = EPSG_WGS84 as i32;
Self::new(source_epsg, target)
}
pub fn to_3857_from(source_epsg: i32) -> Result<Self, String> {
#[allow(clippy::cast_possible_wrap)]
let target = EPSG_WEB_MERCATOR as i32;
Self::new(source_epsg, target)
}
#[inline]
#[must_use]
pub fn source_epsg(&self) -> i32 {
self.source_epsg
}
#[inline]
#[must_use]
pub fn target_epsg(&self) -> i32 {
self.target_epsg
}
#[inline]
#[must_use]
pub fn source_is_geographic(&self) -> bool {
self.source_is_geographic
}
#[inline]
#[must_use]
pub fn target_is_geographic(&self) -> bool {
self.target_is_geographic
}
#[inline]
pub fn transform(&self, x: f64, y: f64) -> Result<(f64, f64), String> {
let (in_x, in_y) = if self.source_is_geographic {
(x.to_radians(), y.to_radians())
} else {
(x, y)
};
let mut point = (in_x, in_y, 0.0);
transform(&self.source_proj, &self.target_proj, &mut point)
.map_err(|e| format!("Transform failed: {e:?}"))?;
let (out_x, out_y) = if self.target_is_geographic {
(point.0.to_degrees(), point.1.to_degrees())
} else {
(point.0, point.1)
};
Ok((out_x, out_y))
}
#[must_use]
pub fn transform_batch(&self, points: &[(f64, f64)]) -> Vec<Result<(f64, f64), String>> {
points.iter().map(|&(x, y)| self.transform(x, y)).collect()
}
}
pub struct TileExtractor<'a> {
reader: &'a CogReader,
bounds: Option<BoundingBox>,
output_crs: u32,
output_size: (usize, usize),
resampling: ResamplingMethod,
selected_bands: Option<Vec<usize>>,
}
impl std::fmt::Debug for TileExtractor<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TileExtractor")
.field("bounds", &self.bounds)
.field("output_crs", &self.output_crs)
.field("output_size", &self.output_size)
.field("resampling", &self.resampling)
.field("selected_bands", &self.selected_bands)
.finish_non_exhaustive()
}
}
impl<'a> TileExtractor<'a> {
#[must_use]
pub fn new(reader: &'a CogReader) -> Self {
Self {
reader,
bounds: None,
output_crs: EPSG_WEB_MERCATOR,
output_size: (256, 256),
resampling: ResamplingMethod::default(),
selected_bands: None,
}
}
#[must_use]
pub fn xyz(mut self, z: u32, x: u32, y: u32) -> Self {
self.bounds = Some(BoundingBox::from_xyz(z, x, y));
self
}
#[must_use]
pub fn bounds(mut self, bbox: BoundingBox) -> Self {
self.bounds = Some(bbox);
self
}
#[must_use]
pub fn output_crs(mut self, epsg: u32) -> Self {
self.output_crs = epsg;
self
}
#[must_use]
pub fn output_size(mut self, width: usize, height: usize) -> Self {
self.output_size = (width, height);
self
}
#[must_use]
pub fn size(mut self, size: usize) -> Self {
self.output_size = (size, size);
self
}
#[must_use]
pub fn resampling(mut self, method: ResamplingMethod) -> Self {
self.resampling = method;
self
}
#[must_use]
pub fn bands(mut self, bands: &[usize]) -> Self {
self.selected_bands = Some(bands.to_vec());
self
}
pub async fn extract(self) -> AnyResult<TileData> {
let bounds = self.bounds.ok_or("Bounds not set: use .xyz() or .bounds()")?;
let reader_clone = self.reader.clone_for_async();
let resampling = self.resampling;
let output_size = self.output_size;
let output_crs = self.output_crs;
if let Some(selected) = self.selected_bands {
tokio::task::spawn_blocking(move || {
extract_tile_with_bands_sync(&reader_clone, &bounds, output_crs, output_size, resampling, &selected)
})
.await
.map_err(|e| format!("Task join error: {e}"))?
} else {
tokio::task::spawn_blocking(move || {
extract_tile_with_extent_resampled_sync(&reader_clone, &bounds, output_crs, output_size, resampling)
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
}
#[inline]
#[must_use]
pub fn get_output_size(&self) -> (usize, usize) {
self.output_size
}
#[inline]
#[must_use]
pub fn get_bounds(&self) -> Option<&BoundingBox> {
self.bounds.as_ref()
}
#[inline]
#[must_use]
pub fn get_selected_bands(&self) -> Option<&[usize]> {
self.selected_bands.as_deref()
}
#[inline]
#[must_use]
pub fn get_output_crs(&self) -> u32 {
self.output_crs
}
}
#[derive(Debug, Clone)]
pub struct ReprojectedRaster {
pub pixels: Vec<f32>,
pub bands: usize,
pub width: usize,
pub height: usize,
pub crs: u32,
pub bounds: BoundingBox,
pub resolution: (f64, f64),
pub nodata: Option<f64>,
}
impl ReprojectedRaster {
#[must_use]
pub fn geo_transform(&self) -> [f64; 6] {
[
self.bounds.minx,
self.resolution.0,
0.0,
self.bounds.maxy,
0.0,
-self.resolution.1,
]
}
#[must_use]
pub fn get_pixel(&self, x: usize, y: usize, band: usize) -> Option<f32> {
if x >= self.width || y >= self.height || band >= self.bands {
return None;
}
let idx = (y * self.width + x) * self.bands + band;
self.pixels.get(idx).copied()
}
#[must_use]
pub fn world_to_pixel(&self, world_x: f64, world_y: f64) -> (f64, f64) {
let px = (world_x - self.bounds.minx) / self.resolution.0;
let py = (self.bounds.maxy - world_y) / self.resolution.1;
(px, py)
}
#[must_use]
pub fn pixel_to_world(&self, px: f64, py: f64) -> (f64, f64) {
let world_x = self.bounds.minx + (px + 0.5) * self.resolution.0;
let world_y = self.bounds.maxy - (py + 0.5) * self.resolution.1;
(world_x, world_y)
}
}
pub struct Reprojector<'a> {
reader: &'a CogReader,
target_crs: Option<u32>,
output_bounds: Option<BoundingBox>,
output_resolution: Option<(f64, f64)>,
output_size: Option<(usize, usize)>,
resampling: ResamplingMethod,
selected_bands: Option<Vec<usize>>,
}
impl std::fmt::Debug for Reprojector<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Reprojector")
.field("target_crs", &self.target_crs)
.field("output_bounds", &self.output_bounds)
.field("output_resolution", &self.output_resolution)
.field("output_size", &self.output_size)
.field("resampling", &self.resampling)
.field("selected_bands", &self.selected_bands)
.finish_non_exhaustive()
}
}
impl<'a> Reprojector<'a> {
#[must_use]
pub fn new(reader: &'a CogReader) -> Self {
Self {
reader,
target_crs: None,
output_bounds: None,
output_resolution: None,
output_size: None,
resampling: ResamplingMethod::default(),
selected_bands: None,
}
}
#[must_use]
pub fn to_crs(mut self, epsg: u32) -> Self {
self.target_crs = Some(epsg);
self
}
#[must_use]
pub fn bounds(mut self, bbox: BoundingBox) -> Self {
self.output_bounds = Some(bbox);
self
}
#[must_use]
pub fn bounds_in_crs(mut self, bbox: BoundingBox, bounds_crs: u32) -> Self {
self.output_bounds = Some(bbox);
let _ = bounds_crs; self
}
#[must_use]
pub fn resolution(mut self, x_res: f64, y_res: f64) -> Self {
self.output_resolution = Some((x_res, y_res));
self
}
#[must_use]
pub fn size(mut self, width: usize, height: usize) -> Self {
self.output_size = Some((width, height));
self
}
#[must_use]
pub fn resampling(mut self, method: ResamplingMethod) -> Self {
self.resampling = method;
self
}
#[must_use]
pub fn bands(mut self, bands: &[usize]) -> Self {
self.selected_bands = Some(bands.to_vec());
self
}
pub async fn extract(self) -> AnyResult<ReprojectedRaster> {
let target_crs = self.target_crs.ok_or("Target CRS not set: use .to_crs()")?;
let reader_clone = self.reader.clone_for_async();
let output_bounds = self.output_bounds;
let output_resolution = self.output_resolution;
let output_size = self.output_size;
let resampling = self.resampling;
let selected_bands = self.selected_bands;
tokio::task::spawn_blocking(move || {
reproject_sync(
&reader_clone,
target_crs,
output_bounds,
output_resolution,
output_size,
resampling,
selected_bands.as_deref(),
)
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
#[must_use]
pub fn source_crs(&self) -> Option<i32> {
self.reader.metadata.crs_code
}
#[must_use]
pub fn streaming(self, chunk_size: usize) -> StreamingReprojector<'a> {
StreamingReprojector {
reader: self.reader,
target_crs: self.target_crs,
output_bounds: self.output_bounds,
output_resolution: self.output_resolution,
output_size: self.output_size,
resampling: self.resampling,
selected_bands: self.selected_bands,
chunk_size,
}
}
}
#[derive(Debug, Clone)]
pub struct StreamingOutputInfo {
pub width: usize,
pub height: usize,
pub bands: usize,
pub crs: u32,
pub bounds: BoundingBox,
pub resolution: (f64, f64),
pub chunk_size: usize,
pub chunks_x: usize,
pub chunks_y: usize,
pub nodata: Option<f64>,
}
impl StreamingOutputInfo {
#[must_use]
pub fn chunk_count(&self) -> usize {
self.chunks_x * self.chunks_y
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn chunk_bounds(&self, chunk_x: usize, chunk_y: usize) -> BoundingBox {
let x_start = chunk_x * self.chunk_size;
let y_start = chunk_y * self.chunk_size;
let x_end = (x_start + self.chunk_size).min(self.width);
let y_end = (y_start + self.chunk_size).min(self.height);
BoundingBox::new(
self.bounds.minx + x_start as f64 * self.resolution.0,
self.bounds.maxy - y_end as f64 * self.resolution.1,
self.bounds.minx + x_end as f64 * self.resolution.0,
self.bounds.maxy - y_start as f64 * self.resolution.1,
)
}
#[must_use]
pub fn chunk_dimensions(&self, chunk_x: usize, chunk_y: usize) -> (usize, usize) {
let x_start = chunk_x * self.chunk_size;
let y_start = chunk_y * self.chunk_size;
let width = (self.chunk_size).min(self.width - x_start);
let height = (self.chunk_size).min(self.height - y_start);
(width, height)
}
}
pub struct RasterChunk<'a> {
reader: &'a CogReader,
pub chunk_pos: (usize, usize),
pub bounds: BoundingBox,
pub dimensions: (usize, usize),
target_crs: u32,
resampling: ResamplingMethod,
selected_bands: Option<Vec<usize>>,
}
impl<'a> RasterChunk<'a> {
pub async fn extract(&self) -> AnyResult<ReprojectedRaster> {
let reader_clone = self.reader.clone_for_async();
let bounds = self.bounds;
let dimensions = self.dimensions;
let target_crs = self.target_crs;
let resampling = self.resampling;
let selected_bands = self.selected_bands.clone();
tokio::task::spawn_blocking(move || {
reproject_sync(
&reader_clone,
target_crs,
Some(bounds),
None, Some(dimensions),
resampling,
selected_bands.as_deref(),
)
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
}
pub struct StreamingReprojector<'a> {
reader: &'a CogReader,
target_crs: Option<u32>,
output_bounds: Option<BoundingBox>,
output_resolution: Option<(f64, f64)>,
output_size: Option<(usize, usize)>,
resampling: ResamplingMethod,
selected_bands: Option<Vec<usize>>,
chunk_size: usize,
}
impl<'a> StreamingReprojector<'a> {
pub fn output_info(&self) -> AnyResult<StreamingOutputInfo> {
let target_crs = self.target_crs.ok_or("Target CRS not set: use .to_crs()")?;
let metadata = &self.reader.metadata;
#[allow(clippy::cast_possible_wrap)]
let default_crs = EPSG_WEB_MERCATOR as i32;
let source_crs = u32::try_from(metadata.crs_code.unwrap_or(default_crs))
.map_err(|e| format!("Invalid source CRS: {e}"))?;
let geo_transform = &metadata.geo_transform;
let (Some(scale), Some(tiepoint)) = (geo_transform.pixel_scale, geo_transform.tiepoint)
else {
return Err("Missing geotransform in source raster".into());
};
#[allow(clippy::cast_precision_loss)]
let src_bounds = BoundingBox::new(
tiepoint[3],
tiepoint[4] - scale[1] * metadata.height as f64,
tiepoint[3] + scale[0] * metadata.width as f64,
tiepoint[4],
);
let output_bounds = if let Some(bounds) = self.output_bounds {
bounds
} else {
transform_bounds(src_bounds, source_crs, target_crs)?
};
#[allow(clippy::cast_precision_loss)]
let (width, height, x_res, y_res) = if let Some((w, h)) = self.output_size {
let x_res = (output_bounds.maxx - output_bounds.minx) / w as f64;
let y_res = (output_bounds.maxy - output_bounds.miny) / h as f64;
(w, h, x_res, y_res)
} else if let Some((x_res, y_res)) = self.output_resolution {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let w = ((output_bounds.maxx - output_bounds.minx) / x_res).ceil() as usize;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let h = ((output_bounds.maxy - output_bounds.miny) / y_res).ceil() as usize;
(w.max(1), h.max(1), x_res, y_res)
} else {
compute_output_resolution(metadata, source_crs, target_crs, &output_bounds)?
};
let bands = if let Some(ref selected) = self.selected_bands {
selected.len()
} else {
metadata.bands
};
let chunks_x = width.div_ceil(self.chunk_size);
let chunks_y = height.div_ceil(self.chunk_size);
Ok(StreamingOutputInfo {
width,
height,
bands,
crs: target_crs,
bounds: output_bounds,
resolution: (x_res, y_res),
chunk_size: self.chunk_size,
chunks_x,
chunks_y,
nodata: metadata.nodata,
})
}
pub fn chunks(&'a self) -> impl Iterator<Item = RasterChunk<'a>> {
let info = self.output_info().expect("Failed to compute output info");
let target_crs = self.target_crs.unwrap();
(0..info.chunks_y).flat_map(move |cy| {
let info = info.clone();
(0..info.chunks_x).map(move |cx| RasterChunk {
reader: self.reader,
chunk_pos: (cx, cy),
bounds: info.chunk_bounds(cx, cy),
dimensions: info.chunk_dimensions(cx, cy),
target_crs,
resampling: self.resampling,
selected_bands: self.selected_bands.clone(),
})
})
}
pub async fn write_geotiff<P: AsRef<std::path::Path>>(
&self,
path: P,
) -> AnyResult<()> {
use crate::geotiff_writer::{GeoTiffCompression, GeoTiffWriteError};
let info = self.output_info()?;
let total_pixels = info.width * info.height * info.bands;
if total_pixels > 512_000_000 {
return Err(format!(
"Output raster too large for single-file write: {} pixels. \
Consider using smaller bounds or lower resolution.",
total_pixels
)
.into());
}
let mut pixels = vec![0.0f32; total_pixels];
for chunk in self.chunks() {
let (cx, cy) = chunk.chunk_pos;
let (chunk_w, chunk_h) = chunk.dimensions;
let chunk_data = chunk.extract().await?;
let x_offset = cx * self.chunk_size;
let y_offset = cy * self.chunk_size;
for row in 0..chunk_h {
let src_start = row * chunk_w * info.bands;
let src_end = src_start + chunk_w * info.bands;
let dst_row = y_offset + row;
let dst_start = (dst_row * info.width + x_offset) * info.bands;
pixels[dst_start..dst_start + chunk_w * info.bands]
.copy_from_slice(&chunk_data.pixels[src_start..src_end]);
}
}
let raster = ReprojectedRaster {
pixels,
bands: info.bands,
width: info.width,
height: info.height,
crs: info.crs,
bounds: info.bounds,
resolution: info.resolution,
nodata: info.nodata,
};
raster
.write_geotiff_compressed(path, GeoTiffCompression::Lzw)
.map_err(|e: GeoTiffWriteError| e.to_string())?;
Ok(())
}
pub async fn for_each_chunk<F>(&self, mut callback: F) -> AnyResult<()>
where
F: FnMut((usize, usize), ReprojectedRaster) -> AnyResult<()>,
{
for chunk in self.chunks() {
let pos = chunk.chunk_pos;
let data = chunk.extract().await?;
callback(pos, data)?;
}
Ok(())
}
pub async fn extract_parallel(
&self,
concurrency: usize,
) -> AnyResult<Vec<((usize, usize), ReprojectedRaster)>> {
use futures::stream::{self, StreamExt};
let chunks: Vec<_> = self.chunks().collect();
let results: Vec<_> = stream::iter(chunks)
.map(|chunk| async move {
let pos = chunk.chunk_pos;
let data = chunk.extract().await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>((pos, data))
})
.buffer_unordered(concurrency)
.collect()
.await;
results.into_iter().collect()
}
}
fn reproject_sync(
reader: &CogReader,
target_crs: u32,
output_bounds: Option<BoundingBox>,
output_resolution: Option<(f64, f64)>,
output_size: Option<(usize, usize)>,
resampling: ResamplingMethod,
selected_bands: Option<&[usize]>,
) -> Result<ReprojectedRaster, Box<dyn std::error::Error + Send + Sync>> {
let metadata = &reader.metadata;
#[allow(clippy::cast_possible_wrap)]
let default_crs = EPSG_WEB_MERCATOR as i32;
let source_crs = u32::try_from(metadata.crs_code.unwrap_or(default_crs))
.map_err(|e| format!("Invalid source CRS: {e}"))?;
let geo_transform = &metadata.geo_transform;
let (Some(scale), Some(tiepoint)) = (geo_transform.pixel_scale, geo_transform.tiepoint) else {
return Err("Missing geotransform in source raster".into());
};
#[allow(clippy::cast_precision_loss)]
let src_bounds = BoundingBox::new(
tiepoint[3],
tiepoint[4] - scale[1] * metadata.height as f64,
tiepoint[3] + scale[0] * metadata.width as f64,
tiepoint[4],
);
let output_bounds = if let Some(bounds) = output_bounds {
bounds
} else {
transform_bounds(src_bounds, source_crs, target_crs)?
};
#[allow(clippy::cast_precision_loss)]
let (width, height, x_res, y_res) = if let Some((w, h)) = output_size {
let x_res = (output_bounds.maxx - output_bounds.minx) / w as f64;
let y_res = (output_bounds.maxy - output_bounds.miny) / h as f64;
(w, h, x_res, y_res)
} else if let Some((x_res, y_res)) = output_resolution {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let w = ((output_bounds.maxx - output_bounds.minx) / x_res).ceil() as usize;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let h = ((output_bounds.maxy - output_bounds.miny) / y_res).ceil() as usize;
(w.max(1), h.max(1), x_res, y_res)
} else {
compute_output_resolution(metadata, source_crs, target_crs, &output_bounds)?
};
let bands: Vec<usize> = if let Some(selected) = selected_bands {
for &band in selected {
if band >= metadata.bands {
return Err(format!(
"Band index {} out of range (COG has {} bands)",
band, metadata.bands
)
.into());
}
}
selected.to_vec()
} else {
(0..metadata.bands).collect()
};
if bands.is_empty() {
return Err("At least one band must be selected".into());
}
let tile_data = extract_tile_with_bands_sync(
reader,
&output_bounds,
target_crs,
(width, height),
resampling,
&bands,
)?;
Ok(ReprojectedRaster {
pixels: tile_data.pixels,
bands: tile_data.bands,
width: tile_data.width,
height: tile_data.height,
crs: target_crs,
bounds: output_bounds,
resolution: (x_res, y_res),
nodata: metadata.nodata,
})
}
#[allow(clippy::similar_names)]
fn transform_bounds(
src_bounds: BoundingBox,
source_crs: u32,
target_crs: u32,
) -> Result<BoundingBox, String> {
if source_crs == target_crs {
return Ok(src_bounds);
}
#[allow(clippy::cast_possible_wrap)]
let transformer = CoordTransformer::new(source_crs as i32, target_crs as i32)?;
let (x1, y1) = transformer.transform(src_bounds.minx, src_bounds.miny)?;
let (x2, y2) = transformer.transform(src_bounds.maxx, src_bounds.miny)?;
let (x3, y3) = transformer.transform(src_bounds.maxx, src_bounds.maxy)?;
let (x4, y4) = transformer.transform(src_bounds.minx, src_bounds.maxy)?;
let mid_x = f64::midpoint(src_bounds.minx, src_bounds.maxx);
let mid_y = f64::midpoint(src_bounds.miny, src_bounds.maxy);
let (x5, y5) = transformer.transform(mid_x, src_bounds.miny)?;
let (x6, y6) = transformer.transform(mid_x, src_bounds.maxy)?;
let (x7, y7) = transformer.transform(src_bounds.minx, mid_y)?;
let (x8, y8) = transformer.transform(src_bounds.maxx, mid_y)?;
let min_x = x1.min(x2).min(x3).min(x4).min(x5).min(x6).min(x7).min(x8);
let max_x = x1.max(x2).max(x3).max(x4).max(x5).max(x6).max(x7).max(x8);
let min_y = y1.min(y2).min(y3).min(y4).min(y5).min(y6).min(y7).min(y8);
let max_y = y1.max(y2).max(y3).max(y4).max(y5).max(y6).max(y7).max(y8);
Ok(BoundingBox::new(min_x, min_y, max_x, max_y))
}
fn compute_output_resolution(
metadata: &crate::cog_reader::CogMetadata,
source_crs: u32,
target_crs: u32,
output_bounds: &BoundingBox,
) -> Result<(usize, usize, f64, f64), Box<dyn std::error::Error + Send + Sync>> {
let geo_transform = &metadata.geo_transform;
let (Some(scale), Some(_)) = (geo_transform.pixel_scale, geo_transform.tiepoint) else {
return Err("Missing geotransform".into());
};
#[allow(clippy::cast_precision_loss)]
let src_pixel_area = scale[0].abs() * scale[1].abs();
#[allow(clippy::cast_possible_wrap)]
let src_pixel_area_m2 = if is_geographic_crs(source_crs as i32) {
src_pixel_area * 111_000.0 * 111_000.0
} else {
src_pixel_area
};
#[allow(clippy::cast_possible_wrap)]
let target_pixel_size = if is_geographic_crs(target_crs as i32) {
(src_pixel_area_m2.sqrt() / 111_000.0).max(0.0001)
} else {
src_pixel_area_m2.sqrt().max(1.0)
};
let x_res = target_pixel_size;
let y_res = target_pixel_size;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let width = ((output_bounds.maxx - output_bounds.minx) / x_res).ceil() as usize;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let height = ((output_bounds.maxy - output_bounds.miny) / y_res).ceil() as usize;
let width = width.clamp(1, 16384);
let height = height.clamp(1, 16384);
#[allow(clippy::cast_precision_loss)]
let x_res = (output_bounds.maxx - output_bounds.minx) / width as f64;
#[allow(clippy::cast_precision_loss)]
let y_res = (output_bounds.maxy - output_bounds.miny) / height as f64;
Ok((width, height, x_res, y_res))
}
pub(crate) fn extract_tile_with_extent_resampled_sync(
reader: &CogReader,
extent: &BoundingBox,
output_crs: u32,
tile_size: (usize, usize),
resampling: ResamplingMethod,
) -> Result<TileData, Box<dyn std::error::Error + Send + Sync>> {
let metadata = &reader.metadata;
let geo_transform = &metadata.geo_transform;
let (Some(base_scale), Some(_tiepoint)) = (geo_transform.pixel_scale, geo_transform.tiepoint) else {
return Err("Missing geotransform".into());
};
#[allow(clippy::cast_possible_wrap)]
let default_epsg = EPSG_WEB_MERCATOR as i32;
let source_epsg = u32::try_from(metadata.crs_code.unwrap_or(default_epsg))
.map_err(|e| format!("Invalid CRS code: {e}"))?;
let strategy = TransformStrategy::new(output_crs, source_epsg)?;
let (src_min_x, src_min_y) = strategy.transform(extent.minx, extent.miny)?;
let (src_max_x, src_max_y) = strategy.transform(extent.maxx, extent.maxy)?;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let extent_src_width = ((src_max_x - src_min_x) / base_scale[0]).abs().max(1.0) as usize;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let extent_src_height = ((src_max_y - src_min_y) / base_scale[1]).abs().max(1.0) as usize;
let overview_idx = reader.best_overview_for_resolution(extent_src_width, extent_src_height);
extract_tile_with_overview(reader, extent, tile_size, overview_idx, strategy, resampling, None)
}
pub(crate) fn extract_tile_with_bands_sync(
reader: &CogReader,
extent: &BoundingBox,
output_crs: u32,
tile_size: (usize, usize),
resampling: ResamplingMethod,
bands: &[usize],
) -> Result<TileData, Box<dyn std::error::Error + Send + Sync>> {
let metadata = &reader.metadata;
let geo_transform = &metadata.geo_transform;
for &band in bands {
if band >= metadata.bands {
return Err(format!("Band index {} out of range (COG has {} bands)", band, metadata.bands).into());
}
}
if bands.is_empty() {
return Err("At least one band must be selected".into());
}
let (Some(base_scale), Some(_tiepoint)) = (geo_transform.pixel_scale, geo_transform.tiepoint) else {
return Err("Missing geotransform".into());
};
let source_epsg = u32::try_from(metadata.crs_code.unwrap_or(3857))
.map_err(|e| format!("Invalid CRS code: {e}"))?;
let strategy = TransformStrategy::new(output_crs, source_epsg)?;
let (src_min_x, src_min_y) = strategy.transform(extent.minx, extent.miny)?;
let (src_max_x, src_max_y) = strategy.transform(extent.maxx, extent.maxy)?;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let extent_src_width = ((src_max_x - src_min_x) / base_scale[0]).abs().max(1.0) as usize;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let extent_src_height = ((src_max_y - src_min_y) / base_scale[1]).abs().max(1.0) as usize;
let overview_idx = reader.best_overview_for_resolution(extent_src_width, extent_src_height);
extract_tile_with_overview(reader, extent, tile_size, overview_idx, strategy, resampling, Some(bands))
}
#[allow(clippy::too_many_lines)]
fn extract_tile_with_overview(
reader: &CogReader,
extent_3857: &BoundingBox,
tile_size: (usize, usize),
overview_idx: Option<usize>,
strategy: TransformStrategy,
resampling: ResamplingMethod,
selected_bands: Option<&[usize]>,
) -> Result<TileData, Box<dyn std::error::Error + Send + Sync>> {
let (tile_size_x, tile_size_y) = tile_size;
let metadata = &reader.metadata;
let geo_transform = &metadata.geo_transform;
let (Some(base_scale), Some(tiepoint)) = (geo_transform.pixel_scale, geo_transform.tiepoint) else {
return Err("Missing geotransform".into());
};
let (eff_width, eff_height, eff_tile_width, eff_tile_height, eff_tiles_across, scale_factor) = if let Some(ovr_idx) = overview_idx {
let ovr = &reader.overviews[ovr_idx];
#[allow(clippy::cast_precision_loss)]
let ovr_scale = ovr.scale as f64;
(ovr.width, ovr.height, ovr.tile_width, ovr.tile_height, ovr.tiles_across, ovr_scale)
} else {
(metadata.width, metadata.height, metadata.tile_width, metadata.tile_height, metadata.tiles_across, 1.0)
};
let scale = [base_scale[0] * scale_factor, base_scale[1] * scale_factor, base_scale[2]];
#[allow(clippy::cast_precision_loss)]
let out_res_x = (extent_3857.maxx - extent_3857.minx) / (tile_size_x as f64);
#[allow(clippy::cast_precision_loss)]
let out_res_y = (extent_3857.maxy - extent_3857.miny) / (tile_size_y as f64);
let tile_width_shift = if eff_tile_width.is_power_of_two() {
Some(eff_tile_width.trailing_zeros() as usize)
} else {
None
};
let tile_height_shift = if eff_tile_height.is_power_of_two() {
Some(eff_tile_height.trailing_zeros() as usize)
} else {
None
};
let tile_width_mask = eff_tile_width - 1;
let tile_height_mask = eff_tile_height - 1;
let mut needed_tiles: HashSet<usize> = HashSet::new();
let tile_index_at_level = |px: usize, py: usize| -> Option<usize> {
if px >= eff_width || py >= eff_height {
return None;
}
let tile_col = px / eff_tile_width;
let tile_row = py / eff_tile_height;
Some(tile_row * eff_tiles_across + tile_col)
};
let mut min_col: Option<usize> = None;
let mut max_col: Option<usize> = None;
let mut min_row: Option<usize> = None;
let mut max_row: Option<usize> = None;
let sample_points = [
(0, 0), (tile_size_x - 1, 0), (0, tile_size_y - 1), (tile_size_x - 1, tile_size_y - 1),
(tile_size_x / 2, 0), (tile_size_x / 2, tile_size_y - 1),
(0, tile_size_y / 2), (tile_size_x - 1, tile_size_y / 2),
(tile_size_x / 2, tile_size_y / 2),
];
for &(out_x, out_y) in &sample_points {
#[allow(clippy::cast_precision_loss)]
let merc_x = extent_3857.minx + (out_x as f64 + 0.5) * out_res_x;
#[allow(clippy::cast_precision_loss)]
let merc_y = extent_3857.maxy - (out_y as f64 + 0.5) * out_res_y;
let (world_x, world_y) = strategy.transform(merc_x, merc_y)?;
let src_pixel_x = tiepoint[0] + (world_x - tiepoint[3]) / scale[0];
let src_pixel_y = tiepoint[1] + (tiepoint[4] - world_y) / scale[1];
#[allow(clippy::cast_precision_loss)]
let clamped_x = src_pixel_x.clamp(0.0, eff_width as f64 - 1.0);
#[allow(clippy::cast_precision_loss)]
let clamped_y = src_pixel_y.clamp(0.0, eff_height as f64 - 1.0);
#[allow(clippy::cast_precision_loss)]
let width_check = src_pixel_x >= -1.0 && src_pixel_x < (eff_width + 1) as f64;
#[allow(clippy::cast_precision_loss)]
let height_check = src_pixel_y >= -1.0 && src_pixel_y < (eff_height + 1) as f64;
if width_check && height_check {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let tile_col = (clamped_x as usize) / eff_tile_width;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let tile_row = (clamped_y as usize) / eff_tile_height;
min_col = Some(min_col.map_or(tile_col, |m| m.min(tile_col)));
max_col = Some(max_col.map_or(tile_col, |m| m.max(tile_col)));
min_row = Some(min_row.map_or(tile_row, |m| m.min(tile_row)));
max_row = Some(max_row.map_or(tile_row, |m| m.max(tile_row)));
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
if let Some(idx) = tile_index_at_level(clamped_x as usize, clamped_y as usize) {
needed_tiles.insert(idx);
}
}
}
let max_tile_count = if let Some(idx) = overview_idx {
reader.overviews[idx].tile_offsets.len()
} else {
metadata.tile_offsets.len()
};
if let (Some(min_c), Some(max_c), Some(min_r), Some(max_r)) = (min_col, max_col, min_row, max_row) {
for row in min_r..=max_r {
for col in min_c..=max_c {
let idx = row * eff_tiles_across + col;
if idx < max_tile_count {
needed_tiles.insert(idx);
}
}
}
}
if needed_tiles.is_empty() {
let output_bands: Vec<usize> = selected_bands.map_or_else(|| (0..metadata.bands).collect(), <[usize]>::to_vec);
let num_output_bands = output_bands.len();
return Ok(TileData {
pixels: vec![0.0; tile_size_x * tile_size_y * num_output_bands],
bands: num_output_bands,
width: tile_size_x,
height: tile_size_y,
bytes_fetched: 0,
tiles_read: 0,
overview_used: overview_idx,
});
}
let mut tile_data_cache: AHashMap<usize, Vec<f32>> = AHashMap::new();
let mut total_bytes_fetched: usize = 0;
let mut tiles_actually_read: usize = 0;
for &tile_idx in &needed_tiles {
let tile_result = if let Some(ovr_idx) = overview_idx {
reader.read_overview_tile_with_bytes(ovr_idx, tile_idx)
} else {
reader.read_tile_with_bytes(tile_idx)
};
if let Ok((data, bytes)) = tile_result {
tile_data_cache.insert(tile_idx, data);
total_bytes_fetched += bytes;
if bytes > 0 {
tiles_actually_read += 1;
}
}
}
if tile_data_cache.is_empty() && overview_idx.is_some() {
return extract_tile_with_overview(reader, extent_3857, tile_size, None, strategy, resampling, selected_bands);
}
let source_bands = metadata.bands;
let output_bands: Vec<usize> = selected_bands.map_or_else(|| (0..source_bands).collect(), <[usize]>::to_vec);
let num_output_bands = output_bands.len();
let mut pixel_data = vec![0.0_f32; tile_size_x * tile_size_y * num_output_bands];
let inv_scale_x = 1.0 / scale[0];
let inv_scale_y = 1.0 / scale[1];
let sample_pixel = |px: usize, py: usize, source_band: usize| -> Option<f32> {
let (tile_col, local_x) = if let Some(shift) = tile_width_shift {
(px >> shift, px & tile_width_mask)
} else {
(px / eff_tile_width, px % eff_tile_width)
};
let (tile_row, local_y) = if let Some(shift) = tile_height_shift {
(py >> shift, py & tile_height_mask)
} else {
(py / eff_tile_height, py % eff_tile_height)
};
let tile_idx = tile_row * eff_tiles_across + tile_col;
let tile_data = tile_data_cache.get(&tile_idx)?;
let pixel_idx = (local_y * eff_tile_width + local_x) * source_bands + source_band;
tile_data.get(pixel_idx).copied()
};
let (src_px_base, src_px_delta, use_precomputed_x) = match &strategy {
TransformStrategy::FastMerc2Geo => {
let lon_base = (extent_3857.minx + 0.5 * out_res_x) * 180.0 / HALF_EARTH;
let base = tiepoint[0] + (lon_base - tiepoint[3]) * inv_scale_x;
let lon_delta = out_res_x * 180.0 / HALF_EARTH;
let delta = lon_delta * inv_scale_x;
(base, delta, true)
}
TransformStrategy::Identity => {
let merc_x_base = extent_3857.minx + 0.5 * out_res_x;
let base = tiepoint[0] + (merc_x_base - tiepoint[3]) * inv_scale_x;
let delta = out_res_x * inv_scale_x;
(base, delta, true)
}
TransformStrategy::Proj4rs(_) => (0.0, 0.0, false), };
let src_pixel_y_coords: Vec<Option<(f64, f64)>> = (0..tile_size_y)
.map(|out_y| {
#[allow(clippy::cast_precision_loss)]
let merc_y = extent_3857.maxy - (out_y as f64 + 0.5) * out_res_y;
let (_, world_y) = strategy.transform(extent_3857.minx, merc_y).ok()?;
let src_pixel_y = tiepoint[1] + (tiepoint[4] - world_y) * inv_scale_y;
#[allow(clippy::cast_precision_loss)]
if src_pixel_y < -0.5 || src_pixel_y > eff_height as f64 + 0.5 {
None
} else {
Some((src_pixel_y, merc_y))
}
})
.collect();
#[allow(clippy::needless_range_loop)]
for out_y in 0..tile_size_y {
let Some((src_pixel_y, merc_y)) = src_pixel_y_coords[out_y] else {
continue; };
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
let src_pixel_y_nearest = src_pixel_y.round().max(0.0).min(eff_height as f64 - 1.0) as usize;
#[allow(clippy::cast_possible_truncation)]
let y0_floor = src_pixel_y.floor() as isize;
for out_x in 0..tile_size_x {
#[allow(clippy::cast_precision_loss)]
let src_pixel_x = if use_precomputed_x {
src_px_base + (out_x as f64) * src_px_delta
} else {
#[allow(clippy::cast_precision_loss)]
let merc_x = extent_3857.minx + (out_x as f64 + 0.5) * out_res_x;
let Ok((world_x, _)) = strategy.transform(merc_x, merc_y) else {
continue;
};
tiepoint[0] + (world_x - tiepoint[3]) * inv_scale_x
};
#[allow(clippy::cast_precision_loss)]
if src_pixel_x < -0.5 || src_pixel_x > eff_width as f64 + 0.5 {
continue;
}
let out_idx = (out_y * tile_size_x + out_x) * num_output_bands;
match resampling {
ResamplingMethod::Nearest => {
#[allow(clippy::cast_possible_truncation)]
let src_px_int = src_pixel_x.round() as isize;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
let src_px_clamped = src_px_int.max(0).min(eff_width as isize - 1) as usize;
for (out_band_idx, &source_band) in output_bands.iter().enumerate() {
if let Some(value) = sample_pixel(src_px_clamped, src_pixel_y_nearest, source_band) {
pixel_data[out_idx + out_band_idx] = value;
}
}
}
ResamplingMethod::Bilinear => {
#[allow(clippy::cast_possible_truncation)]
let x0 = src_pixel_x.floor() as isize;
let x1 = x0 + 1;
let y1 = y0_floor + 1;
#[allow(clippy::cast_precision_loss)]
let fx = src_pixel_x - x0 as f64;
#[allow(clippy::cast_precision_loss)]
let fy = src_pixel_y - y0_floor as f64;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
let x0c = x0.max(0).min(eff_width as isize - 1) as usize;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
let x1c = x1.max(0).min(eff_width as isize - 1) as usize;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
let y0c = y0_floor.max(0).min(eff_height as isize - 1) as usize;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
let y1c = y1.max(0).min(eff_height as isize - 1) as usize;
for (out_band_idx, &source_band) in output_bands.iter().enumerate() {
let v00 = sample_pixel(x0c, y0c, source_band).unwrap_or(0.0);
let v10 = sample_pixel(x1c, y0c, source_band).unwrap_or(0.0);
let v01 = sample_pixel(x0c, y1c, source_band).unwrap_or(0.0);
let v11 = sample_pixel(x1c, y1c, source_band).unwrap_or(0.0);
#[allow(clippy::cast_possible_truncation)]
let weight_x = fx as f32;
#[allow(clippy::cast_possible_truncation)]
let weight_y = fy as f32;
let value = v00 * (1.0 - weight_x) * (1.0 - weight_y)
+ v10 * weight_x * (1.0 - weight_y)
+ v01 * (1.0 - weight_x) * weight_y
+ v11 * weight_x * weight_y;
pixel_data[out_idx + out_band_idx] = value;
}
}
ResamplingMethod::Bicubic => {
#[allow(clippy::cast_possible_truncation)]
let x0 = src_pixel_x.floor() as isize;
#[allow(clippy::cast_precision_loss)]
let fx = src_pixel_x - x0 as f64;
#[allow(clippy::cast_precision_loss)]
let fy = src_pixel_y - y0_floor as f64;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
let y_weights: [(usize, f64); 4] = [
((y0_floor - 1).max(0).min(eff_height as isize - 1) as usize, bicubic_weight(-1.0 - fy)),
((y0_floor).max(0).min(eff_height as isize - 1) as usize, bicubic_weight(-fy)),
((y0_floor + 1).max(0).min(eff_height as isize - 1) as usize, bicubic_weight(1.0 - fy)),
((y0_floor + 2).max(0).min(eff_height as isize - 1) as usize, bicubic_weight(2.0 - fy)),
];
for (out_band_idx, &source_band) in output_bands.iter().enumerate() {
let mut sum = 0.0f32;
let mut weight_sum = 0.0f32;
for i in -1..=2isize {
#[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
let px = (x0 + i).max(0).min(eff_width as isize - 1) as usize;
#[allow(clippy::cast_precision_loss)]
let wx = bicubic_weight(i as f64 - fx);
for &(py, wy) in &y_weights {
if let Some(v) = sample_pixel(px, py, source_band) {
#[allow(clippy::cast_possible_truncation)]
let w = (wx * wy) as f32;
sum += v * w;
weight_sum += w;
}
}
}
pixel_data[out_idx + out_band_idx] = if weight_sum > 0.0 { sum / weight_sum } else { 0.0 };
}
}
}
}
}
let _ = sample_pixel;
Ok(TileData {
pixels: pixel_data,
bands: num_output_bands,
width: tile_size_x,
height: tile_size_y,
bytes_fetched: total_bytes_fetched,
tiles_read: tiles_actually_read,
overview_used: overview_idx,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bbox_from_xyz() {
let bbox = BoundingBox::from_xyz(0, 0, 0);
assert!((bbox.minx - (-20037508.342789244)).abs() < 1.0);
assert!((bbox.maxx - 20037508.342789244).abs() < 1.0);
let bbox_1_0_0 = BoundingBox::from_xyz(1, 0, 0);
let bbox_1_1_0 = BoundingBox::from_xyz(1, 1, 0);
assert!((bbox_1_0_0.maxx - bbox_1_1_0.minx).abs() < 1.0);
}
#[test]
fn test_epsg_proj_strings() {
assert!(get_proj_string(4326).is_some());
assert!(get_proj_string(3857).is_some());
assert!(get_proj_string(99999).is_none());
}
#[test]
fn test_coord_transformer_identity() {
let source_epsg: u32 = 3857;
let use_identity = source_epsg == 3857;
assert!(use_identity);
}
#[test]
fn test_coord_transformer_new() {
let transformer = CoordTransformer::new(4326, 3857).unwrap();
assert_eq!(transformer.source_epsg(), 4326);
assert_eq!(transformer.target_epsg(), 3857);
assert!(transformer.source_is_geographic());
assert!(!transformer.target_is_geographic());
}
#[test]
fn test_coord_transformer_from_lonlat_to() {
let transformer = CoordTransformer::from_lonlat_to(3857).unwrap();
assert_eq!(transformer.source_epsg(), 4326);
assert_eq!(transformer.target_epsg(), 3857);
let (x, y) = transformer.transform(0.0, 0.0).unwrap();
assert!(x.abs() < 1.0);
assert!(y.abs() < 1.0);
}
#[test]
fn test_coord_transformer_to_lonlat_from() {
let transformer = CoordTransformer::to_lonlat_from(3857).unwrap();
assert_eq!(transformer.source_epsg(), 3857);
assert_eq!(transformer.target_epsg(), 4326);
let (lon, lat) = transformer.transform(0.0, 0.0).unwrap();
assert!(lon.abs() < 0.0001);
assert!(lat.abs() < 0.0001);
}
#[test]
fn test_coord_transformer_roundtrip() {
let to_utm = CoordTransformer::new(4326, 32633).unwrap(); let from_utm = CoordTransformer::new(32633, 4326).unwrap();
let lon = 15.0;
let lat = 52.0;
let (x, y) = to_utm.transform(lon, lat).unwrap();
let (lon2, lat2) = from_utm.transform(x, y).unwrap();
assert!((lon - lon2).abs() < 1e-5, "lon roundtrip: {} -> {}", lon, lon2);
assert!((lat - lat2).abs() < 1e-5, "lat roundtrip: {} -> {}", lat, lat2);
}
#[test]
fn test_coord_transformer_batch() {
let transformer = CoordTransformer::new(4326, 3857).unwrap();
let points = vec![
(0.0, 0.0),
(10.0, 51.5),
(-122.4, 37.8),
];
let results = transformer.transform_batch(&points);
assert_eq!(results.len(), 3);
assert!(results[0].is_ok());
assert!(results[1].is_ok());
assert!(results[2].is_ok());
let (x, y) = results[0].as_ref().unwrap();
assert!(x.abs() < 1.0);
assert!(y.abs() < 1.0);
}
#[test]
fn test_coord_transformer_unsupported_epsg() {
let result = CoordTransformer::new(4326, 999999);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not supported"));
}
#[test]
fn test_tile_extractor_defaults() {
let bbox = BoundingBox::from_xyz(5, 10, 12);
assert!(bbox.minx < bbox.maxx);
assert!(bbox.miny < bbox.maxy);
}
#[test]
fn test_tile_extractor_xyz_bounds() {
let bbox = BoundingBox::from_xyz(0, 0, 0);
let expected_extent = 20037508.342789244;
assert!((bbox.minx - (-expected_extent)).abs() < 1.0);
assert!((bbox.maxx - expected_extent).abs() < 1.0);
}
#[test]
fn test_bounding_box_new() {
let bbox = BoundingBox::new(-10.0, -20.0, 30.0, 40.0);
assert_eq!(bbox.minx, -10.0);
assert_eq!(bbox.miny, -20.0);
assert_eq!(bbox.maxx, 30.0);
assert_eq!(bbox.maxy, 40.0);
}
#[test]
fn test_resampling_method_default() {
assert_eq!(ResamplingMethod::default(), ResamplingMethod::Nearest);
}
#[test]
fn test_bicubic_weight() {
let w0 = bicubic_weight(0.0);
assert!(w0 > 0.8, "Weight at 0 should be near 0.889: {}", w0);
let w1 = bicubic_weight(1.0);
assert!(w1.abs() < w0, "Weight at 1 should be smaller than at 0: {}", w1);
let w2 = bicubic_weight(2.0);
assert!(w2.abs() < 0.001, "Weight at 2 should be ~0: {}", w2);
let w3 = bicubic_weight(3.0);
assert_eq!(w3, 0.0, "Weight at 3 should be 0");
assert!((bicubic_weight(0.5) - bicubic_weight(-0.5)).abs() < 0.0001);
}
#[test]
fn test_band_selection_validation() {
let selected_bands = [0, 2];
assert_eq!(selected_bands.len(), 2);
assert_eq!(selected_bands[0], 0);
assert_eq!(selected_bands[1], 2);
}
}
#[cfg(test)]
mod debug_tests {
use super::*;
#[test]
fn test_proj4rs_transform_output() {
let transformer = CoordTransformer::from_3857_to(4326).unwrap();
let test_points = [
(-20037508.342789244, 0.0, "Far West"),
(-10018754.0, 0.0, "West"),
(0.0, 0.0, "Center"),
(10018754.0, 0.0, "East"),
(20037508.342789244, 0.0, "Far East"),
];
for (x, y, name) in test_points {
let (lon, lat) = transformer.transform(x, y).unwrap();
println!("{}: merc({:.0}, {:.0}) -> lon={:.6}, lat={:.6}", name, x, y, lon, lat);
}
}
}
#[cfg(test)]
mod global_cog_tests {
use super::*;
use std::sync::Arc;
use crate::cog_reader::CogReader;
use crate::range_reader::LocalRangeReader;
const TEST_COG_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/copernicus_dem_san_francisco.tif");
#[allow(dead_code)]
mod reference {
pub const TIEPOINT_LON: f64 = -123.0;
pub const TIEPOINT_LAT: f64 = 38.0;
pub const PIXEL_SIZE: f64 = 0.000277777777778;
pub const WIDTH: usize = 3600;
pub const HEIGHT: usize = 3600;
pub const MIN_ELEVATION: f32 = -79.079;
pub const MAX_ELEVATION: f32 = 1006.731;
}
fn get_test_cog() -> Option<CogReader> {
if !std::path::Path::new(TEST_COG_PATH).exists() {
println!("Skipping: test file not found at {}", TEST_COG_PATH);
return None;
}
let reader = LocalRangeReader::new(TEST_COG_PATH).ok()?;
CogReader::from_reader(Arc::new(reader)).ok()
}
#[test]
fn test_cog_metadata_matches_gdal() {
let Some(cog) = get_test_cog() else { return };
assert_eq!(cog.metadata.width, reference::WIDTH);
assert_eq!(cog.metadata.height, reference::HEIGHT);
assert_eq!(cog.metadata.crs_code, Some(4326));
assert_eq!(cog.metadata.bands, 1);
if let Some(scale) = &cog.metadata.geo_transform.pixel_scale {
assert!((scale[0] - reference::PIXEL_SIZE).abs() < 1e-12,
"X pixel scale mismatch: got {}, expected {}", scale[0], reference::PIXEL_SIZE);
assert!((scale[1] - reference::PIXEL_SIZE).abs() < 1e-12,
"Y pixel scale mismatch: got {}, expected {}", scale[1], reference::PIXEL_SIZE);
}
if let Some(tiepoint) = &cog.metadata.geo_transform.tiepoint {
assert!((tiepoint[3] - reference::TIEPOINT_LON).abs() < 1e-10,
"Tiepoint X mismatch: got {}, expected {}", tiepoint[3], reference::TIEPOINT_LON);
assert!((tiepoint[4] - reference::TIEPOINT_LAT).abs() < 1e-10,
"Tiepoint Y mismatch: got {}, expected {}", tiepoint[4], reference::TIEPOINT_LAT);
}
assert!(cog.metadata.geo_transform.is_point_registered,
"Should detect PixelIsPoint from GTRasterTypeGeoKey");
}
#[tokio::test]
async fn test_tile_extraction_full_bounds() {
let Some(cog) = get_test_cog() else { return };
let tile = TileExtractor::new(&cog)
.bounds(BoundingBox::new(-123.0, 37.0, -122.0, 38.0))
.output_crs(4326)
.size(256)
.extract()
.await
.unwrap();
let valid = tile.pixels.iter().filter(|&&v| !v.is_nan()).count();
assert_eq!(valid, tile.pixels.len(), "All pixels should be valid");
let min_elev = tile.pixels.iter().cloned().fold(f32::MAX, f32::min);
let max_elev = tile.pixels.iter().cloned().fold(f32::MIN, f32::max);
assert!(min_elev >= reference::MIN_ELEVATION * 1.5,
"Min elevation {} too low (expected >= {})", min_elev, reference::MIN_ELEVATION * 1.5);
assert!(max_elev <= reference::MAX_ELEVATION * 1.1,
"Max elevation {} too high (expected <= {})", max_elev, reference::MAX_ELEVATION * 1.1);
}
#[tokio::test]
async fn test_output_crs_extraction() {
let Some(cog) = get_test_cog() else { return };
let tile_4326 = TileExtractor::new(&cog)
.bounds(BoundingBox::new(-123.0, 37.0, -122.0, 38.0))
.output_crs(4326)
.size(256)
.extract()
.await
.unwrap();
assert_eq!(tile_4326.width, 256);
assert_eq!(tile_4326.height, 256);
println!("WGS84 extraction: {}x{} with {} bands", tile_4326.width, tile_4326.height, tile_4326.bands);
let tile_3857 = TileExtractor::new(&cog)
.bounds(BoundingBox::new(-13692297.0, 4439106.0, -13580977.0, 4579447.0))
.output_crs(3857)
.size(256)
.extract()
.await
.unwrap();
assert_eq!(tile_3857.width, 256);
assert_eq!(tile_3857.height, 256);
println!("Web Mercator extraction: {}x{} with {} bands", tile_3857.width, tile_3857.height, tile_3857.bands);
let has_valid_4326 = tile_4326.pixels.iter().any(|&v| v != 0.0 && !v.is_nan());
let has_valid_3857 = tile_3857.pixels.iter().any(|&v| v != 0.0 && !v.is_nan());
assert!(has_valid_4326, "WGS84 tile should have valid pixel data");
assert!(has_valid_3857, "Web Mercator tile should have valid pixel data");
println!("Output CRS test passed: extraction works in both 4326 and 3857");
}
#[tokio::test]
async fn test_output_crs_utm_zone_10n() {
let Some(cog) = get_test_cog() else { return };
let tile_utm10n = TileExtractor::new(&cog)
.bounds(BoundingBox::new(500000.0, 4096000.0, 590000.0, 4207000.0))
.output_crs(32610)
.size(256)
.extract()
.await
.unwrap();
assert_eq!(tile_utm10n.width, 256);
assert_eq!(tile_utm10n.height, 256);
let valid_count = tile_utm10n.pixels.iter().filter(|&&v| !v.is_nan()).count();
let total = tile_utm10n.pixels.len();
let valid_pct = valid_count as f64 / total as f64;
assert!(valid_pct > 0.5, "UTM 10N tile should have >50% valid pixels, got {:.1}%", valid_pct * 100.0);
println!("UTM Zone 10N: {}x{}, {:.1}% valid - OK", tile_utm10n.width, tile_utm10n.height, valid_pct * 100.0);
let min_elev = tile_utm10n.pixels.iter().cloned().filter(|v| !v.is_nan()).fold(f32::MAX, f32::min);
let max_elev = tile_utm10n.pixels.iter().cloned().filter(|v| !v.is_nan()).fold(f32::MIN, f32::max);
assert!(min_elev >= reference::MIN_ELEVATION * 2.0,
"Min elevation {} too low", min_elev);
assert!(max_elev <= reference::MAX_ELEVATION * 1.1,
"Max elevation {} too high", max_elev);
println!("Elevation range: {:.1}m to {:.1}m", min_elev, max_elev);
}
#[tokio::test]
async fn test_output_crs_web_mercator() {
let Some(cog) = get_test_cog() else { return };
let tile_3857 = TileExtractor::new(&cog)
.bounds(BoundingBox::new(-13692297.0, 4439106.0, -13580977.0, 4579447.0))
.output_crs(3857)
.size(256)
.extract()
.await
.unwrap();
assert_eq!(tile_3857.width, 256);
assert_eq!(tile_3857.height, 256);
let valid_count = tile_3857.pixels.iter().filter(|&&v| !v.is_nan()).count();
let total = tile_3857.pixels.len();
let valid_pct = valid_count as f64 / total as f64;
assert!(valid_pct > 0.9, "Web Mercator tile should have >90% valid pixels, got {:.1}%", valid_pct * 100.0);
println!("Web Mercator (EPSG:3857): {}x{}, {:.1}% valid - OK", tile_3857.width, tile_3857.height, valid_pct * 100.0);
let min_elev = tile_3857.pixels.iter().cloned().filter(|v| !v.is_nan()).fold(f32::MAX, f32::min);
let max_elev = tile_3857.pixels.iter().cloned().filter(|v| !v.is_nan()).fold(f32::MIN, f32::max);
assert!(min_elev >= reference::MIN_ELEVATION * 2.0,
"Min elevation {} too low", min_elev);
assert!(max_elev <= reference::MAX_ELEVATION * 1.1,
"Max elevation {} too high", max_elev);
println!("Elevation range: {:.1}m to {:.1}m", min_elev, max_elev);
}
#[tokio::test]
async fn test_crs_pixel_value_consistency() {
let Some(cog) = get_test_cog() else { return };
let tile_4326 = TileExtractor::new(&cog)
.bounds(BoundingBox::new(-123.0, 37.0, -122.0, 38.0))
.output_crs(4326)
.size(64)
.extract()
.await
.unwrap();
let tile_3857 = TileExtractor::new(&cog)
.bounds(BoundingBox::new(-13692297.0, 4439106.0, -13580977.0, 4579447.0))
.output_crs(3857)
.size(64)
.extract()
.await
.unwrap();
let tile_utm = TileExtractor::new(&cog)
.bounds(BoundingBox::new(500000.0, 4096000.0, 592000.0, 4207000.0))
.output_crs(32610)
.size(64)
.extract()
.await
.unwrap();
let valid_4326 = tile_4326.pixels.iter().filter(|&&v| v != 0.0 && !v.is_nan()).count();
let valid_3857 = tile_3857.pixels.iter().filter(|&&v| v != 0.0 && !v.is_nan()).count();
let valid_utm = tile_utm.pixels.iter().filter(|&&v| v != 0.0 && !v.is_nan()).count();
println!("Valid pixels - 4326: {}, 3857: {}, UTM: {}", valid_4326, valid_3857, valid_utm);
let mean_4326: f64 = tile_4326.pixels.iter().map(|&v| f64::from(v)).sum::<f64>()
/ tile_4326.pixels.len() as f64;
let mean_3857: f64 = tile_3857.pixels.iter().map(|&v| f64::from(v)).sum::<f64>()
/ tile_3857.pixels.len() as f64;
let mean_utm: f64 = tile_utm.pixels.iter().map(|&v| f64::from(v)).sum::<f64>()
/ tile_utm.pixels.len() as f64;
println!("Mean values - 4326: {:.2}, 3857: {:.2}, UTM: {:.2}", mean_4326, mean_3857, mean_utm);
let max_mean = mean_4326.max(mean_3857).max(mean_utm);
let min_mean = mean_4326.min(mean_3857).min(mean_utm);
if max_mean > 0.0 {
let ratio = max_mean / min_mean.max(0.001);
println!("Mean ratio (max/min): {:.2}", ratio);
assert!(ratio < 3.0, "Mean values should be roughly consistent across CRS");
}
println!("CRS pixel value consistency test passed!");
}
#[test]
fn test_transform_strategy_creation() {
let strategy = TransformStrategy::new(4326, 4326).unwrap();
assert!(matches!(strategy, TransformStrategy::Identity));
let strategy = TransformStrategy::new(3857, 3857).unwrap();
assert!(matches!(strategy, TransformStrategy::Identity));
let strategy = TransformStrategy::new(32610, 32610).unwrap();
assert!(matches!(strategy, TransformStrategy::Identity));
let strategy = TransformStrategy::new(3857, 4326).unwrap();
assert!(matches!(strategy, TransformStrategy::FastMerc2Geo));
let strategy = TransformStrategy::new(4326, 3857).unwrap();
assert!(matches!(strategy, TransformStrategy::Proj4rs(_)));
let strategy = TransformStrategy::new(4326, 32610).unwrap();
assert!(matches!(strategy, TransformStrategy::Proj4rs(_)));
let strategy = TransformStrategy::new(32610, 4326).unwrap();
assert!(matches!(strategy, TransformStrategy::Proj4rs(_)));
let strategy = TransformStrategy::new(32610, 32633).unwrap();
assert!(matches!(strategy, TransformStrategy::Proj4rs(_)));
println!("TransformStrategy creation test passed!");
}
#[test]
fn test_transform_strategy_accuracy() {
let to_merc = TransformStrategy::new(4326, 3857).unwrap();
let from_merc = TransformStrategy::new(3857, 4326).unwrap();
let orig_lon = -122.4;
let orig_lat = 37.8;
let (merc_x, merc_y) = to_merc.transform(orig_lon, orig_lat).unwrap();
let (back_lon, back_lat) = from_merc.transform(merc_x, merc_y).unwrap();
assert!((back_lon - orig_lon).abs() < 0.0001, "4326->3857->4326 lon roundtrip failed: {} vs {}", back_lon, orig_lon);
assert!((back_lat - orig_lat).abs() < 0.0001, "4326->3857->4326 lat roundtrip failed: {} vs {}", back_lat, orig_lat);
println!("4326 -> 3857 -> 4326 roundtrip: OK (merc: {:.1}, {:.1})", merc_x, merc_y);
let to_utm = TransformStrategy::new(4326, 32610).unwrap();
let from_utm = TransformStrategy::new(32610, 4326).unwrap();
let (utm_e, utm_n) = to_utm.transform(orig_lon, orig_lat).unwrap();
let (back_lon, back_lat) = from_utm.transform(utm_e, utm_n).unwrap();
assert!((back_lon - orig_lon).abs() < 0.0001, "4326->UTM->4326 lon roundtrip failed: {} vs {}", back_lon, orig_lon);
assert!((back_lat - orig_lat).abs() < 0.0001, "4326->UTM->4326 lat roundtrip failed: {} vs {}", back_lat, orig_lat);
println!("4326 -> UTM 10N -> 4326 roundtrip: OK (utm: {:.1}, {:.1})", utm_e, utm_n);
let merc_to_utm = TransformStrategy::new(3857, 32610).unwrap();
let utm_to_merc = TransformStrategy::new(32610, 3857).unwrap();
let test_merc_x = -13625000.0;
let test_merc_y = 4550000.0;
let (utm_e, utm_n) = merc_to_utm.transform(test_merc_x, test_merc_y).unwrap();
let (back_x, back_y) = utm_to_merc.transform(utm_e, utm_n).unwrap();
assert!((back_x - test_merc_x).abs() < 1.0, "3857->UTM->3857 x roundtrip failed: {} vs {}", back_x, test_merc_x);
assert!((back_y - test_merc_y).abs() < 1.0, "3857->UTM->3857 y roundtrip failed: {} vs {}", back_y, test_merc_y);
println!("3857 -> UTM 10N -> 3857 roundtrip: OK");
let utm10_to_utm33 = TransformStrategy::new(32610, 32633).unwrap();
let utm33_to_utm10 = TransformStrategy::new(32633, 32610).unwrap();
let test_e = 550000.0;
let test_n = 4180000.0;
let (e33, n33) = utm10_to_utm33.transform(test_e, test_n).unwrap();
let (back_e, back_n) = utm33_to_utm10.transform(e33, n33).unwrap();
assert!((back_e - test_e).abs() < 1.0, "UTM10->UTM33->UTM10 easting roundtrip failed: {} vs {}", back_e, test_e);
assert!((back_n - test_n).abs() < 1.0, "UTM10->UTM33->UTM10 northing roundtrip failed: {} vs {}", back_n, test_n);
println!("UTM 10N -> UTM 33N -> UTM 10N roundtrip: OK");
let to_bng = TransformStrategy::new(4326, 27700).unwrap();
let from_bng = TransformStrategy::new(27700, 4326).unwrap();
let london_lon = -0.1278;
let london_lat = 51.5074;
let (bng_e, bng_n) = to_bng.transform(london_lon, london_lat).unwrap();
let (back_lon, back_lat) = from_bng.transform(bng_e, bng_n).unwrap();
assert!((back_lon - london_lon).abs() < 0.0001, "4326->BNG->4326 lon roundtrip failed: {} vs {}", back_lon, london_lon);
assert!((back_lat - london_lat).abs() < 0.0001, "4326->BNG->4326 lat roundtrip failed: {} vs {}", back_lat, london_lat);
println!("4326 -> BNG (27700) -> 4326 roundtrip: OK (bng: {:.1}, {:.1})", bng_e, bng_n);
let to_laea = TransformStrategy::new(4326, 3035).unwrap();
let from_laea = TransformStrategy::new(3035, 4326).unwrap();
let berlin_lon = 13.405;
let berlin_lat = 52.52;
let (laea_x, laea_y) = to_laea.transform(berlin_lon, berlin_lat).unwrap();
let (back_lon, back_lat) = from_laea.transform(laea_x, laea_y).unwrap();
assert!((back_lon - berlin_lon).abs() < 0.0001, "4326->LAEA->4326 lon roundtrip failed: {} vs {}", back_lon, berlin_lon);
assert!((back_lat - berlin_lat).abs() < 0.0001, "4326->LAEA->4326 lat roundtrip failed: {} vs {}", back_lat, berlin_lat);
println!("4326 -> LAEA Europe (3035) -> 4326 roundtrip: OK (laea: {:.1}, {:.1})", laea_x, laea_y);
let strategy = TransformStrategy::new(3857, 4326).unwrap();
assert!(matches!(strategy, TransformStrategy::FastMerc2Geo), "Should use fast path for 3857->4326");
let proj_strategy = TransformStrategy::Proj4rs(Box::new(CoordTransformer::new(3857, 4326).unwrap()));
let (fast_lon, fast_lat) = strategy.transform(-13625000.0, 4550000.0).unwrap();
let (proj_lon, proj_lat) = proj_strategy.transform(-13625000.0, 4550000.0).unwrap();
assert!((fast_lon - proj_lon).abs() < 0.0001, "Fast path lon differs from proj4rs: {} vs {}", fast_lon, proj_lon);
assert!((fast_lat - proj_lat).abs() < 0.0001, "Fast path lat differs from proj4rs: {} vs {}", fast_lat, proj_lat);
println!("Fast path (3857->4326) matches proj4rs: OK");
println!("TransformStrategy accuracy test passed!");
}
#[test]
fn test_transform_bounds() {
let src_bounds = BoundingBox::new(-180.0, -85.0, 180.0, 85.0);
let dst_bounds = transform_bounds(src_bounds, 4326, 3857).unwrap();
assert!(dst_bounds.minx < -10000000.0, "Min X should be large negative in meters");
assert!(dst_bounds.maxx > 10000000.0, "Max X should be large positive in meters");
println!("4326 -> 3857: ({:.0}, {:.0}) to ({:.0}, {:.0})",
dst_bounds.minx, dst_bounds.miny, dst_bounds.maxx, dst_bounds.maxy);
let same_bounds = transform_bounds(src_bounds, 4326, 4326).unwrap();
assert!((same_bounds.minx - src_bounds.minx).abs() < 0.0001);
assert!((same_bounds.maxx - src_bounds.maxx).abs() < 0.0001);
let sf_bounds = BoundingBox::new(-123.0, 37.0, -122.0, 38.0);
let utm_bounds = transform_bounds(sf_bounds, 4326, 32610).unwrap();
assert!(utm_bounds.minx > 400000.0 && utm_bounds.maxx < 700000.0,
"UTM easting should be reasonable: {} to {}", utm_bounds.minx, utm_bounds.maxx);
assert!(utm_bounds.miny > 4000000.0 && utm_bounds.maxy < 4300000.0,
"UTM northing should be reasonable: {} to {}", utm_bounds.miny, utm_bounds.maxy);
println!("SF Bay (4326 -> UTM 10N): ({:.0}, {:.0}) to ({:.0}, {:.0})",
utm_bounds.minx, utm_bounds.miny, utm_bounds.maxx, utm_bounds.maxy);
}
#[test]
fn test_reprojected_raster_methods() {
let raster = ReprojectedRaster {
pixels: vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0],
bands: 1,
width: 3,
height: 3,
crs: 4326,
bounds: BoundingBox::new(0.0, 0.0, 3.0, 3.0),
resolution: (1.0, 1.0),
nodata: None,
};
assert_eq!(raster.get_pixel(0, 0, 0), Some(1.0));
assert_eq!(raster.get_pixel(2, 2, 0), Some(9.0));
assert_eq!(raster.get_pixel(1, 1, 0), Some(5.0));
assert_eq!(raster.get_pixel(3, 0, 0), None);
let (px, py) = raster.world_to_pixel(1.5, 1.5);
assert!((px - 1.5).abs() < 0.001);
assert!((py - 1.5).abs() < 0.001);
let (wx, wy) = raster.pixel_to_world(1.0, 1.0);
assert!((wx - 1.5).abs() < 0.001);
assert!((wy - 1.5).abs() < 0.001);
let gt = raster.geo_transform();
assert_eq!(gt[0], 0.0); assert_eq!(gt[1], 1.0); assert_eq!(gt[3], 3.0); assert_eq!(gt[5], -1.0);
println!("ReprojectedRaster methods test passed!");
}
#[test]
fn test_streaming_output_info() {
let info = StreamingOutputInfo {
width: 1000,
height: 800,
bands: 3,
crs: 32610,
bounds: BoundingBox::new(500000.0, 4000000.0, 510000.0, 4008000.0),
resolution: (10.0, 10.0),
chunk_size: 256,
chunks_x: 4, chunks_y: 4, nodata: None,
};
assert_eq!(info.chunk_count(), 16);
let (w, h) = info.chunk_dimensions(0, 0);
assert_eq!(w, 256);
assert_eq!(h, 256);
let (w, h) = info.chunk_dimensions(3, 3);
assert_eq!(w, 232); assert_eq!(h, 32);
let b = info.chunk_bounds(0, 0);
assert!((b.minx - 500000.0).abs() < 0.01);
assert!((b.maxy - 4008000.0).abs() < 0.01);
println!("StreamingOutputInfo test passed!");
}
const RGB_COG_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/natural_earth_rgb.tif");
fn get_rgb_cog() -> Option<CogReader> {
if !std::path::Path::new(RGB_COG_PATH).exists() {
println!("Skipping: RGB test file not found at {}", RGB_COG_PATH);
return None;
}
let reader = LocalRangeReader::new(RGB_COG_PATH).ok()?;
CogReader::from_reader(Arc::new(reader)).ok()
}
#[test]
fn test_rgb_cog_metadata_for_tiles() {
let Some(cog) = get_rgb_cog() else { return };
assert_eq!(cog.metadata.bands, 3, "Should have 3 bands");
assert_eq!(cog.metadata.crs_code, Some(4326), "Should be EPSG:4326");
if let Some(tiepoint) = &cog.metadata.geo_transform.tiepoint {
println!("RGB COG origin: ({}, {})", tiepoint[3], tiepoint[4]);
}
}
#[tokio::test]
async fn test_rgb_tile_extraction_basic() {
let Some(cog) = get_rgb_cog() else { return };
let tile = TileExtractor::new(&cog)
.xyz(2, 1, 1)
.output_size(256, 256)
.extract()
.await;
match tile {
Ok(t) => {
assert_eq!(t.width, 256);
assert_eq!(t.height, 256);
assert_eq!(t.bands, 3, "Should extract all 3 RGB bands");
assert_eq!(t.pixels.len(), 256 * 256 * 3,
"Should have width * height * bands pixels");
}
Err(e) => {
println!("Tile extraction returned error (may be out of bounds): {}", e);
}
}
}
#[tokio::test]
async fn test_rgb_tile_extraction_band_selection() {
let Some(cog) = get_rgb_cog() else { return };
let tile = TileExtractor::new(&cog)
.xyz(2, 1, 1)
.output_size(256, 256)
.bands(&[0, 2]) .extract()
.await;
match tile {
Ok(t) => {
assert_eq!(t.bands, 2, "Should have only 2 selected bands");
assert_eq!(t.pixels.len(), 256 * 256 * 2,
"Should have width * height * 2 bands pixels");
}
Err(e) => {
println!("Band selection extraction returned error: {}", e);
}
}
}
#[tokio::test]
async fn test_rgb_tile_extraction_single_band() {
let Some(cog) = get_rgb_cog() else { return };
let tile = TileExtractor::new(&cog)
.xyz(2, 1, 1)
.output_size(128, 128)
.bands(&[1]) .extract()
.await;
match tile {
Ok(t) => {
assert_eq!(t.bands, 1, "Should have only 1 band");
assert_eq!(t.pixels.len(), 128 * 128,
"Should have width * height pixels for single band");
}
Err(e) => {
println!("Single band extraction returned error: {}", e);
}
}
}
#[tokio::test]
async fn test_rgb_tile_extraction_bounds() {
let Some(cog) = get_rgb_cog() else { return };
let tile = TileExtractor::new(&cog)
.bounds(BoundingBox::new(-45.0, -45.0, 45.0, 45.0))
.output_crs(4326)
.output_size(256, 256)
.extract()
.await;
match tile {
Ok(t) => {
assert_eq!(t.width, 256);
assert_eq!(t.height, 256);
assert_eq!(t.bands, 3);
let valid_count = t.pixels.iter().filter(|&&v| !v.is_nan()).count();
assert!(valid_count > 0, "Should have some valid pixels");
println!("Bounds extraction: {}/{} valid pixels", valid_count, t.pixels.len());
}
Err(e) => {
panic!("Bounds extraction should succeed for center of world: {}", e);
}
}
}
#[tokio::test]
async fn test_rgb_tile_resampling_methods() {
let Some(cog) = get_rgb_cog() else { return };
let methods = [
ResamplingMethod::Nearest,
ResamplingMethod::Bilinear,
ResamplingMethod::Bicubic,
];
for method in methods {
let tile = TileExtractor::new(&cog)
.bounds(BoundingBox::new(-10.0, -10.0, 10.0, 10.0))
.output_crs(4326)
.output_size(64, 64)
.resampling(method)
.extract()
.await;
match tile {
Ok(t) => {
assert_eq!(t.width, 64);
assert_eq!(t.height, 64);
let valid = t.pixels.iter().filter(|&&v| !v.is_nan()).count();
println!("{:?} resampling: {}/{} valid pixels", method, valid, t.pixels.len());
}
Err(e) => {
println!("{:?} resampling error: {}", method, e);
}
}
}
}
}