multimodalart HF Staff commited on
Commit
7767094
·
verified ·
1 Parent(s): c9ad6b5

Cap COLMAP threads, single-threaded OpenBLAS

Browse files
Files changed (2) hide show
  1. app.py +2 -0
  2. colmap_sfm.py +98 -4
app.py CHANGED
@@ -31,6 +31,8 @@ sys.path.insert(0, str(APP_DIR))
31
  os.environ.setdefault("TORCH_CUDA_ARCH_LIST", "12.0")
32
  os.environ.setdefault("MAX_JOBS", str(min(32, (os.cpu_count() or 8))))
33
  os.environ.setdefault("WANDB_MODE", "disabled")
 
 
34
 
35
  import spaces # noqa: E402 (must precede any CUDA-touching import)
36
  import torch # noqa: E402
 
31
  os.environ.setdefault("TORCH_CUDA_ARCH_LIST", "12.0")
32
  os.environ.setdefault("MAX_JOBS", str(min(32, (os.cpu_count() or 8))))
33
  os.environ.setdefault("WANDB_MODE", "disabled")
34
+ # pycolmap's bundled OpenBLAS crashes when many matcher threads each spawn BLAS threads.
35
+ os.environ["OPENBLAS_NUM_THREADS"] = "1"
36
 
37
  import spaces # noqa: E402 (must precede any CUDA-touching import)
38
  import torch # noqa: E402
colmap_sfm.py CHANGED
@@ -7,7 +7,7 @@ to a PINHOLE camera model and writes a standard COLMAP scene layout
7
  <out>/images/*.jpg
8
  <out>/sparse/{cameras,images,points3D}.bin
9
 
10
- which is exactly what ReSplat's reference COLMAP loader expects.
11
  """
12
 
13
  from __future__ import annotations
@@ -18,6 +18,11 @@ from pathlib import Path
18
  import pycolmap
19
  from PIL import Image, ImageOps
20
 
 
 
 
 
 
21
  IMAGE_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff")
22
 
23
 
@@ -75,6 +80,12 @@ def run_sfm(
75
  "Upload several overlapping photos of the same scene."
76
  )
77
  log(f"[colmap] {len(names)} images prepared at <= {max_image_size}px")
 
 
 
 
 
 
78
 
79
  database_path = workdir / "colmap.db"
80
  if database_path.exists():
@@ -86,6 +97,7 @@ def run_sfm(
86
  extraction_options.sift.domain_size_pooling = False
87
  extraction_options.use_gpu = False
88
  extraction_options.max_image_size = int(max_image_size)
 
89
 
90
  reader_options = pycolmap.ImageReaderOptions()
91
  reader_options.camera_model = "SIMPLE_RADIAL"
@@ -100,23 +112,28 @@ def run_sfm(
100
  )
101
  log("[colmap] features extracted")
102
 
 
 
103
  if matcher == "sequential":
104
  pairing = pycolmap.SequentialPairingOptions()
105
  pairing.overlap = 10
106
  pairing.quadratic_overlap = True
107
  pycolmap.match_sequential(
108
  database_path=database_path,
 
109
  pairing_options=pairing,
110
  device=pycolmap.Device.cpu,
111
  )
112
  else:
113
- pycolmap.match_exhaustive(database_path=database_path, device=pycolmap.Device.cpu)
 
114
  log(f"[colmap] {matcher} matching done")
115
 
116
  sparse_dir = workdir / "sparse_raw"
117
  sparse_dir.mkdir(parents=True, exist_ok=True)
118
  options = pycolmap.IncrementalPipelineOptions()
119
  options.min_num_matches = 15
 
120
  options.multiple_models = False
121
  options.extract_colors = True
122
  maps = pycolmap.incremental_mapping(
@@ -134,7 +151,7 @@ def run_sfm(
134
  "rotation / blurry frames, and keep the scene static."
135
  )
136
  # Deterministic world scale (same convention COLMAP itself uses), so that the
137
- # fixed near/far planes of the ReSplat checkpoint stay meaningful.
138
  rec.normalize(fixed_scale=False, extent=10.0)
139
  log(f"[colmap] mapped {rec.num_reg_images()}/{len(names)} images, {rec.num_points3D()} points")
140
 
@@ -154,7 +171,7 @@ def run_sfm(
154
  )
155
  log("[colmap] undistorted to PINHOLE")
156
 
157
- # Keep only what ReSplat reads (the undistorter also writes stereo/ scaffolding).
158
  for extra in ("stereo",):
159
  p = scene_dir / extra
160
  if p.is_dir():
@@ -166,7 +183,32 @@ def run_sfm(
166
  except Exception:
167
  pass
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  return {
 
170
  "scene_dir": str(scene_dir),
171
  "sparse_sub": "sparse",
172
  "images_sub": "images",
@@ -175,3 +217,55 @@ def run_sfm(
175
  "num_points3D": int(rec.num_points3D()),
176
  "camera_model": cam_model,
177
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  <out>/images/*.jpg
8
  <out>/sparse/{cameras,images,points3D}.bin
9
 
10
+ which is exactly what CodecSplat's reference COLMAP loader expects.
11
  """
12
 
13
  from __future__ import annotations
 
18
  import pycolmap
19
  from PIL import Image, ImageOps
20
 
21
+ # ZeroGPU hosts expose ~192 CPUs. With COLMAP's default num_threads=-1 the incremental
22
+ # mapper then fails to register anything beyond the initial pair (reproduced locally by
23
+ # forcing 192 threads), so cap the thread count explicitly.
24
+ NUM_THREADS = 16
25
+
26
  IMAGE_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff")
27
 
28
 
 
80
  "Upload several overlapping photos of the same scene."
81
  )
82
  log(f"[colmap] {len(names)} images prepared at <= {max_image_size}px")
83
+ try:
84
+ import os as _os
85
+ cpu = next((l.split(":", 1)[1].strip() for l in open("/proc/cpuinfo") if l.startswith("model name")), "?")
86
+ log(f"[colmap] cpu={cpu} ncpu={_os.cpu_count()} threads={NUM_THREADS}")
87
+ except Exception:
88
+ pass
89
 
90
  database_path = workdir / "colmap.db"
91
  if database_path.exists():
 
97
  extraction_options.sift.domain_size_pooling = False
98
  extraction_options.use_gpu = False
99
  extraction_options.max_image_size = int(max_image_size)
100
+ extraction_options.num_threads = NUM_THREADS
101
 
102
  reader_options = pycolmap.ImageReaderOptions()
103
  reader_options.camera_model = "SIMPLE_RADIAL"
 
112
  )
113
  log("[colmap] features extracted")
114
 
115
+ matching_options = pycolmap.FeatureMatchingOptions()
116
+ matching_options.num_threads = NUM_THREADS
117
  if matcher == "sequential":
118
  pairing = pycolmap.SequentialPairingOptions()
119
  pairing.overlap = 10
120
  pairing.quadratic_overlap = True
121
  pycolmap.match_sequential(
122
  database_path=database_path,
123
+ matching_options=matching_options,
124
  pairing_options=pairing,
125
  device=pycolmap.Device.cpu,
126
  )
127
  else:
128
+ pycolmap.match_exhaustive(database_path=database_path, matching_options=matching_options,
129
+ device=pycolmap.Device.cpu)
130
  log(f"[colmap] {matcher} matching done")
131
 
132
  sparse_dir = workdir / "sparse_raw"
133
  sparse_dir.mkdir(parents=True, exist_ok=True)
134
  options = pycolmap.IncrementalPipelineOptions()
135
  options.min_num_matches = 15
136
+ options.num_threads = NUM_THREADS
137
  options.multiple_models = False
138
  options.extract_colors = True
139
  maps = pycolmap.incremental_mapping(
 
151
  "rotation / blurry frames, and keep the scene static."
152
  )
153
  # Deterministic world scale (same convention COLMAP itself uses), so that the
154
+ # fixed near/far planes of the CodecSplat checkpoint stay meaningful.
155
  rec.normalize(fixed_scale=False, extent=10.0)
156
  log(f"[colmap] mapped {rec.num_reg_images()}/{len(names)} images, {rec.num_points3D()} points")
157
 
 
171
  )
172
  log("[colmap] undistorted to PINHOLE")
173
 
174
+ # Keep only what CodecSplat reads (the undistorter also writes stereo/ scaffolding).
175
  for extra in ("stereo",):
176
  p = scene_dir / extra
177
  if p.is_dir():
 
183
  except Exception:
184
  pass
185
 
186
+ # Read back the undistorted PINHOLE cameras (normalized K, OpenCV c2w), sorted by name.
187
+ und = pycolmap.Reconstruction(str(scene_dir / "sparse"))
188
+ frames = []
189
+ for _, img in und.images.items():
190
+ cam = und.cameras[img.camera_id]
191
+ p = list(cam.params)
192
+ if cam.model.name == "PINHOLE":
193
+ fx, fy, cx, cy = p[:4]
194
+ elif cam.model.name == "SIMPLE_PINHOLE":
195
+ fx = fy = p[0]
196
+ cx, cy = p[1:3]
197
+ else:
198
+ continue
199
+ cfw = img.cam_from_world() if callable(img.cam_from_world) else img.cam_from_world
200
+ w2c = cfw.matrix() # 3x4
201
+ path = scene_dir / "images" / img.name
202
+ if path.exists():
203
+ frames.append({
204
+ "name": img.name, "path": str(path),
205
+ "K": [[fx / cam.width, 0, cx / cam.width], [0, fy / cam.height, cy / cam.height], [0, 0, 1]],
206
+ "w2c": [list(map(float, row)) for row in w2c],
207
+ })
208
+ frames.sort(key=lambda f: f["name"])
209
+
210
  return {
211
+ "frames": frames,
212
  "scene_dir": str(scene_dir),
213
  "sparse_sub": "sparse",
214
  "images_sub": "images",
 
217
  "num_points3D": int(rec.num_points3D()),
218
  "camera_model": cam_model,
219
  }
220
+
221
+
222
+ def run_sfm_subprocess(image_paths, workdir, max_image_size=1280, matcher="exhaustive",
223
+ timeout=900, log=print) -> dict:
224
+ """Run `run_sfm` in a fresh Python process that only imports pycolmap.
225
+
226
+ Inside the Gradio process torch / CUDA / OpenMP / BLAS libraries are already loaded,
227
+ which makes pycolmap's bundled OpenBLAS / OpenMP misbehave (crashes in matching and
228
+ failed image registration). A clean child process avoids all of that.
229
+ """
230
+ import json
231
+ import os
232
+ import subprocess
233
+ import sys
234
+
235
+ workdir = Path(workdir)
236
+ workdir.mkdir(parents=True, exist_ok=True)
237
+ spec = workdir / "sfm_job.json"
238
+ result = workdir / "sfm_result.json"
239
+ spec.write_text(json.dumps({
240
+ "image_paths": [str(p) for p in image_paths], "workdir": str(workdir),
241
+ "max_image_size": int(max_image_size), "matcher": matcher, "result": str(result),
242
+ }))
243
+ env = {k: v for k, v in os.environ.items() if k not in ("LD_PRELOAD",)}
244
+ env["LD_LIBRARY_PATH"] = os.environ.get("_ORIG_LD_LIBRARY_PATH", "")
245
+ env["OPENBLAS_NUM_THREADS"] = "1"
246
+ env.pop("CUDA_VISIBLE_DEVICES", None)
247
+ proc = subprocess.run([sys.executable, os.path.abspath(__file__), str(spec)], env=env,
248
+ capture_output=True, text=True, timeout=timeout)
249
+ for line in (proc.stdout or "").splitlines():
250
+ if line.startswith("[colmap]"):
251
+ log(line)
252
+ if not result.exists():
253
+ tail = "\n".join(((proc.stderr or "") + (proc.stdout or "")).splitlines()[-15:])
254
+ raise RuntimeError(f"COLMAP subprocess failed (exit {proc.returncode}):\n{tail}")
255
+ data = json.loads(result.read_text())
256
+ if "error" in data:
257
+ raise ValueError(data["error"])
258
+ return data
259
+
260
+
261
+ if __name__ == "__main__":
262
+ import json
263
+ import sys
264
+
265
+ job = json.loads(Path(sys.argv[1]).read_text())
266
+ try:
267
+ out = run_sfm(job["image_paths"], Path(job["workdir"]), job["max_image_size"],
268
+ job["matcher"], log=lambda m: print(m, flush=True))
269
+ except ValueError as exc:
270
+ out = {"error": str(exc)}
271
+ Path(job["result"]).write_text(json.dumps(out))