1use 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#[derive(Debug, Clone, PartialEq)]
13pub struct PointValue {
14 pub values: Vec<f64>,
19 pub valid: bool,
22}
23
24fn 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 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 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 if !col_f.is_finite() || !row_f.is_finite() {
61 return Ok(None);
62 }
63
64 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 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 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 let image: Arc<crate::pixel::ImageData> = self.read_window(0, col, row, 1, 1).await?;
141 let pixels = image.pixels.to_f64(); let bands = pixels.shape()[0];
143
144 let mut values = Vec::with_capacity(sel.len());
145 for &b in &sel {
146 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 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 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 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 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 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 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 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}