Skip to content

Commit 327c128

Browse files
committed
Add pixel-level expected output comparison for live merge tests
- Add test/expected/z{0,2}_x*_y*.png: lossless PNG reference tiles generated by running the GEBCO+JAXA merger once and frozen as ground truth - Add test/generate_expected_tiles.py: regeneration script to re-run whenever merger behaviour is intentionally changed - Add TestLiveMerge.test_output_matches_expected_tiles: decodes output tiles to elevation arrays and asserts np.allclose(atol=1.0) against references - Update _EXPECTED_TILES_DIR to point to existing test/expected/ convention
1 parent 816b26a commit 327c128

5 files changed

Lines changed: 225 additions & 2 deletions

File tree

test/expected/z0_x0_y0.png

452 KB
Loading

test/expected/z2_x0_y2.png

461 KB
Loading

test/expected/z2_x2_y1.png

396 KB
Loading

test/generate_expected_tiles.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#!/usr/bin/env python3
2+
"""Generate reference expected-output PNG tiles for the live merge tests.
3+
4+
Runs the merger against the committed GEBCO and JAXA fixture files, extracts
5+
a small set of key output tiles as lossless PNG files, and writes them to
6+
test/fixtures/expected/. The comparison test (TestLiveMerge.test_output_matches_expected_tiles)
7+
loads these PNGs, decodes to elevation values, and checks that new runs of the
8+
merger produce the same results within 1 m tolerance.
9+
10+
Run this script whenever you intentionally change merger behaviour so that the
11+
reference tiles stay in sync:
12+
13+
python test/generate_expected_tiles.py
14+
"""
15+
16+
import io
17+
import json
18+
import os
19+
import sys
20+
import sqlite3
21+
import tempfile
22+
import traceback
23+
from pathlib import Path
24+
25+
from PIL import Image
26+
27+
# Allow running from the repo root without installing the package.
28+
sys.path.insert(0, str(Path(__file__).parent.parent))
29+
30+
from click.testing import CliRunner
31+
from rio_rgbify.scripts.cli import main_group as cli
32+
33+
# ---------------------------------------------------------------------------
34+
# Paths
35+
# ---------------------------------------------------------------------------
36+
37+
FIXTURES_DIR = Path(__file__).parent / "fixtures"
38+
GEBCO_FIXTURE = FIXTURES_DIR / "gebco_sample.mbtiles"
39+
JAXA_FIXTURE = FIXTURES_DIR / "jaxa_sample.mbtiles"
40+
EXPECTED_DIR = Path(__file__).parent / "expected"
41+
42+
# ---------------------------------------------------------------------------
43+
# Key tiles to capture as reference output — (z, x, y, description).
44+
#
45+
# z=0/x=0/y=0 global overview — always present
46+
# z=2/x=2/y=1 East Asia / Pacific coast — JAXA land wins over GEBCO depths
47+
# z=2/x=0/y=2 South Atlantic open ocean — GEBCO-only depths
48+
# ---------------------------------------------------------------------------
49+
50+
KEY_TILES = [
51+
(0, 0, 0, "global_z0"),
52+
(2, 2, 1, "east_asia_z2"),
53+
(2, 0, 2, "south_atlantic_z2"),
54+
]
55+
56+
57+
def _decode_elevation(tile_bytes: bytes):
58+
"""Decode mapbox-encoded RGB(A) tile bytes -> elevation float64 array."""
59+
img = Image.open(io.BytesIO(tile_bytes)).convert("RGB")
60+
arr = __import__("numpy").array(img).astype(__import__("numpy").float64)
61+
r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
62+
return -10000 + ((r * 256 * 256 + g * 256 + b) * 0.1)
63+
64+
65+
def main() -> int:
66+
if not GEBCO_FIXTURE.exists() or not JAXA_FIXTURE.exists():
67+
print("ERROR: Fixture files not found.")
68+
print(" Run `python test/download_fixtures.py` first.")
69+
return 1
70+
71+
EXPECTED_DIR.mkdir(parents=True, exist_ok=True)
72+
73+
with tempfile.TemporaryDirectory() as tmp:
74+
out = os.path.join(tmp, "merged.mbtiles")
75+
cfg_path = os.path.join(tmp, "config.json")
76+
77+
# Mirror the TestLiveMerge._run_merge config but force output_format=png
78+
# so the reference tiles are stored losslessly.
79+
cfg = {
80+
"output_type": "mbtiles",
81+
"sources": [
82+
{
83+
"path": str(JAXA_FIXTURE),
84+
"encoding": "mapbox",
85+
"mask_values": [-10000, 0, -1],
86+
},
87+
{
88+
"path": str(GEBCO_FIXTURE),
89+
"encoding": "mapbox",
90+
"mask_values": [-10000],
91+
},
92+
],
93+
"output_path": out,
94+
"output_encoding": "mapbox",
95+
"output_format": "png",
96+
"resampling": "cubic",
97+
"min_zoom": 0,
98+
"max_zoom": 2,
99+
}
100+
101+
with open(cfg_path, "w") as f:
102+
json.dump(cfg, f)
103+
104+
print("Running merger (this may take ~60 seconds) ...")
105+
runner = CliRunner()
106+
result = runner.invoke(cli, ["merge", "--config", cfg_path, "-j", "1"])
107+
108+
if result.exit_code != 0:
109+
print("ERROR: Merger failed:")
110+
print(result.output)
111+
if result.exception:
112+
traceback.print_exception(
113+
type(result.exception),
114+
result.exception,
115+
result.exception.__traceback__,
116+
)
117+
return 1
118+
119+
print("Extracting key tiles ...")
120+
conn = sqlite3.connect(out)
121+
saved = 0
122+
123+
for z, x, y, desc in KEY_TILES:
124+
row = conn.execute(
125+
"SELECT tile_data FROM tiles"
126+
" WHERE zoom_level=? AND tile_column=? AND tile_row=?",
127+
(z, x, y),
128+
).fetchone()
129+
130+
if row is None:
131+
print(f" SKIP z={z}/x={x}/y={y} ({desc}) - tile not in output")
132+
continue
133+
134+
fname = EXPECTED_DIR / f"z{z}_x{x}_y{y}.png"
135+
fname.write_bytes(row[0])
136+
saved += 1
137+
138+
img = Image.open(io.BytesIO(row[0]))
139+
elev = _decode_elevation(row[0])
140+
import numpy as np
141+
print(
142+
f" OK z={z}/x={x}/y={y} ({desc})"
143+
f" [{img.size[0]}x{img.size[1]}]"
144+
f" median elev = {np.median(elev):.1f} m"
145+
)
146+
147+
conn.close()
148+
149+
print(f"\nDone. {saved} reference tiles written to {EXPECTED_DIR}")
150+
return 0
151+
152+
153+
if __name__ == "__main__":
154+
sys.exit(main())

test/test_merger.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -681,8 +681,12 @@ def test_merge_terrarium_output_encoding(self, tmp_path):
681681
# source[1] = GEBCO (bathymetry, fills ocean gaps), mask_values=[-10000]
682682
# ---------------------------------------------------------------------------
683683

684-
_GEBCO_FIXTURE = Path(__file__).parent / "fixtures" / "gebco_sample.mbtiles"
685-
_JAXA_FIXTURE = Path(__file__).parent / "fixtures" / "jaxa_sample.mbtiles"
684+
_GEBCO_FIXTURE = Path(__file__).parent / "fixtures" / "gebco_sample.mbtiles"
685+
_JAXA_FIXTURE = Path(__file__).parent / "fixtures" / "jaxa_sample.mbtiles"
686+
_EXPECTED_TILES_DIR = Path(__file__).parent / "expected"
687+
688+
# Key tiles extracted by test/generate_expected_tiles.py: (z, x, y)
689+
_REFERENCE_KEY_TILES = [(0, 0, 0), (2, 2, 1), (2, 0, 2)]
686690

687691

688692
@pytest.mark.skipif(
@@ -827,3 +831,68 @@ def test_merge_output_nodata(self, tmp_path):
827831
)
828832
assert result.exit_code == 0, result.output + str(result.exception or "")
829833
assert os.path.exists(out)
834+
835+
@pytest.mark.skipif(
836+
not any(
837+
(_EXPECTED_TILES_DIR / f"z{z}_x{x}_y{y}.png").exists()
838+
for z, x, y in _REFERENCE_KEY_TILES
839+
),
840+
reason=(
841+
"Reference tiles not found — run `python test/generate_expected_tiles.py`"
842+
" to create them"
843+
),
844+
)
845+
def test_output_matches_expected_tiles(self, tmp_path):
846+
"""
847+
Decoded elevation values in the merged output must match pre-generated
848+
reference PNGs within ±1 m tolerance.
849+
850+
The reference tiles in test/fixtures/expected/ are produced with PNG
851+
output (lossless) by running test/generate_expected_tiles.py. Re-run
852+
that script whenever you intentionally change merger behaviour.
853+
"""
854+
# Use PNG output so our results are also lossless and directly comparable
855+
# against the reference PNGs.
856+
result, out = self._run_merge(tmp_path, extra_config={"output_format": "png"})
857+
assert result.exit_code == 0, result.output + str(result.exception or "")
858+
859+
conn = sqlite3.connect(out)
860+
mismatches = []
861+
862+
for z, x, y in _REFERENCE_KEY_TILES:
863+
expected_path = _EXPECTED_TILES_DIR / f"z{z}_x{x}_y{y}.png"
864+
if not expected_path.exists():
865+
continue # skip tiles that weren't generated
866+
867+
# Decode reference tile -> elevation float64 array
868+
ref_arr = np.array(Image.open(expected_path).convert("RGB")).astype(np.float64)
869+
ref_elev = -10000 + (
870+
(ref_arr[:, :, 0] * 256 * 256
871+
+ ref_arr[:, :, 1] * 256
872+
+ ref_arr[:, :, 2]) * 0.1
873+
)
874+
875+
# Decode output tile -> elevation float64 array
876+
row = conn.execute(
877+
"SELECT tile_data FROM tiles"
878+
" WHERE zoom_level=? AND tile_column=? AND tile_row=?",
879+
(z, x, y),
880+
).fetchone()
881+
assert row is not None, f"Tile z={z}/x={x}/y={y} missing from output"
882+
883+
out_arr = np.array(Image.open(io.BytesIO(row[0])).convert("RGB")).astype(np.float64)
884+
out_elev = -10000 + (
885+
(out_arr[:, :, 0] * 256 * 256
886+
+ out_arr[:, :, 1] * 256
887+
+ out_arr[:, :, 2]) * 0.1
888+
)
889+
890+
# Allow ±1 m — catches regression while tolerating minor float rounding
891+
if not np.allclose(ref_elev, out_elev, atol=1.0):
892+
max_delta = float(np.max(np.abs(ref_elev - out_elev)))
893+
mismatches.append(
894+
f"z={z}/x={x}/y={y}: max delta = {max_delta:.1f} m"
895+
)
896+
897+
conn.close()
898+
assert not mismatches, "Elevation mismatch vs reference: " + "; ".join(mismatches)

0 commit comments

Comments
 (0)