Spaces:
Running on Zero
Running on Zero
Download src/utils/camera_conversion.py from ritianyu/SplatAnything: direct link, hf CLI and curl.
- Browser
- Download file 11.2 kB
-
https://huggingface.co/spaces/ritianyu/SplatAnything/resolve/main/src/utils/camera_conversion.py
- Command line
-
hf download hf://spaces/ritianyu/SplatAnything/src/utils/camera_conversion.py
-
curl -L -o camera_conversion.py https://huggingface.co/spaces/ritianyu/SplatAnything/resolve/main/src/utils/camera_conversion.py
11.2 kB
| """Convert existing calibration data into SplatAnything's camera JSON convention.""" | |
| from pathlib import Path | |
| import os | |
| import struct | |
| import numpy as np | |
| from PIL import Image | |
| from src.utils.camera_io import validate_pose, validate_intrinsics | |
| from src.utils.geometry import colmap_to_opencv_intrinsics | |
| def to_opencv_c2w(matrix, pose_convention, camera_axes): | |
| """Invert w2c first, then convert camera axes while preserving the world frame.""" | |
| matrix = np.asarray(matrix, dtype=np.float64) | |
| if matrix.shape == (3, 4): | |
| matrix = np.vstack([matrix, [0, 0, 0, 1]]) | |
| validate_pose(matrix, 'source') | |
| if pose_convention not in {'c2w', 'w2c'} or camera_axes not in {'opencv', 'opengl'}: | |
| raise ValueError('Specify c2w/w2c and opencv/opengl explicitly.') | |
| result = np.linalg.inv(matrix) if pose_convention == 'w2c' else matrix.copy() | |
| if camera_axes == 'opengl': | |
| result = result @ np.diag([1., -1., -1., 1.]) | |
| return result | |
| def _store(records, name, pose, intrinsic, image_size=None): | |
| # Inference takes a flat image directory. Never collapse ambiguous source names silently. | |
| basename = Path(name).name | |
| stem = Path(basename).stem | |
| if basename in records or any(Path(existing).stem == stem for existing in records): | |
| raise ValueError(f'Ambiguous or duplicate source image name: {name!r}. ' | |
| 'Each image needs a unique filename and stem.') | |
| records[basename] = (pose, intrinsic, image_size) | |
| def _match_records(images, records): | |
| missing = [p.name for p in images if p.name not in records] | |
| if missing: | |
| raise ValueError(f'{len(missing)} input images have no calibration: ' | |
| f'{", ".join(missing[:5])}. Keep only calibrated/registered images ' | |
| 'in the input folder and preserve their filenames.') | |
| poses, intrinsics = {}, {} | |
| for path in images: | |
| pose, intrinsic, expected_size = records[path.name] | |
| if expected_size is not None: | |
| with Image.open(path) as image: | |
| actual_size = image.size | |
| if actual_size != expected_size: | |
| raise ValueError(f'{path.name}: image size {actual_size} differs from calibration ' | |
| f'size {expected_size}. Use the matching undistorted images/model; ' | |
| 'do not mix original and resized calibration data.') | |
| poses[path.stem] = pose | |
| intrinsics[path.stem] = intrinsic | |
| return poses, intrinsics | |
| def cameras_from_arrays(images, poses_path, intrinsics_path, image_names_path, | |
| pose_convention, camera_axes, intrinsics_pixel_center='opencv'): | |
| """Match matrix rows to explicit image names; a single K may be shared by all views.""" | |
| names = [line.strip() for line in Path(image_names_path).read_text(encoding='utf-8').splitlines() | |
| if line.strip()] | |
| poses = np.load(poses_path, allow_pickle=False) | |
| intrinsics = np.load(intrinsics_path, allow_pickle=False) | |
| if not isinstance(poses, np.ndarray) or not isinstance(intrinsics, np.ndarray): | |
| raise ValueError('Use numeric .npy arrays, not .npz archives or object arrays.') | |
| if poses.ndim == 2: | |
| poses = poses[None] | |
| if poses.ndim != 3 or poses.shape[1:] not in {(4, 4), (3, 4)}: | |
| raise ValueError('Poses must have shape [N, 4, 4] or [N, 3, 4].') | |
| if len(names) != len(poses): | |
| raise ValueError(f'image_names.txt has {len(names)} names but poses has {len(poses)} rows.') | |
| if intrinsics.shape == (3, 3): | |
| intrinsics = np.broadcast_to(intrinsics, (len(poses), 3, 3)) | |
| if intrinsics.shape != (len(poses), 3, 3): | |
| raise ValueError('Intrinsics must have shape [3, 3] or [N, 3, 3], in original-image pixels.') | |
| if intrinsics_pixel_center not in {'opencv', 'colmap'}: | |
| raise ValueError('Intrinsics pixel center must be opencv or colmap.') | |
| records = {} | |
| for name, pose, intrinsic in zip(names, poses, intrinsics): | |
| intrinsic = np.asarray(intrinsic, dtype=np.float64).copy() | |
| if intrinsics_pixel_center == 'colmap': | |
| intrinsic = colmap_to_opencv_intrinsics(intrinsic) | |
| validate_intrinsics(intrinsic, name) | |
| _store(records, name, to_opencv_c2w(pose, pose_convention, camera_axes), intrinsic) | |
| return _match_records(images, records) | |
| def _pinhole_camera(model, width, height, params): | |
| if model == 'SIMPLE_PINHOLE' and len(params) == 3: | |
| fx, cx, cy = params | |
| fy = fx | |
| elif model == 'PINHOLE' and len(params) == 4: | |
| fx, fy, cx, cy = params | |
| else: | |
| raise ValueError(f'Unsupported COLMAP camera model/parameters: {model}. ' | |
| 'Run COLMAP image_undistorter and use its PINHOLE or SIMPLE_PINHOLE ' | |
| 'model together with the undistorted images.') | |
| if width <= 0 or height <= 0: | |
| raise ValueError('COLMAP camera image dimensions must be positive.') | |
| matrix = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtype=np.float64) | |
| # COLMAP locates the first pixel center at (0.5, 0.5); inference uses (0, 0). | |
| matrix = colmap_to_opencv_intrinsics(matrix) | |
| validate_intrinsics(matrix, 'COLMAP') | |
| return matrix, (width, height) | |
| def _pose_from_colmap(qvec, tvec): | |
| """Convert COLMAP's Hamilton WXYZ quaternion and w2c translation to c2w.""" | |
| qvec = np.asarray(qvec, dtype=np.float64) | |
| norm = np.linalg.norm(qvec) | |
| if not np.isfinite(norm) or norm < 1e-12: | |
| raise ValueError('COLMAP pose has an invalid quaternion.') | |
| w, x, y, z = qvec / norm | |
| rotation = np.array([ | |
| [1-2*(y*y+z*z), 2*(x*y-z*w), 2*(x*z+y*w)], | |
| [2*(x*y+z*w), 1-2*(x*x+z*z), 2*(y*z-x*w)], | |
| [2*(x*z-y*w), 2*(y*z+x*w), 1-2*(x*x+y*y)], | |
| ]) | |
| matrix = np.eye(4) | |
| matrix[:3, :3] = rotation.T | |
| matrix[:3, 3] = -rotation.T @ np.asarray(tvec, dtype=np.float64) | |
| validate_pose(matrix, 'COLMAP') | |
| return matrix | |
| def _read_text_model(model_dir): | |
| cameras, records, image_ids = {}, {}, set() | |
| for line in (model_dir / 'cameras.txt').read_text(encoding='utf-8').splitlines(): | |
| if not line.strip() or line.lstrip().startswith('#'): | |
| continue | |
| parts = line.split() | |
| if len(parts) < 7: | |
| raise ValueError('Malformed cameras.txt camera line.') | |
| camera_id = int(parts[0]) | |
| if camera_id in cameras: | |
| raise ValueError(f'Duplicate COLMAP camera ID: {camera_id}') | |
| cameras[camera_id] = _pinhole_camera(parts[1], int(parts[2]), int(parts[3]), | |
| [float(value) for value in parts[4:]]) | |
| with (model_dir / 'images.txt').open(encoding='utf-8') as handle: | |
| for line in handle: | |
| if not line.strip() or line.lstrip().startswith('#'): | |
| continue | |
| parts = line.strip().split(maxsplit=9) | |
| if len(parts) != 10: | |
| raise ValueError('Malformed images.txt image header.') | |
| image_id = int(parts[0]) | |
| if image_id in image_ids: | |
| raise ValueError(f'Duplicate COLMAP image ID: {image_id}') | |
| image_ids.add(image_id) | |
| camera_id = int(parts[8]) | |
| if camera_id not in cameras: | |
| raise ValueError(f'COLMAP image {parts[9]} refers to missing camera {camera_id}.') | |
| matrix, size = cameras[camera_id] | |
| pose = _pose_from_colmap([float(v) for v in parts[1:5]], [float(v) for v in parts[5:8]]) | |
| _store(records, parts[9], pose, matrix, size) | |
| points_line = handle.readline() | |
| if not points_line or len(points_line.split()) % 3: | |
| raise ValueError('Each images.txt image header must be followed by a POINTS2D line ' | |
| '(an empty line is valid for zero observations).') | |
| return records | |
| def _unpack(handle, layout): | |
| size = struct.calcsize('<' + layout) | |
| data = handle.read(size) | |
| if len(data) != size: | |
| raise ValueError(f'Truncated COLMAP binary file: {handle.name}') | |
| return struct.unpack('<' + layout, data) | |
| def _read_binary_model(model_dir): | |
| cameras, records, image_ids = {}, {}, set() | |
| with (model_dir / 'cameras.bin').open('rb') as handle: | |
| count, = _unpack(handle, 'Q') | |
| for _ in range(count): | |
| camera_id, model_id, width, height = _unpack(handle, 'iiQQ') | |
| if model_id not in {0, 1}: | |
| raise ValueError(f'Unsupported COLMAP binary camera model ID {model_id}. ' | |
| 'Use image_undistorter to export a pinhole model and matching images.') | |
| if camera_id in cameras: | |
| raise ValueError(f'Duplicate COLMAP camera ID: {camera_id}') | |
| model, num_params = ('SIMPLE_PINHOLE', 3) if model_id == 0 else ('PINHOLE', 4) | |
| cameras[camera_id] = _pinhole_camera(model, width, height, _unpack(handle, 'd'*num_params)) | |
| with (model_dir / 'images.bin').open('rb') as handle: | |
| count, = _unpack(handle, 'Q') | |
| for _ in range(count): | |
| values = _unpack(handle, 'i7di') | |
| image_id, camera_id = values[0], values[-1] | |
| if image_id in image_ids: | |
| raise ValueError(f'Duplicate COLMAP image ID: {image_id}') | |
| image_ids.add(image_id) | |
| name = bytearray() | |
| while True: | |
| char = handle.read(1) | |
| if not char: | |
| raise ValueError('Truncated COLMAP image name.') | |
| if char == b'\0': | |
| break | |
| name.extend(char) | |
| name = name.decode('utf-8') | |
| if camera_id not in cameras: | |
| raise ValueError(f'COLMAP image {name} refers to missing camera {camera_id}.') | |
| matrix, size = cameras[camera_id] | |
| _store(records, name, _pose_from_colmap(values[1:5], values[5:8]), matrix, size) | |
| points, = _unpack(handle, 'Q') | |
| end = handle.tell() + points * 24 | |
| if end > os.fstat(handle.fileno()).st_size: | |
| raise ValueError(f'Truncated COLMAP point observations for image {name}.') | |
| handle.seek(end) | |
| return records | |
| def cameras_from_colmap(images, model_dir, model_format='auto'): | |
| """Read the cameras/images pair only; points3D, pycolmap, and GPU libraries are not needed.""" | |
| model_dir = Path(model_dir) | |
| if model_format not in {'auto', 'bin', 'txt'}: | |
| raise ValueError('COLMAP model format must be auto, bin, or txt.') | |
| if model_format == 'auto': | |
| model_format = next((ext for ext in ('bin', 'txt') if all( | |
| (model_dir / f'{name}.{ext}').is_file() for name in ('cameras', 'images'))), None) | |
| if model_format is None or not all((model_dir / f'{name}.{model_format}').is_file() | |
| for name in ('cameras', 'images')): | |
| raise ValueError(f'No matching cameras/images .bin or .txt pair found in {model_dir}. ' | |
| 'Point --model at one sparse reconstruction, e.g. sparse/0.') | |
| records = _read_binary_model(model_dir) if model_format == 'bin' else _read_text_model(model_dir) | |
| return _match_records(images, records) | |