Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,37 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import gradio as gr
|
| 2 |
from PIL import Image
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
try:
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
except Exception as e:
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
| 16 |
with gr.Row():
|
| 17 |
with gr.Column(scale=2):
|
| 18 |
-
|
| 19 |
-
gallery = gr.Gallery(label="
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
with gr.Column(scale=1):
|
| 29 |
-
preview = gr.Model3D(label="
|
| 30 |
-
|
| 31 |
-
logs = gr.Markdown("Logs will appear here
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
return demo
|
| 35 |
|
| 36 |
if __name__ == "__main__":
|
| 37 |
-
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import datetime as dt
|
| 5 |
+
import io
|
| 6 |
+
import os
|
| 7 |
+
import shutil
|
| 8 |
+
import subprocess
|
| 9 |
+
import textwrap
|
| 10 |
+
import uuid
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import List, Optional, Tuple
|
| 13 |
+
|
| 14 |
import gradio as gr
|
| 15 |
from PIL import Image
|
| 16 |
|
| 17 |
+
# Optional: Open3D for meshing
|
| 18 |
+
try:
|
| 19 |
+
import open3d as o3d
|
| 20 |
+
except Exception:
|
| 21 |
+
o3d = None # We’ll still run COLMAP and return the fused point cloud if meshing libs aren’t present.
|
| 22 |
+
|
| 23 |
+
# Be gentle with HF CPU boxes that choke on many threads
|
| 24 |
+
os.environ.setdefault("OMP_NUM_THREADS", "4")
|
| 25 |
+
|
| 26 |
+
def _run(cmd: List[str], cwd: Optional[Path] = None, env: Optional[dict] = None) -> Tuple[int, str]:
|
| 27 |
+
"""Run a subprocess and capture merged stdout/stderr as text."""
|
| 28 |
+
p = subprocess.run(
|
| 29 |
+
cmd,
|
| 30 |
+
cwd=str(cwd) if cwd else None,
|
| 31 |
+
env=env,
|
| 32 |
+
stdout=subprocess.PIPE,
|
| 33 |
+
stderr=subprocess.STDOUT,
|
| 34 |
+
text=True,
|
| 35 |
+
)
|
| 36 |
+
return p.returncode, p.stdout
|
| 37 |
+
|
| 38 |
+
def _ensure_tool(tool: str) -> bool:
|
| 39 |
+
return shutil.which(tool) is not None
|
| 40 |
+
|
| 41 |
+
def _save_images(files: List[gr.File], out_dir: Path, max_px: int) -> None:
|
| 42 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 43 |
+
for f in files:
|
| 44 |
+
# gr.File returns a path str in .name on Spaces
|
| 45 |
+
src = Path(f.name)
|
| 46 |
+
with Image.open(src) as im:
|
| 47 |
+
im = im.convert("RGB")
|
| 48 |
+
w, h = im.size
|
| 49 |
+
scale = min(max_px / max(w, h), 1.0)
|
| 50 |
+
if scale < 1.0:
|
| 51 |
+
im = im.resize((int(w * scale), int(h * scale)))
|
| 52 |
+
im.save(out_dir / src.name, quality=92)
|
| 53 |
+
|
| 54 |
+
def run_pipeline(
|
| 55 |
+
files: List[gr.File],
|
| 56 |
+
max_px: int,
|
| 57 |
+
match_mode: str,
|
| 58 |
+
use_gpu_sift: bool,
|
| 59 |
+
voxel: float,
|
| 60 |
+
depth: int,
|
| 61 |
+
tris: int,
|
| 62 |
+
):
|
| 63 |
+
logs: List[str] = []
|
| 64 |
try:
|
| 65 |
+
if not files:
|
| 66 |
+
return None, [], "Please upload 3–30 images.", gr.update(visible=False)
|
| 67 |
+
|
| 68 |
+
if not _ensure_tool("colmap"):
|
| 69 |
+
return None, [], "COLMAP not found. Make sure `packages.txt` contains `colmap`.", gr.update(visible=False)
|
| 70 |
+
|
| 71 |
+
run_id = dt.datetime.now().strftime("run_%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8]
|
| 72 |
+
run_dir = Path("runs") / run_id
|
| 73 |
+
imgs_dir = run_dir / "images"
|
| 74 |
+
db = run_dir / "db.db"
|
| 75 |
+
sparse_dir = run_dir / "sparse"
|
| 76 |
+
dense_dir = run_dir / "dense"
|
| 77 |
+
run_dir.mkdir(parents=True, exist_ok=True)
|
| 78 |
+
|
| 79 |
+
logs.append(f"Workspace: {run_dir}")
|
| 80 |
+
_save_images(files, imgs_dir, max_px)
|
| 81 |
+
img_count = len(list(imgs_dir.glob("*")))
|
| 82 |
+
logs.append(f"Ingested {img_count} image(s). Max side capped at {max_px}px")
|
| 83 |
+
|
| 84 |
+
# 1) Features
|
| 85 |
+
feat_cmd = [
|
| 86 |
+
"colmap", "feature_extractor",
|
| 87 |
+
"--database_path", str(db),
|
| 88 |
+
"--image_path", str(imgs_dir),
|
| 89 |
+
"--ImageReader.single_camera", "1",
|
| 90 |
+
"--SiftExtraction.use_gpu", "1" if (use_gpu_sift and _ensure_tool("nvidia-smi")) else "0",
|
| 91 |
+
]
|
| 92 |
+
code, out = _run(feat_cmd, cwd=run_dir)
|
| 93 |
+
logs.append("[feature_extractor]\n" + out)
|
| 94 |
+
if code != 0:
|
| 95 |
+
raise RuntimeError("COLMAP feature extraction failed.")
|
| 96 |
+
|
| 97 |
+
# 2) Matching
|
| 98 |
+
if match_mode == "sequential":
|
| 99 |
+
match_cmd = ["colmap", "sequential_matcher", "--database_path", str(db)]
|
| 100 |
+
elif match_mode == "exhaustive":
|
| 101 |
+
match_cmd = ["colmap", "exhaustive_matcher", "--database_path", str(db)]
|
| 102 |
+
else:
|
| 103 |
+
# Spatial matcher needs priors; default to exhaustive if none
|
| 104 |
+
match_cmd = ["colmap", "exhaustive_matcher", "--database_path", str(db)]
|
| 105 |
+
code, out = _run(match_cmd, cwd=run_dir)
|
| 106 |
+
logs.append(f"[{match_mode}_matcher]\n" + out)
|
| 107 |
+
if code != 0:
|
| 108 |
+
raise RuntimeError("COLMAP matching failed.")
|
| 109 |
+
|
| 110 |
+
# 3) Sparse reconstruction
|
| 111 |
+
sparse_dir.mkdir(exist_ok=True)
|
| 112 |
+
code, out = _run(
|
| 113 |
+
["colmap", "mapper", "--database_path", str(db), "--image_path", str(imgs_dir), "--output_path", str(sparse_dir)],
|
| 114 |
+
cwd=run_dir,
|
| 115 |
+
)
|
| 116 |
+
logs.append("[mapper]\n" + out)
|
| 117 |
+
if code != 0 or not any((sparse_dir).glob("*/cameras.txt")):
|
| 118 |
+
raise RuntimeError("COLMAP mapper failed or produced no model.")
|
| 119 |
+
|
| 120 |
+
model_dirs = sorted(sparse_dir.glob("*"))
|
| 121 |
+
model_dir = model_dirs[0]
|
| 122 |
+
|
| 123 |
+
# 4) Undistort & dense
|
| 124 |
+
code, out = _run(
|
| 125 |
+
["colmap", "image_undistorter", "--image_path", str(imgs_dir), "--input_path", str(model_dir), "--output_path", str(dense_dir), "--output_type", "COLMAP"],
|
| 126 |
+
cwd=run_dir,
|
| 127 |
+
)
|
| 128 |
+
logs.append("[image_undistorter]\n" + out)
|
| 129 |
+
if code != 0:
|
| 130 |
+
raise RuntimeError("Undistortion failed.")
|
| 131 |
+
|
| 132 |
+
code, out = _run(
|
| 133 |
+
["colmap", "patch_match_stereo", "--workspace_path", str(dense_dir), "--workspace_format", "COLMAP", "--PatchMatchStereo.geom_consistency", "true"],
|
| 134 |
+
cwd=run_dir,
|
| 135 |
+
)
|
| 136 |
+
logs.append("[patch_match_stereo]\n" + out)
|
| 137 |
+
if code != 0:
|
| 138 |
+
raise RuntimeError("PatchMatch failed.")
|
| 139 |
+
|
| 140 |
+
fused = run_dir / "fused.ply"
|
| 141 |
+
code, out = _run(
|
| 142 |
+
["colmap", "stereo_fusion", "--workspace_path", str(dense_dir), "--workspace_format", "COLMAP", "--input_type", "geometric", "--output_path", str(fused)],
|
| 143 |
+
cwd=run_dir,
|
| 144 |
+
)
|
| 145 |
+
logs.append("[stereo_fusion]\n" + out)
|
| 146 |
+
if code != 0 or not fused.exists():
|
| 147 |
+
raise RuntimeError("Fusion failed.")
|
| 148 |
+
|
| 149 |
+
# 5) Meshing (Open3D). If not available, just return fused point cloud.
|
| 150 |
+
mesh_paths = []
|
| 151 |
+
preview_path = fused # default to point cloud preview
|
| 152 |
+
|
| 153 |
+
if o3d is not None:
|
| 154 |
+
pcd = o3d.io.read_point_cloud(str(fused))
|
| 155 |
+
if voxel and voxel > 0:
|
| 156 |
+
pcd = pcd.voxel_down_sample(voxel)
|
| 157 |
+
pcd.estimate_normals(o3d.geometry.KDTreeSearchParamKNN(knn=20))
|
| 158 |
+
|
| 159 |
+
# Poisson surface reconstruction
|
| 160 |
+
mesh, _ = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson(pcd, depth=depth)
|
| 161 |
+
mesh.remove_degenerate_triangles()
|
| 162 |
+
mesh.remove_duplicated_triangles()
|
| 163 |
+
mesh.remove_duplicated_vertices()
|
| 164 |
+
mesh.remove_non_manifold_edges()
|
| 165 |
+
|
| 166 |
+
if tris and tris > 0:
|
| 167 |
+
mesh = mesh.simplify_quadric_decimation(tris)
|
| 168 |
+
|
| 169 |
+
mesh.compute_vertex_normals()
|
| 170 |
+
|
| 171 |
+
mesh_ply = run_dir / "mesh.ply"
|
| 172 |
+
mesh_obj = run_dir / "mesh.obj"
|
| 173 |
+
o3d.io.write_triangle_mesh(str(mesh_ply), mesh)
|
| 174 |
+
o3d.io.write_triangle_mesh(str(mesh_obj), mesh)
|
| 175 |
+
mesh_paths = [mesh_ply, mesh_obj]
|
| 176 |
+
preview_path = mesh_ply
|
| 177 |
+
|
| 178 |
+
files_out = [preview_path] + mesh_paths
|
| 179 |
+
file_list = [str(p) for p in files_out if Path(p).exists()]
|
| 180 |
+
|
| 181 |
+
return str(preview_path), file_list, "\n".join(logs[-80:]), gr.update(visible=True)
|
| 182 |
+
|
| 183 |
except Exception as e:
|
| 184 |
+
logs.append("\n[ERROR]\n" + textwrap.fill(str(e), width=100))
|
| 185 |
+
return None, [], "\n".join(logs[-120:]), gr.update(visible=False)
|
| 186 |
+
|
| 187 |
+
def build_ui():
|
| 188 |
+
with gr.Blocks(title="Sparse Multi-View 3D (Urban Planning)", theme=gr.themes.Soft()) as demo:
|
| 189 |
+
gr.Markdown(
|
| 190 |
+
"""
|
| 191 |
+
# 🗺️ Sparse Multi-View 3D for Urban Planning
|
| 192 |
|
| 193 |
+
Upload **3–30 photos** of a scene (streetscape, plaza, façade). We estimate camera poses with **COLMAP**,
|
| 194 |
+
build a **dense point cloud**, and (optionally) **mesh** it with Open3D.
|
| 195 |
+
|
| 196 |
+
**Tips for sparse captures:** overlap ~60–70%, vary viewpoint (walk an arc), avoid moving cars/people.
|
| 197 |
+
"""
|
| 198 |
+
)
|
| 199 |
with gr.Row():
|
| 200 |
with gr.Column(scale=2):
|
| 201 |
+
images = gr.File(label="Upload images (JPG/PNG)", file_types=["image"], file_count="multiple")
|
| 202 |
+
gallery = gr.Gallery(label="Preview", columns=6, height=160)
|
| 203 |
+
|
| 204 |
+
def _show_gallery(files: List[gr.File]):
|
| 205 |
+
rows = []
|
| 206 |
+
for f in files or []:
|
| 207 |
+
try:
|
| 208 |
+
with Image.open(f.name) as im:
|
| 209 |
+
rows.append((Path(f.name).name, im.convert("RGB")))
|
| 210 |
+
except Exception:
|
| 211 |
+
pass
|
| 212 |
+
return rows
|
| 213 |
+
|
| 214 |
+
images.change(_show_gallery, inputs=images, outputs=gallery)
|
| 215 |
+
|
| 216 |
+
with gr.Accordion("Reconstruction settings", open=False):
|
| 217 |
+
max_px = gr.Slider(1024, 4096, value=2400, step=64, label="Max image size (px, longest side)")
|
| 218 |
+
match_mode = gr.Radio(["exhaustive", "sequential", "spatial"], value="sequential", label="Matching mode")
|
| 219 |
+
use_gpu_sift = gr.Checkbox(True, label="Use GPU SIFT if available")
|
| 220 |
+
|
| 221 |
+
with gr.Accordion("Meshing", open=True):
|
| 222 |
+
voxel = gr.Slider(0.0, 0.05, value=0.01, step=0.005, label="Voxel downsample (m, approx units)")
|
| 223 |
+
depth = gr.Slider(6, 12, value=9, step=1, label="Poisson depth (higher → more detail)")
|
| 224 |
+
tris = gr.Slider(0, 500_000, value=150_000, step=10_000, label="Target triangles (0 = keep)")
|
| 225 |
+
|
| 226 |
+
run = gr.Button("▶ Reconstruct 3D", variant="primary")
|
| 227 |
+
|
| 228 |
with gr.Column(scale=1):
|
| 229 |
+
preview = gr.Model3D(label="Preview (PLY/OBJ)", visible=False)
|
| 230 |
+
outputs = gr.Files(label="Downloads")
|
| 231 |
+
logs = gr.Markdown("Logs will appear here…")
|
| 232 |
+
|
| 233 |
+
run.click(
|
| 234 |
+
run_pipeline,
|
| 235 |
+
inputs=[images, max_px, match_mode, use_gpu_sift, voxel, depth, tris],
|
| 236 |
+
outputs=[preview, outputs, logs, preview],
|
| 237 |
+
queue=True,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
gr.Markdown(
|
| 241 |
+
"""
|
| 242 |
+
### Notes & Scaling
|
| 243 |
+
- Results are in **arbitrary units** (SfM scale). For metric scale, align in GIS/CAD with known distances.
|
| 244 |
+
- Outdoor scenes with repetitive textures (glass/trees) can be challenging—add more oblique views if possible.
|
| 245 |
+
"""
|
| 246 |
+
)
|
| 247 |
return demo
|
| 248 |
|
| 249 |
if __name__ == "__main__":
|
| 250 |
+
build_ui().launch()
|