Tohru127 commited on
Commit
9891441
·
verified ·
1 Parent(s): f8d6272

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +238 -25
app.py CHANGED
@@ -1,37 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
2
  from PIL import Image
3
 
4
- def run_pipeline(gallery, max_px, match_mode, use_gpu_sift, voxel, depth, tris):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  try:
6
- logs = []
7
- # TODO: your pipeline here
8
- # return (preview_path, [file_paths...], "\n".join(logs[-6:]), gr.update(visible=True))
9
- return None, [], "Ready.", gr.update(visible=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  except Exception as e:
11
- return None, None, f"[ERROR]\n{e}", gr.update(visible=False)
 
 
 
 
 
 
 
12
 
13
- def ui():
14
- with gr.Blocks(title="Sparse Multi-View 3D (Urban Planning)") as demo:
15
- gr.Markdown("# Sparse Multi-View 3D for Urban Planning")
 
 
 
16
  with gr.Row():
17
  with gr.Column(scale=2):
18
- imgs = gr.UploadButton("Upload images (JPG/PNG)", file_types=["image"], file_count="multiple")
19
- gallery = gr.Gallery(label="Selected", columns=6, height=160)
20
- imgs.upload(lambda files: [(f.name, Image.open(f).convert('RGB')) for f in files], imgs, gallery)
21
- max_px = gr.Slider(1024, 4096, value=2400, step=64, label="Max image size (px)")
22
- match_mode = gr.Radio(["exhaustive", "sequential", "spatial"], value="sequential", label="Matching mode")
23
- use_gpu_sift = gr.Checkbox(True, label="Use GPU for SIFT (if available)")
24
- voxel = gr.Slider(0.0, 0.05, value=0.01, step=0.005, label="Voxel downsample")
25
- depth = gr.Slider(6, 12, value=9, step=1, label="Poisson depth")
26
- tris = gr.Slider(0, 500_000, value=150_000, step=10_000, label="Target triangles")
27
- run = gr.Button("▶ Reconstruct 3D")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  with gr.Column(scale=1):
29
- preview = gr.Model3D(label="Mesh preview", visible=False)
30
- files_out = gr.Files(label="Downloads (GLB/OBJ/PLY)")
31
- logs = gr.Markdown("Logs will appear here after running…")
32
- run.click(run_pipeline, [gallery, max_px, match_mode, use_gpu_sift, voxel, depth, tris],
33
- [preview, files_out, logs, preview], queue=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  return demo
35
 
36
  if __name__ == "__main__":
37
- ui().launch()
 
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()