Skip to main content

oxigeo_cli/util/
raster.rs

1//! Raster utilities for CLI operations
2
3use anyhow::{Context, Result};
4use oxigeo_core::{
5    buffer::RasterBuffer,
6    io::FileDataSource,
7    types::{GeoTransform, NoDataValue, RasterDataType},
8};
9use oxigeo_geotiff::{
10    CogWriter, CogWriterOptions, Compression, GeoTiffReader, GeoTiffWriter, GeoTiffWriterOptions,
11    WriterConfig,
12};
13use std::path::Path;
14
15/// Raster metadata extracted from a file
16#[derive(Debug, Clone)]
17pub struct RasterInfo {
18    /// Image width in pixels
19    pub width: u64,
20    /// Image height in pixels
21    pub height: u64,
22    /// Number of bands (samples per pixel)
23    pub bands: u32,
24    /// Data type of raster samples
25    pub data_type: RasterDataType,
26    /// Geographic transform (origin, pixel size, rotation)
27    pub geo_transform: Option<GeoTransform>,
28    /// EPSG CRS code, if any
29    pub epsg_code: Option<u32>,
30    /// NoData value, if any
31    pub no_data_value: Option<f64>,
32}
33
34/// Read raster metadata from a GeoTIFF file
35pub fn read_raster_info(path: &Path) -> Result<RasterInfo> {
36    let source = FileDataSource::open(path)
37        .with_context(|| format!("Failed to open file: {}", path.display()))?;
38
39    let reader = GeoTiffReader::open(source)
40        .with_context(|| format!("Failed to read GeoTIFF: {}", path.display()))?;
41
42    let width = reader.width();
43    let height = reader.height();
44    let bands = reader.band_count();
45    let data_type = reader
46        .data_type()
47        .ok_or_else(|| anyhow::anyhow!("Could not determine data type"))?;
48    let geo_transform = reader.geo_transform().copied();
49    let epsg_code = reader.epsg_code();
50    let nodata = reader.nodata();
51    let no_data_value = nodata.as_f64();
52
53    Ok(RasterInfo {
54        width,
55        height,
56        bands,
57        data_type,
58        geo_transform,
59        epsg_code,
60        no_data_value,
61    })
62}
63
64/// Read a single band from a GeoTIFF file at the primary level
65///
66/// `band_index` is zero-based. The returned [`RasterBuffer`] contains only the
67/// requested band's samples: `GeoTiffReader::read_band` de-interleaves chunky
68/// (`PlanarConfiguration = 1`) storage and selects out of planar (`= 2`)
69/// storage on our behalf.
70///
71/// This used to normalise the driver's output through an `extract_single_band`
72/// helper, because `read_band` ignored its band argument and returned the whole
73/// interleaved image. That is no longer so; see
74/// <https://github.com/cool-japan/oxigeo/issues/14>.
75pub fn read_band(path: &Path, band_index: u32) -> Result<RasterBuffer> {
76    let source = FileDataSource::open(path)
77        .with_context(|| format!("Failed to open file: {}", path.display()))?;
78
79    let reader = GeoTiffReader::open(source)
80        .with_context(|| format!("Failed to read GeoTIFF: {}", path.display()))?;
81
82    let width = reader.width();
83    let height = reader.height();
84    let data_type = reader
85        .data_type()
86        .ok_or_else(|| anyhow::anyhow!("Could not determine data type"))?;
87    let nodata = reader.nodata();
88    let samples_per_pixel = reader.band_count();
89
90    if band_index >= samples_per_pixel {
91        anyhow::bail!(
92            "Band index {} out of range (file has {} band(s))",
93            band_index,
94            samples_per_pixel
95        );
96    }
97
98    let data = reader
99        .read_band(0, band_index as usize)
100        .with_context(|| "Failed to read band data")?;
101
102    check_plane_len(
103        data.len(),
104        width,
105        height,
106        band_index,
107        data_type.size_bytes(),
108    )?;
109
110    RasterBuffer::new(data, width, height, data_type, nodata)
111        .with_context(|| "Failed to create RasterBuffer from band data")
112}
113
114/// Rejects a band plane that is not `width * height * bytes_per_sample` bytes.
115///
116/// `RasterBuffer::new` would reject it too, but with a message that does not
117/// mention the band; this turns a driver-side regression into a clear report.
118fn check_plane_len(
119    got: usize,
120    width: u64,
121    height: u64,
122    band_index: u32,
123    bytes_per_sample: usize,
124) -> Result<()> {
125    let expected = (width as usize)
126        .checked_mul(height as usize)
127        .and_then(|px| px.checked_mul(bytes_per_sample))
128        .ok_or_else(|| {
129            anyhow::anyhow!(
130                "Raster dimensions {}x{} ({} bytes/sample) overflow usize",
131                width,
132                height,
133                bytes_per_sample
134            )
135        })?;
136
137    if got != expected {
138        anyhow::bail!(
139            "Unexpected band {} data size: got {} bytes, expected {} ({}x{} x {} byte(s))",
140            band_index,
141            got,
142            expected,
143            width,
144            height,
145            bytes_per_sample
146        );
147    }
148    Ok(())
149}
150
151/// Read a region from a specific band of a GeoTIFF file
152///
153/// The region is clamped to the image extent. `GeoTiffReader::read_window`
154/// touches only the tiles or strips that overlap it and returns just this
155/// band's samples, so this no longer stitches tiles and de-interleaves by hand.
156/// The hand-rolled version could not read `PlanarConfiguration = 2` files
157/// correctly; see <https://github.com/cool-japan/oxigeo/issues/14>.
158pub fn read_band_region(
159    path: &Path,
160    band_index: u32,
161    x_offset: u64,
162    y_offset: u64,
163    width: u64,
164    height: u64,
165) -> Result<RasterBuffer> {
166    let source = FileDataSource::open(path)
167        .with_context(|| format!("Failed to open file: {}", path.display()))?;
168
169    let reader = GeoTiffReader::open(source)
170        .with_context(|| format!("Failed to read GeoTIFF: {}", path.display()))?;
171
172    // Validate region bounds
173    let img_width = reader.width();
174    let img_height = reader.height();
175
176    if x_offset >= img_width || y_offset >= img_height {
177        anyhow::bail!(
178            "Region offset ({}, {}) is outside image bounds ({}x{})",
179            x_offset,
180            y_offset,
181            img_width,
182            img_height
183        );
184    }
185
186    // Clamp region to image bounds
187    let actual_width = width.min(img_width.saturating_sub(x_offset));
188    let actual_height = height.min(img_height.saturating_sub(y_offset));
189
190    if actual_width == 0 || actual_height == 0 {
191        anyhow::bail!("Invalid region dimensions");
192    }
193
194    let data_type = reader
195        .data_type()
196        .ok_or_else(|| anyhow::anyhow!("Could not determine data type"))?;
197    let nodata = reader.nodata();
198    let samples_per_pixel = reader.band_count();
199
200    if band_index >= samples_per_pixel {
201        anyhow::bail!(
202            "Band index {} out of range (file has {} band(s))",
203            band_index,
204            samples_per_pixel
205        );
206    }
207
208    let output = reader
209        .read_window(
210            0,
211            band_index as usize,
212            x_offset,
213            y_offset,
214            actual_width,
215            actual_height,
216        )
217        .with_context(|| {
218            format!(
219                "Failed to read region ({}, {}) {}x{} of band {}",
220                x_offset, y_offset, actual_width, actual_height, band_index
221            )
222        })?;
223
224    check_plane_len(
225        output.len(),
226        actual_width,
227        actual_height,
228        band_index,
229        data_type.size_bytes(),
230    )?;
231
232    RasterBuffer::new(output, actual_width, actual_height, data_type, nodata)
233        .with_context(|| "Failed to create RasterBuffer from region data")
234}
235
236/// Write a single band to a GeoTIFF file
237pub fn write_single_band(
238    path: &Path,
239    buffer: &RasterBuffer,
240    geo_transform: Option<GeoTransform>,
241    epsg_code: Option<u32>,
242    no_data_value: Option<f64>,
243) -> Result<()> {
244    // Create writer configuration
245    let mut config = WriterConfig::new(buffer.width(), buffer.height(), 1, buffer.data_type());
246
247    // Set geo_transform if provided
248    if let Some(gt) = geo_transform {
249        config = config.with_geo_transform(gt);
250    }
251
252    // Set EPSG code if provided
253    if let Some(epsg) = epsg_code {
254        config = config.with_epsg_code(epsg);
255    }
256
257    // Set NoData value if provided
258    if let Some(no_data) = no_data_value {
259        let nodata_val = match buffer.data_type() {
260            RasterDataType::Int8
261            | RasterDataType::Int16
262            | RasterDataType::Int32
263            | RasterDataType::Int64
264            | RasterDataType::UInt8
265            | RasterDataType::UInt16
266            | RasterDataType::UInt32
267            | RasterDataType::UInt64 => NoDataValue::Integer(no_data as i64),
268            _ => NoDataValue::Float(no_data),
269        };
270        config = config.with_nodata(nodata_val);
271    }
272
273    // Create writer with config and options
274    let mut writer = GeoTiffWriter::create(path, config, GeoTiffWriterOptions::default())
275        .with_context(|| format!("Failed to create GeoTIFF: {}", path.display()))?;
276
277    // Write the band data
278    writer
279        .write(buffer.as_bytes())
280        .with_context(|| format!("Failed to write band to {}", path.display()))?;
281
282    Ok(())
283}
284
285/// Write multiple bands to a GeoTIFF file
286pub fn write_multi_band(
287    path: &Path,
288    buffers: &[RasterBuffer],
289    geo_transform: Option<GeoTransform>,
290    epsg_code: Option<u32>,
291    no_data_value: Option<f64>,
292) -> Result<()> {
293    if buffers.is_empty() {
294        anyhow::bail!("No bands provided");
295    }
296
297    // Verify all bands have the same dimensions and data type
298    let first_width = buffers[0].width();
299    let first_height = buffers[0].height();
300    let first_data_type = buffers[0].data_type();
301    for (i, buffer) in buffers.iter().enumerate().skip(1) {
302        if buffer.width() != first_width || buffer.height() != first_height {
303            anyhow::bail!(
304                "Band {} has different dimensions ({} x {}) than first band ({} x {})",
305                i,
306                buffer.width(),
307                buffer.height(),
308                first_width,
309                first_height
310            );
311        }
312        if buffer.data_type() != first_data_type {
313            anyhow::bail!(
314                "Band {} has different data type ({:?}) than first band ({:?})",
315                i,
316                buffer.data_type(),
317                first_data_type
318            );
319        }
320    }
321
322    // Interleave band data (pixel-by-pixel, all bands per pixel)
323    let bytes_per_pixel = first_data_type.size_bytes() as u64;
324    let pixel_count = first_width * first_height;
325    let total_bytes = (pixel_count * bytes_per_pixel * buffers.len() as u64) as usize;
326    let mut interleaved_data = vec![0u8; total_bytes];
327
328    for pixel_idx in 0..pixel_count {
329        for (band_idx, buffer) in buffers.iter().enumerate() {
330            let src_offset = (pixel_idx * bytes_per_pixel) as usize;
331            let dst_offset = ((pixel_idx * bytes_per_pixel) * buffers.len() as u64
332                + band_idx as u64 * bytes_per_pixel) as usize;
333            let src_end = src_offset + (bytes_per_pixel as usize);
334            let dst_end = dst_offset + (bytes_per_pixel as usize);
335            interleaved_data[dst_offset..dst_end]
336                .copy_from_slice(&buffer.as_bytes()[src_offset..src_end]);
337        }
338    }
339
340    // Create writer configuration
341    let mut config = WriterConfig::new(
342        first_width,
343        first_height,
344        buffers.len() as u16,
345        first_data_type,
346    );
347
348    // Set geo_transform if provided
349    if let Some(gt) = geo_transform {
350        config = config.with_geo_transform(gt);
351    }
352
353    // Set EPSG code if provided
354    if let Some(epsg) = epsg_code {
355        config = config.with_epsg_code(epsg);
356    }
357
358    // Set NoData value if provided
359    if let Some(no_data) = no_data_value {
360        let nodata_val = match first_data_type {
361            RasterDataType::Int8
362            | RasterDataType::Int16
363            | RasterDataType::Int32
364            | RasterDataType::Int64
365            | RasterDataType::UInt8
366            | RasterDataType::UInt16
367            | RasterDataType::UInt32
368            | RasterDataType::UInt64 => NoDataValue::Integer(no_data as i64),
369            _ => NoDataValue::Float(no_data),
370        };
371        config = config.with_nodata(nodata_val);
372    }
373
374    // Create writer with config and options
375    let mut writer = GeoTiffWriter::create(path, config, GeoTiffWriterOptions::default())
376        .with_context(|| format!("Failed to create GeoTIFF: {}", path.display()))?;
377
378    // Write the interleaved band data
379    writer
380        .write(&interleaved_data)
381        .with_context(|| format!("Failed to write bands to {}", path.display()))?;
382
383    Ok(())
384}
385
386/// Options for writing a Cloud-Optimized GeoTIFF.
387#[derive(Debug, Clone)]
388pub struct CogWriteOptions {
389    /// Geographic transform (origin, pixel size, rotation)
390    pub geo_transform: Option<GeoTransform>,
391    /// EPSG CRS code
392    pub epsg_code: Option<u32>,
393    /// NoData fill value
394    pub no_data_value: Option<f64>,
395    /// Overview downsampling factors (e.g., `[2, 4, 8, 16]`).
396    /// An empty `Vec` means no overviews.
397    pub overview_levels: Vec<u32>,
398    /// COG tile size in pixels (must be a power of 2)
399    pub tile_size: u32,
400    /// Compression scheme
401    pub compression: Compression,
402}
403
404impl Default for CogWriteOptions {
405    fn default() -> Self {
406        Self {
407            geo_transform: None,
408            epsg_code: None,
409            no_data_value: None,
410            overview_levels: vec![2, 4, 8, 16],
411            tile_size: 256,
412            compression: Compression::Lzw,
413        }
414    }
415}
416
417/// Writes raster bands to a Cloud-Optimized GeoTIFF (COG).
418///
419/// `options.overview_levels` is a list of downsampling factors (e.g., `[2, 4, 8, 16]`).
420/// An empty `Vec` means "no overviews".
421pub fn write_raster_cog(
422    path: &Path,
423    buffers: &[RasterBuffer],
424    options: CogWriteOptions,
425) -> Result<()> {
426    let CogWriteOptions {
427        geo_transform,
428        epsg_code,
429        no_data_value,
430        overview_levels,
431        tile_size,
432        compression,
433    } = options;
434    if buffers.is_empty() {
435        anyhow::bail!("No bands provided for COG write");
436    }
437
438    let first_width = buffers[0].width();
439    let first_height = buffers[0].height();
440    let first_data_type = buffers[0].data_type();
441
442    for (i, buffer) in buffers.iter().enumerate().skip(1) {
443        if buffer.width() != first_width || buffer.height() != first_height {
444            anyhow::bail!(
445                "Band {} has different dimensions than the first band ({} x {} vs {} x {})",
446                i,
447                buffer.width(),
448                buffer.height(),
449                first_width,
450                first_height
451            );
452        }
453        if buffer.data_type() != first_data_type {
454            anyhow::bail!(
455                "Band {} has different data type ({:?}) than first band ({:?})",
456                i,
457                buffer.data_type(),
458                first_data_type
459            );
460        }
461    }
462
463    // Interleave band data exactly as write_multi_band does
464    let bytes_per_pixel = first_data_type.size_bytes() as u64;
465    let pixel_count = first_width * first_height;
466    let total_bytes = (pixel_count * bytes_per_pixel * buffers.len() as u64) as usize;
467    let mut interleaved_data = vec![0u8; total_bytes];
468
469    for pixel_idx in 0..pixel_count {
470        for (band_idx, buffer) in buffers.iter().enumerate() {
471            let src_offset = (pixel_idx * bytes_per_pixel) as usize;
472            let dst_offset = ((pixel_idx * bytes_per_pixel) * buffers.len() as u64
473                + band_idx as u64 * bytes_per_pixel) as usize;
474            let src_end = src_offset + bytes_per_pixel as usize;
475            let dst_end = dst_offset + bytes_per_pixel as usize;
476            interleaved_data[dst_offset..dst_end]
477                .copy_from_slice(&buffer.as_bytes()[src_offset..src_end]);
478        }
479    }
480
481    let generate_overviews = !overview_levels.is_empty();
482
483    let mut config = WriterConfig::new(
484        first_width,
485        first_height,
486        buffers.len() as u16,
487        first_data_type,
488    )
489    .with_compression(compression)
490    .with_tile_size(tile_size, tile_size);
491
492    if let Some(gt) = geo_transform {
493        config = config.with_geo_transform(gt);
494    }
495    if let Some(epsg) = epsg_code {
496        config = config.with_epsg_code(epsg);
497    }
498    if let Some(no_data) = no_data_value {
499        let nodata_val = match first_data_type {
500            RasterDataType::Int8
501            | RasterDataType::Int16
502            | RasterDataType::Int32
503            | RasterDataType::Int64
504            | RasterDataType::UInt8
505            | RasterDataType::UInt16
506            | RasterDataType::UInt32
507            | RasterDataType::UInt64 => NoDataValue::Integer(no_data as i64),
508            _ => NoDataValue::Float(no_data),
509        };
510        config = config.with_nodata(nodata_val);
511    }
512
513    use oxigeo_geotiff::OverviewResampling;
514    config = config.with_overviews(generate_overviews, OverviewResampling::Average);
515    if generate_overviews {
516        config = config.with_overview_levels(overview_levels);
517    }
518
519    let mut writer = CogWriter::create(path, config, CogWriterOptions::default())
520        .with_context(|| format!("Failed to create COG: {}", path.display()))?;
521
522    writer
523        .write(&interleaved_data)
524        .with_context(|| format!("Failed to write COG data to {}", path.display()))?;
525
526    Ok(())
527}
528
529/// Reads raster info from a URI or bare file path.
530///
531/// Cloud URIs (`s3://`, `gs://`, `az://`) and `file://` URIs give a clear error
532/// directing the user to use local paths until GeoTiffReader is wired to accept
533/// arbitrary DataSource objects.
534pub fn read_raster_info_uri(uri: &str) -> Result<RasterInfo> {
535    if crate::util::cloud::is_cloud_uri(uri) || uri.starts_with("file://") {
536        // Opening via the cloud/URI datasource path is not yet wired to
537        // GeoTiffReader<T: DataSource> in this crate. Give a helpful error.
538        anyhow::bail!(
539            "cloud URI reading for raster requires GeoTiffReader<DataSource>; \
540             use a local file path for now (got: {})",
541            uri
542        );
543    }
544    read_raster_info(Path::new(uri))
545}
546
547/// Calculate output geotransform for a subset operation
548pub fn calculate_subset_geotransform(
549    original: &GeoTransform,
550    x_offset: u64,
551    y_offset: u64,
552) -> GeoTransform {
553    let new_origin_x = original.origin_x + (x_offset as f64 * original.pixel_width);
554    let new_origin_y = original.origin_y + (y_offset as f64 * original.pixel_height);
555
556    GeoTransform {
557        origin_x: new_origin_x,
558        origin_y: new_origin_y,
559        pixel_width: original.pixel_width,
560        pixel_height: original.pixel_height,
561        row_rotation: original.row_rotation,
562        col_rotation: original.col_rotation,
563    }
564}
565
566/// Calculate pixel window from geographic bounding box
567pub fn geo_to_pixel_window(
568    geo_transform: &GeoTransform,
569    min_x: f64,
570    min_y: f64,
571    max_x: f64,
572    max_y: f64,
573    raster_width: u64,
574    raster_height: u64,
575) -> Result<(u64, u64, u64, u64)> {
576    // Calculate inverse geotransform
577    let det = geo_transform.pixel_width * geo_transform.pixel_height
578        - geo_transform.row_rotation * geo_transform.col_rotation;
579
580    if det.abs() < 1e-10 {
581        anyhow::bail!("Invalid geotransform: determinant is zero");
582    }
583
584    // Convert corner coordinates to pixel space using inverse geotransform
585    // Inverse formulas: pixel_x = (pixel_height * (geo_x - origin_x) - col_rotation * (geo_y - origin_y)) / det
586    //                   pixel_y = (-row_rotation * (geo_x - origin_x) + pixel_width * (geo_y - origin_y)) / det
587    let calc_pixel_x = |geo_x: f64, geo_y: f64| -> f64 {
588        (geo_transform.pixel_height * (geo_x - geo_transform.origin_x)
589            - geo_transform.col_rotation * (geo_y - geo_transform.origin_y))
590            / det
591    };
592
593    let calc_pixel_y = |geo_x: f64, geo_y: f64| -> f64 {
594        (-geo_transform.row_rotation * (geo_x - geo_transform.origin_x)
595            + geo_transform.pixel_width * (geo_y - geo_transform.origin_y))
596            / det
597    };
598
599    let px_min_x = calc_pixel_x(min_x, max_y);
600    let px_max_x = calc_pixel_x(max_x, min_y);
601    let px_min_y = calc_pixel_y(min_x, max_y);
602    let px_max_y = calc_pixel_y(max_x, min_y);
603
604    // Clamp to raster bounds
605    let x_off = px_min_x.max(0.0).floor() as u64;
606    let y_off = px_min_y.max(0.0).floor() as u64;
607    let x_max = px_max_x.min(raster_width as f64).ceil() as u64;
608    let y_max = px_max_y.min(raster_height as f64).ceil() as u64;
609
610    let width = x_max.saturating_sub(x_off);
611    let height = y_max.saturating_sub(y_off);
612
613    if width == 0 || height == 0 {
614        anyhow::bail!("Bounding box does not intersect raster");
615    }
616
617    Ok((x_off, y_off, width, height))
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn test_calculate_subset_geotransform() {
626        let original = GeoTransform {
627            origin_x: 0.0,
628            origin_y: 100.0,
629            pixel_width: 1.0,
630            pixel_height: -1.0,
631            row_rotation: 0.0,
632            col_rotation: 0.0,
633        };
634
635        let subset = calculate_subset_geotransform(&original, 10, 5);
636        assert_eq!(subset.origin_x, 10.0);
637        assert_eq!(subset.origin_y, 95.0);
638        assert_eq!(subset.pixel_width, 1.0);
639        assert_eq!(subset.pixel_height, -1.0);
640    }
641
642    #[test]
643    fn test_geo_to_pixel_window() {
644        let geo_transform = GeoTransform {
645            origin_x: 0.0,
646            origin_y: 100.0,
647            pixel_width: 1.0,
648            pixel_height: -1.0,
649            row_rotation: 0.0,
650            col_rotation: 0.0,
651        };
652
653        let result = geo_to_pixel_window(&geo_transform, 10.0, 80.0, 20.0, 90.0, 100, 100);
654        assert!(result.is_ok());
655
656        let (x_off, y_off, width, height) = result.expect("should succeed");
657        assert_eq!(x_off, 10);
658        assert_eq!(y_off, 10);
659        assert_eq!(width, 10);
660        assert_eq!(height, 10);
661    }
662}