1use std::sync::Arc;
4use std::time::Duration;
5#[cfg(feature = "perf-tracing")]
6use std::time::Instant;
7
8use bytes::Bytes;
9
10use crate::blocking::{default_blocking_permits, CpuLimiter};
11use crate::bounds::Bbox;
12use crate::error::{AgError, AgResult};
13use crate::io::CogReader;
14#[cfg(feature = "perf-tracing")]
15use crate::perf::duration_ms;
16use crate::pixel::ImageData;
17use crate::request::{
18 RequestContext, RequestLimiter, RequestOptions, DEFAULT_MAX_IN_FLIGHT_REQUESTS,
19 DEFAULT_REQUEST_TIMEOUT,
20};
21use crate::style::params::compile_cached;
22use crate::style::TileStyle;
23use crate::tms::{TileCoord, Tms};
24
25const PASSTHROUGH_PIXEL_TOLERANCE: f64 = 1e-3;
26
27#[derive(Clone)]
29pub struct Tiler {
30 reader: Arc<CogReader>,
31 cpu: CpuLimiter,
32 requests: RequestLimiter,
33 request_timeout: Duration,
34 work_budget: crate::resource::WorkBudget,
35}
36
37#[derive(Clone, Copy, Debug)]
42pub struct TilerConfig {
43 pub max_blocking_tasks: usize,
45 pub max_in_flight_requests: usize,
47 pub request_timeout: Duration,
49}
50
51impl Default for TilerConfig {
52 fn default() -> Self {
53 Self {
54 max_blocking_tasks: default_blocking_permits(),
55 max_in_flight_requests: DEFAULT_MAX_IN_FLIGHT_REQUESTS,
56 request_timeout: DEFAULT_REQUEST_TIMEOUT,
57 }
58 }
59}
60
61impl TilerConfig {
62 fn blocking_task_limit(self) -> usize {
63 self.max_blocking_tasks.max(1)
64 }
65}
66
67struct ReadyPlan {
69 level: usize,
70 col: i64,
71 row: i64,
72 w: u64,
73 h: u64,
74 passthrough: bool,
76 dst_crs: crate::crs::Crs,
77 dst_bbox: Bbox,
78}
79
80impl Tiler {
81 pub fn new(reader: Arc<CogReader>) -> Self {
87 let cpu = reader.cpu_limiter();
88 let work_budget = reader.work_budget();
89 Self {
90 reader,
91 cpu,
92 requests: RequestLimiter::default(),
93 request_timeout: DEFAULT_REQUEST_TIMEOUT,
94 work_budget,
95 }
96 }
97
98 pub fn with_config(reader: Arc<CogReader>, config: TilerConfig) -> Self {
102 let work_budget = reader.work_budget();
103 Self {
104 reader,
105 cpu: CpuLimiter::new(config.blocking_task_limit()),
106 requests: RequestLimiter::new(config.max_in_flight_requests),
107 request_timeout: config.request_timeout,
108 work_budget,
109 }
110 }
111
112 pub fn with_request_limiter(mut self, requests: RequestLimiter) -> Self {
114 self.requests = requests;
115 self
116 }
117
118 pub fn with_work_budget(mut self, work_budget: crate::resource::WorkBudget) -> Self {
120 self.work_budget = work_budget;
121 self
122 }
123
124 pub fn is_u8(&self) -> AgResult<bool> {
126 let ifd = self.reader.data_ifd(0)?;
127 Ok(ifd.bits_per_sample().iter().all(|&bits| bits == 8)
128 && ifd
129 .sample_format()
130 .iter()
131 .all(|format| *format == async_tiff::tags::SampleFormat::Uint))
132 }
133
134 pub fn band_count(&self) -> AgResult<usize> {
136 use async_tiff::tags::ExtraSamples;
137 let ifd = self.reader.data_ifd(0)?;
138 let samples = usize::from(ifd.samples_per_pixel());
139 let alpha = ifd.extra_samples().is_some_and(|extras| {
140 extras.len() <= samples
141 && extras.iter().any(|sample| {
142 matches!(
143 sample,
144 ExtraSamples::AssociatedAlpha | ExtraSamples::UnassociatedAlpha
145 )
146 })
147 });
148 Ok(samples - usize::from(alpha))
149 }
150
151 pub fn wgs84_bounds(&self) -> AgResult<[f64; 4]> {
153 self.reader.wgs84_bounds()
154 }
155
156 fn plan_tile(&self, coord: TileCoord, style: &TileStyle) -> AgResult<Option<ReadyPlan>> {
161 let tms = Tms::cached(coord.tms)?;
162 let dst_bbox = tms.tile_bbox(coord.z, coord.x, coord.y)?;
163 let dst_crs = tms.crs();
164
165 let bbox_in_src = if self.reader.src_crs() == &dst_crs {
166 dst_bbox
167 } else {
168 let mut read_bbox = dst_bbox;
169 if crate::warp::projection::is_epsg(&dst_crs, 4326)
170 && crate::warp::projection::is_epsg(self.reader.src_crs(), 3857)
171 && (dst_bbox.ymin <= -90.0 || dst_bbox.ymax >= 90.0)
172 {
173 let [_, south, _, north] = self.reader.wgs84_bounds()?;
175 let pole = 90.0_f64.next_down();
176 read_bbox.ymin = read_bbox.ymin.max(south.max(-pole));
177 read_bbox.ymax = read_bbox.ymax.min(north.min(pole));
178 if read_bbox.ymin >= read_bbox.ymax {
179 return Ok(None);
180 }
181 }
182 match Self::reproject_bbox_sync(&read_bbox, &dst_crs, self.reader.src_crs()) {
183 Ok(b) => b,
184 Err(AgError::ProjectionDomain(_)) => return Ok(None),
185 Err(e) => return Err(e),
186 }
187 };
188
189 let target_gsd =
190 bbox_in_src.width().abs().max(bbox_in_src.height().abs()) / f64::from(style.tilesize);
191 let level = if style.raster.categorical {
192 0
194 } else {
195 self.reader.pick_overview(target_gsd)
196 };
197
198 let level_tr = self.reader.level_transform(level)?;
199 let (rc, rr, rw, rh) = level_tr
200 .window_for_bbox(&bbox_in_src)
201 .ok_or_else(|| AgError::Warp("source pixel window is non-finite, out of range, or its GeoTransform is not invertible".into()))?;
202 let same_crs = self.reader.src_crs() == &dst_crs;
203 let level_gsd = self.reader.level_gsd(level).unwrap_or(target_gsd);
204 let res_matches = level_gsd > 0.0 && ((target_gsd - level_gsd).abs() / level_gsd) < 0.01;
205 let aligned = same_crs && res_matches;
206 let ts = style.tilesize as usize;
207 let kernel = if same_crs {
208 style.resampling
209 } else {
210 style.reproject
211 };
212
213 if aligned && kernel != crate::warp::resampler::Resampling::CubicSpline {
216 if let Some((pc, pr)) = Self::passthrough_window(&level_tr, &dst_bbox, ts) {
217 if let Ok((iw, ih)) = self.reader.level_dimensions(level) {
218 let in_bounds = pc >= 0
219 && pr >= 0
220 && (pc as u64 + ts as u64) <= iw as u64
221 && (pr as u64 + ts as u64) <= ih as u64;
222 if in_bounds {
223 return Ok(Some(ReadyPlan {
224 level,
225 col: pc,
226 row: pr,
227 w: ts as u64,
228 h: ts as u64,
229 passthrough: true,
230 dst_crs,
231 dst_bbox,
232 }));
233 }
234 }
235 }
236 }
237
238 let halo = match kernel {
239 crate::warp::resampler::Resampling::Lanczos => 3,
240 crate::warp::resampler::Resampling::Cubic
241 | crate::warp::resampler::Resampling::CubicSpline => 2,
242 crate::warp::resampler::Resampling::Nearest => 0,
243 _ => 1,
244 };
245 let raw = (
246 rc.checked_sub(halo)
247 .ok_or_else(|| AgError::Warp("column halo overflow".into()))?,
248 rr.checked_sub(halo)
249 .ok_or_else(|| AgError::Warp("row halo overflow".into()))?,
250 rw.checked_add(2 * halo as u64)
251 .ok_or_else(|| AgError::Warp("width halo overflow".into()))?,
252 rh.checked_add(2 * halo as u64)
253 .ok_or_else(|| AgError::Warp("height halo overflow".into()))?,
254 );
255 let (col, row, w, h) = self.clamp_window(level, raw.0, raw.1, raw.2, raw.3)?;
256 if w == 0 || h == 0 {
257 return Ok(None);
258 }
259 Ok(Some(ReadyPlan {
260 level,
261 col,
262 row,
263 w,
264 h,
265 passthrough: false,
266 dst_crs,
267 dst_bbox,
268 }))
269 }
270
271 pub async fn warp_tile(
278 &self,
279 coord: TileCoord,
280 style: &TileStyle,
281 ) -> AgResult<Option<Arc<ImageData>>> {
282 self.warp_tile_with_options(coord, style, &RequestOptions::default())
283 .await
284 }
285
286 pub async fn warp_tile_with_options(
288 &self,
289 coord: TileCoord,
290 style: &TileStyle,
291 options: &RequestOptions,
292 ) -> AgResult<Option<Arc<ImageData>>> {
293 self.requests
294 .run(self.request_timeout, options, |context| async move {
295 self.work_budget
296 .run_request(&context, |attempt| async move {
297 self.warp_tile_in_request(coord, style, &attempt).await
298 })
299 .await
300 })
301 .await
302 }
303
304 pub(crate) async fn warp_tile_in_request(
305 &self,
306 coord: TileCoord,
307 style: &TileStyle,
308 context: &RequestContext,
309 ) -> AgResult<Option<Arc<ImageData>>> {
310 context.check()?;
311 compile_cached(style).await?;
312 self.check_raster_source()?;
313 let Some(plan) = self.plan_tile_async(coord, style, context).await? else {
314 return Ok(None);
315 };
316 let bands = u64::from(self.reader.data_ifd(plan.level)?.samples_per_pixel())
317 .max(style.raster.mosaic_band_count() as u64);
318 let bytes = tile_work_bytes(plan.w, plan.h, bands, u64::from(style.tilesize))?;
319 let context = &context.with_work(self.work_budget.try_reserve(bytes)?);
320 let mut src: Arc<ImageData> = self
321 .reader
322 .read_window(plan.level, plan.col, plan.row, plan.w, plan.h)
323 .await?;
324 context.check()?;
325 if let Some(nodata) = style.nodata {
326 Arc::make_mut(&mut src).nodata = Some(nodata);
327 }
328 let raster = style.raster.clone();
329 let size = style.tilesize as usize;
330 let kernel = if src.crs == plan.dst_crs {
331 style.resampling
332 } else {
333 style.reproject
334 };
335 let cancellation = context.clone();
336 self.run_tile_cpu(context, "raster mosaic channels", move || {
337 let warped =
338 match raster.warp_dependencies(&src, &plan.dst_crs, plan.dst_bbox, size, kernel) {
339 Ok(image) => image,
340 Err(AgError::ProjectionDomain(_)) => return Ok(None),
341 Err(error) => return Err(error),
342 };
343 cancellation.check()?;
344 Ok(Some(Arc::new(raster.prepare_mosaic(warped))))
345 })
346 .await
347 }
348
349 #[cfg_attr(feature = "perf-tracing", tracing::instrument(
357 skip(self, style),
358 fields(tms = coord.tms.name(), z = coord.z, x = coord.x, y = coord.y, format = ?style.format),
359 err
360 ))]
361 pub async fn tile(&self, coord: TileCoord, style: &TileStyle) -> AgResult<Option<Bytes>> {
362 self.tile_with_options(coord, style, &RequestOptions::default())
363 .await
364 }
365
366 pub async fn tile_with_options(
368 &self,
369 coord: TileCoord,
370 style: &TileStyle,
371 options: &RequestOptions,
372 ) -> AgResult<Option<Bytes>> {
373 self.requests
374 .run(self.request_timeout, options, |context| async move {
375 self.work_budget
376 .run_request(&context, |attempt| async move {
377 self.tile_in_request(coord, style, &attempt).await
378 })
379 .await
380 })
381 .await
382 }
383
384 pub(crate) fn check_raster_source(&self) -> AgResult<()> {
385 {
386 use async_tiff::tags::ExtraSamples;
387 for level in 0..self.reader.level_count() {
388 if self
389 .reader
390 .data_ifd(level)?
391 .extra_samples()
392 .is_some_and(|extras| {
393 extras.iter().any(|sample| {
394 matches!(
395 sample,
396 ExtraSamples::AssociatedAlpha | ExtraSamples::UnassociatedAlpha
397 )
398 })
399 })
400 {
401 return Err(AgError::query("source_alpha", "Raster Style requires preserved fractional Alpha; this reader currently exposes a boolean mask"));
402 }
403 }
404 }
405 Ok(())
406 }
407
408 pub(crate) async fn tile_in_request(
409 &self,
410 coord: TileCoord,
411 style: &TileStyle,
412 context: &RequestContext,
413 ) -> AgResult<Option<Bytes>> {
414 context.check()?;
415 self.check_raster_source()?;
416 style.raster.statistics.check_scope(false)?;
417 #[cfg(feature = "perf-tracing")]
418 let started = Instant::now();
419 let compiled = compile_cached(style).await?;
420 let Some(plan) = self.plan_tile_async(coord, style, context).await? else {
421 crate::perf_trace! {
422 tracing::debug!(
423 total_ms = duration_ms(started.elapsed()),
424 "tile internals timing: no tile"
425 );
426 }
427 return Ok(None);
428 };
429 let raster = if style.raster.stretch.needs_statistics() {
430 crate::style::statistics::resolve(
431 style,
432 std::slice::from_ref(&self.reader),
433 false,
434 &self.cpu,
435 &self.work_budget,
436 context,
437 )
438 .await?
439 } else {
440 compiled.raster.clone()
441 };
442 let bands = u64::from(self.reader.data_ifd(plan.level)?.samples_per_pixel());
443 let bytes = tile_work_bytes(plan.w, plan.h, bands, u64::from(style.tilesize))?;
444 let context = &context.with_work(self.work_budget.try_reserve(bytes)?);
445 #[cfg(feature = "perf-tracing")]
446 let planned = started.elapsed();
447 let mut src: Arc<ImageData> = self
448 .reader
449 .read_window(plan.level, plan.col, plan.row, plan.w, plan.h)
450 .await?;
451 context.check()?;
452 if let Some(nodata) = style.nodata {
453 Arc::make_mut(&mut src).nodata = Some(nodata);
454 }
455 #[cfg(feature = "perf-tracing")]
456 let read_done = started.elapsed();
457
458 let ts = style.tilesize as usize;
459 let kernel = if src.crs == plan.dst_crs {
460 style.resampling
461 } else {
462 style.reproject
463 };
464 let cancellation = context.clone();
465 let bytes = self
466 .run_tile_cpu(context, "tile", move || {
467 let result = if plan.passthrough {
468 raster.render(&src, src.nodata, compiled.format)
469 } else {
470 raster.render_warped(
471 &src,
472 &plan.dst_crs,
473 plan.dst_bbox,
474 ts,
475 kernel,
476 compiled.format,
477 )
478 };
479 cancellation.check()?;
480 match result {
481 Ok(bytes) => Ok(Some(bytes)),
482 Err(AgError::ProjectionDomain(_)) => Ok(None),
483 Err(error) => Err(error),
484 }
485 })
486 .await?;
487 let Some(bytes) = bytes else {
488 return Ok(None);
489 };
490 #[cfg(feature = "perf-tracing")]
491 let done = started.elapsed();
492 crate::perf_trace! {
493 tracing::debug!(
494 plan_ms = duration_ms(planned),
495 read_window_ms = duration_ms(read_done - planned),
496 fused_cpu_ms = duration_ms(done - read_done),
497 total_ms = duration_ms(done),
498 bytes = bytes.len(),
499 "tile internals timing"
500 );
501 }
502 Ok(Some(bytes))
503 }
504
505 async fn plan_tile_async(
506 &self,
507 coord: TileCoord,
508 style: &TileStyle,
509 context: &RequestContext,
510 ) -> AgResult<Option<ReadyPlan>> {
511 let this = self.clone();
512 let style = style.clone();
513 self.run_tile_cpu(context, "plan", move || this.plan_tile(coord, &style))
514 .await
515 }
516
517 async fn run_tile_cpu<T, F>(
528 &self,
529 context: &RequestContext,
530 label: &'static str,
531 f: F,
532 ) -> AgResult<T>
533 where
534 T: Send + 'static,
535 F: FnOnce() -> AgResult<T> + Send + 'static,
536 {
537 context.check()?;
538 let permit = self
539 .cpu
540 .acquire()
541 .await
542 .map_err(|_| AgError::Warp(format!("{label} cpu limiter closed")))?;
543 let context = context.clone();
544 tokio::task::spawn_blocking(move || {
545 let _permit = permit;
546 context.check()?;
547 let result = f();
548 context.check()?;
549 result
550 })
551 .await
552 .map_err(|e| AgError::Warp(format!("{label} join: {e}")))?
553 }
554
555 fn passthrough_window(
564 level_tr: &crate::bounds::GeoTransform,
565 dst_bbox: &Bbox,
566 ts: usize,
567 ) -> Option<(i64, i64)> {
568 let (x0, y0) = level_tr.forward(0.0, 0.0);
569 let (x1, y1) = level_tr.forward(1.0, 0.0);
570 let (x2, y2) = level_tr.forward(0.0, 1.0);
571 if !(x1 > x0 && y2 < y0 && y1 == y0 && x2 == x0) {
572 return None;
573 }
574 let inv = level_tr.inverse()?;
575 let (c0, r0) = inv.forward(dst_bbox.xmin, dst_bbox.ymax);
577 let (c1, r1) = inv.forward(dst_bbox.xmax, dst_bbox.ymin);
578 let frac = |v: f64| (v - v.round()).abs();
579 if frac(c0) > PASSTHROUGH_PIXEL_TOLERANCE || frac(r0) > PASSTHROUGH_PIXEL_TOLERANCE {
580 return None;
581 }
582 let span_c = (c1 - c0).abs();
583 let span_r = (r1 - r0).abs();
584 if (span_c - ts as f64).abs() > PASSTHROUGH_PIXEL_TOLERANCE
585 || (span_r - ts as f64).abs() > PASSTHROUGH_PIXEL_TOLERANCE
586 {
587 return None;
588 }
589 Some((c0.round() as i64, r0.round() as i64))
590 }
591
592 fn reproject_bbox_sync(
596 bbox: &crate::bounds::Bbox,
597 from_crs: &crate::crs::Crs,
598 src_crs: &crate::crs::Crs,
599 ) -> AgResult<Bbox> {
600 crate::warp::projection::reproject_boundary(
601 [
602 (bbox.xmin, bbox.ymin),
603 (bbox.xmax, bbox.ymin),
604 (bbox.xmax, bbox.ymax),
605 (bbox.xmin, bbox.ymax),
606 ],
607 from_crs,
608 src_crs,
609 )
610 }
611
612 fn clamp_window(
615 &self,
616 level: usize,
617 col: i64,
618 row: i64,
619 w: u64,
620 h: u64,
621 ) -> AgResult<(i64, i64, u64, u64)> {
622 let ifd = self.reader.data_ifd(level)?;
623 let iw = ifd.image_width() as i64;
624 let ih = ifd.image_height() as i64;
625 let col_end = col
626 .checked_add_unsigned(w)
627 .ok_or_else(|| AgError::Warp("source column endpoint out of range".into()))?
628 .min(iw);
629 let row_end = row
630 .checked_add_unsigned(h)
631 .ok_or_else(|| AgError::Warp("source row endpoint out of range".into()))?
632 .min(ih);
633 let col = col.max(0);
634 let row = row.max(0);
635 let w = (col_end - col).max(0) as u64;
636 let h = (row_end - row).max(0) as u64;
637 Ok((col, row, w, h))
638 }
639}
640
641fn tile_work_bytes(width: u64, height: u64, bands: u64, tile_size: u64) -> AgResult<u64> {
644 width
645 .checked_mul(height)
646 .and_then(|n| n.checked_mul(bands))
647 .and_then(|n| n.checked_mul(16))
648 .and_then(|source| {
649 tile_size
650 .checked_mul(tile_size)
651 .and_then(|pixels| {
652 bands
653 .checked_mul(48)
654 .and_then(|per_band| per_band.checked_add(48))
655 .and_then(|per_pixel| pixels.checked_mul(per_pixel))
656 })
657 .and_then(|tile| source.checked_add(tile))
658 })
659 .ok_or_else(|| AgError::ResourceLimit("tile working-set estimate overflow".into()))
660}
661
662#[cfg(test)]
663mod tests {
664 #[test]
665 fn passthrough_rejects_flipped_and_rotated_grids() {
666 let bbox = crate::bounds::Bbox::new(0.0, 0.0, 256.0, 256.0);
667 for gt in [
668 [256.0, -1.0, 0.0, 256.0, 0.0, -1.0],
669 [0.0, 0.0, 1.0, 256.0, -1.0, 0.0],
670 ] {
671 assert!(super::Tiler::passthrough_window(
672 &crate::bounds::GeoTransform::from_gdal(>),
673 &bbox,
674 256
675 )
676 .is_none());
677 }
678 }
679 use super::{Tiler, TilerConfig};
680 use crate::bounds::{Bbox, GeoTransform};
681
682 #[test]
683 fn tiler_type_compiles() {
684 let _ = std::mem::size_of::<Tiler>();
685 }
686
687 fn lonlat_to_tile(lon: f64, lat: f64, z: u8) -> (u32, u32) {
689 let n = 2f64.powi(z as i32);
690 let x = ((lon + 180.0) / 360.0 * n).floor().clamp(0.0, n - 1.0) as u32;
691 let lat_rad = lat.to_radians();
692 let y = ((1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / std::f64::consts::PI) / 2.0
693 * n)
694 .floor()
695 .clamp(0.0, n - 1.0) as u32;
696 (x, y)
697 }
698
699 #[tokio::test]
700 async fn tile_returns_none_outside_data_and_some_inside() {
701 use crate::io::CogReader;
702 use crate::style::TileStyle;
703 use crate::tms::{TileCoord, TileMatrixSet};
704 use async_tiff::reader::ObjectReader;
705 use object_store::local::LocalFileSystem;
706 use object_store::path::Path as OsPath;
707 use std::sync::Arc;
708
709 let tif = std::env::current_dir()
710 .unwrap()
711 .join("tests/fixtures/rgb_nodata.tif");
712
713 let os_path = OsPath::from_filesystem_path(&tif).unwrap();
714 let reader = CogReader::builder()
715 .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
716 .open(ObjectReader::new(Arc::new(LocalFileSystem::new()), os_path))
717 .await
718 .unwrap();
719 let tiler = Tiler::new(Arc::new(reader));
720 let style = TileStyle::from_raster_query("version=3.0&type=gray&bidx=1").unwrap();
721 let [w, s, e, n] = tiler.wgs84_bounds().unwrap();
722
723 let z = 12u8;
725 let (cx, cy) = lonlat_to_tile((w + e) / 2.0, (s + n) / 2.0, z);
726 let inside = tiler
727 .tile(
728 TileCoord {
729 tms: TileMatrixSet::WebMercatorQuad,
730 z,
731 x: cx,
732 y: cy,
733 },
734 &style,
735 )
736 .await
737 .unwrap();
738 assert!(inside.is_some(), "in-bounds tile must produce bytes");
739
740 let anti_lon = {
742 let l = (w + e) / 2.0 + 180.0;
743 if l > 180.0 {
744 l - 360.0
745 } else {
746 l
747 }
748 };
749 let (ax, ay) = lonlat_to_tile(anti_lon, -(s + n) / 2.0, z);
750 let outside = tiler
751 .tile(
752 TileCoord {
753 tms: TileMatrixSet::WebMercatorQuad,
754 z,
755 x: ax,
756 y: ay,
757 },
758 &style,
759 )
760 .await
761 .unwrap();
762 assert!(
763 outside.is_none(),
764 "fully out-of-bounds tile must return None, got bytes"
765 );
766 }
767
768 #[tokio::test]
769 #[ignore = "requires optional 904 MiB local JL1 fixture"]
770 async fn local_jl1_cog_center_tile_is_not_opaque_black() {
771 use crate::io::CogReader;
772 use crate::style::TileStyle;
773 use crate::tms::{TileCoord, TileMatrixSet};
774 use async_tiff::reader::ObjectReader;
775 use object_store::local::LocalFileSystem;
776 use object_store::path::Path as OsPath;
777 use std::sync::Arc;
778
779 let tif = std::env::current_dir()
780 .unwrap()
781 .join("data/JL1GF04A_PMS_20230301124138_3857_cog.tif");
782
783 let os_path = OsPath::from_filesystem_path(&tif).unwrap();
784 let reader = CogReader::builder()
785 .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
786 .open(ObjectReader::new(Arc::new(LocalFileSystem::new()), os_path))
787 .await
788 .unwrap();
789 let tiler = Tiler::new(Arc::new(reader));
790 let style = TileStyle::from_raster_query(
791 "version=3.0&type=gray&bidx=1&resampling=bilinear&reproject=bilinear&extensions=%5B%22https%3A%2F%2Fmapseekai.github.io%2Fraster-style%2Fextensions%2Fraster%2Ftile%2Fv1.0.0%2Fschema.json%22%5D&extension_fields=%7B%22raster%3Atile%22%3A%7B%22tilesize%22%3A256%7D%7D",
792 )
793 .unwrap();
794 let png = tiler
795 .tile(
796 TileCoord {
797 tms: TileMatrixSet::WebMercatorQuad,
798 z: 14,
799 x: 12866,
800 y: 7017,
801 },
802 &style,
803 )
804 .await
805 .unwrap()
806 .expect("JL1 center tile must intersect the COG");
807 let rgba = image::load_from_memory(&png).unwrap().to_rgba8();
808 assert!(
809 rgba.pixels()
810 .any(|pixel| pixel[3] > 0 && (pixel[0] != 0 || pixel[1] != 0 || pixel[2] != 0)),
811 "JL1 center tile must retain source imagery instead of becoming opaque black"
812 );
813 }
814
815 #[test]
816 fn tiler_config_clamps_blocking_limit_to_one() {
817 let cfg = TilerConfig {
818 max_blocking_tasks: 0,
819 ..Default::default()
820 };
821 assert_eq!(cfg.blocking_task_limit(), 1);
822 }
823
824 #[test]
825 fn passthrough_hits_on_exact_aligned_tile() {
826 let gt = GeoTransform::from_gdal(&[0.0, 1.0, 0.0, 256.0, 0.0, -1.0]);
829 let bbox = Bbox::new(0.0, 0.0, 256.0, 256.0);
830 assert_eq!(Tiler::passthrough_window(>, &bbox, 256), Some((0, 0)));
831
832 let bbox2 = Bbox::new(512.0, 0.0, 768.0, 256.0);
834 assert_eq!(Tiler::passthrough_window(>, &bbox2, 256), Some((512, 0)));
835 }
836
837 #[test]
838 fn passthrough_rejects_subpixel_offset() {
839 let gt = GeoTransform::from_gdal(&[0.0, 1.0, 0.0, 256.0, 0.0, -1.0]);
840 let bbox = Bbox::new(0.5, 0.0, 256.5, 256.0);
843 assert_eq!(Tiler::passthrough_window(>, &bbox, 256), None);
844 }
845
846 #[test]
847 fn passthrough_rejects_scale_mismatch() {
848 let gt = GeoTransform::from_gdal(&[0.0, 1.0, 0.0, 256.0, 0.0, -1.0]);
849 let bbox = Bbox::new(0.0, -256.0, 512.0, 256.0);
851 assert_eq!(Tiler::passthrough_window(>, &bbox, 256), None);
852 }
853
854 #[tokio::test]
855 async fn cancelled_cpu_stage_keeps_all_permits_until_it_exits() {
856 use crate::request::{RequestLimiter, RequestOptions};
857 let reader = crate::io::CogReader::builder()
858 .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
859 .open_object_store(
860 std::sync::Arc::new(
861 object_store::local::LocalFileSystem::new_with_prefix(".").unwrap(),
862 ),
863 "tests/fixtures/rgb_nodata.tif",
864 )
865 .await
866 .unwrap();
867 let tiler = Tiler::with_config(
868 std::sync::Arc::new(reader),
869 TilerConfig {
870 max_blocking_tasks: 1,
871 ..Default::default()
872 },
873 );
874 let cpu = tiler.cpu.clone();
875 let admission = RequestLimiter::new(1);
876 let task_admission = admission.clone();
877 let budget = crate::resource::WorkBudget::new(100);
878 let task_budget = budget.clone();
879 let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
880 let (release_tx, release_rx) = std::sync::mpsc::channel();
881 let task = tokio::spawn(async move {
882 task_admission
883 .run(
884 std::time::Duration::from_secs(30),
885 &RequestOptions::default(),
886 |context| async move {
887 let context = context.with_work(task_budget.try_reserve(70).unwrap());
888 tiler
889 .run_tile_cpu(&context, "held test stage", move || {
890 entered_tx.send(()).unwrap();
891 release_rx.recv().unwrap();
892 Ok(())
893 })
894 .await
895 },
896 )
897 .await
898 });
899 entered_rx.await.unwrap();
900 task.abort();
901 let _ = task.await;
902 assert_eq!(cpu.available_permits(), 0);
903 assert_eq!(admission.available_permits(), 0);
904 assert_eq!(budget.used_bytes(), 70);
905 release_tx.send(()).unwrap();
906 tokio::time::timeout(std::time::Duration::from_secs(2), async {
907 while cpu.available_permits() != 1
908 || admission.available_permits() != 1
909 || budget.used_bytes() != 0
910 {
911 tokio::task::yield_now().await;
912 }
913 })
914 .await
915 .unwrap();
916 }
917
918 #[tokio::test]
919 async fn tile_rejects_work_budget_before_reading_pixels() {
920 let reader = crate::io::CogReader::builder()
921 .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
922 .open_object_store(
923 std::sync::Arc::new(
924 object_store::local::LocalFileSystem::new_with_prefix(".").unwrap(),
925 ),
926 "tests/fixtures/rgb_nodata.tif",
927 )
928 .await
929 .unwrap();
930 let budget = crate::resource::WorkBudget::new(1);
931 let tiler = Tiler::new(std::sync::Arc::new(reader)).with_work_budget(budget.clone());
932 let (x, y) = lonlat_to_tile(10.032, 20.032, 18);
933 let coord = crate::tms::TileCoord {
934 tms: crate::tms::TileMatrixSet::WebMercatorQuad,
935 z: 18,
936 x,
937 y,
938 };
939 assert!(matches!(
940 tiler.tile(coord, &crate::style::TileStyle::default()).await,
941 Err(crate::error::AgError::ResourceLimit(_))
942 ));
943 assert_eq!(budget.used_bytes(), 0);
944 }
945
946 #[tokio::test]
947 async fn lanczos_plan_reads_full_kernel_halo() {
948 let reader = crate::io::CogReader::builder()
949 .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
950 .open_object_store(
951 std::sync::Arc::new(
952 object_store::local::LocalFileSystem::new_with_prefix(".").unwrap(),
953 ),
954 "tests/fixtures/rgb_nodata.tif",
955 )
956 .await
957 .unwrap();
958 let tiler = Tiler::new(std::sync::Arc::new(reader));
959 let (x, y) = lonlat_to_tile(10.032, 20.032, 18);
960 let coord = crate::tms::TileCoord {
961 tms: crate::tms::TileMatrixSet::WebMercatorQuad,
962 z: 18,
963 x,
964 y,
965 };
966 let nearest = tiler
967 .plan_tile(coord, &crate::style::TileStyle::default())
968 .unwrap()
969 .unwrap();
970 let lanczos = tiler
971 .plan_tile(
972 coord,
973 &crate::style::TileStyle::from_raster_query(
974 "version=3.0&type=gray&reproject=lanczos&bidx=1",
975 )
976 .unwrap(),
977 )
978 .unwrap()
979 .unwrap();
980 assert!(lanczos.col <= nearest.col - 3 && lanczos.row <= nearest.row - 3);
981 assert!(lanczos.w >= nearest.w + 6 && lanczos.h >= nearest.h + 6);
982 }
983
984 #[tokio::test]
985 async fn request_nodata_is_applied_before_interpolation() {
986 let store = std::sync::Arc::new(
987 object_store::local::LocalFileSystem::new_with_prefix(".").unwrap(),
988 );
989 let reader = crate::io::CogReader::builder()
990 .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
991 .open_object_store(store, "tests/fixtures/rgb_nodata.tif")
992 .await
993 .unwrap();
994 let tiler = Tiler::new(std::sync::Arc::new(reader));
995 let [w, s, e, n] = tiler.wgs84_bounds().unwrap();
996 let (x, y) = lonlat_to_tile((w + e) / 2.0, (s + n) / 2.0, 12);
997 let coord = crate::tms::TileCoord {
998 tms: crate::tms::TileMatrixSet::WebMercatorQuad,
999 z: 12,
1000 x,
1001 y,
1002 };
1003 let style = crate::style::TileStyle::from_raster_query(
1004 "version=3.0&type=gray&nodata=100&reproject=bilinear&bidx=1",
1005 )
1006 .unwrap();
1007 let plan = tiler.plan_tile(coord, &style).unwrap().unwrap();
1008 let mut source = (*tiler
1009 .reader
1010 .read_window(plan.level, plan.col, plan.row, plan.w, plan.h)
1011 .await
1012 .unwrap())
1013 .clone();
1014 source.nodata = Some(100.0);
1015 source.pixels = crate::pixel::PixelData::F64(
1017 source.pixels.band_to_f64(0).insert_axis(ndarray::Axis(0)),
1018 );
1019 let expected = crate::warp::warp(
1020 &source,
1021 &plan.dst_crs,
1022 plan.dst_bbox,
1023 256,
1024 256,
1025 style.reproject,
1026 )
1027 .unwrap();
1028 let actual = tiler.warp_tile(coord, &style).await.unwrap().unwrap();
1029 assert_eq!(actual.mask, expected.mask);
1030 for (a, b) in actual
1031 .pixels
1032 .to_f64()
1033 .iter()
1034 .zip(expected.pixels.to_f64().iter())
1035 {
1036 assert!(a == b || (a.is_nan() && b.is_nan()));
1037 }
1038 }
1039}