Skip to content

Commit 6399e66

Browse files
committed
Add COG overview tests for CPU and GPU paths (#1150)
Tests cover resampling methods (mean, nearest, mode, min, max, median), multi-level overviews, auto-generation, round-trip value preservation, public API dispatch, and GPU/CPU parity for block-reduce.
1 parent 98b0ae9 commit 6399e66

File tree

1 file changed

+242
-0
lines changed

1 file changed

+242
-0
lines changed

xrspatial/geotiff/tests/test_cog.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
134376
def 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

Comments
 (0)