| | |
| | | import hashlib |
| | | import io |
| | | import json |
| | | import re |
| | | import struct |
| | | import subprocess |
| | | import time |
| | |
| | | |
| | | |
| | | IMAGE_SUFFIXES = {".jpg", ".jpeg"} |
| | | DENSE_DEVICES = {"cpu", "cuda"} |
| | | |
| | | |
| | | def write_progress(path: Path | None, percent: int, stage: str, message: str, image_count: int) -> None: |
| | | """Publish completed milestones for the local asynchronous console.""" |
| | | if path is None: |
| | | return |
| | | path.parent.mkdir(parents=True, exist_ok=True) |
| | | temporary = path.with_suffix(f"{path.suffix}.tmp") |
| | | temporary.write_text(json.dumps({"percent": percent, "stage": stage, "message": message, "inputImages": image_count, "updatedAt": datetime.now(UTC).isoformat(), "estimate": True}, ensure_ascii=False), encoding="utf-8") |
| | | temporary.replace(path) |
| | | |
| | | |
| | | def sha256(path: Path) -> str: |
| | |
| | | return images |
| | | |
| | | |
| | | def run(command: list[str], cwd: Path) -> None: |
| | | def run(command: list[str], cwd: Path) -> str: |
| | | completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False) |
| | | (cwd / f"{Path(command[0]).stem}.stdout.log").write_text(completed.stdout, encoding="utf-8") |
| | | (cwd / f"{Path(command[0]).stem}.stderr.log").write_text(completed.stderr, encoding="utf-8") |
| | | if completed.returncode: |
| | | message = (completed.stderr or completed.stdout or "OpenMVS command failed.").strip() |
| | | raise RuntimeError(f"{Path(command[0]).name} failed: {message[-1000:]}") |
| | | return f"{completed.stdout}\n{completed.stderr}" |
| | | |
| | | |
| | | def verify_cuda_dense_log(output: str) -> None: |
| | | """Require OpenMVS to identify CUDA/GPU before labelling a run as GPU MVS.""" |
| | | if not re.search(r"\b(?:CUDA|GPU)\b", output, flags=re.IGNORECASE): |
| | | raise RuntimeError("The CUDA OpenMVS densification log did not report CUDA/GPU execution.") |
| | | |
| | | |
| | | def openmvs_tool_log(cwd: Path, tool_name: str) -> str: |
| | | """OpenMVS writes its own timestamped process log instead of stdout.""" |
| | | candidates = sorted(cwd.glob(f"{tool_name}-*.log"), key=lambda item: item.stat().st_mtime_ns) |
| | | return candidates[-1].read_text(encoding="utf-8", errors="replace") if candidates else "" |
| | | |
| | | |
| | | def with_openmvs_log(error: RuntimeError, cwd: Path, tool_name: str) -> RuntimeError: |
| | | """Attach the native tool log, where OpenMVS records CUDA failures.""" |
| | | tool_log = openmvs_tool_log(cwd, tool_name).strip() |
| | | if not tool_log: |
| | | return error |
| | | return RuntimeError(f"{error}\n{tool_name} native log:\n{tool_log[-1000:]}") |
| | | |
| | | |
| | | def ply_vertex_count(path: Path) -> int: |
| | |
| | | setattr(args, field, getattr(args, field).resolve()) |
| | | |
| | | def main() -> int: |
| | | parser = argparse.ArgumentParser(description="Create an OpenMVS dense point cloud, mesh and texture using only CPU.") |
| | | parser = argparse.ArgumentParser(description="Create an OpenMVS dense point cloud, mesh and texture with CPU stages and an optional CUDA MVS stage.") |
| | | parser.add_argument("--input", type=Path, required=True, help="Original coherent JPG/JPEG sequence.") |
| | | parser.add_argument("--sparse-model", type=Path, required=True, help="COLMAP binary sparse model directory.") |
| | | parser.add_argument("--output", type=Path, required=True, help="New or empty result directory.") |
| | |
| | | parser.add_argument("--mesh-close-holes", type=int, default=0) |
| | | parser.add_argument("--mesh-smooth", type=int, default=0) |
| | | parser.add_argument("--texture-outlier-threshold", type=float, default=0.0) |
| | | parser.add_argument("--dense-device", choices=sorted(DENSE_DEVICES), default="cpu", help="Use CUDA only for OpenMVS DensifyPointCloud; preparation, meshing and texturing remain CPU stages.") |
| | | parser.add_argument("--progress-file", type=Path, help="Optional JSON progress file for the local asynchronous console.") |
| | | args = parser.parse_args() |
| | | resolve_external_tool_paths(args) |
| | | if args.threads < 1 or args.max_resolution < 640 or args.dense_resolution_level < 0 or args.dense_min_resolution < 1 or args.dense_number_views < 2 or args.dense_number_views_fuse < 2 or args.dense_number_views_fuse > args.dense_number_views or args.target_faces < 10000 or args.mesh_close_holes < 0 or args.mesh_smooth < 0 or args.texture_outlier_threshold < 0: |
| | |
| | | if len(reconstruction.images) < 3: |
| | | raise SystemExit("Sparse model has fewer than three registered images.") |
| | | args.output.mkdir(parents=True, exist_ok=True) |
| | | write_progress(args.progress_file, 58, "dense_prepare", "正在准备 RGB 处理副本和去畸变影像。", len(images)) |
| | | started = time.perf_counter() |
| | | processed = args.output / "processed_rgb_images" |
| | | processed.mkdir() |
| | |
| | | image.convert("RGB").save(processed / source.name, quality=100, subsampling=0, optimize=False) |
| | | manifest.append({"image": source.name, "source_bytes": source.stat().st_size, "source_sha256": sha256(source), "source_size": list(image.size), "source_mode": image.mode}) |
| | | (processed / "conversion_manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | write_progress(args.progress_file, 64, "dense_undistort", "正在去畸变并转换 OpenMVS 场景。", len(images)) |
| | | undistorted = args.output / "undistorted_rgb" |
| | | pycolmap.undistort_images(undistorted, args.sparse_model, processed, num_threads=args.threads, jpeg_quality=95) |
| | | scene = args.output / "scene.mvs" |
| | | run([str(tools["InterfaceCOLMAP"]), "--input-file", str(undistorted), "--image-folder", str(undistorted / "images"), "--output-file", str(scene), "--max-threads", str(args.threads)], args.output) |
| | | dense = args.output / "dense.mvs" |
| | | run([str(tools["DensifyPointCloud"]), "--input-file", str(scene), "--output-file", str(dense), "--max-threads", str(args.threads), "--resolution-level", str(args.dense_resolution_level), "--max-resolution", str(args.max_resolution), "--min-resolution", str(args.dense_min_resolution), "--number-views", str(args.dense_number_views), "--number-views-fuse", str(args.dense_number_views_fuse), "--iters", "3", "--geometric-iters", "2", "--postprocess-dmaps", "5", "--filter-point-cloud", "1", "--tower-mode", "0"], args.output) |
| | | write_progress(args.progress_file, 68, "dense_fusion", "正在进行 OpenMVS 深度估计和点云融合。", len(images)) |
| | | try: |
| | | dense_log = run([str(tools["DensifyPointCloud"]), "--input-file", str(scene), "--output-file", str(dense), "--max-threads", str(args.threads), "--resolution-level", str(args.dense_resolution_level), "--max-resolution", str(args.max_resolution), "--min-resolution", str(args.dense_min_resolution), "--number-views", str(args.dense_number_views), "--number-views-fuse", str(args.dense_number_views_fuse), "--iters", "3", "--geometric-iters", "2", "--postprocess-dmaps", "5", "--filter-point-cloud", "1", "--tower-mode", "0"], args.output) |
| | | except RuntimeError as error: |
| | | raise with_openmvs_log(error, args.output, "DensifyPointCloud") from error |
| | | dense_log += openmvs_tool_log(args.output, "DensifyPointCloud") |
| | | if args.dense_device == "cuda": |
| | | verify_cuda_dense_log(dense_log) |
| | | mesh = args.output / "mesh.mvs" |
| | | write_progress(args.progress_file, 83, "mesh", "稠密点云已完成,正在重建三角网格。", len(images)) |
| | | run([str(tools["ReconstructMesh"]), "--input-file", str(dense), "--output-file", str(mesh), "--max-threads", str(args.threads), "--target-face-num", str(args.target_faces), "--remove-spurious", "20", "--remove-spikes", "1", "--close-holes", str(args.mesh_close_holes), "--smooth", str(args.mesh_smooth)], args.output) |
| | | textured = args.output / "textured.mvs" |
| | | write_progress(args.progress_file, 90, "texture", "网格已完成,正在生成纹理和浏览器预览。", len(images)) |
| | | run([str(tools["TextureMesh"]), "--input-file", str(dense), "--mesh-file", str(args.output / "mesh.ply"), "--output-file", str(textured), "--export-type", "glb", "--max-threads", str(args.threads), "--resolution-level", "0", "--min-resolution", "640", "--max-texture-size", "4096", "--close-holes", "0", "--outlier-threshold", str(args.texture_outlier_threshold)], args.output) |
| | | textured_scene = trimesh.load(args.output / "textured.glb", force="scene") |
| | | (args.output / "textured_embedded.glb").write_bytes(textured_scene.export(file_type="glb")) |
| | | geometry_preview_faces = build_geometry_preview_glb(args.output / "mesh.ply", args.output / "geometry_preview.glb") |
| | | point_color_preview_faces = build_point_color_preview_glb(args.output / "dense.ply", args.output / "mesh.ply", args.output / "point_color_preview.glb") |
| | | preview_report = build_filtered_preview_glb(args.output / "textured.glb", args.output / "textured_preview_filtered.glb", args.output / "textured_preview_filter_report.json") |
| | | write_progress(args.progress_file, 98, "export", "正在整理点云、网格、纹理和元数据。", len(images)) |
| | | geometry = list(textured_scene.geometry.values()) |
| | | texture_files = sorted(path.name for path in args.output.glob("textured_*.png")) |
| | | metadata = { |
| | | "capability": "05-3d-pointcloud", "classification": "B", "created_at": datetime.now(UTC).isoformat(), |
| | | "method": "COLMAP CPU sparse SfM, RGB processing-copy undistortion, OpenMVS CPU PatchMatch depth fusion, mesh reconstruction, and texture atlas export", |
| | | "model": "none (classical multi-view stereo CPU baseline)", "device": f"CPU (OpenMVS max_threads={args.threads})", |
| | | "thresholds": {"max_threads": args.threads, "dense_resolution_level": args.dense_resolution_level, "dense_max_resolution_px": args.max_resolution, "dense_min_resolution_px": args.dense_min_resolution, "dense_number_views": args.dense_number_views, "dense_fusion_min_views": args.dense_number_views_fuse, "mesh_target_faces": args.target_faces, "mesh_close_holes": args.mesh_close_holes, "mesh_smooth": args.mesh_smooth, "texture_outlier_threshold": args.texture_outlier_threshold}, |
| | | "method": f"COLMAP CPU sparse SfM, RGB processing-copy undistortion, OpenMVS {'CUDA' if args.dense_device == 'cuda' else 'CPU'} PatchMatch depth fusion, CPU mesh reconstruction, and CPU texture atlas export", |
| | | "model": "none (classical multi-view stereo)", "device": f"CPU preparation/mesh/texture + {'CUDA' if args.dense_device == 'cuda' else 'CPU'} OpenMVS densification (max_threads={args.threads})", |
| | | "execution_stages": {"sparse_model_input": "CPU", "image_conversion_and_undistortion": "CPU", "dense_mvs": "CUDA" if args.dense_device == "cuda" else "CPU", "mesh_reconstruction": "CPU", "texture_export": "CPU"}, |
| | | "device_selection": {"requested_dense_device": args.dense_device, "actual_dense_device": args.dense_device, "fallback_used": False, "policy": "CPU is the complete default path. CUDA is accepted only after OpenMVS reports CUDA/GPU execution; this script never silently changes a requested CUDA run to CPU."}, |
| | | "thresholds": {"max_threads": args.threads, "dense_device": args.dense_device, "dense_resolution_level": args.dense_resolution_level, "dense_max_resolution_px": args.max_resolution, "dense_min_resolution_px": args.dense_min_resolution, "dense_number_views": args.dense_number_views, "dense_fusion_min_views": args.dense_number_views_fuse, "mesh_target_faces": args.target_faces, "mesh_close_holes": args.mesh_close_holes, "mesh_smooth": args.mesh_smooth, "texture_outlier_threshold": args.texture_outlier_threshold}, |
| | | "dense_photo_reconstruction": {"input_images": len(images), "registered_images": len(reconstruction.images), "sparse_points": len(reconstruction.points3D), "dense_points": ply_vertex_count(args.output / "dense.ply"), "dense_point_cloud_file": "dense.ply", "mesh_file": "mesh.ply", "textured_model_file": "textured_preview_filtered.glb", "geometry_preview_model_file": "geometry_preview.glb", "geometry_preview_faces": geometry_preview_faces, "point_color_preview_model_file": "point_color_preview.glb", "point_color_preview_faces": point_color_preview_faces, "source_textured_model_file": "textured.glb", "embedded_source_textured_model_file": "textured_embedded.glb", "texture_preview_filter_report": "textured_preview_filter_report.json", "texture_file": texture_files[0] if texture_files else None, "texture_files": texture_files, "mesh_vertices": sum(len(item.vertices) for item in geometry), "mesh_faces": sum(len(item.faces) for item in geometry), "preview_mesh_faces": preview_report["kept_faces"], "preview_mesh_face_ratio": preview_report["kept_face_ratio"], "coordinate_basis": "local_sfm_coordinates_arbitrary_scale_and_orientation", "processed_rgb_conversion_manifest": "processed_rgb_images/conversion_manifest.json"}, |
| | | "elapsed_seconds": round(time.perf_counter() - started, 3), |
| | | "limitations": ["CPU MVS baseline only; review visual quality before raising resolution or treating the output as operational.", "Source imagery remains unchanged; processing copies have no survey-grade coordinate claim.", "OpenMVS is AGPL-3.0-only and needs a separate commercial license assessment."], |
| | | "limitations": ["CUDA mode accelerates only OpenMVS depth estimation and fusion; feature preparation, meshing and texture export remain CPU stages.", "Review visual quality before raising resolution or treating the output as operational.", "Source imagery remains unchanged; processing copies have no survey-grade coordinate claim.", "OpenMVS is AGPL-3.0-only and needs a separate commercial license assessment."], |
| | | } |
| | | (args.output / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | print(json.dumps(metadata, ensure_ascii=False, indent=2)) |