@@ -131,6 +131,248 @@ def test_write_rejects_4d(self, tmp_path):
131131 to_geotiff (arr , str (tmp_path / 'bad.tif' ))
132132
133133
134+ class TestCOGOverviewResampling :
135+ """Test overview resampling methods produce correct results."""
136+
137+ def test_overview_mean (self , tmp_path ):
138+ arr = np .array ([[1 , 3 , 5 , 7 ],
139+ [2 , 4 , 6 , 8 ],
140+ [9 , 11 , 13 , 15 ],
141+ [10 , 12 , 14 , 16 ]], dtype = np .float32 )
142+ path = str (tmp_path / 'cog_1150_mean.tif' )
143+ write (arr , path , compression = 'deflate' , tiled = True , tile_size = 4 ,
144+ cog = True , overview_levels = [1 ], overview_resampling = 'mean' )
145+
146+ with open (path , 'rb' ) as f :
147+ data = f .read ()
148+ header = parse_header (data )
149+ ifds = parse_all_ifds (data , header )
150+ assert len (ifds ) == 2
151+ # Overview should be 2x2
152+ ov_ifd = ifds [1 ]
153+ assert ov_ifd .width == 2
154+ assert ov_ifd .height == 2
155+
156+ def test_overview_nearest (self , tmp_path ):
157+ arr = np .arange (64 , dtype = np .float32 ).reshape (8 , 8 )
158+ path = str (tmp_path / 'cog_1150_nearest.tif' )
159+ write (arr , path , compression = 'deflate' , tiled = True , tile_size = 4 ,
160+ cog = True , overview_levels = [1 ], overview_resampling = 'nearest' )
161+
162+ result , _ = read_to_array_local (path )
163+ np .testing .assert_array_equal (result , arr )
164+
165+ def test_overview_mode (self , tmp_path ):
166+ # Categorical data: mode should pick the most common value
167+ arr = np .array ([[1 , 1 , 2 , 2 ],
168+ [1 , 1 , 2 , 2 ],
169+ [3 , 3 , 4 , 4 ],
170+ [3 , 3 , 4 , 4 ]], dtype = np .int32 )
171+ path = str (tmp_path / 'cog_1150_mode.tif' )
172+ write (arr , path , compression = 'deflate' , tiled = True , tile_size = 4 ,
173+ cog = True , overview_levels = [1 ], overview_resampling = 'mode' )
174+
175+ with open (path , 'rb' ) as f :
176+ data = f .read ()
177+ header = parse_header (data )
178+ ifds = parse_all_ifds (data , header )
179+ assert len (ifds ) == 2
180+
181+ @pytest .mark .parametrize ('method' , ['min' , 'max' , 'median' ])
182+ def test_overview_other_methods (self , tmp_path , method ):
183+ arr = np .arange (256 , dtype = np .float32 ).reshape (16 , 16 )
184+ path = str (tmp_path / f'cog_1150_{ method } .tif' )
185+ write (arr , path , compression = 'deflate' , tiled = True , tile_size = 8 ,
186+ cog = True , overview_levels = [1 ], overview_resampling = method )
187+
188+ with open (path , 'rb' ) as f :
189+ data = f .read ()
190+ header = parse_header (data )
191+ ifds = parse_all_ifds (data , header )
192+ assert len (ifds ) >= 2
193+
194+
195+ class TestCOGMultipleOverviews :
196+ def test_multiple_overview_levels (self , tmp_path ):
197+ """Multiple explicit overview levels produce correct number of IFDs."""
198+ arr = np .arange (4096 , dtype = np .float32 ).reshape (64 , 64 )
199+ path = str (tmp_path / 'cog_1150_multi.tif' )
200+ write (arr , path , compression = 'deflate' , tiled = True , tile_size = 8 ,
201+ cog = True , overview_levels = [1 , 2 , 3 ])
202+
203+ with open (path , 'rb' ) as f :
204+ data = f .read ()
205+ header = parse_header (data )
206+ ifds = parse_all_ifds (data , header )
207+ # Full res + 3 overviews
208+ assert len (ifds ) == 4
209+
210+ def test_auto_overviews_large_raster (self , tmp_path ):
211+ """Auto-generation on a larger raster produces multiple levels."""
212+ arr = np .random .RandomState (42 ).rand (512 , 512 ).astype (np .float32 )
213+ path = str (tmp_path / 'cog_1150_auto_large.tif' )
214+ write (arr , path , compression = 'deflate' , tiled = True , tile_size = 64 ,
215+ cog = True )
216+
217+ with open (path , 'rb' ) as f :
218+ data = f .read ()
219+ header = parse_header (data )
220+ ifds = parse_all_ifds (data , header )
221+ # 512 -> 256 -> 128 -> 64: should stop, so 3 overview levels + full = 4
222+ assert len (ifds ) >= 3
223+
224+ def test_cog_overview_round_trip_values (self , tmp_path ):
225+ """Full-res values are preserved through COG write with overviews."""
226+ arr = np .random .RandomState (99 ).rand (32 , 32 ).astype (np .float32 )
227+ gt = GeoTransform (- 120.0 , 45.0 , 0.001 , - 0.001 )
228+ path = str (tmp_path / 'cog_1150_rt_values.tif' )
229+ write (arr , path , geo_transform = gt , crs_epsg = 4326 ,
230+ compression = 'deflate' , tiled = True , tile_size = 16 ,
231+ cog = True , overview_levels = [1 , 2 ])
232+
233+ result , geo = read_to_array_local (path )
234+ np .testing .assert_array_equal (result , arr )
235+ assert geo .crs_epsg == 4326
236+
237+
238+ class TestCOGPublicAPIOverviews :
239+ def test_to_geotiff_cog_with_overviews (self , tmp_path ):
240+ """Public to_geotiff() with cog=True writes overviews."""
241+ y = np .linspace (45.0 , 44.0 , 32 )
242+ x = np .linspace (- 120.0 , - 119.0 , 32 )
243+ data = np .random .RandomState (42 ).rand (32 , 32 ).astype (np .float32 )
244+
245+ da = xr .DataArray (
246+ data , dims = ['y' , 'x' ],
247+ coords = {'y' : y , 'x' : x },
248+ attrs = {'crs' : 4326 },
249+ )
250+
251+ path = str (tmp_path / 'cog_1150_api.tif' )
252+ to_geotiff (da , path , compression = 'deflate' , cog = True ,
253+ tile_size = 16 , overview_levels = [1 ])
254+
255+ result = open_geotiff (path )
256+ np .testing .assert_array_almost_equal (result .values , data , decimal = 5 )
257+
258+ # Verify COG structure
259+ with open (path , 'rb' ) as f :
260+ raw = f .read ()
261+ header = parse_header (raw )
262+ ifds = parse_all_ifds (raw , header )
263+ assert len (ifds ) >= 2
264+
265+ def test_to_geotiff_cog_auto_overviews (self , tmp_path ):
266+ """Public API auto-generates overviews when only cog=True."""
267+ data = np .random .RandomState (7 ).rand (64 , 64 ).astype (np .float32 )
268+ da = xr .DataArray (data , dims = ['y' , 'x' ])
269+
270+ path = str (tmp_path / 'cog_1150_api_auto.tif' )
271+ to_geotiff (da , path , compression = 'deflate' , cog = True , tile_size = 16 )
272+
273+ with open (path , 'rb' ) as f :
274+ raw = f .read ()
275+ header = parse_header (raw )
276+ ifds = parse_all_ifds (raw , header )
277+ assert len (ifds ) >= 2
278+
279+
280+ try :
281+ import cupy
282+ _HAS_CUPY = True
283+ except ImportError :
284+ _HAS_CUPY = False
285+
286+
287+ @pytest .mark .skipif (not _HAS_CUPY , reason = "CuPy not installed" )
288+ class TestGPUCOGOverviews :
289+ """GPU-specific COG overview tests (require CuPy + CUDA)."""
290+
291+ def test_gpu_cog_round_trip (self , tmp_path ):
292+ import cupy
293+ arr = np .random .RandomState (42 ).rand (32 , 32 ).astype (np .float32 )
294+ gpu_arr = cupy .asarray (arr )
295+
296+ path = str (tmp_path / 'cog_1150_gpu_rt.tif' )
297+ from xrspatial .geotiff import write_geotiff_gpu
298+ write_geotiff_gpu (gpu_arr , path , crs = 4326 , compression = 'deflate' ,
299+ cog = True , overview_levels = [1 ])
300+
301+ result = open_geotiff (path )
302+ np .testing .assert_array_almost_equal (result .values , arr , decimal = 5 )
303+
304+ with open (path , 'rb' ) as f :
305+ raw = f .read ()
306+ header = parse_header (raw )
307+ ifds = parse_all_ifds (raw , header )
308+ assert len (ifds ) >= 2
309+
310+ def test_gpu_cog_auto_overviews (self , tmp_path ):
311+ import cupy
312+ arr = np .random .RandomState (7 ).rand (64 , 64 ).astype (np .float32 )
313+ gpu_arr = cupy .asarray (arr )
314+
315+ path = str (tmp_path / 'cog_1150_gpu_auto.tif' )
316+ from xrspatial .geotiff import write_geotiff_gpu
317+ write_geotiff_gpu (gpu_arr , path , compression = 'deflate' ,
318+ cog = True , tile_size = 16 )
319+
320+ with open (path , 'rb' ) as f :
321+ raw = f .read ()
322+ header = parse_header (raw )
323+ ifds = parse_all_ifds (raw , header )
324+ assert len (ifds ) >= 2
325+
326+ def test_gpu_overview_resampling_nearest (self , tmp_path ):
327+ import cupy
328+ arr = np .arange (64 , dtype = np .float32 ).reshape (8 , 8 )
329+ gpu_arr = cupy .asarray (arr )
330+
331+ path = str (tmp_path / 'cog_1150_gpu_nearest.tif' )
332+ from xrspatial .geotiff import write_geotiff_gpu
333+ write_geotiff_gpu (gpu_arr , path , compression = 'deflate' ,
334+ cog = True , overview_levels = [1 ],
335+ overview_resampling = 'nearest' )
336+
337+ result = open_geotiff (path )
338+ np .testing .assert_array_equal (result .values , arr )
339+
340+ def test_gpu_make_overview_values (self ):
341+ """GPU overview block-reduce matches CPU for simple case."""
342+ import cupy
343+ from xrspatial .geotiff ._gpu_decode import make_overview_gpu
344+ from xrspatial .geotiff ._writer import _make_overview
345+
346+ arr = np .random .RandomState (42 ).rand (16 , 16 ).astype (np .float32 )
347+ gpu_arr = cupy .asarray (arr )
348+
349+ for method in ('mean' , 'nearest' , 'min' , 'max' ):
350+ cpu_ov = _make_overview (arr , method = method )
351+ gpu_ov = make_overview_gpu (gpu_arr , method = method ).get ()
352+ np .testing .assert_allclose (gpu_ov , cpu_ov , rtol = 1e-5 ,
353+ err_msg = f"Mismatch for method={ method } " )
354+
355+ def test_gpu_to_geotiff_dispatches_with_overviews (self , tmp_path ):
356+ """to_geotiff auto-dispatches CuPy data with overview params."""
357+ import cupy
358+ arr = np .random .RandomState (11 ).rand (32 , 32 ).astype (np .float32 )
359+ da = xr .DataArray (cupy .asarray (arr ), dims = ['y' , 'x' ],
360+ attrs = {'crs' : 4326 })
361+
362+ path = str (tmp_path / 'cog_1150_gpu_dispatch.tif' )
363+ to_geotiff (da , path , compression = 'deflate' , cog = True ,
364+ overview_levels = [1 ])
365+
366+ result = open_geotiff (path )
367+ np .testing .assert_array_almost_equal (result .values , arr , decimal = 5 )
368+
369+ with open (path , 'rb' ) as f :
370+ raw = f .read ()
371+ header = parse_header (raw )
372+ ifds = parse_all_ifds (raw , header )
373+ assert len (ifds ) >= 2
374+
375+
134376def read_to_array_local (path ):
135377 """Helper to call read_to_array for local files."""
136378 from xrspatial .geotiff ._reader import read_to_array
0 commit comments