From 385be2eca72eb3833efa4be0a0088b34e764788a Mon Sep 17 00:00:00 2001
From: shuishen <1109946754@qq.com>
Date: Mon, 31 Aug 2026 09:04:45 +0800
Subject: [PATCH] feat(pointcloud): complete annotation and result lifecycle workflows
---
capabilities/05-3d-pointcloud/run_cpu_dense_reconstruction.py | 63 ++++++++++++++++++++++++++++---
1 files changed, 56 insertions(+), 7 deletions(-)
diff --git a/capabilities/05-3d-pointcloud/run_cpu_dense_reconstruction.py b/capabilities/05-3d-pointcloud/run_cpu_dense_reconstruction.py
index ade6837..ed6525d 100644
--- a/capabilities/05-3d-pointcloud/run_cpu_dense_reconstruction.py
+++ b/capabilities/05-3d-pointcloud/run_cpu_dense_reconstruction.py
@@ -11,6 +11,7 @@
import hashlib
import io
import json
+import re
import struct
import subprocess
import time
@@ -25,6 +26,17 @@
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:
@@ -39,13 +51,34 @@
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:
@@ -344,7 +377,7 @@
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.")
@@ -359,6 +392,8 @@
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:
@@ -373,6 +408,7 @@
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()
@@ -382,31 +418,44 @@
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))
--
Gitblit v1.9.3