diff --git a/lidar_pipeline/fetch_ign.py b/lidar_pipeline/fetch_ign.py index 2c241be..359596b 100644 --- a/lidar_pipeline/fetch_ign.py +++ b/lidar_pipeline/fetch_ign.py @@ -216,6 +216,7 @@ def fetch_tiles(input_dir, specs, output_dir=None, only_viz=None, resolutions=(0 Returns: Liste des chemins téléchargés. """ + from .dtm import EDGE_NEIGHBORS_DIRNAME input_dir = Path(input_dir) complete = None if output_dir is not None and only_viz and not force: @@ -223,6 +224,10 @@ def fetch_tiles(input_dir, specs, output_dir=None, only_viz=None, resolutions=(0 keys = [step_to_keyword(v) for v in only_viz] complete = cells_with_all_viz(Path(output_dir) / "visualisations", keys, resolutions) + # Explicite : jamais de promotion quand on télécharge directement DANS + # edge_neighbors/ (appel de _fetch_edge_neighbors avec input_dir=edge_dir) + # — sinon une dalle voisine pourrait se retrouver déplacée vers elle-même. + is_edge_dir = input_dir.name == EDGE_NEIGHBORS_DIRNAME downloaded = [] for col, row in specs: name = tile_filename(col, row) @@ -232,6 +237,19 @@ def fetch_tiles(input_dir, specs, output_dir=None, only_viz=None, resolutions=(0 logger.info(f" {name} : déjà présent dans input/ — aucun téléchargement") _report_download(output_dir, tile, "skip", "déjà dans input/") continue + if not is_edge_dir: + # dest.exists() ci-dessus ne teste que le nom exact : un ".part" + # voisin n'y correspond jamais, donc .exists() suffit à exclure + # un téléchargement voisin encore en cours. + neighbor_copy = input_dir / EDGE_NEIGHBORS_DIRNAME / name + if neighbor_copy.exists(): + import os + os.replace(neighbor_copy, dest) + logger.info(f" {name} : déjà téléchargée comme voisine " + f"— déplacée dans input/") + _report_download(output_dir, tile, "skip", + "déjà téléchargée comme voisine — déplacée dans input/") + continue if output_dir is not None and not force: if complete is not None: if (col, row) in complete: diff --git a/lidar_pipeline/pipeline.py b/lidar_pipeline/pipeline.py index fc12322..4e32775 100644 --- a/lidar_pipeline/pipeline.py +++ b/lidar_pipeline/pipeline.py @@ -496,6 +496,7 @@ class LidarArchaeoPipeline: from .dtm import _tile_coords, _NEIGHBOR_OFFSETS, EDGE_NEIGHBORS_DIRNAME from .fetch_ign import fetch_tiles, tile_filename edge_dir = self.input_dir / EDGE_NEIGHBORS_DIRNAME + self._cleanup_edge_neighbor_duplicates(edge_dir) wanted = set() for laz_file in files: coords = _tile_coords(Path(laz_file).name) @@ -523,6 +524,47 @@ class LidarArchaeoPipeline: logger.info(f"Raccord des bords : {len(fetched)}/{len(missing)} dalle(s) " f"voisine(s) téléchargée(s) ({time.time() - t0:.0f} s)") + def _cleanup_edge_neighbor_duplicates(self, edge_dir): + """Supprime de edge_neighbors/ les dalles déjà présentes dans input/. + + Une dalle téléchargée comme voisine avant d'être requise comme tuile + principale (ou l'inverse) peut se retrouver dupliquée aux deux + emplacements (avant que fetch_tiles ne promeuve les nouvelles + voisines) ; l'exemplaire d'input/ fait foi (_neighbor_laz_files + cherche dans input/ avant edge_neighbors/), le doublon ne sert donc + à rien et peut peser plusieurs centaines de Mo. Les fichiers ".part" + (téléchargement en cours) ne sont jamais touchés, et un doublon de + taille différente est conservé (l'exemplaire d'input/ pourrait être + tronqué : on ne supprime jamais ce qui serait la seule copie saine). + """ + from .dtm import EDGE_NEIGHBORS_DIRNAME + if not edge_dir.is_dir(): + return + removed = 0 + freed = 0 + for path in edge_dir.iterdir(): + if not path.is_file() or path.suffix == ".part": + continue + twin = self.input_dir / path.name + if not twin.exists(): + continue + try: + size = path.stat().st_size + if twin.stat().st_size != size: + logger.warning(f"Raccord des bords : {path.name} présent dans input/ " + f"et {EDGE_NEIGHBORS_DIRNAME}/ avec des tailles " + f"différentes — doublon conservé") + continue + path.unlink() + freed += size + removed += 1 + except OSError: + pass + if removed: + logger.info(f"Raccord des bords : {removed} doublon(s) supprimé(s) de " + f"{EDGE_NEIGHBORS_DIRNAME}/ (déjà dans input/, " + f"{freed / 1e6:.0f} Mo libérés)") + def _report(self, basename, phase, state, detail=None, res=None): """Émet un événement de progression (file de génération, best-effort).""" report_event(self.output_dir, basename, phase, state, detail=detail, res=res) diff --git a/lidar_pipeline/tests/test_fetch_ign.py b/lidar_pipeline/tests/test_fetch_ign.py index b3311d1..76e2c46 100644 --- a/lidar_pipeline/tests/test_fetch_ign.py +++ b/lidar_pipeline/tests/test_fetch_ign.py @@ -146,6 +146,66 @@ def test_fetch_tiles_downloads_incomplete_tile(tmp_path, monkeypatch): assert [p.name for p in result] == [fetch_ign.tile_filename(1054, 6882)] +def test_fetch_tiles_promotes_edge_neighbor_copy(tmp_path, monkeypatch): + """Une dalle déjà téléchargée comme voisine est déplacée dans input/ sans réseau.""" + from lidar_pipeline import fetch_ign + + input_dir = tmp_path / "input" + input_dir.mkdir() + edge_dir = input_dir / "edge_neighbors" + edge_dir.mkdir() + name = fetch_ign.tile_filename(1055, 6882) + neighbor_copy = edge_dir / name + neighbor_copy.write_bytes(b"laz-voisine") + + def fail(*args, **kwargs): + raise AssertionError("ne doit pas être appelé (pas de réseau attendu)") + + monkeypatch.setattr(fetch_ign, "find_tile_url", fail) + monkeypatch.setattr(fetch_ign, "download_file", fail) + + result = fetch_ign.fetch_tiles(input_dir, [(1055, 6882)]) + + assert not neighbor_copy.exists() + assert (input_dir / name).read_bytes() == b"laz-voisine" + assert result == [] # promotion = "skip", comme le cas déjà-présent + + +def test_fetch_tiles_does_not_promote_when_called_on_edge_dir(tmp_path, monkeypatch): + """Appel avec input_dir=edge_dir (cas _fetch_edge_neighbors) : jamais de promotion.""" + from lidar_pipeline import fetch_ign + + edge_dir = tmp_path / "input" / "edge_neighbors" + edge_dir.mkdir(parents=True) + inner_edge = edge_dir / "edge_neighbors" + name = fetch_ign.tile_filename(1055, 6882) + # Même si un dossier edge_neighbors/edge_neighbors/ existait par + # accident, il ne doit jamais être déplacé vers edge_dir/. + inner_edge.mkdir() + (inner_edge / name).write_bytes(b"ne-doit-pas-bouger") + + calls = [] + + def fake_find_tile_url(col, row, timeout=20): + calls.append((col, row)) + return "https://example.org/dalle.copc.laz" + + def fake_download(url, dest, timeout=120, chunk=1024 * 1024): + from pathlib import Path + Path(dest).write_bytes(b"laz") + return 3 + + monkeypatch.setattr(fetch_ign, "find_tile_url", fake_find_tile_url) + monkeypatch.setattr(fetch_ign, "download_file", fake_download) + + result = fetch_ign.fetch_tiles(edge_dir, [(1055, 6882)]) + + assert calls == [(1055, 6882)] # téléchargée normalement, pas promue + assert (inner_edge / name).read_bytes() == b"ne-doit-pas-bouger" + assert (edge_dir / name).read_bytes() == b"laz" + assert len(result) == 1 + + def test_fetch_tiles_force_downloads_complete(tmp_path, monkeypatch): """force=True télécharge même une tuile complète (régénération avec --force).""" from pathlib import Path diff --git a/lidar_pipeline/tests/test_pipeline.py b/lidar_pipeline/tests/test_pipeline.py index cd6966a..73bd15c 100644 --- a/lidar_pipeline/tests/test_pipeline.py +++ b/lidar_pipeline/tests/test_pipeline.py @@ -107,6 +107,52 @@ class TestFetchEdgeNeighbors: pipeline._fetch_edge_neighbors([lazh]) +class TestCleanupEdgeNeighborDuplicates: + """Nettoyage des doublons entre input/ et input/edge_neighbors/.""" + + def test_removes_true_duplicates_keeps_unique_and_part(self, tmp_path): + from lidar_pipeline.pipeline import LidarArchaeoPipeline + + input_dir = tmp_path / "input" + input_dir.mkdir() + edge_dir = input_dir / "edge_neighbors" + edge_dir.mkdir() + + dup_name = "LHD_FXX_1000_6779_PTS_LAMB93_IGN69.copc.laz" + unique_name = "LHD_FXX_1001_6779_PTS_LAMB93_IGN69.copc.laz" + part_name = "LHD_FXX_1002_6779_PTS_LAMB93_IGN69.copc.laz.part" + mismatch_name = "LHD_FXX_1003_6779_PTS_LAMB93_IGN69.copc.laz" + + (input_dir / dup_name).write_bytes(b"authoritative") + (edge_dir / dup_name).write_bytes(b"authoritative") + # input/ tronqué (taille différente) : la voisine peut être la seule + # copie saine, elle doit rester. + (input_dir / mismatch_name).write_bytes(b"") + (edge_dir / mismatch_name).write_bytes(b"complete") + (edge_dir / unique_name).write_bytes(b"voisine-unique") + (edge_dir / part_name).write_bytes(b"en-cours") + + pipeline = LidarArchaeoPipeline(input_dir, tmp_path / "output", + edge_buffer=100.0) + pipeline._cleanup_edge_neighbor_duplicates(edge_dir) + + assert not (edge_dir / dup_name).exists() + assert (input_dir / dup_name).read_bytes() == b"authoritative" + assert (edge_dir / unique_name).exists() + assert (edge_dir / part_name).exists() + assert (edge_dir / mismatch_name).read_bytes() == b"complete" + + def test_noop_when_edge_dir_missing(self, tmp_path): + from lidar_pipeline.pipeline import LidarArchaeoPipeline + + input_dir = tmp_path / "input" + input_dir.mkdir() + pipeline = LidarArchaeoPipeline(input_dir, tmp_path / "output", + edge_buffer=100.0) + # Ne doit pas lever si edge_neighbors/ n'existe pas encore. + pipeline._cleanup_edge_neighbor_duplicates(input_dir / "edge_neighbors") + + class TestLidarArchaeoPipeline: def test_init_creates_dirs(self, tmp_path): from lidar_pipeline.pipeline import LidarArchaeoPipeline