Initial commit

This commit is contained in:
Tie 2026-04-01 21:13:58 +02:00
commit b22231c8b6
40 changed files with 2443 additions and 0 deletions

0
backend/__init__.py Normal file
View file

32
backend/config.py Normal file
View file

@ -0,0 +1,32 @@
import os
from pathlib import Path
# URL Potree - configurée via variable d'environnement POTREE_URL
# Pour le développement local : POTREE_URL=http://localhost:8090
# Pour la production : POTREE_URL=http://potree_server:8090
def load_potree_config():
"""Charge la configuration Potree depuis les variables d'environnement."""
return os.getenv(
"POTREE_URL", "http://localhost:8090"
).strip().rstrip("/")
def get_entwine_path():
"""Retourne le chemin de entwine ou None si non trouvé"""
path = os.getenv("ENTWINE_PATH")
if path:
return path.strip()
return None
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"
UPLOADS_DIR = DATA_DIR / "uploads"
EPT_DIR = DATA_DIR / "ept" # était POTREE_DIR
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
EPT_DIR.mkdir(parents=True, exist_ok=True)
SUPPORTED_FORMATS = [".las", ".laz", ".ply", ".xyz", ".pts"]
POTREE_URL = load_potree_config()
ENTWINE_PATH = get_entwine_path()

94
backend/main.py Normal file
View file

@ -0,0 +1,94 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, RedirectResponse
from config import EPT_DIR
from routes import upload, viewer, admin
from utils.disk import get_disk_usage, get_entwine_path
import subprocess
ENTWINE_PATH = get_entwine_path()
app = FastAPI(title="PointCloud Backend")
app.mount("/ept_data", StaticFiles(directory=str(EPT_DIR)), name="ept_data")
app.mount("/static", StaticFiles(directory="static"), name="static")
app.mount("/potree", StaticFiles(directory="static/potree"), name="potree")
# ── Fichiers Potree dans /static ─────────────────────────────────────────────
app.mount("/static/potree", StaticFiles(directory="static/potree"), name="static_potree")
app.include_router(upload.router)
app.include_router(viewer.router)
app.include_router(admin.router)
@app.get("/api")
def home():
return RedirectResponse(url="/docs")
@app.get("/health")
def health():
return {
"ok": True,
"entwine_available": ENTWINE_PATH is not None,
"entwine_path": ENTWINE_PATH,
"disk_free_gb": get_disk_usage(),
}
def get_pdal_info() -> dict:
"""Retourne version et path de pdal via subprocess."""
info = {"path": None, "version": None}
try:
result = subprocess.run(
["which", "pdal"],
capture_output=True, text=True, check=False
)
if result.returncode == 0:
info["path"] = result.stdout.strip()
except Exception:
pass
try:
result = subprocess.run(
["pdal", "--version"],
capture_output=True, text=True, check=False
)
if result.returncode == 0:
# Sortie : "---\npdal 2.10.0 (git-version: 22e6b2)\n---"
lines = [l.strip() for l in result.stdout.strip().splitlines()
if l.strip() and not l.startswith("-")]
if lines:
info["version"] = lines[0]
except Exception:
pass
return info
@app.get("/", response_class=HTMLResponse)
def home():
entwine_path = get_entwine_path()
pdal = get_pdal_info()
return f"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backend - PointCloud</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet">
</head>
<body class="min-h-screen bg-base-100 p-4">
<h2>Backend PointCloud OK </h2>
<ul>
<li><a href="/docs">/docs</a> Documentation API</li>
<li><a href="/health">/health</a> État du service</li>
</ul>
<h3>Configuration</h3>
<ul>
<li>entwine : {"" + entwine_path if entwine_path else "❌ Non trouvé"}</li>
<li>pdal path : {"" + pdal["path"] if pdal["path"] else "❌ Non trouvé"}</li>
<li>pdal version : {pdal["version"] if pdal["version"] else "❌ Inconnue"}</li>
<li>Espace disque : {get_disk_usage()} GB libres</li>
</ul>
</body>
</html>"""

184
backend/routes/admin.py Normal file
View file

@ -0,0 +1,184 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from config import EPT_DIR, UPLOADS_DIR, ENTWINE_PATH, DATA_DIR
from services.manifest import read_manifest
from services.converter import ENTWINE_AVAILABLE, ENTWINE_PATH, run_entwine
import shutil
import subprocess
import os
router = APIRouter()
@router.get("/backend-config")
def backend_config():
"""Retourne la configuration du backend"""
import os
try:
stat = shutil.disk_usage(DATA_DIR)
disk_free_gb = round(stat.free / (1024**3), 2)
except:
disk_free_gb = "?"
return {
"entwine_available": ENTWINE_AVAILABLE,
"entwine_path": ENTWINE_PATH,
"pdal_available": shutil.which("pdal") is not None,
"disk_free_gb": disk_free_gb,
}
@router.get("/debug/{pc_id}")
def debug(pc_id: str):
out_dir = EPT_DIR / pc_id
if not out_dir.exists():
raise HTTPException(status_code=404, detail=f"ID {pc_id} non trouvé")
manifest = read_manifest(out_dir)
files = []
total_size = 0
for p in out_dir.rglob("*"):
if p.is_file():
size = p.stat().st_size
total_size += size
files.append({
"path": str(p.relative_to(out_dir)),
"size_mb": round(size / (1024 * 1024), 2),
})
entry_file = manifest.get("entry_file")
entry_exists = False
if entry_file:
entry_exists = (EPT_DIR / entry_file).exists()
return {
"pc_id": pc_id,
"exists": True,
"manifest": manifest,
"entry_exists": entry_exists,
"stats": {
"total_files": len(files),
"total_size_mb": round(total_size / (1024 * 1024), 2),
},
"files": sorted(files, key=lambda x: x["size_mb"], reverse=True)[:20],
"entwine_available": ENTWINE_AVAILABLE,
"entwine_path": ENTWINE_PATH,
}
@router.delete("/delete/{pc_id}")
def delete_pointcloud(pc_id: str):
out_dir = EPT_DIR / pc_id
if not out_dir.exists():
raise HTTPException(status_code=404, detail=f"ID {pc_id} non trouvé")
try:
for ext in [".las", ".laz", ".ply", ".xyz", ".pts"]:
original = UPLOADS_DIR / f"{pc_id}{ext}"
if original.exists():
original.unlink()
shutil.rmtree(out_dir)
return {"ok": True, "message": f"Nuage {pc_id} supprimé"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur suppression : {str(e)}")
@router.post("/crop/{pc_id}")
def crop_pointcloud(pc_id: str, box: dict):
"""
Crop le nuage de points avec PDAL.
Args:
pc_id: ID du nuage de points à cropper
box: dict avec les coordonnées de la box 3D
{"minX", "minY", "minZ", "maxX", "maxY", "maxZ"}
"""
out_dir = EPT_DIR / pc_id
if not out_dir.exists():
raise HTTPException(status_code=404, detail=f"ID {pc_id} non trouvé")
manifest = read_manifest(out_dir)
if not manifest or not manifest.get("ept_dir"):
raise HTTPException(status_code=400, detail="Manifeste invalide")
ept_dir = EPT_DIR / manifest["ept_dir"]
# Vérifier que PDAL est disponible
try:
pdal_path = subprocess.run(
["which", "pdal"],
capture_output=True, text=True, check=False
).stdout.strip()
if not pdal_path:
raise HTTPException(500, "PDAL non disponible")
except Exception:
raise HTTPException(500, "PDAL non disponible")
# Construire le pipeline PDAL pour le crop
pipeline = {
"pipeline": [
{
"type": "readers.las",
"filename": str(ept_dir / "ept.json"),
"skip_z": False,
"force_z": True
},
{
"type": "filters.crop",
"crop_box": [
box.get("minX", 0),
box.get("minY", 0),
box.get("minZ", 0),
box.get("maxX", 0),
box.get("maxY", 0),
box.get("maxZ", 0)
]
},
{
"type": "writers.ept",
"filename": str(out_dir / "cropped.las"),
"force_z": True
}
]
}
try:
result = subprocess.run(
[pdal_path, "--pipeline=JSON", "--stdin"],
input=pipeline,
capture_output=True,
text=True,
timeout=7200,
env=os.environ.copy()
)
if result.returncode != 0:
raise HTTPException(
500,
f"PDAL crop failed (code {result.returncode}):\n{result.stderr}"
)
# Convertir le fichier LAS croppé en EPT
cropped_las = out_dir / "cropped.las"
if not cropped_las.exists():
raise HTTPException(500, "Fichier LAS croppé non généré")
# Supprimer le fichier original
for ext in [".las", ".laz", ".ply", ".xyz", ".pts"]:
original = UPLOADS_DIR / f"{pc_id}{ext}"
if original.exists():
original.unlink()
# Convertir en EPT
run_entwine(cropped_las, out_dir)
return {
"ok": True,
"id": pc_id, # Même ID, le nuage a été mis à jour
"size_mb": round(cropped_las.stat().st_size / (1024 * 1024), 2),
"conversion_time_seconds": 0,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur crop : {str(e)}")

47
backend/routes/upload.py Normal file
View file

@ -0,0 +1,47 @@
from fastapi import APIRouter, UploadFile, File, HTTPException
from fastapi.responses import JSONResponse
import uuid, shutil, time
from pathlib import Path
from config import UPLOADS_DIR, EPT_DIR, SUPPORTED_FORMATS
from services.converter import run_entwine, ENTWINE_AVAILABLE
router = APIRouter()
@router.post("/upload")
async def upload(file: UploadFile = File(...)):
suffix = Path(file.filename).suffix.lower()
if suffix not in SUPPORTED_FORMATS:
raise HTTPException(400, f"Format non supporté: {suffix}")
if not ENTWINE_AVAILABLE:
raise HTTPException(500, "entwine non disponible sur ce serveur")
pc_id = str(uuid.uuid4())[:8]
upload_path = UPLOADS_DIR / f"{pc_id}{suffix}"
out_dir = EPT_DIR / pc_id
file_size = 0
with open(upload_path, "wb") as f:
while chunk := await file.read(1024 * 1024):
f.write(chunk)
file_size += len(chunk)
if file_size == 0:
upload_path.unlink()
raise HTTPException(400, "Fichier vide")
if out_dir.exists():
shutil.rmtree(out_dir, ignore_errors=True)
out_dir.mkdir(parents=True, exist_ok=True)
start = time.time()
result = run_entwine(upload_path, out_dir)
return JSONResponse({
"id": pc_id,
"filename": file.filename,
"size_mb": round(file_size / (1024 * 1024), 2),
"viewer_path": f"/viewer/{pc_id}",
"embed_path": f"/viewer-embed/{pc_id}",
"ept_dir": result.get("ept_dir"),
"conversion_time_seconds": round(time.time() - start, 2),
})

53
backend/routes/viewer.py Normal file
View file

@ -0,0 +1,53 @@
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse, HTMLResponse
from config import EPT_DIR, POTREE_URL
import config
from services.manifest import read_manifest
from services.html_generator import generate_viewer_html
import shutil
router = APIRouter()
@router.get("/viewer/list", response_class=HTMLResponse)
def list_pointclouds(request: Request):
"""Liste les nuages de points disponibles"""
from fastapi import Request
pointclouds = []
for item in sorted(EPT_DIR.iterdir(), key=lambda x: x.stat().st_ctime, reverse=True):
if item.is_dir():
total_size = 0
file_count = 0
for f in item.rglob("*"):
if f.is_file():
total_size += f.stat().st_size
file_count += 1
if file_count > 0:
pointclouds.append({
"id": item.name,
"size_mb": round(total_size / (1024 * 1024), 2),
"file_count": file_count,
"created": item.stat().st_ctime,
})
return request.app.state.templates.TemplateResponse(
"partials/cloud_list.html",
{"request": request, "pointclouds": pointclouds},
)
@router.get("/viewer/{pc_id}")
def viewer(pc_id: str, potree_url: str = Query(default=POTREE_URL, description="URL du serveur Potree")):
out_dir = EPT_DIR / pc_id
if not out_dir.exists():
raise HTTPException(404, f"ID {pc_id} non trouvé")
manifest = read_manifest(out_dir)
return HTMLResponse(generate_viewer_html(pc_id, manifest.get("ept_dir"), embed=False, potree_url=potree_url))
@router.get("/viewer-embed/{pc_id}")
def viewer_embed(pc_id: str, potree_url: str = Query(default=POTREE_URL, description="URL du serveur Potree")):
out_dir = EPT_DIR / pc_id
if not out_dir.exists():
raise HTTPException(404, f"ID {pc_id} non trouvé")
manifest = read_manifest(out_dir)
return HTMLResponse(generate_viewer_html(pc_id, manifest.get("ept_dir"), embed=True, potree_url=potree_url))

View file

@ -0,0 +1,75 @@
import os
import json
import subprocess
from pathlib import Path
from fastapi import HTTPException
from config import EPT_DIR
from services.manifest import save_manifest
from utils.disk import get_entwine_path
ENTWINE_PATH = get_entwine_path()
ENTWINE_AVAILABLE = ENTWINE_PATH is not None
def run_entwine(input_path: Path, out_dir: Path) -> dict:
if not ENTWINE_AVAILABLE:
raise HTTPException(status_code=500,
detail="entwine n'est pas installé ou introuvable dans le PATH")
out_dir.mkdir(parents=True, exist_ok=True)
# entwine build -i <input> -o <output_dir>
cmd = [
ENTWINE_PATH, "build",
"-i", str(input_path.absolute()),
"-o", str(out_dir.absolute()),
]
print(f"Exécution: {' '.join(cmd)}")
print(f"CMD: {cmd}")
proc = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False,
timeout=7200,
env=os.environ.copy()
)
if proc.returncode != 0:
raise HTTPException(status_code=500,
detail=f"entwine failed (code {proc.returncode}):\n{proc.stdout}")
result = _analyze_ept_output(out_dir, proc.stdout)
save_manifest(out_dir, result)
return result
def _analyze_ept_output(out_dir: Path, entwine_output: str) -> dict:
"""
Entwine produit un dossier EPT dont la structure est :
out_dir/
ept.json fichier d'entrée principal
ept-data/ tuiles binaires
ept-hierarchy/ hiérarchie des noeuds
"""
ept_json = out_dir / "ept.json"
result = {
"format": "ept",
"entry_file": None,
"entry_type": None,
"stdout": entwine_output[-2000:],
}
if ept_json.exists():
result["entry_file"] = ept_json.relative_to(EPT_DIR).as_posix()
result["entry_type"] = "ept.json"
# Le dossier EPT = le dossier contenant ept.json
result["ept_dir"] = str(ept_json.parent.relative_to(EPT_DIR))
else:
raise HTTPException(status_code=500,
detail=f"entwine a terminé mais ept.json introuvable dans {out_dir}")
return result

View file

@ -0,0 +1,87 @@
from pathlib import Path
from typing import Optional
from config import EPT_DIR, POTREE_URL
import config
def generate_viewer_html(pc_id: str, ept_dir: Optional[str],
embed: bool = False, potree_url: Optional[str] = None) -> str:
# Fallback : cherche ept.json si le manifest est absent
if not ept_dir:
out_dir = EPT_DIR / pc_id
ept_json = out_dir / "ept.json"
if ept_json.exists():
ept_dir = pc_id
else:
return "<h3>Erreur : ept.json introuvable pour cet ID</h3>"
height_style = "100vh" if embed else "800px"
base_url = "/static/potree"
potree_url = potree_url or config.POTREE_URL
# L'URL vers ept.json servi via le montage statique /ept_data
ept_json_url = f"/ept_data/{ept_dir}/ept.json"
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>EPT Viewer - {pc_id}</title>
<link rel="stylesheet" href="{base_url}/build/potree/potree.css">
<link rel="stylesheet" href="{base_url}/libs/jquery-ui/jquery-ui.min.css">
<link rel="stylesheet" href="{base_url}/libs/spectrum/spectrum.css">
<link rel="stylesheet" href="{base_url}/libs/jstree/themes/mixed/style.css">
</head>
<body>
<script src="{base_url}/libs/jquery/jquery-3.1.1.min.js"></script>
<script src="{base_url}/libs/spectrum/spectrum.js"></script>
<script src="{base_url}/libs/jquery-ui/jquery-ui.min.js"></script>
<script src="{base_url}/libs/other/BinaryHeap.js"></script>
<script src="{base_url}/libs/tween/tween.min.js"></script>
<script src="{base_url}/libs/d3/d3.js"></script>
<script src="{base_url}/libs/proj4/proj4.js"></script>
<script src="{base_url}/libs/openlayers3/ol.js"></script>
<script src="{base_url}/libs/i18next/i18next.js"></script>
<script src="{base_url}/libs/jstree/jstree.js"></script>
<script src="{base_url}/libs/copc/index.js"></script>
<script src="{base_url}/build/potree/potree.js"></script>
<script src="{base_url}/libs/plasio/js/laslaz.js"></script>
<div class="potree_container" style="position:absolute;width:100%;height:{height_style};left:0;top:0;">
<div id="potree_render_area"></div>
<div id="potree_sidebar_container"></div>
</div>
<script type="module">
window.viewer = new Potree.Viewer(document.getElementById("potree_render_area"));
viewer.setEDLEnabled(true);
viewer.setFOV(60);
viewer.setPointBudget(1_000_000);
viewer.loadSettingsFromURL();
viewer.setBackground("skybox");
viewer.setDescription("EPT - {pc_id}");
viewer.loadGUI(() => {{
viewer.setLanguage('en');
$("#menu_tools").next().show();
viewer.toggleSidebar();
}});
// Potree 2.x charge EPT directement via l'URL de ept.json
const eptUrl = "{ept_json_url}";
console.log("Chargement EPT depuis:", eptUrl);
Potree.loadPointCloud(eptUrl, "{pc_id}", e => {{
let pointcloud = e.pointcloud;
let material = pointcloud.material;
material.size = 1;
material.pointSizeType = Potree.PointSizeType.ADAPTIVE;
material.shape = Potree.PointShape.SQUARE;
viewer.scene.addPointCloud(pointcloud);
viewer.fitToScreen();
console.log("EPT chargé avec succès");
}});
</script>
</body>
</html>"""

View file

@ -0,0 +1,24 @@
import json
import time
from pathlib import Path
def save_manifest(out_dir: Path, data: dict):
manifest = {
"conversion_time": time.time(),
"format": data.get("format", "ept"), # était "version"
"entry_file": data.get("entry_file"),
"entry_type": data.get("entry_type"),
"ept_dir": data.get("ept_dir"),
}
with open(out_dir / "manifest.json", 'w', encoding='utf-8') as f:
json.dump(manifest, f, indent=2)
def read_manifest(out_dir: Path) -> dict:
manifest_file = out_dir / "manifest.json"
if not manifest_file.exists():
return {}
try:
with open(manifest_file, 'r', encoding='utf-8') as f:
return json.load(f)
except:
return {}

34
backend/utils/disk.py Normal file
View file

@ -0,0 +1,34 @@
import shutil
from pathlib import Path
from config import DATA_DIR, BASE_DIR
def get_disk_usage() -> float | str:
try:
stat = shutil.disk_usage(DATA_DIR)
return round(stat.free / (1024**3), 2)
except:
return "?"
def get_entwine_path() -> str | None:
import subprocess
try:
result = subprocess.run(["which", "entwine"],
capture_output=True, text=True, check=False)
if result.returncode == 0:
path = result.stdout.strip()
if path and Path(path).exists():
return path
except Exception:
pass
common_paths = [
"/usr/local/bin/entwine",
"/usr/bin/entwine",
str(BASE_DIR / "entwine"),
str(BASE_DIR / "bin" / "entwine"),
]
for path in common_paths:
if Path(path).exists():
return path
return None