Comments, docstrings, logs, CLI help, map UI, legends, PDF sheet, scripts, compose files and AGENTS.md are now English. Data keys stay unchanged (relief_oriente, densite_sol, visualisations/, API JSON keys, link params). Wrong comments and help defaults found along the way are corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
668 lines
27 KiB
Python
668 lines
27 KiB
Python
"""Tests for the tile catalog (index.py)."""
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
|
||
def test_parse_basename_coords_valid():
|
||
"""Parse the coordinates of a valid LHD basename."""
|
||
from lidar_pipeline.index import parse_basename_coords
|
||
assert parse_basename_coords("LHD_FXX_1000_6881_PTS_LAMB93_IGN69") == (1000, 6881)
|
||
assert parse_basename_coords("LHD_FXX_1049_6895_PTS_LAMB93_IGN69") == (1049, 6895)
|
||
|
||
|
||
def test_parse_basename_coords_with_res_suffix():
|
||
"""Directory names with a resolution suffix can be parsed too."""
|
||
from lidar_pipeline.index import parse_basename_coords
|
||
assert parse_basename_coords("LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2") == (1000, 6881)
|
||
|
||
|
||
def test_parse_basename_coords_invalid():
|
||
"""Non-LHD names return None."""
|
||
from lidar_pipeline.index import parse_basename_coords
|
||
assert parse_basename_coords("random_dir") is None
|
||
assert parse_basename_coords("DTM") is None
|
||
assert parse_basename_coords("") is None
|
||
|
||
|
||
def test_strip_res_suffix_primary():
|
||
"""Directory without a suffix = primary resolution (0.5)."""
|
||
from lidar_pipeline.index import _strip_res_suffix
|
||
base, res = _strip_res_suffix("LHD_FXX_1000_6881_PTS_LAMB93_IGN69")
|
||
assert base == "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||
assert res == 0.5
|
||
|
||
|
||
def test_strip_res_suffix_multi():
|
||
"""Directory with the _r0p2 suffix = resolution 0.2."""
|
||
from lidar_pipeline.index import _strip_res_suffix
|
||
base, res = _strip_res_suffix("LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2")
|
||
assert base == "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||
assert res == 0.2
|
||
|
||
|
||
def test_compute_bbox():
|
||
"""Compute the bounding box of a set of tiles."""
|
||
from lidar_pipeline.index import compute_bbox
|
||
tiles = [
|
||
{'col': 1000, 'row': 6881},
|
||
{'col': 1001, 'row': 6882},
|
||
{'col': 1002, 'row': 6880},
|
||
]
|
||
bbox = compute_bbox(tiles)
|
||
assert bbox == {'min_col': 1000, 'max_col': 1002, 'min_row': 6880, 'max_row': 6882}
|
||
|
||
|
||
def test_compute_bbox_empty():
|
||
"""No tile → None."""
|
||
from lidar_pipeline.index import compute_bbox
|
||
assert compute_bbox([]) is None
|
||
|
||
|
||
def _make_fake_viz_dir(vis_dir, basename, col, row, viz_keys=('hillshade_multi', 'svf'), ext='webp', res_suffix=''):
|
||
"""Create a fake visualization directory with small images.
|
||
|
||
The resolution suffix only appears in the directory name (mirroring
|
||
the pipeline: files stay prefixed with the bare basename).
|
||
"""
|
||
from PIL import Image as PILImage
|
||
import numpy as np
|
||
|
||
dir_name = f"LHD_FXX_{col}_{row}_PTS_LAMB93_IGN69{res_suffix}"
|
||
tile_dir = Path(vis_dir) / dir_name
|
||
tile_dir.mkdir(parents=True, exist_ok=True)
|
||
for v in viz_keys:
|
||
arr = np.random.randint(0, 255, (50, 50, 3), dtype=np.uint8)
|
||
img = PILImage.fromarray(arr)
|
||
fname = f"LHD_FXX_{col}_{row}_PTS_LAMB93_IGN69_{v}.{ext}"
|
||
img.save(str(tile_dir / fname), format='WEBP', quality=80)
|
||
return tile_dir
|
||
|
||
|
||
def test_scan_tiles(tmp_path):
|
||
"""scan_tiles detects the tile directories and their visualizations."""
|
||
from lidar_pipeline.index import scan_tiles
|
||
|
||
vis_dir = tmp_path / "visualisations"
|
||
vis_dir.mkdir()
|
||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi', 'svf'))
|
||
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',))
|
||
|
||
tiles = scan_tiles(vis_dir)
|
||
assert len(tiles) == 2
|
||
names = sorted(t['dir_name'] for t in tiles)
|
||
assert "LHD_FXX_1000_6881_PTS_LAMB93_IGN69" in names
|
||
assert "LHD_FXX_1001_6881_PTS_LAMB93_IGN69" in names
|
||
# Check that the visualizations are detected
|
||
t0 = next(t for t in tiles if t['col'] == 1000)
|
||
assert 'hillshade_multi' in t0['viz']
|
||
assert 'svf' in t0['viz']
|
||
|
||
|
||
def test_scan_tiles_ignores_non_lhd(tmp_path):
|
||
"""Non-LHD directories (e.g. temp, DTM) are ignored."""
|
||
from lidar_pipeline.index import scan_tiles
|
||
|
||
vis_dir = tmp_path / "visualisations"
|
||
vis_dir.mkdir()
|
||
(vis_dir / "random_folder").mkdir()
|
||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881)
|
||
|
||
tiles = scan_tiles(vis_dir)
|
||
assert len(tiles) == 1
|
||
assert tiles[0]['col'] == 1000
|
||
|
||
|
||
def test_scan_tiles_multi_resolution(tmp_path):
|
||
"""Directories with a resolution suffix are decoded correctly."""
|
||
from lidar_pipeline.index import scan_tiles
|
||
|
||
vis_dir = tmp_path / "visualisations"
|
||
vis_dir.mkdir()
|
||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, res_suffix='')
|
||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, res_suffix='_r0p2')
|
||
|
||
tiles = scan_tiles(vis_dir)
|
||
assert len(tiles) == 2
|
||
resolutions = sorted(t['resolution'] for t in tiles)
|
||
assert resolutions == [0.2, 0.5]
|
||
|
||
|
||
def test_res_suffix_str():
|
||
"""The resolution suffix mirrors the pipeline naming."""
|
||
from lidar_pipeline.index import _res_suffix_str
|
||
assert _res_suffix_str(0.5) == ''
|
||
assert _res_suffix_str(0.2) == '_r0p2'
|
||
|
||
|
||
def test_collect_tile_metadata(tmp_path):
|
||
"""The metadata read the DTM method and the viz dates/sizes."""
|
||
import os
|
||
from datetime import datetime
|
||
from lidar_pipeline.index import _collect_tile_metadata
|
||
|
||
basename = "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||
tile_dir = tmp_path / "visualisations" / basename
|
||
tile_dir.mkdir(parents=True)
|
||
viz_file = tile_dir / f"{basename}_hillshade_multi.webp"
|
||
viz_file.write_bytes(b"fake")
|
||
|
||
dtm_dir = tmp_path / "DTM"
|
||
dtm_dir.mkdir()
|
||
method_file = dtm_dir / f"{basename}_dtm_method.txt"
|
||
method_file.write_text("ign", encoding="utf-8")
|
||
# Deterministic dates: method.txt older than the viz
|
||
os.utime(method_file, (1600000000, 1600000000))
|
||
os.utime(viz_file, (1700000000, 1700000000))
|
||
fmt = lambda ts: datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
|
||
|
||
tile = {
|
||
'basename': basename, 'resolution': 0.5,
|
||
'dir_path': str(tile_dir),
|
||
'viz': {'hillshade_multi': {'filename': viz_file.name, 'ext': 'webp'}},
|
||
}
|
||
meta = _collect_tile_metadata(tile, dtm_dir)
|
||
assert meta['method'] == 'ign'
|
||
assert meta['generated'] == fmt(1600000000)
|
||
assert meta['viz']['hillshade_multi']['size'] == 4
|
||
assert meta['viz']['hillshade_multi']['date'] == fmt(1700000000)
|
||
|
||
|
||
def test_collect_tile_metadata_resolution_suffix(tmp_path):
|
||
"""A 0.2 m tile reads its dedicated _dtm_r0p2_method.txt sidecar."""
|
||
from lidar_pipeline.index import _collect_tile_metadata
|
||
|
||
basename = "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||
tile_dir = tmp_path / "visualisations" / (basename + "_r0p2")
|
||
tile_dir.mkdir(parents=True)
|
||
dtm_dir = tmp_path / "DTM"
|
||
dtm_dir.mkdir()
|
||
(dtm_dir / f"{basename}_dtm_r0p2_method.txt").write_text("smrf", encoding="utf-8")
|
||
|
||
tile = {'basename': basename, 'resolution': 0.2,
|
||
'dir_path': str(tile_dir),
|
||
'viz': {}}
|
||
meta = _collect_tile_metadata(tile, dtm_dir)
|
||
assert meta['method'] == 'smrf'
|
||
# The date comes from the sidecar (written right after the DTM is created)
|
||
assert meta['generated'] is not None
|
||
assert meta['viz'] == {}
|
||
|
||
|
||
def test_collect_tile_metadata_fallback_date(tmp_path):
|
||
"""Without a DTM sidecar, the generation date falls back to the oldest viz file."""
|
||
from lidar_pipeline.index import _collect_tile_metadata
|
||
|
||
basename = "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||
tile_dir = tmp_path / "visualisations" / basename
|
||
tile_dir.mkdir(parents=True)
|
||
f = tile_dir / f"{basename}_svf.webp"
|
||
f.write_bytes(b"x")
|
||
|
||
tile = {'basename': basename, 'resolution': 0.5,
|
||
'dir_path': str(tile_dir),
|
||
'viz': {'svf': {'filename': f.name, 'ext': 'webp'}}}
|
||
meta = _collect_tile_metadata(tile, tmp_path / "DTM")
|
||
assert meta['method'] is None
|
||
assert meta['generated'] is not None
|
||
|
||
|
||
def test_build_index_generates_inventory(tmp_path):
|
||
"""build_index generates the index_tiles.json inventory and the thumbnails."""
|
||
from lidar_pipeline.index import build_index
|
||
|
||
output_dir = tmp_path / "output"
|
||
vis_dir = output_dir / "visualisations"
|
||
vis_dir.mkdir(parents=True)
|
||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881,
|
||
('aspect', 'slope', 'positive_openness',
|
||
'wavelet', 'hillshade_multi', 'svf'))
|
||
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',))
|
||
|
||
result = build_index(output_dir)
|
||
assert result is not None
|
||
inv_path = Path(result)
|
||
assert inv_path.exists()
|
||
assert inv_path.name == "index_tiles.json"
|
||
# The old HTML interface was removed: no more shell or assets
|
||
assert not (output_dir / "index.html").exists()
|
||
assert not (output_dir / "assets").exists()
|
||
|
||
# Inventory served by /api/tiles to the lightweight machines: tiles with corners,
|
||
# layer labels, counter.
|
||
data = json.loads(inv_path.read_text(encoding='utf-8'))
|
||
assert data['tiles'] and data['tiles'][0]['corners']
|
||
assert 'aspect' in data['viz_meta']
|
||
assert data['viz_meta']['aspect']['label']
|
||
assert data['stats']['n_tiles'] == len(data['tiles'])
|
||
# Check the generated thumbnails
|
||
thumb_dir = output_dir / "index_thumbs"
|
||
assert thumb_dir.is_dir()
|
||
thumbs = list(thumb_dir.glob("*.jpg"))
|
||
assert len(thumbs) >= 2 # at least hillshade for each tile
|
||
|
||
|
||
def test_mid_thumbnails_generated(tmp_path):
|
||
"""640 px intermediate thumbnail generated and referenced in the index."""
|
||
from lidar_pipeline.index import build_index
|
||
|
||
output_dir = tmp_path / "output"
|
||
vis_dir = output_dir / "visualisations"
|
||
vis_dir.mkdir(parents=True)
|
||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
|
||
|
||
assert build_index(output_dir) is not None
|
||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||
viz = data['tiles'][0]['viz']['hillshade_multi']
|
||
assert 'mid' in viz
|
||
# The URL carries a ?v= suffix (cache busting): path without it
|
||
assert (output_dir / viz['mid'].split('?')[0]).exists()
|
||
assert viz['mid'].split('?')[0].endswith("_mid.jpg")
|
||
|
||
|
||
def test_subtiles_cover_all_layers(tmp_path):
|
||
"""Every layer of a 0.2 m tile is cut into sub-tiles (no more short list)."""
|
||
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ, build_index
|
||
|
||
assert _CARTO_SUBTILED_VIZ == ()
|
||
|
||
output_dir = tmp_path / "output"
|
||
vis_dir = output_dir / "visualisations"
|
||
vis_dir.mkdir(parents=True)
|
||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881,
|
||
('hillshade_multi', 'svf', 'topo'), res_suffix='_r0p2')
|
||
|
||
assert build_index(output_dir) is not None
|
||
sub_dir = output_dir / "index_subtiles"
|
||
for viz in ('hillshade_multi', 'svf', 'topo'):
|
||
avif = sub_dir / f"LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_{viz}_0_0.avif"
|
||
assert avif.exists(), viz
|
||
# The index references the sub-tiles for each layer, not the whole-tile fallback
|
||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||
subs = [t for t in data['tiles'] if t.get('sub_k') == 2]
|
||
assert len(subs) == 4
|
||
for t in subs:
|
||
for viz in ('hillshade_multi', 'svf', 'topo'):
|
||
assert t['viz'][viz]['full'].startswith("index_subtiles/"), viz
|
||
|
||
|
||
def test_panel_restricted_to_kept_layers():
|
||
"""The panel is restricted to the kept layers (PANEL_VIZ): the default
|
||
main layer and the precision layer are in it."""
|
||
from lidar_pipeline.index import (PANEL_VIZ, DEFAULT_VIZ, PRECISION_VIZ, VIEW_MODES,
|
||
DEFAULT_VIEW_MODE, KEYWORD_TO_STEP, default_main_layer)
|
||
from lidar_pipeline.pipeline import VIZ_STEPS
|
||
assert DEFAULT_VIZ in PANEL_VIZ and PRECISION_VIZ in PANEL_VIZ
|
||
assert DEFAULT_VIEW_MODE in VIEW_MODES
|
||
assert default_main_layer(["densite_sol", "aspect"]) == "aspect"
|
||
assert default_main_layer(["densite_sol"]) is None
|
||
# Each kept layer matches a valid --only step
|
||
steps = {name for name, _ in VIZ_STEPS}
|
||
for key in PANEL_VIZ:
|
||
assert KEYWORD_TO_STEP.get(key, key) in steps
|
||
|
||
|
||
def test_build_index_merges_cross_resolution_viz(tmp_path):
|
||
"""A layer produced only at 0.5 m stays visible on the 0.2 m tile.
|
||
|
||
The last run may have been interrupted between the two resolution passes:
|
||
the cross-resolution merge must point thumbnails and URLs to the original
|
||
directory of each file (dir_name), not to the dir_path of the displayed
|
||
tile — otherwise the thumbnails fail and the layer disappears.
|
||
"""
|
||
from lidar_pipeline.index import build_index
|
||
|
||
output_dir = tmp_path / "output"
|
||
vis_dir = output_dir / "visualisations"
|
||
vis_dir.mkdir(parents=True)
|
||
base = "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
|
||
# 0.5 m pass: aspect + slope; 0.2 m pass: aspect only
|
||
_make_fake_viz_dir(vis_dir, base, 1000, 6881, ('aspect', 'slope'))
|
||
_make_fake_viz_dir(vis_dir, base, 1000, 6881, ('aspect',), res_suffix='_r0p2')
|
||
|
||
assert build_index(output_dir) is not None
|
||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||
# A single displayed position (finest resolution), two layers
|
||
tiles = [t for t in data['tiles'] if (t['col'], t['row']) == (1000, 6881)]
|
||
keys = set()
|
||
for t in tiles:
|
||
keys.update(t['viz'].keys())
|
||
assert {'aspect', 'slope'} <= keys
|
||
# Slope thumbnail generated + every thumb/full really exists on disk
|
||
assert (output_dir / "index_thumbs" / f"{base}_r0p2_slope.jpg").exists()
|
||
for t in tiles:
|
||
for v in t['viz'].values():
|
||
assert (output_dir / v['thumb'].split('?')[0]).exists(), v['thumb']
|
||
assert (output_dir / v['full'].split('?')[0]).exists(), v['full']
|
||
|
||
|
||
def test_build_index_regenerates_stale_thumbnails(tmp_path):
|
||
"""A recomputed tile (newer source) regenerates its thumbnail."""
|
||
import os
|
||
import time
|
||
import numpy as np
|
||
from PIL import Image as PILImage
|
||
from lidar_pipeline.index import build_index
|
||
|
||
output_dir = tmp_path / "output"
|
||
vis_dir = output_dir / "visualisations"
|
||
vis_dir.mkdir(parents=True)
|
||
tile_dir = _make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
|
||
|
||
assert build_index(output_dir) is not None
|
||
thumb_path = output_dir / "index_thumbs" / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_hillshade_multi.jpg"
|
||
assert thumb_path.exists()
|
||
m1 = thumb_path.stat().st_mtime
|
||
|
||
# Tile recomputed: source rewritten with a newer mtime
|
||
src = tile_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_hillshade_multi.webp"
|
||
arr = np.random.randint(0, 255, (50, 50, 3), dtype=np.uint8)
|
||
PILImage.fromarray(arr).save(str(src), format='WEBP', quality=80)
|
||
os.utime(src, (m1 + 5, m1 + 5))
|
||
|
||
assert build_index(output_dir) is not None
|
||
m2 = thumb_path.stat().st_mtime
|
||
assert m2 > m1 # thumbnail regenerated
|
||
|
||
# Source not modified since → no needless regeneration
|
||
os.utime(src, (time.time() - 10, time.time() - 10))
|
||
assert build_index(output_dir) is not None
|
||
assert thumb_path.stat().st_mtime == m2
|
||
|
||
|
||
def test_build_subtiles_regenerates_stale_crops(tmp_path):
|
||
"""A recomputed 0.2 m tile regenerates its sub-tiles (per visualization)."""
|
||
import os
|
||
from lidar_pipeline.index import build_index
|
||
|
||
output_dir = tmp_path / "output"
|
||
vis_dir = output_dir / "visualisations"
|
||
vis_dir.mkdir(parents=True)
|
||
tile_dir = _make_fake_viz_dir(vis_dir, "a", 1000, 6881,
|
||
('hillshade_multi', 'aspect'), res_suffix='_r0p2')
|
||
|
||
assert build_index(output_dir) is not None
|
||
sub_dir = output_dir / "index_subtiles"
|
||
hill_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_hillshade_multi_0_0.avif"
|
||
aspect_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_aspect_0_0.avif"
|
||
assert hill_avif.exists() and aspect_avif.exists()
|
||
hill_mid = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_hillshade_multi_0_0_mid.webp"
|
||
assert hill_mid.exists()
|
||
m_hill_1 = hill_avif.stat().st_mtime
|
||
m_aspect_1 = aspect_avif.stat().st_mtime
|
||
|
||
# Recomputation: only the hillshade source is newer
|
||
src = tile_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_hillshade_multi.webp"
|
||
os.utime(src, (m_hill_1 + 5, m_hill_1 + 5))
|
||
|
||
assert build_index(output_dir) is not None
|
||
assert hill_avif.stat().st_mtime > m_hill_1 # hillshade sub-tiles regenerated
|
||
assert aspect_avif.stat().st_mtime == m_aspect_1 # aspect untouched
|
||
|
||
|
||
def test_urls_versioned_for_cache_busting(tmp_path):
|
||
"""Image URLs change when a tile is regenerated.
|
||
|
||
Versioned URLs (?v=) are served with an immutable cache: a
|
||
recomputation MUST change the URL to force a reload, including
|
||
live during a run (the inventory is rewritten after each tile). Each URL
|
||
carries the mtime of ITS file (see test_urls_versioned_by_served_file_mtime).
|
||
"""
|
||
import os
|
||
import re
|
||
from lidar_pipeline.index import build_index
|
||
|
||
output_dir = tmp_path / "output"
|
||
vis_dir = output_dir / "visualisations"
|
||
vis_dir.mkdir(parents=True)
|
||
tile_dir = _make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
|
||
|
||
assert build_index(output_dir) is not None
|
||
|
||
def read_urls():
|
||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||
viz = data['tiles'][0]['viz']['hillshade_multi']
|
||
return viz['thumb'], viz['mid'], viz['full']
|
||
|
||
thumb1, mid1, full1 = read_urls()
|
||
for url in (thumb1, mid1, full1):
|
||
assert re.search(r"\?v=\d+$", url), url # versioned (ms mtime)
|
||
assert (output_dir / url.split('?')[0]).exists(), url
|
||
|
||
# Same source, rebuild → identical URLs (browser cache kept)
|
||
assert build_index(output_dir) is not None
|
||
assert read_urls() == (thumb1, mid1, full1)
|
||
|
||
# Regeneration: newer source mtime → new URLs
|
||
src = tile_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_hillshade_multi.webp"
|
||
m = src.stat().st_mtime
|
||
os.utime(src, (m + 10, m + 10))
|
||
assert build_index(output_dir) is not None
|
||
thumb2, mid2, full2 = read_urls()
|
||
assert (thumb2, mid2, full2) != (thumb1, mid1, full1)
|
||
assert thumb2.split('?')[0] == thumb1.split('?')[0] # same file, new version
|
||
|
||
|
||
def test_subtile_urls_versioned(tmp_path):
|
||
"""The URLs of the 0.2 m sub-tiles are versioned too (?v=)."""
|
||
import re
|
||
from lidar_pipeline.index import build_index
|
||
|
||
output_dir = tmp_path / "output"
|
||
vis_dir = output_dir / "visualisations"
|
||
vis_dir.mkdir(parents=True)
|
||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',),
|
||
res_suffix='_r0p2')
|
||
|
||
assert build_index(output_dir) is not None
|
||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||
subs = [t for t in data['tiles'] if t.get('sub_k') == 2]
|
||
assert subs
|
||
for t in subs:
|
||
for v in t['viz'].values():
|
||
for url in (v['thumb'], v['mid'], v['full']):
|
||
assert re.search(r"\?v=\d+$", url), url
|
||
assert (output_dir / url.split('?')[0]).exists(), url
|
||
|
||
|
||
def test_build_index_empty_returns_none(tmp_path):
|
||
"""No tile → build_index returns None without crashing."""
|
||
from lidar_pipeline.index import build_index
|
||
|
||
output_dir = tmp_path / "output"
|
||
(output_dir / "visualisations").mkdir(parents=True)
|
||
result = build_index(output_dir)
|
||
assert result is None
|
||
|
||
|
||
|
||
def test_attach_gps_bounds():
|
||
"""attach_gps_bounds adds ordered GPS bounds (metropolitan France)."""
|
||
from lidar_pipeline.index import attach_gps_bounds
|
||
tiles = [{'col': 1000, 'row': 6881}, {'col': 1042, 'row': 6900}]
|
||
attach_gps_bounds(tiles)
|
||
for t in tiles:
|
||
assert 'bounds' in t
|
||
(lat_s, lon_w), (lat_n, lon_e) = t['bounds']
|
||
assert lat_n > lat_s
|
||
assert lon_e > lon_w
|
||
# Metropolitan France
|
||
assert 41 < lat_s < 51
|
||
assert -5 < lon_w < 10
|
||
|
||
|
||
def test_attach_gps_bounds_row_is_north_edge():
|
||
"""The file row number = NORTH edge (IGN LiDAR HD convention).
|
||
|
||
Checked against the DTM bounds: X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
|
||
The historical regression placed Y ∈ [row, row+1] (1 km too far north).
|
||
"""
|
||
from rasterio.warp import transform as warp_transform
|
||
from lidar_pipeline.index import attach_gps_bounds
|
||
|
||
col, row = 1054, 6882
|
||
tiles = [{'col': col, 'row': row}]
|
||
attach_gps_bounds(tiles)
|
||
corners = tiles[0]['corners']
|
||
|
||
# Exact reference of the real cell: SW, SE, NE, NW
|
||
xs = [col * 1000, (col + 1) * 1000, (col + 1) * 1000, col * 1000]
|
||
ys = [(row - 1) * 1000, (row - 1) * 1000, row * 1000, row * 1000]
|
||
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys)
|
||
for k in range(4):
|
||
assert abs(corners[k][0] - lats[k]) < 1e-9
|
||
assert abs(corners[k][1] - lons[k]) < 1e-9
|
||
|
||
# The old convention (row = south edge) would be off by about 1 km
|
||
lat_n = max(c[0] for c in corners)
|
||
assert abs(lat_n - max(lats)) < 1e-9 # north edge = Y = row×1000
|
||
|
||
|
||
|
||
def test_pick_display_viz_prefers_hillshade():
|
||
"""The default viz choice prefers hillshade_multi."""
|
||
from lidar_pipeline.index import _pick_display_viz
|
||
assert _pick_display_viz(['svf', 'hillshade_multi', 'slope']) == 'hillshade_multi'
|
||
assert _pick_display_viz(['svf', 'slope']) == 'svf'
|
||
assert _pick_display_viz(['topo']) == 'topo'
|
||
|
||
|
||
def test_subdivision_k():
|
||
"""0.5 m/px (2000 px) stays whole; 0.2 m/px (5000 px) is split 2×2."""
|
||
from lidar_pipeline.index import _subdivision_k
|
||
assert _subdivision_k(0.5) == 1
|
||
assert _subdivision_k(0.2) == 2
|
||
assert _subdivision_k(1.0) == 1
|
||
|
||
|
||
def test_subtile_corners_grid():
|
||
"""The sub-tiles rebuild the tile grid exactly."""
|
||
from lidar_pipeline.index import _subtile_corners
|
||
corners = [[10.0, 2.0], [10.0, 3.0], [11.0, 3.0], [11.0, 2.0]] # SW SE NE NW
|
||
k = 2
|
||
sw_quad = _subtile_corners(corners, 0, 0, k) # south-west quadrant
|
||
ne_quad = _subtile_corners(corners, 1, 1, k) # north-east quadrant
|
||
# The SW quadrant shares the SW corner of the tile
|
||
assert sw_quad[0] == corners[0]
|
||
# The NE quadrant shares the NE corner of the tile
|
||
assert ne_quad[2] == corners[2]
|
||
# The SW quadrant has its NE corner at the center of the tile
|
||
assert sw_quad[2] == [10.5, 2.5]
|
||
# Adjacency: east edge of SW = west edge of SE (0,0)-(1,0)
|
||
se_quad = _subtile_corners(corners, 1, 0, k)
|
||
assert sw_quad[1] == se_quad[0]
|
||
assert sw_quad[2] == se_quad[3]
|
||
|
||
|
||
def test_approx_wgs84_to_l93_roundtrip():
|
||
"""The affine WGS84→L93 approximation is the exact inverse of L93→WGS84."""
|
||
from lidar_pipeline.index import _approx_l93_to_wgs84, _approx_wgs84_to_l93
|
||
for x, y in ((1054000.0, 6882000.0), (700000.0, 6600000.0), (950123.0, 6410456.0)):
|
||
lon, lat = _approx_l93_to_wgs84(x, y)
|
||
x2, y2 = _approx_wgs84_to_l93(lon, lat)
|
||
assert abs(x2 - x) < 1e-6 and abs(y2 - y) < 1e-6
|
||
|
||
|
||
|
||
|
||
def test_subtile_corners_shared_edges_exact():
|
||
"""Shared edges: two neighboring sub-tiles get the SAME point.
|
||
|
||
Without sharing, the bilinear interpolation is evaluated twice on
|
||
different fractions and the edges miss by less than a pixel
|
||
— broken seam (white line) at medium zoom.
|
||
"""
|
||
from lidar_pipeline.index import _subtile_corners
|
||
corners = [[10.0, 2.0], [10.0, 3.0], [11.0, 3.0], [11.0, 2.0]]
|
||
k = 4
|
||
a = _subtile_corners(corners, 0, 0, k)
|
||
b = _subtile_corners(corners, 1, 0, k)
|
||
assert a[1] == b[0] # east edge of (0,0) = west edge of (1,0)
|
||
assert a[2] == b[3]
|
||
c = _subtile_corners(corners, 0, 1, k)
|
||
assert a[3] == c[0] # north edge of (0,0) = south edge of (0,1)
|
||
d = _subtile_corners(corners, 3, 3, k)
|
||
assert d[2] == corners[2] # tile NE corner shared as is
|
||
|
||
|
||
def test_overview_corners_shared_between_tiles(tmp_path):
|
||
"""A tile's corner is also its neighbor's (same point).
|
||
|
||
Two E-W adjacent tiles share a vertical edge: the NE of the western
|
||
tile = the NW of the eastern tile. Without sharing, the bilinear interpolations
|
||
of neighboring sub-tiles miss by less than a pixel — broken seam
|
||
at medium zoom.
|
||
"""
|
||
from lidar_pipeline.index import build_index
|
||
|
||
output_dir = tmp_path / "output"
|
||
vis_dir = output_dir / "visualisations"
|
||
vis_dir.mkdir(parents=True)
|
||
for col in (1000, 1001):
|
||
_make_fake_viz_dir(vis_dir, "a", col, 6881, ('hillshade_multi',))
|
||
|
||
assert build_index(output_dir) is not None
|
||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||
tiles = {t['col']: t for t in data['tiles']}
|
||
a_ne = tiles[1000]['corners'][2] # NE of the western tile
|
||
b_nw = tiles[1001]['corners'][3] # NW of the eastern tile
|
||
assert a_ne == b_nw, f"shared corners diverge: {a_ne} != {b_nw}"
|
||
|
||
|
||
def test_urls_versioned_by_served_file_mtime(tmp_path):
|
||
"""?v= follows the mtime of the SERVED file — prerequisite of the immutable cache.
|
||
|
||
A thumbnail recomputed later (unchanged source) changes content:
|
||
its URL must change too, without touching those of the intact files.
|
||
"""
|
||
from lidar_pipeline.index import build_index
|
||
|
||
output_dir = tmp_path / "output"
|
||
vis_dir = output_dir / "visualisations"
|
||
vis_dir.mkdir(parents=True)
|
||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
|
||
# 0.2 m: the tile goes through sub-tiling (own thumbnails/mid/AVIF)
|
||
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',),
|
||
res_suffix='_r0p2')
|
||
|
||
assert build_index(output_dir) is not None
|
||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||
import time
|
||
for t in data['tiles']:
|
||
for viz in t['viz'].values():
|
||
for tier in ('thumb', 'mid', 'full'):
|
||
url = viz.get(tier)
|
||
if not url or '?v=' not in url:
|
||
continue
|
||
path, v = url.split('?v=')
|
||
assert v.isdigit()
|
||
# The version is the ms mtime of the served file itself
|
||
# (1 s tolerance: file system granularity).
|
||
assert abs(int(v) - int((output_dir / path).stat().st_mtime * 1000)) < 1000
|
||
|
||
# 0.5 m thumbnail recomputed later: only ITS URL changes.
|
||
tiles05 = [t for t in data['tiles'] if t.get('resolution') == 0.5]
|
||
url_before = tiles05[0]['viz']['hillshade_multi']['thumb']
|
||
thumb_path = output_dir / url_before.split('?')[0]
|
||
time.sleep(0.05)
|
||
from PIL import Image as PILImage
|
||
PILImage.new('RGB', (30, 30), (200, 10, 10)).save(str(thumb_path), format='JPEG')
|
||
assert build_index(output_dir) is not None
|
||
data2 = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||
tiles05b = [t for t in data2['tiles'] if t.get('resolution') == 0.5]
|
||
url_after = tiles05b[0]['viz']['hillshade_multi']['thumb']
|
||
assert url_after.split('?')[0] == url_before.split('?')[0]
|
||
assert url_after != url_before
|
||
|
||
|
||
def test_inventory_contains_quality_table(tmp_path):
|
||
import json
|
||
from lidar_pipeline.index import build_index
|
||
from lidar_pipeline.quality import write_quality
|
||
from lidar_pipeline.tests.test_tiles import _make_dalle
|
||
base = _make_dalle(tmp_path, 1054, 6882, ["relief_oriente"])
|
||
write_quality(tmp_path, base, {"version": 1, "ground_density": 7.5})
|
||
build_index(tmp_path, "webp")
|
||
inv = json.loads((tmp_path / "index_tiles.json").read_text(encoding="utf-8"))
|
||
assert inv["quality"] == {base: {"version": 1, "ground_density": 7.5}}
|