""" voxelize_pbr_view.py - Multi-view transform PBR voxelization Extends voxelize_pbr.py with scale and mesh rotation logic Based on dual_grid_view.py and test_ovoxel_pbr_transform.py implementation """ import os import copy import sys import importlib import argparse import json import math import pandas as pd import pickle import numpy as np import torch from easydict import EasyDict as edict from functools import partial import o_voxel from utils import get_new_camera_matrix, sphere_normalize_torch # ==================== PBR-specific transform functions ==================== def transform_vertices(vertices, frame): """ Apply multi-view transform to vertices based on camera transform matrix. Args: vertices: torch.Tensor, shape [N, 3], vertex coordinates frame: dict containing transform_matrix Returns: transformed_vertices: torch.Tensor, shape [N, 3] """ device = vertices.device c2w_orig = torch.tensor(frame['transform_matrix'], dtype=torch.float32, device=device) # Old and new camera matrices radius = c2w_orig[:3, 3].norm().item() c2w_new = get_new_camera_matrix(radius=radius, yaw=-90/180.0*math.pi, pitch=0.0, dtype=torch.float32, device=device) w2c_orig = torch.inverse(c2w_orig) # Initial and final axis alignment matrices R_init = torch.tensor([ [1.0, 0.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0] ], dtype=torch.float32, device=device) R_back = torch.tensor([ [1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0] ], dtype=torch.float32, device=device) R_ply = torch.tensor([ [1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0] ], dtype=torch.float32, device=device) T_cam = c2w_new @ w2c_orig @ R_ply T_final = R_back @ T_cam @ R_init # Apply transform vertices = vertices.reshape(-1, 3) verts_h = torch.cat([vertices, torch.ones((vertices.shape[0], 1), dtype=torch.float32, device=device)], dim=1) verts_trans = (T_final @ verts_h.T).T[:, :3] return verts_trans def transform_normals(normals, frame): """ Apply multi-view transform to normals (rotation only). Consistent with test_ovoxel_pbr_transform.py implementation. Args: normals: torch.Tensor or np.ndarray, shape [N, 3] or [N, 3, 3] frame: dict containing transform_matrix Returns: transformed_normals: np.ndarray (always returns numpy for dump compatibility) """ is_numpy = isinstance(normals, np.ndarray) if is_numpy: normals = torch.from_numpy(normals).float() device = normals.device original_shape = normals.shape # Flatten to [N, 3] for processing if len(original_shape) == 3: normals_flat = normals.reshape(-1, 3) else: normals_flat = normals c2w_orig = torch.tensor(frame['transform_matrix'], dtype=torch.float32, device=device) # Old and new camera matrices radius = c2w_orig[:3, 3].norm().item() c2w_new = get_new_camera_matrix(radius=radius, yaw=-90/180.0*math.pi, pitch=0.0, dtype=torch.float32, device=device) w2c_orig = torch.inverse(c2w_orig) # Axis alignment matrices (rotation part only, 3x3) R_init = torch.tensor([ [1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0] ], dtype=torch.float32, device=device) R_back = torch.tensor([ [1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, 0.0] ], dtype=torch.float32, device=device) R_ply = torch.tensor([ [1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, 0.0] ], dtype=torch.float32, device=device) # Use rotation part only T_cam_rot = c2w_new[:3, :3] @ w2c_orig[:3, :3] @ R_ply T_final_rot = R_back @ T_cam_rot @ R_init # Apply rotation transform normals_trans = torch.matmul(normals_flat, T_final_rot.T) # Re-normalize normals_trans = torch.nn.functional.normalize(normals_trans, dim=-1) # Restore original shape if len(original_shape) == 3: normals_trans = normals_trans.reshape(original_shape) # Always return numpy array for dump compatibility return normals_trans.numpy() def prepare_pbr_dump(dump): """ Prepare PBR dump data for processing. Consistent with voxelize_pbr.py preprocessing. Args: dump: raw PBR dump data Returns: processed dump data """ dump = copy.deepcopy(dump) # Fix dump alpha map for mat in dump['materials']: if mat['alphaTexture'] is not None and mat['alphaMode'] == 'OPAQUE': mat['alphaMode'] = 'BLEND' # Append default material dump['materials'].append({ "baseColorFactor": [0.8, 0.8, 0.8], "alphaFactor": 1.0, "metallicFactor": 0.0, "roughnessFactor": 0.5, "alphaMode": "OPAQUE", "alphaCutoff": 0.5, "baseColorTexture": None, "alphaTexture": None, "metallicTexture": None, "roughnessTexture": None, }) # Filter out empty objects dump['objects'] = [ obj for obj in dump['objects'] if obj['vertices'].size != 0 and obj['faces'].size != 0 ] return dump def transform_pbr_dump(dump, frame): """ Apply multi-view transform to entire PBR dump data. Processing flow (based on test_ovoxel_pbr_transform.py): 1. Box normalize all vertices (scale only, no center shift) 2. Sphere normalize 3. Apply multi-view transform 4. Normalize back to [-0.5, 0.5]^3 Note: All object vertices are processed together (not per-object) for consistency. Args: dump: PBR dump data (already preprocessed via prepare_pbr_dump) frame: camera frame info Returns: transformed_dump: transformed dump data total_scale: total scale from original mesh to final mesh """ transformed_dump = copy.deepcopy(dump) # 1. Collect all vertices all_vertices_list = [] vertex_counts = [] for obj in transformed_dump['objects']: all_vertices_list.append(obj['vertices']) vertex_counts.append(len(obj['vertices'])) if len(all_vertices_list) == 0: return transformed_dump, 1.0 all_vertices = np.concatenate(all_vertices_list, axis=0) # 2. Box normalize (scale only, no center shift, consistent with original rendering) vertices_min = all_vertices.min(axis=0) vertices_max = all_vertices.max(axis=0) box_scale_init = 0.99999 / (vertices_max - vertices_min).max() all_vertices_box_normalized = all_vertices * box_scale_init all_vertices_tensor = torch.from_numpy(all_vertices_box_normalized).float() # 3. Sphere normalize all vertices together all_vertices_sphere, sphere_center, sphere_radius = sphere_normalize_torch(all_vertices_tensor) # 4. Multi-view transform all_transformed = transform_vertices(all_vertices_sphere, frame) # 5. Normalize back to [-0.5, 0.5]^3 (all vertices together) abs_max = all_transformed.abs().max().item() box_scale_final = 0.49999 / abs_max all_transformed_normalized = all_transformed * box_scale_final # Compute total scale (from original mesh to final normalized mesh) total_scale = box_scale_init * box_scale_final / sphere_radius.item() # 6. Split back to individual objects start_idx = 0 for i, obj in enumerate(transformed_dump['objects']): end_idx = start_idx + vertex_counts[i] obj['vertices'] = all_transformed_normalized[start_idx:end_idx].numpy() start_idx = end_idx # Transform normals if obj['normals'] is not None and obj['normals'].size > 0: obj['normals'] = transform_normals(obj['normals'], frame) # Fix mat_ids (replace -1 with default material index) obj['mat_ids'][obj['mat_ids'] == -1] = len(transformed_dump['materials']) - 1 # Validate range assert np.all(obj['mat_ids'] >= 0), 'invalid mat_ids' assert np.all(obj['vertices'] >= -0.5) and np.all(obj['vertices'] <= 0.5), 'vertices out of range' return transformed_dump, total_scale def _pbr_voxelize_view(file, sha256, pbr_dump_root, transform_root, root, view_indices=None): """ Process multi-view PBR voxelization for a single sha256. Args: file: local_path from metadata sha256: sha256 string pbr_dump_root: directory containing PBR dump files transform_root: directory containing transform json files root: output directory for PBR voxels view_indices: list of view indices to process, None for all views """ try: pack = {'sha256': sha256} dump = None # Load transforms transform_path = os.path.join(transform_root, sha256, 'transforms.json') if not os.path.exists(transform_path): print(f'Transform file not found for {sha256}, skipping') return {'sha256': sha256, 'error': 'Transform file not found'} with open(transform_path, 'r') as f: transforms_json = json.load(f) transform_mats = transforms_json['frames'] # Determine views to process if view_indices is None: view_indices = list(range(len(transform_mats))) else: view_indices = [i for i in view_indices if i < len(transform_mats)] # Track processed and skipped counts processed_count = 0 skipped_count = 0 for view_idx in view_indices: for res in opt.resolution: need_process = False # Check if already processed # Path structure: pbr_voxels_view_fix_{res}/{sha256}/view{idx:02d}.vxz sha256_dir = os.path.join(root, f'pbr_voxels_view_fix_{res}', sha256) vxz_path = os.path.join(sha256_dir, f'view{view_idx:02d}.vxz') if os.path.exists(vxz_path): try: info = o_voxel.io.read_vxz_info(vxz_path) pack[f'pbr_voxelized_view_fix{view_idx:02d}_{res}'] = True pack[f'num_pbr_voxels_view_fix{view_idx:02d}_{res}'] = info['num_voxel'] skipped_count += 1 except Exception as e: print(f'Error reading {sha256}/view{view_idx:02d}.vxz: {e}, will reprocess') need_process = True else: need_process = True # Process PBR dump if need_process: # Lazy load dump if dump is None: pbr_dump_file = os.path.join(pbr_dump_root, 'pbr_dumps', f'{sha256}.pickle') if not os.path.exists(pbr_dump_file): print(f'PBR dump not found for {sha256}, skipping') return {'sha256': sha256, 'error': 'PBR dump not found'} with open(pbr_dump_file, 'rb') as f: dump = pickle.load(f) # Prepare dump data dump = prepare_pbr_dump(dump) if len(dump['objects']) == 0: print(f'No valid objects in PBR dump for {sha256}, skipping') return {'sha256': sha256, 'error': 'No valid objects in PBR dump'} # Get transform for current view frame = transform_mats[view_idx] # Multi-view transform (deep copy from original dump each time) transformed_dump, total_scale = transform_pbr_dump(dump, frame) # PBR voxelization coord, attr = o_voxel.convert.blender_dump_to_volumetric_attr( transformed_dump, grid_size=res, aabb=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]], mip_level_offset=0, verbose=False, timing=False ) # Remove normal and emissive (consistent with voxelize_pbr.py) del attr['normal'] del attr['emissive'] # Save .vxz file os.makedirs(sha256_dir, exist_ok=True) o_voxel.io.write_vxz(vxz_path, coord, attr) # Save scale info scale_path = os.path.join(sha256_dir, f'view{view_idx:02d}_scale.json') scale_info = { 'sha256': sha256, 'view_idx': view_idx, 'total_scale': float(total_scale), } with open(scale_path, 'w') as f: json.dump(scale_info, f, indent=2) pack[f'pbr_voxelized_view_fix{view_idx:02d}_{res}'] = True pack[f'num_pbr_voxels_view_fix{view_idx:02d}_{res}'] = len(coord) pack[f'pbr_voxel_scale_view_fix{view_idx:02d}_{res}'] = float(total_scale) processed_count += 1 # Record processing stats pack['_processed_count'] = processed_count pack['_skipped_count'] = skipped_count return pack except Exception as e: print(f'Error processing {sha256}: {e}') import traceback traceback.print_exc() return {'sha256': sha256, 'error': str(e)} if __name__ == '__main__': dataset_utils = importlib.import_module(f'datasets.{sys.argv[1]}') parser = argparse.ArgumentParser() parser.add_argument('--root', type=str, required=True, help='Directory to save the metadata') parser.add_argument('--pbr_dump_root', type=str, default=None, help='Directory to load PBR dumps') parser.add_argument('--transform_root', type=str, default=None, help='Directory to load transform json files (renders_cond)') parser.add_argument('--pbr_voxel_root', type=str, default=None, help='Directory to save voxelized PBR attributes') parser.add_argument('--filter_low_aesthetic_score', type=float, default=None, help='Filter objects with aesthetic score lower than this value') parser.add_argument('--instances', type=str, default=None, help='Instances to process') parser.add_argument('--view_indices', type=str, default=None, help='View indices to process, e.g., "0,1,2" or "0-5". None for all views') parser.add_argument('--skip_list', type=str, default=None, help='Path to a file containing sha256 hashes to skip (one per line). ' 'Supports format: "sha256" or "dataset/sha256"') parser.add_argument('--clean_pbr_dir', type=str, default=None, help='Path to clean_pbr directory. Will auto-load {dataset}_clean_output.txt as ok-list, ' 'only sha256 in ok-list will be processed') parser.add_argument('--clean_pbr_name', type=str, default=None, help='Dataset name prefix for clean_pbr file (e.g., ObjaverseXL_github). ' 'Defaults to sys.argv[1] if not specified') dataset_utils.add_args(parser) parser.add_argument('--resolution', type=str, default='1024') parser.add_argument('--rank', type=int, default=0) parser.add_argument('--world_size', type=int, default=1) parser.add_argument('--max_workers', type=int, default=0) opt = parser.parse_args(sys.argv[2:]) opt = edict(vars(opt)) opt.resolution = sorted([int(x) for x in opt.resolution.split(',')], reverse=True) opt.pbr_dump_root = opt.pbr_dump_root or opt.root opt.transform_root = opt.transform_root or os.path.join(opt.root, 'renders_cond') opt.pbr_voxel_root = opt.pbr_voxel_root or opt.root # Parse view_indices view_indices = None if opt.view_indices is not None: view_indices = [] for part in opt.view_indices.split(','): if '-' in part: start, end = map(int, part.split('-')) view_indices.extend(range(start, end + 1)) else: view_indices.append(int(part)) view_indices = list(set(view_indices)) # Deduplicate view_indices.sort() # Load skip list (sha256 hashes to skip) skip_set = set() if opt.skip_list is not None and os.path.exists(opt.skip_list): with open(opt.skip_list, 'r') as f: for line in f: line = line.strip() if line and not line.startswith('#'): # Support "dataset/sha256" and plain "sha256" format, extract pure sha256 skip_set.add(line.split('/')[-1]) print(f'Loaded {len(skip_set)} items from skip_list: {opt.skip_list}') # Load clean_pbr ok-list (only process approved sha256) ok_set = None if opt.clean_pbr_dir is not None: dataset_name = opt.clean_pbr_name or sys.argv[1] clean_file = os.path.join(opt.clean_pbr_dir, f'{dataset_name}_clean_output.txt') if os.path.exists(clean_file): ok_set = set() with open(clean_file, 'r') as f: for line in f: line = line.strip() if line and not line.startswith('#'): ok_set.add(line.split('/')[-1]) print(f'Loaded {len(ok_set)} ok items from clean_pbr: {clean_file}') else: print(f'Warning: clean_pbr file not found: {clean_file}, proceeding without ok-list filter') for res in opt.resolution: os.makedirs(os.path.join(opt.pbr_voxel_root, f'pbr_voxels_view_fix_{res}', 'new_records'), exist_ok=True) # Get file list if not os.path.exists(os.path.join(opt.root, 'metadata.csv')): raise ValueError('metadata.csv not found') metadata = pd.read_csv(os.path.join(opt.root, 'metadata.csv')).set_index('sha256') if os.path.exists(os.path.join(opt.root, 'aesthetic_scores', 'metadata.csv')): metadata = metadata.combine_first(pd.read_csv(os.path.join(opt.root, 'aesthetic_scores','metadata.csv')).set_index('sha256')) if os.path.exists(os.path.join(opt.pbr_dump_root, 'pbr_dumps', 'metadata.csv')): metadata = metadata.combine_first(pd.read_csv(os.path.join(opt.pbr_dump_root, 'pbr_dumps', 'metadata.csv')).set_index('sha256')) # Check already processed pbr_voxels_view_fix for res in opt.resolution: if os.path.exists(os.path.join(opt.pbr_voxel_root, f'pbr_voxels_view_fix_{res}', 'metadata.csv')): pbr_voxel_metadata = pd.read_csv(os.path.join(opt.pbr_voxel_root, f'pbr_voxels_view_fix_{res}', 'metadata.csv')).set_index('sha256') metadata = metadata.combine_first(pbr_voxel_metadata) metadata = metadata.reset_index() if opt.instances is None: if opt.filter_low_aesthetic_score is not None: metadata = metadata[metadata['aesthetic_score'] >= opt.filter_low_aesthetic_score] metadata = metadata[metadata['pbr_dumped'] == True] # Filter out objects with all views already processed if view_indices is not None: for res in opt.resolution: # Check if each specified view is already processed all_views_done_col = f'_all_views_done_{res}' metadata[all_views_done_col] = True for view_idx in view_indices: col_name = f'pbr_voxelized_view_fix{view_idx:02d}_{res}' if col_name in metadata.columns: metadata[all_views_done_col] = metadata[all_views_done_col] & (metadata[col_name] == True) else: metadata[all_views_done_col] = False break # Keep objects with at least one incomplete resolution any_incomplete = None for res in opt.resolution: all_views_done_col = f'_all_views_done_{res}' if all_views_done_col in metadata.columns: if any_incomplete is None: any_incomplete = ~metadata[all_views_done_col] else: any_incomplete = any_incomplete | ~metadata[all_views_done_col] if any_incomplete is not None: before_filter = len(metadata) metadata = metadata[any_incomplete] print(f'Filtered out {before_filter - len(metadata)} already completed objects') else: if os.path.exists(opt.instances): with open(opt.instances, 'r') as f: instances = f.read().splitlines() else: instances = opt.instances.split(',') metadata = metadata[metadata['sha256'].isin(instances)] # Apply skip_list filter (exclude specified sha256) if skip_set: before_skip = len(metadata) metadata = metadata[~metadata['sha256'].isin(skip_set)] print(f'Skip list: filtered out {before_skip - len(metadata)} objects, {len(metadata)} remaining') # Apply clean_pbr ok-list filter (only keep approved sha256) if ok_set is not None: before_ok = len(metadata) metadata = metadata[metadata['sha256'].isin(ok_set)] print(f'Ok list: kept {len(metadata)} objects out of {before_ok} (filtered {before_ok - len(metadata)})') metadata = metadata.sample(frac=1, random_state=444).reset_index(drop=True) start = len(metadata) * opt.rank // opt.world_size end = len(metadata) * (opt.rank + 1) // opt.world_size metadata = metadata[start:end] print(f'Processing {len(metadata)} objects...') if view_indices: print(f'View indices to process: {view_indices}') else: print('Processing all available views') # Process objects func = partial(_pbr_voxelize_view, pbr_dump_root=opt.pbr_dump_root, transform_root=opt.transform_root, root=opt.pbr_voxel_root, view_indices=view_indices) pbr_voxelized = dataset_utils.foreach_instance(metadata, opt.root, func, max_workers=opt.max_workers, desc='Voxelizing PBR views') # Processing summary total_processed = pbr_voxelized['_processed_count'].sum() if '_processed_count' in pbr_voxelized.columns else 0 total_skipped = pbr_voxelized['_skipped_count'].sum() if '_skipped_count' in pbr_voxelized.columns else 0 print(f'\n========== Processing Summary ==========') print(f'Total processed (new): {int(total_processed)}') print(f'Total skipped (existing): {int(total_skipped)}') print(f'Total items: {int(total_processed + total_skipped)}') print(f'=========================================\n') if 'error' in pbr_voxelized.columns: errors = pbr_voxelized[pbr_voxelized['error'].notna()] if len(errors) > 0: with open('errors_pbr_view.txt', 'w') as f: f.write('\n'.join(errors['sha256'].tolist())) print(f'Errors written to errors_pbr_view.txt ({len(errors)} errors)') # Save metadata for res in opt.resolution: # Collect all view-related columns view_cols = [col for col in pbr_voxelized.columns if f'pbr_voxelized_view_fix' in col and f'_{res}' in col] if view_cols: # Save metadata for each view pbr_voxel_metadata = pbr_voxelized[pbr_voxelized[view_cols].any(axis=1)] if len(pbr_voxel_metadata) > 0: # Save simplified metadata cols_to_save = ['sha256'] + [col for col in pbr_voxelized.columns if f'_{res}' in col] cols_to_save = [col for col in cols_to_save if col in pbr_voxelized.columns] pbr_voxel_metadata[cols_to_save].to_csv( os.path.join(opt.pbr_voxel_root, f'pbr_voxels_view_fix_{res}', 'new_records', f'part_{opt.rank}.csv'), index=False ) print('Done!')