use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::{Context, Result, anyhow};
use rayon::prelude::*;
use surtgis_core::io::{GeoTiffOptions, read_geotiff, write_geotiff};
use surtgis_core::{Raster, crs::CRS, raster::GeoTransform};
#[derive(Debug, Clone, Copy)]
enum Method {
Nearest,
Bilinear,
}
impl Method {
fn parse(s: &str) -> Result<Self> {
match s.to_ascii_lowercase().as_str() {
"nearest" | "nn" => Ok(Method::Nearest),
"bilinear" | "bl" => Ok(Method::Bilinear),
other => Err(anyhow!(
"unknown resampling method: '{}'. Supported: nearest, bilinear",
other
)),
}
}
}
fn parse_epsg(s: &str) -> Result<u32> {
let trimmed = s.trim();
let stripped = trimmed
.strip_prefix("EPSG:")
.or_else(|| trimmed.strip_prefix("epsg:"))
.unwrap_or(trimmed);
stripped
.parse::<u32>()
.with_context(|| format!("invalid EPSG code: '{}'. Expected e.g. EPSG:32719", s))
}
pub fn handle(
input: PathBuf,
output: PathBuf,
to: String,
from: Option<String>,
method: String,
pixel_size: Option<f64>,
compress: bool,
) -> Result<()> {
let dst_epsg = parse_epsg(&to)?;
let method = Method::parse(&method)?;
let raster: Raster<f64> = read_geotiff(&input, None)
.with_context(|| format!("failed to read input GeoTIFF: {}", input.display()))?;
let src_epsg = match from {
Some(f) => parse_epsg(&f)?,
None => raster.crs().and_then(|c| c.epsg()).ok_or_else(|| {
anyhow!("source CRS not embedded in input GeoTIFF; pass --from EPSG:XXXX to override")
})?,
};
let start = Instant::now();
if src_epsg == dst_epsg {
eprintln!(
"source and target CRS are the same (EPSG:{}); copying input to output",
src_epsg
);
write_output(&raster, &output, compress)?;
eprintln!(
"✓ wrote {} in {:.2} s",
output.display(),
start.elapsed().as_secs_f64()
);
return Ok(());
}
eprintln!(
"Reprojecting EPSG:{} → EPSG:{} ({}, {} method)",
src_epsg,
dst_epsg,
raster.shape().0,
match method {
Method::Nearest => "nearest",
Method::Bilinear => "bilinear",
}
);
let out = reproject(&raster, src_epsg, dst_epsg, method, pixel_size)?;
write_output(&out, &output, compress)?;
eprintln!(
"✓ wrote {} ({}×{}) in {:.2} s",
output.display(),
out.shape().0,
out.shape().1,
start.elapsed().as_secs_f64()
);
Ok(())
}
fn write_output(raster: &Raster<f64>, output: &Path, compress: bool) -> Result<()> {
let opts = if compress {
Some(GeoTiffOptions {
compression: "DEFLATE".into(),
..Default::default()
})
} else {
None
};
write_geotiff(raster, output, opts)
.with_context(|| format!("failed to write {}", output.display()))
}
fn reproject(
src: &Raster<f64>,
src_epsg: u32,
dst_epsg: u32,
method: Method,
pixel_size_override: Option<f64>,
) -> Result<Raster<f64>> {
use proj4rs::Proj;
let src_proj = Proj::from_epsg_code(src_epsg as u16)
.map_err(|e| anyhow!("proj4rs failed to load EPSG:{}: {:?}", src_epsg, e))?;
let dst_proj = Proj::from_epsg_code(dst_epsg as u16)
.map_err(|e| anyhow!("proj4rs failed to load EPSG:{}: {:?}", dst_epsg, e))?;
let src_gt = *src.transform();
let (src_rows, src_cols) = src.shape();
let mut samples: Vec<(f64, f64)> = Vec::new();
for &r in &[0usize, src_rows / 2, src_rows] {
for &c in &[0usize, src_cols / 2, src_cols] {
let x = src_gt.origin_x + c as f64 * src_gt.pixel_width;
let y = src_gt.origin_y + r as f64 * src_gt.pixel_height;
samples.push((x, y));
}
}
let mut min_x = f64::MAX;
let mut min_y = f64::MAX;
let mut max_x = f64::MIN;
let mut max_y = f64::MIN;
for &(x, y) in &samples {
let (in_x, in_y) = if src_proj.is_latlong() {
(x.to_radians(), y.to_radians())
} else {
(x, y)
};
let (tx, ty) = proj4rs::adaptors::transform_xy(&src_proj, &dst_proj, in_x, in_y)
.map_err(|e| anyhow!("proj4rs transform failed at ({}, {}): {:?}", x, y, e))?;
let (out_x, out_y) = if dst_proj.is_latlong() {
(tx.to_degrees(), ty.to_degrees())
} else {
(tx, ty)
};
min_x = min_x.min(out_x);
min_y = min_y.min(out_y);
max_x = max_x.max(out_x);
max_y = max_y.max(out_y);
}
let px = match pixel_size_override {
Some(p) => p,
None => infer_default_pixel_size(
&src_gt, &src_proj, &dst_proj, &samples, max_x, max_y, min_x, min_y,
)?,
};
let out_cols = ((max_x - min_x) / px).ceil() as usize;
let out_rows = ((max_y - min_y) / px).ceil() as usize;
if out_rows == 0 || out_cols == 0 {
return Err(anyhow!(
"output dimensions are zero (extent: {:.3}..{:.3}, {:.3}..{:.3}; pixel {}). Use --pixel-size",
min_x,
max_x,
min_y,
max_y,
px
));
}
if out_rows > 200_000 || out_cols > 200_000 {
return Err(anyhow!(
"output dimensions too large ({}x{}). Refine --pixel-size",
out_rows,
out_cols
));
}
let out_gt = GeoTransform::new(min_x, max_y, px, -px);
let mut out = Raster::<f64>::new(out_rows, out_cols);
out.set_transform(out_gt);
out.set_crs(Some(CRS::from_epsg(dst_epsg)));
if let Some(nd) = src.nodata() {
out.set_nodata(Some(nd));
}
let src_data = src.data();
let row_results: Vec<Vec<f64>> = (0..out_rows)
.into_par_iter()
.map(|out_r| {
let mut row = vec![f64::NAN; out_cols];
let dst_y = max_y - (out_r as f64 + 0.5) * px;
for out_c in 0..out_cols {
let dst_x = min_x + (out_c as f64 + 0.5) * px;
let (in_x, in_y) = if dst_proj.is_latlong() {
(dst_x.to_radians(), dst_y.to_radians())
} else {
(dst_x, dst_y)
};
let (sx, sy) =
match proj4rs::adaptors::transform_xy(&dst_proj, &src_proj, in_x, in_y) {
Ok(p) => p,
Err(_) => continue,
};
let (src_x, src_y) = if src_proj.is_latlong() {
(sx.to_degrees(), sy.to_degrees())
} else {
(sx, sy)
};
let src_c_f = (src_x - src_gt.origin_x) / src_gt.pixel_width - 0.5;
let src_r_f = (src_y - src_gt.origin_y) / src_gt.pixel_height - 0.5;
let val = match method {
Method::Nearest => {
sample_nearest(src_data, src_rows, src_cols, src_r_f, src_c_f)
}
Method::Bilinear => {
sample_bilinear(src_data, src_rows, src_cols, src_r_f, src_c_f)
}
};
if let Some(v) = val {
row[out_c] = v;
}
}
row
})
.collect();
for (out_r, row) in row_results.into_iter().enumerate() {
for (out_c, v) in row.into_iter().enumerate() {
let _ = out.set(out_r, out_c, v);
}
}
Ok(out)
}
fn sample_nearest(
data: &ndarray::Array2<f64>,
rows: usize,
cols: usize,
src_r_f: f64,
src_c_f: f64,
) -> Option<f64> {
let r = src_r_f.round();
let c = src_c_f.round();
if r < 0.0 || c < 0.0 || r >= rows as f64 || c >= cols as f64 {
return None;
}
let v = data[[r as usize, c as usize]];
if v.is_finite() { Some(v) } else { None }
}
fn sample_bilinear(
data: &ndarray::Array2<f64>,
rows: usize,
cols: usize,
src_r_f: f64,
src_c_f: f64,
) -> Option<f64> {
let c0 = src_c_f.floor() as isize;
let r0 = src_r_f.floor() as isize;
let fc = src_c_f - c0 as f64;
let fr = src_r_f - r0 as f64;
if c0 < 0 || r0 < 0 || (c0 + 1) >= cols as isize || (r0 + 1) >= rows as isize {
return None;
}
let r0u = r0 as usize;
let c0u = c0 as usize;
let v00 = data[[r0u, c0u]];
let v01 = data[[r0u, c0u + 1]];
let v10 = data[[r0u + 1, c0u]];
let v11 = data[[r0u + 1, c0u + 1]];
if !(v00.is_finite() && v01.is_finite() && v10.is_finite() && v11.is_finite()) {
return None;
}
Some(
v00 * (1.0 - fc) * (1.0 - fr)
+ v01 * fc * (1.0 - fr)
+ v10 * (1.0 - fc) * fr
+ v11 * fc * fr,
)
}
fn infer_default_pixel_size(
src_gt: &GeoTransform,
src_proj: &proj4rs::Proj,
dst_proj: &proj4rs::Proj,
samples: &[(f64, f64)],
max_x: f64,
max_y: f64,
min_x: f64,
min_y: f64,
) -> Result<f64> {
if src_proj.is_latlong() == dst_proj.is_latlong() {
return Ok(src_gt.pixel_width.abs());
}
let src_x_min = samples.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
let src_x_max = samples
.iter()
.map(|p| p.0)
.fold(f64::NEG_INFINITY, f64::max);
let src_width = src_x_max - src_x_min;
if src_width <= 0.0 {
return Err(anyhow!("could not infer pixel size; use --pixel-size"));
}
let dst_width = max_x - min_x;
let _ = (max_y, min_y);
Ok(dst_width / src_width * src_gt.pixel_width.abs())
}