Skip to content

Commit d1350d8

Browse files
committed
Add Support for Meshroom data convertion
1 parent 5003d0e commit d1350d8

File tree

2 files changed

+371
-0
lines changed

2 files changed

+371
-0
lines changed
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
# Copyright 2022 the Regents of the University of California, Nerfstudio Team and contributors. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Helper utils for processing meshroom data into the nerfstudio format."""
16+
17+
import json
18+
import math
19+
import numpy as np
20+
from pathlib import Path
21+
from typing import Dict, List, Optional
22+
from copy import deepcopy as dc
23+
24+
from nerfstudio.process_data.process_data_utils import CAMERA_MODELS
25+
from nerfstudio.utils.rich_utils import CONSOLE
26+
27+
# Rotation matrix to adjust coordinate system
28+
ROT_MAT = np.array([[1, 0, 0, 0],
29+
[0, 0, 1, 0],
30+
[0,-1, 0, 0],
31+
[0, 0, 0, 1]])
32+
33+
def reflect(axis, size=4):
34+
"""Create a reflection matrix along the specified axis."""
35+
_diag = np.ones(size)
36+
_diag[axis] = -1
37+
refl = np.diag(_diag)
38+
return refl
39+
40+
def Mat2Nerf(mat):
41+
"""Convert a matrix to NeRF coordinate system."""
42+
M = np.array(mat)
43+
M = ((M @ reflect(2)) @ reflect(1))
44+
return M
45+
46+
def closest_point_2_lines(oa, da, ob, db):
47+
"""Find the point closest to both rays of form o+t*d."""
48+
da = da / np.linalg.norm(da)
49+
db = db / np.linalg.norm(db)
50+
c = np.cross(da, db)
51+
denom = np.linalg.norm(c)**2
52+
t = ob - oa
53+
ta = np.linalg.det([t, db, c]) / (denom + 1e-10)
54+
tb = np.linalg.det([t, da, c]) / (denom + 1e-10)
55+
if ta > 0:
56+
ta = 0
57+
if tb > 0:
58+
tb = 0
59+
return (oa+ta*da+ob+tb*db) * 0.5, denom
60+
61+
def central_point(out):
62+
"""Find a central point all cameras are looking at."""
63+
CONSOLE.print("Computing center of attention...")
64+
totw = 0.0
65+
totp = np.array([0.0, 0.0, 0.0])
66+
for f in out["frames"]:
67+
mf = np.array(f["transform_matrix"])[0:3,:]
68+
for g in out["frames"]:
69+
mg = np.array(g["transform_matrix"])[0:3,:]
70+
p, w = closest_point_2_lines(mf[:,3], mf[:,2], mg[:,3], mg[:,2])
71+
if w > 0.01:
72+
totp += p*w
73+
totw += w
74+
75+
if len(out["frames"]) == 0:
76+
CONSOLE.print("[bold red]No frames found when computing center of attention[/bold red]")
77+
return totp
78+
79+
if (totw == 0) and (not totp.any()):
80+
CONSOLE.print("[bold red]Center of attention is zero[/bold red]")
81+
return totp
82+
83+
totp /= totw
84+
CONSOLE.print(f"The center of attention is: {totp}")
85+
86+
return totp
87+
88+
def build_sensor(intrinsic):
89+
"""Build camera intrinsics from Meshroom data."""
90+
out = {}
91+
out["w"] = float(intrinsic['width'])
92+
out["h"] = float(intrinsic['height'])
93+
94+
# Focal length in mm
95+
focal = float(intrinsic['focalLength'])
96+
97+
# Sensor width in mm
98+
sensor_width = float(intrinsic['sensorWidth'])
99+
sensor_height = float(intrinsic['sensorHeight'])
100+
101+
# Focal length in pixels
102+
out["fl_x"] = (out["w"] * focal) / sensor_width
103+
104+
# Check W/H ratio to sensor ratio
105+
if np.isclose((out["w"] / out["h"]), (sensor_width / sensor_height)):
106+
out["fl_y"] = (out["h"] * focal) / sensor_height
107+
else:
108+
CONSOLE.print("[yellow]WARNING: W/H ratio does not match sensor ratio, this is likely a bug from Meshroom. Will use fl_x to set fl_y.[/yellow]")
109+
out["fl_y"] = out["fl_x"]
110+
111+
camera_angle_x = math.atan(out["w"] / (out['fl_x']) * 2) * 2
112+
camera_angle_y = math.atan(out["h"] / (out['fl_y']) * 2) * 2
113+
114+
out["camera_angle_x"] = camera_angle_x
115+
out["camera_angle_y"] = camera_angle_y
116+
117+
out["cx"] = float(intrinsic['principalPoint'][0]) + (out["w"] / 2.0)
118+
out["cy"] = float(intrinsic['principalPoint'][1]) + (out["h"] / 2.0)
119+
120+
if intrinsic['type'] == 'radial3':
121+
for i, coef in enumerate(intrinsic['distortionParams']):
122+
out[f"k{i + 1}"] = float(coef)
123+
124+
return out
125+
126+
def meshroom_to_json(
127+
image_filename_map: Dict[str, Path],
128+
json_filename: Path,
129+
output_dir: Path,
130+
ply_filename: Optional[Path] = None,
131+
verbose: bool = False,
132+
) -> List[str]:
133+
"""Convert Meshroom data into a nerfstudio dataset.
134+
135+
Args:
136+
image_filename_map: Mapping of original image filenames to their saved locations.
137+
json_filename: Path to the Meshroom json file.
138+
output_dir: Path to the output directory.
139+
ply_filename: Path to the exported ply file.
140+
verbose: Whether to print verbose output.
141+
142+
Returns:
143+
Summary of the conversion.
144+
"""
145+
summary_log = []
146+
147+
with open(json_filename, 'r') as f:
148+
data = json.load(f)
149+
150+
# Create output structure
151+
out = {}
152+
out['aabb_scale'] = 16 # Default value
153+
154+
# Extract transforms from Meshroom data
155+
transforms = {}
156+
for pose in data.get('poses', []):
157+
transform = pose['pose']['transform']
158+
rot = np.asarray(transform['rotation'])
159+
rot = rot.reshape(3, 3).astype(float)
160+
161+
ctr = np.asarray(transform['center'])
162+
ctr = ctr.astype(float)
163+
164+
M = np.eye(4)
165+
M[:3, :3] = rot
166+
M[:3, 3] = ctr
167+
168+
M = Mat2Nerf(M.astype(float))
169+
transforms[pose['poseId']] = np.dot(ROT_MAT, M)
170+
171+
# Extract intrinsics from Meshroom data
172+
intrinsics = {}
173+
for intrinsic in data.get('intrinsics', []):
174+
intrinsics[intrinsic['intrinsicId']] = build_sensor(intrinsic)
175+
176+
# Set camera model based on intrinsic type
177+
if data.get('intrinsics') and 'type' in data['intrinsics'][0]:
178+
intrinsic_type = data['intrinsics'][0]['type']
179+
if intrinsic_type in ['radial1', 'radial3']:
180+
out["camera_model"] = CAMERA_MODELS["perspective"].value
181+
elif intrinsic_type in ['fisheye', 'fisheye4']:
182+
out["camera_model"] = CAMERA_MODELS["fisheye"].value
183+
else:
184+
# Default to perspective
185+
out["camera_model"] = CAMERA_MODELS["perspective"].value
186+
else:
187+
out["camera_model"] = CAMERA_MODELS["perspective"].value
188+
189+
# Build frames
190+
frames = []
191+
skipped_images = 0
192+
193+
for view in data.get('views', []):
194+
# Get the image name from the path
195+
path = Path(view['path'])
196+
name = path.stem
197+
198+
# Check if the image exists in our mapping
199+
if name not in image_filename_map:
200+
if verbose:
201+
CONSOLE.print(f"[yellow]Missing image for {name}, skipping[/yellow]")
202+
skipped_images += 1
203+
continue
204+
205+
# Get poseId and intrinsicId
206+
poseId = view['poseId']
207+
intrinsicId = view['intrinsicId']
208+
209+
# Check if we have the necessary data
210+
if poseId not in transforms:
211+
if verbose:
212+
CONSOLE.print(f"[yellow]PoseId {poseId} not found in transforms, skipping image: {name}[/yellow]")
213+
skipped_images += 1
214+
continue
215+
216+
if intrinsicId not in intrinsics:
217+
if verbose:
218+
CONSOLE.print(f"[yellow]IntrinsicId {intrinsicId} not found, skipping image: {name}[/yellow]")
219+
skipped_images += 1
220+
continue
221+
222+
# Create camera data
223+
camera = {}
224+
camera.update(dc(intrinsics[intrinsicId]))
225+
camera['transform_matrix'] = transforms[poseId]
226+
camera['file_path'] = image_filename_map[name].as_posix()
227+
228+
frames.append(camera)
229+
230+
out['frames'] = frames
231+
232+
# Calculate center point
233+
center = central_point(out)
234+
235+
# Adjust camera positions by centering
236+
for f in out["frames"]:
237+
f["transform_matrix"][0:3, 3] -= center
238+
f["transform_matrix"] = f["transform_matrix"].tolist()
239+
240+
# Include point cloud if provided
241+
if ply_filename is not None:
242+
import open3d as o3d
243+
244+
# Create the applied transform
245+
applied_transform = np.eye(4)[:3, :]
246+
applied_transform = applied_transform[np.array([2, 0, 1]), :]
247+
out["applied_transform"] = applied_transform.tolist()
248+
249+
# Load and transform point cloud
250+
pc = o3d.io.read_point_cloud(str(ply_filename))
251+
points3D = np.asarray(pc.points)
252+
points3D = np.einsum("ij,bj->bi", applied_transform[:3, :3], points3D) + applied_transform[:3, 3]
253+
pc.points = o3d.utility.Vector3dVector(points3D)
254+
o3d.io.write_point_cloud(str(output_dir / "sparse_pc.ply"), pc)
255+
out["ply_file_path"] = "sparse_pc.ply"
256+
summary_log.append(f"Imported {ply_filename} as starting points")
257+
258+
# Write output
259+
with open(output_dir / "transforms.json", "w", encoding="utf-8") as f:
260+
json.dump(out, f, indent=4)
261+
262+
# Add summary info
263+
if skipped_images == 1:
264+
summary_log.append(f"{skipped_images} image skipped due to missing camera pose or intrinsic data.")
265+
elif skipped_images > 1:
266+
summary_log.append(f"{skipped_images} images were skipped due to missing camera poses or intrinsic data.")
267+
268+
summary_log.append(f"Final dataset contains {len(out['frames'])} frames.")
269+
270+
return summary_log

nerfstudio/scripts/process_data.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from typing_extensions import Annotated
2727

2828
from nerfstudio.process_data import (
29+
meshroom_utils,
2930
metashape_utils,
3031
odm_utils,
3132
polycam_utils,
@@ -330,6 +331,105 @@ def main(self) -> None:
330331
CONSOLE.rule()
331332

332333

334+
@dataclass
335+
class _NoDefaultProcessMeshroom:
336+
"""Private class to order the parameters of ProcessMeshroom in the right order for default values."""
337+
338+
json: Path
339+
"""Path to the Meshroom sfm.json file."""
340+
341+
342+
@dataclass
343+
class ProcessMeshroom(BaseConverterToNerfstudioDataset, _NoDefaultProcessMeshroom):
344+
"""Process Meshroom data into a nerfstudio dataset.
345+
346+
This script assumes that cameras have been aligned using Meshroom. After alignment, it is necessary to export the
347+
camera poses as a `.json` file.
348+
349+
Optional: Meshroom does not align or constrain solved cameras, you may want to add a SfMTransform after the StructureFromMotion node, set the Transformation Method to Manual, and adjust camera positioning.
350+
351+
When you Start Meshroom processing, it generates an output folder for the ConvertSfMFormat node (right click > Open Folder). The sfm.json file needed for this script's --input function will be generated there.
352+
353+
This script does the following:
354+
1. Scales images to a specified size.
355+
2. Converts Meshroom poses into the nerfstudio format.
356+
"""
357+
358+
ply: Optional[Path] = None
359+
"""Path to the Meshroom point export ply file."""
360+
361+
num_downscales: int = 3
362+
"""Number of times to downscale the images. Downscales by 2 each time. For example a value of 3
363+
will downscale the images by 2x, 4x, and 8x."""
364+
max_dataset_size: int = 600
365+
"""Max number of images to train on. If the dataset has more, images will be sampled approximately evenly. If -1,
366+
use all images."""
367+
368+
def main(self) -> None:
369+
"""Process images into a nerfstudio dataset."""
370+
371+
if self.json.suffix != ".json":
372+
raise ValueError(f"JSON file {self.json} must have a .json extension")
373+
if not self.json.exists():
374+
raise ValueError(f"JSON file {self.json} doesn't exist")
375+
if self.eval_data is not None:
376+
raise ValueError("Cannot use eval_data since cameras were already aligned with Meshroom.")
377+
378+
if self.ply is not None:
379+
if self.ply.suffix != ".ply":
380+
raise ValueError(f"PLY file {self.ply} must have a .ply extension")
381+
if not self.ply.exists():
382+
raise ValueError(f"PLY file {self.ply} doesn't exist")
383+
384+
self.output_dir.mkdir(parents=True, exist_ok=True)
385+
image_dir = self.output_dir / "images"
386+
image_dir.mkdir(parents=True, exist_ok=True)
387+
388+
summary_log = []
389+
390+
# Copy images to output directory
391+
image_filenames, num_orig_images = process_data_utils.get_image_filenames(self.data, self.max_dataset_size)
392+
copied_image_paths = process_data_utils.copy_images_list(
393+
image_filenames,
394+
image_dir=image_dir,
395+
verbose=self.verbose,
396+
num_downscales=self.num_downscales,
397+
)
398+
num_frames = len(copied_image_paths)
399+
400+
copied_image_paths = [Path("images/" + copied_image_path.name) for copied_image_path in copied_image_paths]
401+
original_names = [image_path.stem for image_path in image_filenames]
402+
image_filename_map = dict(zip(original_names, copied_image_paths))
403+
404+
if self.max_dataset_size > 0 and num_frames != num_orig_images:
405+
summary_log.append(f"Started with {num_frames} images out of {num_orig_images} total")
406+
summary_log.append(
407+
"To change the size of the dataset add the argument [yellow]--max_dataset_size[/yellow] to "
408+
f"larger than the current value ({self.max_dataset_size}), or -1 to use all images."
409+
)
410+
else:
411+
summary_log.append(f"Started with {num_frames} images")
412+
413+
# Save json
414+
if num_frames == 0:
415+
CONSOLE.print("[bold red]No images found, exiting")
416+
sys.exit(1)
417+
summary_log.extend(
418+
meshroom_utils.meshroom_to_json(
419+
image_filename_map=image_filename_map,
420+
json_filename=self.json,
421+
output_dir=self.output_dir,
422+
ply_filename=self.ply,
423+
verbose=self.verbose,
424+
)
425+
)
426+
427+
CONSOLE.rule("[bold green]:tada: :tada: :tada: All DONE :tada: :tada: :tada:")
428+
429+
for summary in summary_log:
430+
CONSOLE.print(summary, justify="center")
431+
CONSOLE.rule()
432+
333433
@dataclass
334434
class _NoDefaultProcessRealityCapture:
335435
"""Private class to order the parameters of ProcessRealityCapture in the right order for default values."""
@@ -529,6 +629,7 @@ def main(self) -> None: ...
529629
Annotated[ProcessRealityCapture, tyro.conf.subcommand(name="realitycapture")],
530630
Annotated[ProcessRecord3D, tyro.conf.subcommand(name="record3d")],
531631
Annotated[ProcessODM, tyro.conf.subcommand(name="odm")],
632+
Annotated[ProcessMeshroom, tyro.conf.subcommand(name="meshroom")],
532633
]
533634

534635
# Add aria subcommand if projectaria_tools is installed.

0 commit comments

Comments
 (0)