Skip to main content

async_geotiff/io/
point.rs

1//! `CogReader::point` — sample full-resolution pixel value(s) at a coordinate.
2
3use std::sync::Arc;
4
5use crate::bounds::GeoTransform;
6use crate::crs::Crs;
7use crate::error::{AgError, AgResult};
8use crate::io::cog::CogReader;
9use crate::warp::projection::with_cached;
10
11/// Pixel value(s) sampled at a coordinate.
12#[derive(Debug, Clone, PartialEq)]
13pub struct PointValue {
14    /// One value per requested output band, in `bidx` order (or every output
15    /// band when `bidx` is empty). Bands follow [`CogReader::read_window`]:
16    /// promoting Alpha to a mask removes that band and preserves the order of
17    /// the others. Values are raw decoded samples cast to f64, without scale/offset.
18    pub values: Vec<f64>,
19    /// `false` when the pixel is masked or any returned band equals the dataset
20    /// nodata sentinel; `true` otherwise.
21    pub valid: bool,
22}
23
24/// Reproject `(x, y)` from `coord_crs` to `src_crs`, map to an integer level-0
25/// pixel via `transform.inverse()`, and bounds-check against `(width, height)`.
26/// Returns `Ok(None)` if the point is outside the extent or the projection's
27/// valid domain. Non-identity projection uses the blocking thread's cached
28/// projector; it is created and dropped without crossing an await.
29fn coord_to_pixel(
30    x: f64,
31    y: f64,
32    coord_crs: &Crs,
33    src_crs: &Crs,
34    transform: GeoTransform,
35    width: u32,
36    height: u32,
37) -> AgResult<Option<(i64, i64)>> {
38    // 1) Reproject to source CRS (skip when equal). Projector is !Send but we
39    //    never await here, so it is created and dropped within this fn.
40    let (sx, sy) = if coord_crs == src_crs {
41        (x, y)
42    } else {
43        let mut pts = [(x, y)];
44        match with_cached(coord_crs, src_crs, |projector| projector.convert(&mut pts)) {
45            Ok(()) => pts[0],
46            Err(AgError::ProjectionDomain(_)) => return Ok(None),
47            Err(e) => return Err(e),
48        }
49    };
50
51    // 2) World → fractional pixel.
52    let inv = transform
53        .inverse()
54        .ok_or_else(|| AgError::Warp("source GeoTransform not invertible".into()))?;
55    let (col_f, row_f) = inv.forward(sx, sy);
56
57    // A non-finite projected coord (e.g. a pathological projection result not
58    // flagged as ProjectionDomain, or a NaN input) would cast to pixel 0 via
59    // `NaN as i64`, so treat it as outside coverage.
60    if !col_f.is_finite() || !row_f.is_finite() {
61        return Ok(None);
62    }
63
64    // 3) Floor to integer pixel, bounds-check.
65    let col = col_f.floor() as i64;
66    let row = row_f.floor() as i64;
67    if col < 0 || row < 0 || col >= i64::from(width) || row >= i64::from(height) {
68        return Ok(None);
69    }
70    Ok(Some((col, row)))
71}
72
73impl CogReader {
74    /// Sample the full-resolution (level 0) pixel value(s) at `(x, y)`.
75    ///
76    /// `coord_crs` is the CRS of the input coordinate (pass `Crs::epsg(4326)`
77    /// for lon/lat); it is reprojected to the dataset's source CRS internally.
78    /// `bidx` selects one-based bands from the same output as [`Self::read_window`];
79    /// an empty slice returns all output bands in order. Without an independent
80    /// mask IFD, the first tagged Alpha band becomes the validity mask and is
81    /// removed; the remaining bands retain their relative order. An independent
82    /// mask takes precedence and leaves all native bands, including Alpha,
83    /// available for selection. Values are raw samples cast to f64, without
84    /// scale/offset.
85    ///
86    /// Returns `Ok(None)` when the coordinate is outside the dataset extent or
87    /// the projection's valid domain. Returns `Err(AgError::query)` when a band
88    /// index is 0 or exceeds the band count.
89    pub async fn point(
90        &self,
91        x: f64,
92        y: f64,
93        coord_crs: &Crs,
94        bidx: &[u32],
95    ) -> AgResult<Option<PointValue>> {
96        let (width, height) = self.level_dimensions(0)?;
97
98        // Validate against the reader's post-promotion band order before any read.
99        let total = self.data_ifd(0)?.samples_per_pixel() as usize
100            - usize::from(self.promoted_alpha_band(0)?.is_some());
101        let sel: Vec<usize> = if bidx.is_empty() {
102            (0..total).collect()
103        } else {
104            let mut out = Vec::with_capacity(bidx.len());
105            for &b in bidx {
106                if b == 0 || b as usize > total {
107                    return Err(AgError::query(
108                        "bidx",
109                        format!("band {b} out of range for {total} bands"),
110                    ));
111                }
112                out.push(b as usize - 1);
113            }
114            out
115        };
116
117        let transform = self.transform();
118        let pixel = if coord_crs == self.src_crs() {
119            coord_to_pixel(x, y, coord_crs, self.src_crs(), transform, width, height)?
120        } else {
121            let permit = self.cpu_limiter().acquire().await?;
122            let coord_crs = coord_crs.clone();
123            let src_crs = self.src_crs().clone();
124            let request = crate::request::RequestContext::current();
125            tokio::task::spawn_blocking(move || {
126                let _permit = permit;
127                crate::request::RequestContext::run_blocking(request, || {
128                    coord_to_pixel(x, y, &coord_crs, &src_crs, transform, width, height)
129                })
130            })
131            .await
132            .map_err(|error| AgError::Warp(format!("point projection task: {error}")))??
133        };
134        let (col, row) = match pixel {
135            Some(p) => p,
136            None => return Ok(None),
137        };
138
139        // Read the 1x1 window at full resolution.
140        let image: Arc<crate::pixel::ImageData> = self.read_window(0, col, row, 1, 1).await?;
141        let pixels = image.pixels.to_f64(); // (bands, 1, 1)
142        let bands = pixels.shape()[0];
143
144        let mut values = Vec::with_capacity(sel.len());
145        for &b in &sel {
146            // The expected output count was checked before reading; malformed
147            // decoded data must still return an error rather than index out of bounds.
148            if b >= bands {
149                return Err(AgError::query(
150                    "bidx",
151                    format!("band {} out of range for {} decoded bands", b + 1, bands),
152                ));
153            }
154            values.push(pixels[[b, 0, 0]]);
155        }
156
157        // Validity: mask says invalid, or any selected band hits nodata.
158        let mut valid = true;
159        if let Some(m) = &image.mask {
160            if !m[[0, 0]] {
161                valid = false;
162            }
163        }
164        if valid {
165            if let Some(nd) = image.nodata {
166                let hit = if nd.is_nan() {
167                    values.iter().any(|v| v.is_nan())
168                } else {
169                    values.iter().any(|v| *v == nd || (v - nd).abs() < 1e-9)
170                };
171                if hit {
172                    valid = false;
173                }
174            }
175        }
176
177        Ok(Some(PointValue { values, valid }))
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::bounds::GeoTransform;
185    use crate::crs::Crs;
186
187    #[cfg(feature = "proj")]
188    #[tokio::test]
189    async fn point_projection_obeys_cpu_budget_and_queued_cancellation() {
190        use crate::request::{RequestLimiter, RequestOptions};
191        use std::time::Duration;
192        let store = Arc::new(object_store::local::LocalFileSystem::new_with_prefix(".").unwrap());
193        let cpu = crate::CpuLimiter::new(1);
194        let reader = CogReader::builder()
195            .cpu_limiter(cpu.clone())
196            .open_object_store(store, "tests/fixtures/projected.tif")
197            .await
198            .unwrap();
199        let (width, height) = reader.level_dimensions(0).unwrap();
200        let source_point = reader
201            .transform()
202            .forward(f64::from(width) / 2. + 0.5, f64::from(height) / 2. + 0.5);
203        let mut geographic = [source_point];
204        crate::warp::mercator::mercator_to_lonlat(&mut geographic);
205        let (lon, lat) = geographic[0];
206        let reference = reader.point(lon, lat, &Crs::epsg(4326), &[]).await.unwrap();
207        assert!(reference.is_some());
208        let permit = cpu.acquire().await.unwrap();
209        // Identity conversion remains inline and can use the warm native block.
210        let identity = reader
211            .point(source_point.0, source_point.1, reader.src_crs(), &[])
212            .await
213            .unwrap();
214        assert_eq!(identity, reference);
215        let requests = RequestLimiter::new(1);
216        let geographic_crs = Crs::epsg(4326);
217        let result = requests
218            .run(
219                Duration::from_millis(30),
220                &RequestOptions::default(),
221                |_| reader.point(lon, lat, &geographic_crs, &[]),
222            )
223            .await;
224        assert!(matches!(result, Err(AgError::DeadlineExceeded)));
225        assert_eq!(requests.available_permits(), 1);
226        drop(permit);
227        assert_eq!(
228            reader.point(lon, lat, &Crs::epsg(4326), &[]).await.unwrap(),
229            reference
230        );
231    }
232
233    // origin (0,0), 1 unit/pixel, north-up: world (X,Y) → pixel (col=X, row=-Y).
234    fn tr() -> GeoTransform {
235        GeoTransform::from_gdal(&[0.0, 1.0, 0.0, 0.0, 0.0, -1.0])
236    }
237
238    #[test]
239    fn maps_interior_coord_to_pixel_same_crs() {
240        let crs = Crs::epsg(3857);
241        let p = coord_to_pixel(3.5, -2.5, &crs, &crs, tr(), 10, 10).unwrap();
242        assert_eq!(p, Some((3, 2)));
243    }
244
245    #[test]
246    fn out_of_extent_returns_none() {
247        let crs = Crs::epsg(3857);
248        let p = coord_to_pixel(50.0, -50.0, &crs, &crs, tr(), 10, 10).unwrap();
249        assert_eq!(p, None);
250        let p2 = coord_to_pixel(-5.0, 5.0, &crs, &crs, tr(), 10, 10).unwrap();
251        assert_eq!(p2, None);
252    }
253
254    #[test]
255    fn pixel_at_origin_is_zero_zero() {
256        let crs = Crs::epsg(3857);
257        let p = coord_to_pixel(0.5, -0.5, &crs, &crs, tr(), 10, 10).unwrap();
258        assert_eq!(p, Some((0, 0)));
259    }
260
261    #[test]
262    fn non_finite_projected_coord_is_none() {
263        let crs = Crs::epsg(3857);
264        let p = coord_to_pixel(f64::NAN, 0.0, &crs, &crs, tr(), 10, 10).unwrap();
265        assert_eq!(p, None);
266    }
267
268    // ---- integration tests against a local fixture ----
269
270    async fn rgb_reader() -> CogReader {
271        use object_store::local::LocalFileSystem;
272        let fs = std::sync::Arc::new(LocalFileSystem::new_with_prefix(".").unwrap());
273        CogReader::open_object_store(fs, "tests/fixtures/rgb_nodata.tif")
274            .await
275            .unwrap()
276    }
277
278    #[tokio::test]
279    async fn point_inside_returns_all_bands() {
280        let r = rgb_reader().await;
281        let [w, s, e, n] = r.wgs84_bounds().unwrap();
282        let (lon, lat) = ((w + e) / 2.0, (s + n) / 2.0);
283        let pv = r
284            .point(lon, lat, &Crs::epsg(4326), &[])
285            .await
286            .unwrap()
287            .expect("center point should be inside");
288        assert_eq!(pv.values.len(), 3);
289        for v in &pv.values {
290            assert!((0.0..=255.0).contains(v), "u8 band value out of range: {v}");
291        }
292    }
293
294    #[tokio::test]
295    async fn point_bidx_selects_and_orders_bands() {
296        let r = rgb_reader().await;
297        let [w, s, e, n] = r.wgs84_bounds().unwrap();
298        let (lon, lat) = ((w + e) / 2.0, (s + n) / 2.0);
299        let all = r
300            .point(lon, lat, &Crs::epsg(4326), &[])
301            .await
302            .unwrap()
303            .unwrap();
304        let one = r
305            .point(lon, lat, &Crs::epsg(4326), &[1])
306            .await
307            .unwrap()
308            .unwrap();
309        assert_eq!(one.values.len(), 1);
310        assert_eq!(one.values[0], all.values[0]);
311        let rev = r
312            .point(lon, lat, &Crs::epsg(4326), &[3, 1])
313            .await
314            .unwrap()
315            .unwrap();
316        assert_eq!(rev.values, vec![all.values[2], all.values[0]]);
317    }
318
319    #[tokio::test]
320    async fn point_bad_band_index_is_query_error() {
321        let r = rgb_reader().await;
322        let [w, s, e, n] = r.wgs84_bounds().unwrap();
323        let (lon, lat) = ((w + e) / 2.0, (s + n) / 2.0);
324        assert!(matches!(
325            r.point(lon, lat, &Crs::epsg(4326), &[0]).await,
326            Err(AgError::Query { .. })
327        ));
328        assert!(matches!(
329            r.point(lon, lat, &Crs::epsg(4326), &[99]).await,
330            Err(AgError::Query { .. })
331        ));
332    }
333
334    #[cfg(feature = "proj")]
335    #[tokio::test]
336    async fn point_reprojects_lonlat_onto_non_4326_dataset() {
337        // Fixture is EPSG:3857; querying in lon/lat (4326) forces the
338        // reprojection branch in `coord_to_pixel` (cached Projector + convert),
339        // unlike the 4326 fixture where the equal-CRS short-circuit fires.
340        use object_store::local::LocalFileSystem;
341        let fs = std::sync::Arc::new(LocalFileSystem::new_with_prefix(".").unwrap());
342        let r = CogReader::open_object_store(fs, "tests/fixtures/projected.tif")
343            .await
344            .unwrap();
345        assert_ne!(r.src_crs(), &Crs::epsg(4326), "fixture should be non-4326");
346        let [w, s, e, n] = r.wgs84_bounds().unwrap();
347        let (lon, lat) = ((w + e) / 2.0, (s + n) / 2.0);
348        let pv = r
349            .point(lon, lat, &Crs::epsg(4326), &[])
350            .await
351            .unwrap()
352            .expect("center lon/lat should reproject to an interior pixel");
353        assert!(!pv.values.is_empty());
354    }
355
356    #[tokio::test]
357    async fn point_outside_extent_is_none() {
358        let r = rgb_reader().await;
359        let [_w, _s, _e, n] = r.wgs84_bounds().unwrap();
360        // A latitude well north of the dataset's northern edge (clamped ≤ 90).
361        let lat = (n + 5.0).min(89.9);
362        let pv = r.point(0.0, lat, &Crs::epsg(4326), &[]).await.unwrap();
363        assert!(pv.is_none(), "point north of the extent must be None");
364    }
365
366    #[tokio::test]
367    async fn point_validity_flag_is_consistent_and_masks_nodata() {
368        let r = rgb_reader().await;
369        let [w, s, e, n] = r.wgs84_bounds().unwrap();
370        // Sample a 20x20 grid over the dataset. Every returned PointValue must
371        // have 3 values. The fixture has nodata around its SW corner, so at
372        // least one returned point must be invalid — this strictly exercises
373        // the nodata→invalid masking path (a regression that always returned
374        // valid=true would fail here).
375        let mut saw_any = false;
376        let mut saw_invalid = false;
377        for i in 0..20 {
378            for j in 0..20 {
379                let lon = w + (e - w) * (i as f64 + 0.5) / 20.0;
380                let lat = s + (n - s) * (j as f64 + 0.5) / 20.0;
381                if let Some(pv) = r.point(lon, lat, &Crs::epsg(4326), &[]).await.unwrap() {
382                    saw_any = true;
383                    assert_eq!(pv.values.len(), 3);
384                    if !pv.valid {
385                        saw_invalid = true;
386                    }
387                }
388            }
389        }
390        assert!(saw_any, "at least one interior grid point should be inside");
391        assert!(
392            saw_invalid,
393            "fixture has nodata; at least one grid point must be masked invalid"
394        );
395    }
396}