Spaces:
Sleeping
Sleeping
| """ | |
| COLMAP Service - Wrapper for COLMAP reconstruction pipeline. | |
| """ | |
| import asyncio | |
| import logging | |
| import re | |
| import sqlite3 | |
| import subprocess | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Callable, Optional | |
| import numpy as np | |
| import open3d as o3d | |
| from backend.config import settings | |
| SIMULATION_MODE = settings.simulation_mode | |
| logger = logging.getLogger(__name__) | |
| COLMAP_PATH = settings.colmap_path | |
| USE_GPU = settings.colmap_use_gpu | |
| def _get_match_stats(db_path: Path) -> dict: | |
| """Read lightweight feature/match statistics from the COLMAP SQLite database.""" | |
| stats = { | |
| "images": 0, | |
| "cameras": 0, | |
| "keypoints_rows": 0, | |
| "matches_rows": 0, | |
| "two_view_geometries_rows": 0, | |
| } | |
| if not db_path.exists(): | |
| return stats | |
| try: | |
| conn = sqlite3.connect(str(db_path)) | |
| cur = conn.cursor() | |
| cur.execute("SELECT COUNT(*) FROM images") | |
| stats["images"] = int(cur.fetchone()[0]) | |
| cur.execute("SELECT COUNT(*) FROM cameras") | |
| stats["cameras"] = int(cur.fetchone()[0]) | |
| cur.execute("SELECT COUNT(*) FROM keypoints") | |
| stats["keypoints_rows"] = int(cur.fetchone()[0]) | |
| cur.execute("SELECT COUNT(*) FROM matches") | |
| stats["matches_rows"] = int(cur.fetchone()[0]) | |
| cur.execute("SELECT COUNT(*) FROM two_view_geometries") | |
| stats["two_view_geometries_rows"] = int(cur.fetchone()[0]) | |
| conn.close() | |
| except Exception as e: | |
| logger.warning(f"Could not read COLMAP database stats: {e}") | |
| return stats | |
| def _mesh_from_sparse_model(sparse_model_dir: Path, output_mesh_path: Path) -> None: | |
| """Create a CPU-only mesh from COLMAP sparse points via Open3D alpha shape.""" | |
| txt_dir = sparse_model_dir.parent / "sparse_txt" | |
| txt_dir.mkdir(parents=True, exist_ok=True) | |
| subprocess.run( | |
| [ | |
| COLMAP_PATH, | |
| "model_converter", | |
| "--input_path", | |
| str(sparse_model_dir), | |
| "--output_path", | |
| str(txt_dir), | |
| "--output_type", | |
| "TXT", | |
| ], | |
| check=True, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| text=True, | |
| ) | |
| points_txt = txt_dir / "points3D.txt" | |
| if not points_txt.exists(): | |
| raise ColmapError("Sparse model conversion failed: points3D.txt was not generated") | |
| points: list[list[float]] = [] | |
| with points_txt.open("r", encoding="utf-8", errors="ignore") as f: | |
| for line in f: | |
| if not line or line.startswith("#"): | |
| continue | |
| parts = line.strip().split() | |
| if len(parts) < 4: | |
| continue | |
| try: | |
| x, y, z = float(parts[1]), float(parts[2]), float(parts[3]) | |
| points.append([x, y, z]) | |
| except ValueError: | |
| continue | |
| if len(points) < 4: | |
| raise ColmapError( | |
| f"Sparse model has too few points for fallback meshing ({len(points)} points)." | |
| ) | |
| pcd = o3d.geometry.PointCloud() | |
| pcd.points = o3d.utility.Vector3dVector(np.asarray(points, dtype=np.float64)) | |
| pcd = pcd.voxel_down_sample(voxel_size=0.01) | |
| pcd.estimate_normals() | |
| # For very small sparse models, alpha-shape is unstable; use convex hull first. | |
| # This ensures low-feature datasets still produce a coarse but valid output mesh. | |
| if len(points) < 50: | |
| try: | |
| mesh, _ = pcd.compute_convex_hull() | |
| mesh.compute_vertex_normals() | |
| except Exception: | |
| # Last-resort tiny placeholder centered at sparse cloud centroid. | |
| center = np.asarray(pcd.get_center()) | |
| extent = np.asarray(pcd.get_max_bound()) - np.asarray(pcd.get_min_bound()) | |
| radius = float(max(np.linalg.norm(extent), 1e-3) * 0.5) | |
| mesh = o3d.geometry.TriangleMesh.create_sphere(radius=radius) | |
| mesh.translate(center) | |
| mesh.compute_vertex_normals() | |
| else: | |
| # Alpha-shape works reasonably on larger sparse SfM point clouds without CUDA. | |
| mesh = o3d.geometry.TriangleMesh.create_from_point_cloud_alpha_shape(pcd, alpha=0.05) | |
| mesh.compute_vertex_normals() | |
| if len(mesh.triangles) == 0: | |
| raise ColmapError("Sparse fallback meshing produced an empty mesh") | |
| o3d.io.write_triangle_mesh(str(output_mesh_path), mesh) | |
| class ColmapError(Exception): | |
| """Raised when COLMAP subprocess fails.""" | |
| pass | |
| class ProgressCallback: | |
| """Type alias for progress callback function.""" | |
| step: str | |
| progress: int | |
| message: str | |
| async def run_pipeline( | |
| job_id: str, | |
| image_dir: Path, | |
| output_dir: Path, | |
| progress_callback: Callable[[str, int, str], None], | |
| ) -> Path: | |
| """ | |
| Run full COLMAP reconstruction pipeline. | |
| Args: | |
| job_id: Unique job identifier | |
| image_dir: Directory containing input images | |
| output_dir: Directory for COLMAP output | |
| progress_callback: Callback for progress updates | |
| Returns: | |
| Path to the generated mesh.ply file | |
| Raises: | |
| ColmapError: If any COLMAP step fails | |
| """ | |
| if SIMULATION_MODE: | |
| return await _run_simulation(job_id, image_dir, output_dir, progress_callback) | |
| db = output_dir / "database.db" | |
| sparse = output_dir / "sparse" | |
| dense = output_dir / "dense" | |
| sparse.mkdir(parents=True, exist_ok=True) | |
| dense.mkdir(parents=True, exist_ok=True) | |
| progress_callback("extracting", 10, "Extracting image features...") | |
| await _run_colmap( | |
| [ | |
| COLMAP_PATH, | |
| "feature_extractor", | |
| "--database_path", | |
| str(db), | |
| "--image_path", | |
| str(image_dir), | |
| "--ImageReader.single_camera", | |
| "0", | |
| "--SiftExtraction.use_gpu", | |
| USE_GPU, | |
| "--SiftExtraction.max_num_features", | |
| "16384", | |
| "--SiftExtraction.peak_threshold", | |
| "0.002", | |
| ] | |
| ) | |
| feature_stats = _get_match_stats(db) | |
| if feature_stats["images"] < 2: | |
| raise ColmapError( | |
| "COLMAP indexed fewer than 2 valid images during feature extraction. " | |
| f"DB stats: images={feature_stats['images']}, cameras={feature_stats['cameras']}, " | |
| f"keypoints_rows={feature_stats['keypoints_rows']}. " | |
| "Use standard photo images (JPG/PNG), avoid screenshots/graphics, and ensure files are not corrupted." | |
| ) | |
| progress_callback("matching", 25, "Matching keypoints across images...") | |
| await _run_colmap( | |
| [ | |
| COLMAP_PATH, | |
| "exhaustive_matcher", | |
| "--database_path", | |
| str(db), | |
| "--SiftMatching.use_gpu", | |
| USE_GPU, | |
| "--SiftMatching.guided_matching", | |
| "1", | |
| ] | |
| ) | |
| match_stats = _get_match_stats(db) | |
| if match_stats["matches_rows"] == 0 and match_stats["two_view_geometries_rows"] == 0: | |
| logger.warning( | |
| "No matches found with default matcher settings; retrying with relaxed thresholds." | |
| ) | |
| await _run_colmap( | |
| [ | |
| COLMAP_PATH, | |
| "exhaustive_matcher", | |
| "--database_path", | |
| str(db), | |
| "--SiftMatching.use_gpu", | |
| USE_GPU, | |
| "--SiftMatching.guided_matching", | |
| "1", | |
| "--SiftMatching.max_ratio", | |
| "0.95", | |
| "--SiftMatching.max_distance", | |
| "0.9", | |
| ] | |
| ) | |
| match_stats = _get_match_stats(db) | |
| if match_stats["matches_rows"] == 0 and match_stats["two_view_geometries_rows"] == 0: | |
| raise ColmapError( | |
| "No image matches were found. " | |
| f"DB stats: images={match_stats['images']}, keypoints_rows={match_stats['keypoints_rows']}, " | |
| f"matches_rows={match_stats['matches_rows']}, two_view_geometries={match_stats['two_view_geometries_rows']}. " | |
| "Use 8-12 distinct photos with 60-80% overlap, sharp focus, and consistent lighting." | |
| ) | |
| progress_callback("sparse", 45, "Building sparse 3D model...") | |
| await _run_colmap( | |
| [ | |
| COLMAP_PATH, | |
| "mapper", | |
| "--database_path", | |
| str(db), | |
| "--image_path", | |
| str(image_dir), | |
| "--output_path", | |
| str(sparse), | |
| "--Mapper.init_min_num_inliers", | |
| "40", | |
| "--Mapper.abs_pose_min_num_inliers", | |
| "20", | |
| "--Mapper.min_num_matches", | |
| "20", | |
| "--Mapper.ba_local_num_images", | |
| "10", | |
| ] | |
| ) | |
| sparse_0 = sparse / "0" | |
| if not sparse_0.exists() or not list(sparse_0.glob("*.bin")): | |
| raise ColmapError( | |
| "Sparse reconstruction failed — not enough matched keypoints. " | |
| "Add more overlapping images or ensure consistent lighting. " | |
| "Tips: Use 8-12 images with good overlap, ensure consistent lighting, " | |
| "avoid reflective surfaces." | |
| ) | |
| progress_callback("undistort", 55, "Preparing images for dense reconstruction...") | |
| await _run_colmap( | |
| [ | |
| COLMAP_PATH, | |
| "image_undistorter", | |
| "--image_path", | |
| str(image_dir), | |
| "--input_path", | |
| str(sparse_0), | |
| "--output_path", | |
| str(dense), | |
| "--output_type", | |
| "COLMAP", | |
| ] | |
| ) | |
| progress_callback( | |
| "dense", 70, "Running dense reconstruction (this takes a while)..." | |
| ) | |
| mesh_ply = output_dir / "mesh.ply" | |
| try: | |
| await _run_colmap( | |
| [ | |
| COLMAP_PATH, | |
| "patch_match_stereo", | |
| "--workspace_path", | |
| str(dense), | |
| "--workspace_format", | |
| "COLMAP", | |
| "--PatchMatchStereo.geom_consistency", | |
| "true", | |
| "--PatchMatchStereo.gpu_index", | |
| "-1" if USE_GPU == "0" else "0", | |
| ] | |
| ) | |
| progress_callback("fusion", 82, "Fusing depth maps into point cloud...") | |
| fused_ply = dense / "fused.ply" | |
| await _run_colmap( | |
| [ | |
| COLMAP_PATH, | |
| "stereo_fusion", | |
| "--workspace_path", | |
| str(dense), | |
| "--workspace_format", | |
| "COLMAP", | |
| "--input_type", | |
| "geometric", | |
| "--output_path", | |
| str(fused_ply), | |
| ] | |
| ) | |
| progress_callback("meshing", 92, "Generating surface mesh...") | |
| await _run_colmap( | |
| [ | |
| COLMAP_PATH, | |
| "poisson_mesher", | |
| "--input_path", | |
| str(fused_ply), | |
| "--output_path", | |
| str(mesh_ply), | |
| ] | |
| ) | |
| except ColmapError as e: | |
| if "requires CUDA" not in str(e): | |
| raise | |
| # CPU-safe fallback for environments without CUDA-enabled dense stereo. | |
| progress_callback( | |
| "meshing", | |
| 88, | |
| "CUDA dense stereo unavailable, falling back to sparse CPU meshing...", | |
| ) | |
| try: | |
| await asyncio.to_thread(_mesh_from_sparse_model, sparse_0, mesh_ply) | |
| except Exception as fallback_err: | |
| raise ColmapError( | |
| "Dense reconstruction requires CUDA on this system and sparse fallback meshing failed. " | |
| f"Fallback error: {fallback_err}" | |
| ) | |
| progress_callback("done", 100, "Reconstruction complete!") | |
| return mesh_ply | |
| async def _run_colmap(cmd: list[str], timeout: int = 600) -> None: | |
| """Run a COLMAP subprocess asynchronously.""" | |
| logger.info(f"Running COLMAP command: {' '.join(cmd[:3])}...") | |
| try: | |
| proc = await asyncio.create_subprocess_exec( | |
| *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE | |
| ) | |
| stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) | |
| if proc.returncode != 0: | |
| error_msg = stderr.decode("utf-8", errors="ignore")[-2000:] | |
| logger.error(f"COLMAP failed: {error_msg}") | |
| # Some distro COLMAP builds expose a smaller option surface. | |
| # If an option is unsupported, remove it and retry once. | |
| m = re.search(r"unrecognised option '([^']+)'", error_msg) | |
| if m: | |
| bad_opt = m.group(1) | |
| retry_cmd: list[str] = [] | |
| i = 0 | |
| while i < len(cmd): | |
| token = cmd[i] | |
| if token == bad_opt: | |
| i += 2 | |
| continue | |
| retry_cmd.append(token) | |
| i += 1 | |
| if len(retry_cmd) < len(cmd): | |
| logger.warning(f"Retrying COLMAP without unsupported option: {bad_opt}") | |
| proc2 = await asyncio.create_subprocess_exec( | |
| *retry_cmd, | |
| stdout=asyncio.subprocess.PIPE, | |
| stderr=asyncio.subprocess.PIPE, | |
| ) | |
| stdout2, stderr2 = await asyncio.wait_for( | |
| proc2.communicate(), timeout=timeout | |
| ) | |
| if proc2.returncode == 0: | |
| logger.debug(f"COLMAP command completed on compatibility retry: {retry_cmd[1]}") | |
| return | |
| error_msg = stderr2.decode("utf-8", errors="ignore")[-2000:] | |
| logger.error(f"COLMAP retry failed: {error_msg}") | |
| raise ColmapError(f"COLMAP error: {error_msg}") | |
| logger.debug(f"COLMAP command completed: {cmd[1]}") | |
| except asyncio.TimeoutError: | |
| logger.error(f"COLMAP command timed out after {timeout}s") | |
| raise ColmapError(f"COLMAP command timed out after {timeout} seconds") | |
| except FileNotFoundError: | |
| logger.error(f"COLMAP not found at: {COLMAP_PATH}") | |
| raise ColmapError( | |
| f"COLMAP not found. Please install COLMAP and set COLMAP_PATH." | |
| ) | |
| async def _run_simulation( | |
| job_id: str, | |
| image_dir: Path, | |
| output_dir: Path, | |
| progress_callback: Callable[[str, int, str], None], | |
| ) -> Path: | |
| """Simulate COLMAP pipeline for demonstration purposes.""" | |
| import trimesh | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| glb_path = output_dir / "model.glb" | |
| steps = [ | |
| ("extracting", 10, "Extracting image features..."), | |
| ("matching", 25, "Matching keypoints across images..."), | |
| ("sparse", 45, "Building sparse 3D model..."), | |
| ("undistort", 55, "Preparing images for dense reconstruction..."), | |
| ("dense", 70, "Running dense reconstruction..."), | |
| ("fusion", 82, "Fusing depth maps into point cloud..."), | |
| ("meshing", 92, "Generating surface mesh..."), | |
| ] | |
| for step, progress, message in steps: | |
| progress_callback(step, progress, message) | |
| await asyncio.sleep(1.5) | |
| num_vertices = np.random.randint(2000, 5000) | |
| num_faces = np.random.randint(1500, 4000) | |
| vertices = np.random.uniform(-1, 1, (num_vertices, 3)) | |
| faces = np.random.randint(0, num_vertices, (num_faces, 3)) | |
| mesh = trimesh.Trimesh(vertices=vertices, faces=faces) | |
| mesh.export(str(glb_path)) | |
| progress_callback("done", 100, "Reconstruction complete!") | |
| return glb_path | |