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. This function is robust to the underlying
67/// driver returning either an already-isolated single band buffer or a full
68/// interleaved multi-band buffer (see `extract_single_band`): either way,
69/// the returned [`RasterBuffer`] contains only the requested band's samples.
70pub fn read_band(path: &Path, band_index: u32) -> Result<RasterBuffer> {
71    let source = FileDataSource::open(path)
72        .with_context(|| format!("Failed to open file: {}", path.display()))?;
73
74    let reader = GeoTiffReader::open(source)
75        .with_context(|| format!("Failed to read GeoTIFF: {}", path.display()))?;
76
77    let width = reader.width();
78    let height = reader.height();
79    let data_type = reader
80        .data_type()
81        .ok_or_else(|| anyhow::anyhow!("Could not determine data type"))?;
82    let nodata = reader.nodata();
83    let samples_per_pixel = reader.band_count();
84
85    if band_index >= samples_per_pixel {
86        anyhow::bail!(
87            "Band index {} out of range (file has {} band(s))",
88            band_index,
89            samples_per_pixel
90        );
91    }
92
93    let raw = reader
94        .read_band(0, band_index as usize)
95        .with_context(|| "Failed to read band data")?;
96
97    let data = extract_single_band(
98        &raw,
99        width,
100        height,
101        band_index,
102        data_type.size_bytes(),
103        samples_per_pixel as usize,
104    )?;
105
106    RasterBuffer::new(data, width, height, data_type, nodata)
107        .with_context(|| "Failed to create RasterBuffer from band data")
108}
109
110/// Extracts a single band's samples from raster data returned by a driver.
111///
112/// Tolerates two possible shapes of `raw`:
113/// - Already a single band's worth of data (`width * height * bytes_per_sample`
114///   bytes) — returned as-is.
115/// - A full interleaved multi-band buffer (`width * height * bytes_per_sample *
116///   samples_per_pixel` bytes) — the requested band's samples are
117///   de-interleaved out, one sample per pixel.
118///
119/// This defends against upstream drivers that ignore the requested band index
120/// and always return the full interleaved buffer for multi-band images.
121fn extract_single_band(
122    raw: &[u8],
123    width: u64,
124    height: u64,
125    band_index: u32,
126    bytes_per_sample: usize,
127    samples_per_pixel: usize,
128) -> Result<Vec<u8>> {
129    let pixel_count = (width * height) as usize;
130    let single_band_len = pixel_count * bytes_per_sample;
131
132    if raw.len() == single_band_len {
133        return Ok(raw.to_vec());
134    }
135
136    if band_index as usize >= samples_per_pixel {
137        anyhow::bail!(
138            "Band index {} out of range (file has {} band(s))",
139            band_index,
140            samples_per_pixel
141        );
142    }
143
144    let interleaved_len = single_band_len * samples_per_pixel;
145    if raw.len() != interleaved_len {
146        anyhow::bail!(
147            "Unexpected band data size: got {} bytes, expected {} (single-band) or {} \
148             (interleaved, {} band(s))",
149            raw.len(),
150            single_band_len,
151            interleaved_len,
152            samples_per_pixel
153        );
154    }
155
156    let mut out = vec![0u8; single_band_len];
157    let band_offset = band_index as usize * bytes_per_sample;
158    let pixel_stride = bytes_per_sample * samples_per_pixel;
159
160    for pixel in 0..pixel_count {
161        let src_start = pixel * pixel_stride + band_offset;
162        let dst_start = pixel * bytes_per_sample;
163        out[dst_start..dst_start + bytes_per_sample]
164            .copy_from_slice(&raw[src_start..src_start + bytes_per_sample]);
165    }
166
167    Ok(out)
168}
169
170/// Read a region from a specific band of a GeoTIFF file
171pub fn read_band_region(
172    path: &Path,
173    band_index: u32,
174    x_offset: u64,
175    y_offset: u64,
176    width: u64,
177    height: u64,
178) -> Result<RasterBuffer> {
179    let source = FileDataSource::open(path)
180        .with_context(|| format!("Failed to open file: {}", path.display()))?;
181
182    let reader = GeoTiffReader::open(source)
183        .with_context(|| format!("Failed to read GeoTIFF: {}", path.display()))?;
184
185    // Validate region bounds
186    let img_width = reader.width();
187    let img_height = reader.height();
188
189    if x_offset >= img_width || y_offset >= img_height {
190        anyhow::bail!(
191            "Region offset ({}, {}) is outside image bounds ({}x{})",
192            x_offset,
193            y_offset,
194            img_width,
195            img_height
196        );
197    }
198
199    // Clamp region to image bounds
200    let actual_width = width.min(img_width.saturating_sub(x_offset));
201    let actual_height = height.min(img_height.saturating_sub(y_offset));
202
203    if actual_width == 0 || actual_height == 0 {
204        anyhow::bail!("Invalid region dimensions");
205    }
206
207    let data_type = reader
208        .data_type()
209        .ok_or_else(|| anyhow::anyhow!("Could not determine data type"))?;
210    let nodata = reader.nodata();
211
212    // Get tile/strip information
213    let bytes_per_sample = data_type.size_bytes();
214    let samples_per_pixel = reader.band_count() as usize;
215
216    if band_index as usize >= samples_per_pixel {
217        anyhow::bail!(
218            "Band index {} out of range (file has {} band(s))",
219            band_index,
220            samples_per_pixel
221        );
222    }
223
224    // Check if this is a tiled layout
225    let tile_size = reader.tile_size();
226
227    if tile_size.is_none() {
228        // Striped or non-tiled layout - read full band and subset
229        let region = ImageRegion::new(
230            img_width,
231            img_height,
232            x_offset,
233            y_offset,
234            actual_width,
235            actual_height,
236        );
237        let config = RasterConfig::new(bytes_per_sample, samples_per_pixel, data_type, nodata);
238        return read_and_subset_strip(&reader, band_index, region, config);
239    }
240
241    // Tiled layout - read only overlapping tiles
242    let (tile_width, tile_height) =
243        tile_size.ok_or_else(|| anyhow::anyhow!("Tile size not available"))?;
244    let tile_width = tile_width as u64;
245    let tile_height = tile_height as u64;
246
247    let (tiles_x, tiles_y) = reader.tile_count();
248
249    // Calculate tile range that overlaps with the region
250    let tile_x_start = (x_offset / tile_width) as u32;
251    let tile_y_start = (y_offset / tile_height) as u32;
252    let tile_x_end = (x_offset + actual_width)
253        .div_ceil(tile_width)
254        .min(tiles_x as u64) as u32;
255    let tile_y_end = (y_offset + actual_height)
256        .div_ceil(tile_height)
257        .min(tiles_y as u64) as u32;
258
259    // Allocate output buffer (single band only)
260    let output_size = (actual_width * actual_height) as usize * bytes_per_sample;
261    let mut output = vec![0u8; output_size];
262
263    // Tiles/strips read via `read_tile` always return raw data with all bands
264    // interleaved per pixel (this API has no band parameter), so the
265    // requested band's samples must be de-interleaved out during the copy.
266    let src_pixel_stride = bytes_per_sample * samples_per_pixel;
267    let band_offset = band_index as usize * bytes_per_sample;
268
269    // Read and assemble tiles
270    for tile_y in tile_y_start..tile_y_end {
271        for tile_x in tile_x_start..tile_x_end {
272            let tile_data = reader
273                .read_tile(0, tile_x, tile_y)
274                .with_context(|| format!("Failed to read tile ({}, {})", tile_x, tile_y))?;
275
276            // Calculate tile boundaries in image coordinates
277            let tile_img_x = tile_x as u64 * tile_width;
278            let tile_img_y = tile_y as u64 * tile_height;
279
280            // Calculate intersection of tile with requested region
281            let copy_x_start = x_offset.max(tile_img_x);
282            let copy_y_start = y_offset.max(tile_img_y);
283            let copy_x_end = (x_offset + actual_width).min(tile_img_x + tile_width);
284            let copy_y_end = (y_offset + actual_height).min(tile_img_y + tile_height);
285
286            if samples_per_pixel == 1 {
287                // Fast path: no de-interleaving needed, copy whole rows.
288                for row in copy_y_start..copy_y_end {
289                    let tile_row = (row - tile_img_y) as usize;
290                    let tile_col_start = (copy_x_start - tile_img_x) as usize;
291                    let tile_col_end = (copy_x_end - tile_img_x) as usize;
292
293                    let out_row = (row - y_offset) as usize;
294                    let out_col_start = (copy_x_start - x_offset) as usize;
295
296                    let src_offset =
297                        (tile_row * tile_width as usize + tile_col_start) * bytes_per_sample;
298                    let dst_offset =
299                        (out_row * actual_width as usize + out_col_start) * bytes_per_sample;
300                    let copy_bytes = (tile_col_end - tile_col_start) * bytes_per_sample;
301
302                    if src_offset + copy_bytes <= tile_data.len()
303                        && dst_offset + copy_bytes <= output.len()
304                    {
305                        output[dst_offset..dst_offset + copy_bytes]
306                            .copy_from_slice(&tile_data[src_offset..src_offset + copy_bytes]);
307                    }
308                }
309                continue;
310            }
311
312            // Multi-band tile: extract only the requested band's samples,
313            // one pixel at a time.
314            for row in copy_y_start..copy_y_end {
315                let tile_row = (row - tile_img_y) as usize;
316                let out_row = (row - y_offset) as usize;
317
318                for col in copy_x_start..copy_x_end {
319                    let tile_col = (col - tile_img_x) as usize;
320                    let out_col = (col - x_offset) as usize;
321
322                    let src_offset = (tile_row * tile_width as usize + tile_col) * src_pixel_stride
323                        + band_offset;
324                    let dst_offset = (out_row * actual_width as usize + out_col) * bytes_per_sample;
325
326                    if src_offset + bytes_per_sample <= tile_data.len()
327                        && dst_offset + bytes_per_sample <= output.len()
328                    {
329                        output[dst_offset..dst_offset + bytes_per_sample]
330                            .copy_from_slice(&tile_data[src_offset..src_offset + bytes_per_sample]);
331                    }
332                }
333            }
334        }
335    }
336
337    RasterBuffer::new(output, actual_width, actual_height, data_type, nodata)
338        .with_context(|| "Failed to create RasterBuffer from region data")
339}
340
341/// Image dimensions and region configuration
342#[derive(Debug, Clone, Copy)]
343struct ImageRegion {
344    img_width: u64,
345    img_height: u64,
346    x_offset: u64,
347    y_offset: u64,
348    width: u64,
349    height: u64,
350}
351
352impl ImageRegion {
353    fn new(
354        img_width: u64,
355        img_height: u64,
356        x_offset: u64,
357        y_offset: u64,
358        width: u64,
359        height: u64,
360    ) -> Self {
361        Self {
362            img_width,
363            img_height,
364            x_offset,
365            y_offset,
366            width,
367            height,
368        }
369    }
370}
371
372/// Raster data configuration
373#[derive(Debug, Clone, Copy)]
374struct RasterConfig {
375    bytes_per_sample: usize,
376    samples_per_pixel: usize,
377    data_type: RasterDataType,
378    nodata: NoDataValue,
379}
380
381impl RasterConfig {
382    fn new(
383        bytes_per_sample: usize,
384        samples_per_pixel: usize,
385        data_type: RasterDataType,
386        nodata: NoDataValue,
387    ) -> Self {
388        Self {
389            bytes_per_sample,
390            samples_per_pixel,
391            data_type,
392            nodata,
393        }
394    }
395}
396
397/// Helper function for reading and subsetting strip-based (non-tiled) data
398fn read_and_subset_strip(
399    reader: &GeoTiffReader<FileDataSource>,
400    band_index: u32,
401    region: ImageRegion,
402    config: RasterConfig,
403) -> Result<RasterBuffer> {
404    // Read the band. The underlying driver may return either an
405    // already-isolated single band or a full interleaved multi-band buffer;
406    // `extract_single_band` normalizes either shape into single-band data.
407    let raw = reader
408        .read_band(0, band_index as usize)
409        .with_context(|| "Failed to read band data")?;
410
411    let data = extract_single_band(
412        &raw,
413        region.img_width,
414        region.img_height,
415        band_index,
416        config.bytes_per_sample,
417        config.samples_per_pixel,
418    )?;
419
420    // Subset the data
421    let output_size = (region.width * region.height) as usize * config.bytes_per_sample;
422    let mut output = vec![0u8; output_size];
423
424    for row in 0..region.height {
425        let src_row = region.y_offset + row;
426        if src_row >= region.img_height {
427            break;
428        }
429
430        let src_offset =
431            (src_row * region.img_width + region.x_offset) as usize * config.bytes_per_sample;
432        let dst_offset = (row * region.width) as usize * config.bytes_per_sample;
433        let copy_bytes = region.width as usize * config.bytes_per_sample;
434
435        if src_offset + copy_bytes <= data.len() && dst_offset + copy_bytes <= output.len() {
436            output[dst_offset..dst_offset + copy_bytes]
437                .copy_from_slice(&data[src_offset..src_offset + copy_bytes]);
438        }
439    }
440
441    RasterBuffer::new(
442        output,
443        region.width,
444        region.height,
445        config.data_type,
446        config.nodata,
447    )
448    .with_context(|| "Failed to create RasterBuffer from subsetted data")
449}
450
451/// Write a single band to a GeoTIFF file
452pub fn write_single_band(
453    path: &Path,
454    buffer: &RasterBuffer,
455    geo_transform: Option<GeoTransform>,
456    epsg_code: Option<u32>,
457    no_data_value: Option<f64>,
458) -> Result<()> {
459    // Create writer configuration
460    let mut config = WriterConfig::new(buffer.width(), buffer.height(), 1, buffer.data_type());
461
462    // Set geo_transform if provided
463    if let Some(gt) = geo_transform {
464        config = config.with_geo_transform(gt);
465    }
466
467    // Set EPSG code if provided
468    if let Some(epsg) = epsg_code {
469        config = config.with_epsg_code(epsg);
470    }
471
472    // Set NoData value if provided
473    if let Some(no_data) = no_data_value {
474        let nodata_val = match buffer.data_type() {
475            RasterDataType::Int8
476            | RasterDataType::Int16
477            | RasterDataType::Int32
478            | RasterDataType::Int64
479            | RasterDataType::UInt8
480            | RasterDataType::UInt16
481            | RasterDataType::UInt32
482            | RasterDataType::UInt64 => NoDataValue::Integer(no_data as i64),
483            _ => NoDataValue::Float(no_data),
484        };
485        config = config.with_nodata(nodata_val);
486    }
487
488    // Create writer with config and options
489    let mut writer = GeoTiffWriter::create(path, config, GeoTiffWriterOptions::default())
490        .with_context(|| format!("Failed to create GeoTIFF: {}", path.display()))?;
491
492    // Write the band data
493    writer
494        .write(buffer.as_bytes())
495        .with_context(|| format!("Failed to write band to {}", path.display()))?;
496
497    Ok(())
498}
499
500/// Write multiple bands to a GeoTIFF file
501pub fn write_multi_band(
502    path: &Path,
503    buffers: &[RasterBuffer],
504    geo_transform: Option<GeoTransform>,
505    epsg_code: Option<u32>,
506    no_data_value: Option<f64>,
507) -> Result<()> {
508    if buffers.is_empty() {
509        anyhow::bail!("No bands provided");
510    }
511
512    // Verify all bands have the same dimensions and data type
513    let first_width = buffers[0].width();
514    let first_height = buffers[0].height();
515    let first_data_type = buffers[0].data_type();
516    for (i, buffer) in buffers.iter().enumerate().skip(1) {
517        if buffer.width() != first_width || buffer.height() != first_height {
518            anyhow::bail!(
519                "Band {} has different dimensions ({} x {}) than first band ({} x {})",
520                i,
521                buffer.width(),
522                buffer.height(),
523                first_width,
524                first_height
525            );
526        }
527        if buffer.data_type() != first_data_type {
528            anyhow::bail!(
529                "Band {} has different data type ({:?}) than first band ({:?})",
530                i,
531                buffer.data_type(),
532                first_data_type
533            );
534        }
535    }
536
537    // Interleave band data (pixel-by-pixel, all bands per pixel)
538    let bytes_per_pixel = first_data_type.size_bytes() as u64;
539    let pixel_count = first_width * first_height;
540    let total_bytes = (pixel_count * bytes_per_pixel * buffers.len() as u64) as usize;
541    let mut interleaved_data = vec![0u8; total_bytes];
542
543    for pixel_idx in 0..pixel_count {
544        for (band_idx, buffer) in buffers.iter().enumerate() {
545            let src_offset = (pixel_idx * bytes_per_pixel) as usize;
546            let dst_offset = ((pixel_idx * bytes_per_pixel) * buffers.len() as u64
547                + band_idx as u64 * bytes_per_pixel) as usize;
548            let src_end = src_offset + (bytes_per_pixel as usize);
549            let dst_end = dst_offset + (bytes_per_pixel as usize);
550            interleaved_data[dst_offset..dst_end]
551                .copy_from_slice(&buffer.as_bytes()[src_offset..src_end]);
552        }
553    }
554
555    // Create writer configuration
556    let mut config = WriterConfig::new(
557        first_width,
558        first_height,
559        buffers.len() as u16,
560        first_data_type,
561    );
562
563    // Set geo_transform if provided
564    if let Some(gt) = geo_transform {
565        config = config.with_geo_transform(gt);
566    }
567
568    // Set EPSG code if provided
569    if let Some(epsg) = epsg_code {
570        config = config.with_epsg_code(epsg);
571    }
572
573    // Set NoData value if provided
574    if let Some(no_data) = no_data_value {
575        let nodata_val = match first_data_type {
576            RasterDataType::Int8
577            | RasterDataType::Int16
578            | RasterDataType::Int32
579            | RasterDataType::Int64
580            | RasterDataType::UInt8
581            | RasterDataType::UInt16
582            | RasterDataType::UInt32
583            | RasterDataType::UInt64 => NoDataValue::Integer(no_data as i64),
584            _ => NoDataValue::Float(no_data),
585        };
586        config = config.with_nodata(nodata_val);
587    }
588
589    // Create writer with config and options
590    let mut writer = GeoTiffWriter::create(path, config, GeoTiffWriterOptions::default())
591        .with_context(|| format!("Failed to create GeoTIFF: {}", path.display()))?;
592
593    // Write the interleaved band data
594    writer
595        .write(&interleaved_data)
596        .with_context(|| format!("Failed to write bands to {}", path.display()))?;
597
598    Ok(())
599}
600
601/// Options for writing a Cloud-Optimized GeoTIFF.
602#[derive(Debug, Clone)]
603pub struct CogWriteOptions {
604    /// Geographic transform (origin, pixel size, rotation)
605    pub geo_transform: Option<GeoTransform>,
606    /// EPSG CRS code
607    pub epsg_code: Option<u32>,
608    /// NoData fill value
609    pub no_data_value: Option<f64>,
610    /// Overview downsampling factors (e.g., `[2, 4, 8, 16]`).
611    /// An empty `Vec` means no overviews.
612    pub overview_levels: Vec<u32>,
613    /// COG tile size in pixels (must be a power of 2)
614    pub tile_size: u32,
615    /// Compression scheme
616    pub compression: Compression,
617}
618
619impl Default for CogWriteOptions {
620    fn default() -> Self {
621        Self {
622            geo_transform: None,
623            epsg_code: None,
624            no_data_value: None,
625            overview_levels: vec![2, 4, 8, 16],
626            tile_size: 256,
627            compression: Compression::Lzw,
628        }
629    }
630}
631
632/// Writes raster bands to a Cloud-Optimized GeoTIFF (COG).
633///
634/// `options.overview_levels` is a list of downsampling factors (e.g., `[2, 4, 8, 16]`).
635/// An empty `Vec` means "no overviews".
636pub fn write_raster_cog(
637    path: &Path,
638    buffers: &[RasterBuffer],
639    options: CogWriteOptions,
640) -> Result<()> {
641    let CogWriteOptions {
642        geo_transform,
643        epsg_code,
644        no_data_value,
645        overview_levels,
646        tile_size,
647        compression,
648    } = options;
649    if buffers.is_empty() {
650        anyhow::bail!("No bands provided for COG write");
651    }
652
653    let first_width = buffers[0].width();
654    let first_height = buffers[0].height();
655    let first_data_type = buffers[0].data_type();
656
657    for (i, buffer) in buffers.iter().enumerate().skip(1) {
658        if buffer.width() != first_width || buffer.height() != first_height {
659            anyhow::bail!(
660                "Band {} has different dimensions than the first band ({} x {} vs {} x {})",
661                i,
662                buffer.width(),
663                buffer.height(),
664                first_width,
665                first_height
666            );
667        }
668        if buffer.data_type() != first_data_type {
669            anyhow::bail!(
670                "Band {} has different data type ({:?}) than first band ({:?})",
671                i,
672                buffer.data_type(),
673                first_data_type
674            );
675        }
676    }
677
678    // Interleave band data exactly as write_multi_band does
679    let bytes_per_pixel = first_data_type.size_bytes() as u64;
680    let pixel_count = first_width * first_height;
681    let total_bytes = (pixel_count * bytes_per_pixel * buffers.len() as u64) as usize;
682    let mut interleaved_data = vec![0u8; total_bytes];
683
684    for pixel_idx in 0..pixel_count {
685        for (band_idx, buffer) in buffers.iter().enumerate() {
686            let src_offset = (pixel_idx * bytes_per_pixel) as usize;
687            let dst_offset = ((pixel_idx * bytes_per_pixel) * buffers.len() as u64
688                + band_idx as u64 * bytes_per_pixel) as usize;
689            let src_end = src_offset + bytes_per_pixel as usize;
690            let dst_end = dst_offset + bytes_per_pixel as usize;
691            interleaved_data[dst_offset..dst_end]
692                .copy_from_slice(&buffer.as_bytes()[src_offset..src_end]);
693        }
694    }
695
696    let generate_overviews = !overview_levels.is_empty();
697
698    let mut config = WriterConfig::new(
699        first_width,
700        first_height,
701        buffers.len() as u16,
702        first_data_type,
703    )
704    .with_compression(compression)
705    .with_tile_size(tile_size, tile_size);
706
707    if let Some(gt) = geo_transform {
708        config = config.with_geo_transform(gt);
709    }
710    if let Some(epsg) = epsg_code {
711        config = config.with_epsg_code(epsg);
712    }
713    if let Some(no_data) = no_data_value {
714        let nodata_val = match first_data_type {
715            RasterDataType::Int8
716            | RasterDataType::Int16
717            | RasterDataType::Int32
718            | RasterDataType::Int64
719            | RasterDataType::UInt8
720            | RasterDataType::UInt16
721            | RasterDataType::UInt32
722            | RasterDataType::UInt64 => NoDataValue::Integer(no_data as i64),
723            _ => NoDataValue::Float(no_data),
724        };
725        config = config.with_nodata(nodata_val);
726    }
727
728    use oxigeo_geotiff::OverviewResampling;
729    config = config.with_overviews(generate_overviews, OverviewResampling::Average);
730    if generate_overviews {
731        config = config.with_overview_levels(overview_levels);
732    }
733
734    let mut writer = CogWriter::create(path, config, CogWriterOptions::default())
735        .with_context(|| format!("Failed to create COG: {}", path.display()))?;
736
737    writer
738        .write(&interleaved_data)
739        .with_context(|| format!("Failed to write COG data to {}", path.display()))?;
740
741    Ok(())
742}
743
744/// Reads raster info from a URI or bare file path.
745///
746/// Cloud URIs (`s3://`, `gs://`, `az://`) and `file://` URIs give a clear error
747/// directing the user to use local paths until GeoTiffReader is wired to accept
748/// arbitrary DataSource objects.
749pub fn read_raster_info_uri(uri: &str) -> Result<RasterInfo> {
750    if crate::util::cloud::is_cloud_uri(uri) || uri.starts_with("file://") {
751        // Opening via the cloud/URI datasource path is not yet wired to
752        // GeoTiffReader<T: DataSource> in this crate. Give a helpful error.
753        anyhow::bail!(
754            "cloud URI reading for raster requires GeoTiffReader<DataSource>; \
755             use a local file path for now (got: {})",
756            uri
757        );
758    }
759    read_raster_info(Path::new(uri))
760}
761
762/// Calculate output geotransform for a subset operation
763pub fn calculate_subset_geotransform(
764    original: &GeoTransform,
765    x_offset: u64,
766    y_offset: u64,
767) -> GeoTransform {
768    let new_origin_x = original.origin_x + (x_offset as f64 * original.pixel_width);
769    let new_origin_y = original.origin_y + (y_offset as f64 * original.pixel_height);
770
771    GeoTransform {
772        origin_x: new_origin_x,
773        origin_y: new_origin_y,
774        pixel_width: original.pixel_width,
775        pixel_height: original.pixel_height,
776        row_rotation: original.row_rotation,
777        col_rotation: original.col_rotation,
778    }
779}
780
781/// Calculate pixel window from geographic bounding box
782pub fn geo_to_pixel_window(
783    geo_transform: &GeoTransform,
784    min_x: f64,
785    min_y: f64,
786    max_x: f64,
787    max_y: f64,
788    raster_width: u64,
789    raster_height: u64,
790) -> Result<(u64, u64, u64, u64)> {
791    // Calculate inverse geotransform
792    let det = geo_transform.pixel_width * geo_transform.pixel_height
793        - geo_transform.row_rotation * geo_transform.col_rotation;
794
795    if det.abs() < 1e-10 {
796        anyhow::bail!("Invalid geotransform: determinant is zero");
797    }
798
799    // Convert corner coordinates to pixel space using inverse geotransform
800    // Inverse formulas: pixel_x = (pixel_height * (geo_x - origin_x) - col_rotation * (geo_y - origin_y)) / det
801    //                   pixel_y = (-row_rotation * (geo_x - origin_x) + pixel_width * (geo_y - origin_y)) / det
802    let calc_pixel_x = |geo_x: f64, geo_y: f64| -> f64 {
803        (geo_transform.pixel_height * (geo_x - geo_transform.origin_x)
804            - geo_transform.col_rotation * (geo_y - geo_transform.origin_y))
805            / det
806    };
807
808    let calc_pixel_y = |geo_x: f64, geo_y: f64| -> f64 {
809        (-geo_transform.row_rotation * (geo_x - geo_transform.origin_x)
810            + geo_transform.pixel_width * (geo_y - geo_transform.origin_y))
811            / det
812    };
813
814    let px_min_x = calc_pixel_x(min_x, max_y);
815    let px_max_x = calc_pixel_x(max_x, min_y);
816    let px_min_y = calc_pixel_y(min_x, max_y);
817    let px_max_y = calc_pixel_y(max_x, min_y);
818
819    // Clamp to raster bounds
820    let x_off = px_min_x.max(0.0).floor() as u64;
821    let y_off = px_min_y.max(0.0).floor() as u64;
822    let x_max = px_max_x.min(raster_width as f64).ceil() as u64;
823    let y_max = px_max_y.min(raster_height as f64).ceil() as u64;
824
825    let width = x_max.saturating_sub(x_off);
826    let height = y_max.saturating_sub(y_off);
827
828    if width == 0 || height == 0 {
829        anyhow::bail!("Bounding box does not intersect raster");
830    }
831
832    Ok((x_off, y_off, width, height))
833}
834
835#[cfg(test)]
836mod tests {
837    use super::*;
838
839    #[test]
840    fn test_calculate_subset_geotransform() {
841        let original = GeoTransform {
842            origin_x: 0.0,
843            origin_y: 100.0,
844            pixel_width: 1.0,
845            pixel_height: -1.0,
846            row_rotation: 0.0,
847            col_rotation: 0.0,
848        };
849
850        let subset = calculate_subset_geotransform(&original, 10, 5);
851        assert_eq!(subset.origin_x, 10.0);
852        assert_eq!(subset.origin_y, 95.0);
853        assert_eq!(subset.pixel_width, 1.0);
854        assert_eq!(subset.pixel_height, -1.0);
855    }
856
857    #[test]
858    fn test_geo_to_pixel_window() {
859        let geo_transform = GeoTransform {
860            origin_x: 0.0,
861            origin_y: 100.0,
862            pixel_width: 1.0,
863            pixel_height: -1.0,
864            row_rotation: 0.0,
865            col_rotation: 0.0,
866        };
867
868        let result = geo_to_pixel_window(&geo_transform, 10.0, 80.0, 20.0, 90.0, 100, 100);
869        assert!(result.is_ok());
870
871        let (x_off, y_off, width, height) = result.expect("should succeed");
872        assert_eq!(x_off, 10);
873        assert_eq!(y_off, 10);
874        assert_eq!(width, 10);
875        assert_eq!(height, 10);
876    }
877}