import os from pathlib import Path import json import time import random from typing import * import traceback import itertools from numbers import Number import io import numpy as np import cv2 from PIL import Image import torch import torchvision.transforms.v2.functional as TF try: import utils3d_moge as utils3d except ImportError: import utils3d import pipeline from tqdm import tqdm from ..utils.io import * from ..utils.geometry_numpy import mask_aware_nearest_resize_numpy, harmonic_mean_numpy, norm3d, depth_occlusion_edge_numpy from ..utils.data_augmentation import sample_perspective, warp_perspective, image_color_augmentation from ..utils.tools import catch_exception _NO_BUCKET = object() class _SourceBatchGroup(pipeline.Batch): """Regroup surviving instances back into the source batch they came from. ``_sample_batch`` emits fixed-size batches whose instances share one target ``(height, width)``; the upstream ``Filter`` may then drop some of them as invalid. Since the ``Parallel`` nodes in between are FIFO, every instance of a source batch arrives contiguously, so only one bucket is ever open: a new ``batch_id`` means the previous source batch is complete. Incomplete buckets are dropped. That matches what the trainer effectively saw before this was keyed by source batch -- a short bucket was keyed by ``(height, width)``, which is derived from two continuous random draws and so practically never recurred, meaning the bucket could never be completed and was retained forever. The behaviour is the same; the unbounded retention is not. To train on the survivors instead of dropping them, put the bucket to the output in ``_discard_incomplete`` below. """ def __init__(self, batch_size: int, name: Optional[str] = None): super().__init__(batch_size=batch_size, patience=None, name=name) self.num_incomplete_batches = 0 self.num_dropped_instances = 0 def _discard_incomplete(self, bucket: list): if bucket: self.num_incomplete_batches += 1 self.num_dropped_instances += len(bucket) def loop(self): from pipeline.components import EndOfInput, ExceptionInNode from pipeline.queue import ShutDown bucket: list = [] bucket_id = _NO_BUCKET try: while True: item = self.input.get() if isinstance(item, (EndOfInput, ExceptionInNode)): # A full bucket is emitted as soon as it fills, so anything # still open here is necessarily incomplete. self._discard_incomplete(bucket) bucket, bucket_id = [], _NO_BUCKET self.output.put(item) continue item_id = item.get('batch_id', _NO_BUCKET) if item_id != bucket_id: self._discard_incomplete(bucket) bucket, bucket_id = [], item_id bucket.append(item) if len(bucket) >= self.batch_size: self.output.put(bucket) bucket, bucket_id = [], _NO_BUCKET except ShutDown: return def _default_name(self): return f"SourceBatchGroup(size={self.batch_size})" class TrainDataLoaderPipeline: def __init__(self, config: dict, batch_size: int, buffer_size: int = 8, workspace: Path = None, seed: Optional[int] = None): self.config = config self.workspace = workspace self._rng = random.Random(seed) num_load_workers = int(os.environ.get('MOGE_NUM_LOAD_WORKERS', 4)) num_process_workers = int(os.environ.get('MOGE_NUM_PROCESS_WORKERS', 8)) self.batch_size = batch_size self.clamp_max_depth = config['clamp_max_depth'] self.fov_range_absolute = config.get('fov_range_absolute', 0.0) self.fov_range_relative = config.get('fov_range_relative', 0.0) self.center_augmentation = config.get('center_augmentation', 0.0) self.image_augmentation = config.get('image_augmentation', []) self.depth_interpolation = config.get('depth_interpolation', 'bilinear') # Reject-and-resample: if the warped finite-depth ratio (probed at low # resolution) falls below ``min_valid_after_warp``, we resample the # perspective up to ``resample_max_retries`` extra times and keep the # best attempt. Per-dataset overrides are honored via the dataset cfg. self.min_valid_after_warp = config.get('min_valid_after_warp', 0.01) self.resample_max_retries = config.get('resample_max_retries', 8) if 'image_sizes' in config: self.image_size_strategy = 'fixed' self.image_sizes = config['image_sizes'] elif 'aspect_ratio_range' in config and 'area_range' in config: self.image_size_strategy = 'aspect_area' self.aspect_ratio_range = config['aspect_ratio_range'] self.area_range = config['area_range'] else: raise ValueError('Invalid image size configuration') # Load datasets self.datasets = {} for dataset in tqdm(config['datasets'], desc='Loading datasets'): name = dataset['name'] content = Path(dataset['path'], dataset.get('index', '.index.txt')).joinpath().read_text() filenames = content.splitlines() self.datasets[name] = { **dataset, 'path': dataset['path'], 'filenames': filenames, } self.dataset_names = [dataset['name'] for dataset in config['datasets']] self.dataset_weights = [dataset['weight'] for dataset in config['datasets']] # debug sample balance self.dataset_sample_count = {name: 0 for name in self.dataset_names} # Build pipeline # Regroup by source batch so that ``Filter`` dropping invalid # instances does not lead to mixed-size batches. self._batch_grouper = _SourceBatchGroup(self.batch_size) self.pipeline = pipeline.Sequential([ self._sample_batch, pipeline.Unbatch(), pipeline.Parallel([self._load_instance] * num_load_workers), pipeline.Parallel([self._process_instance] * num_process_workers), pipeline.Filter(lambda instance: instance['label_type'] != 'invalid'), self._batch_grouper, self._collate_batch, pipeline.Buffer(buffer_size), ]) def state_dict(self) -> dict: """Return the RNG state so training can resume with the same data order.""" return {'rng_state': self._rng.getstate()} def load_state_dict(self, state_dict: dict): """Restore the RNG state from a previous ``state_dict()`` call.""" self._rng.setstate(state_dict['rng_state']) def _get_invalid_instance(self, torch_instance: bool = False, height: int = 256, width: int = 256, gen_info: bool = False) -> Dict[str, Union[np.ndarray, torch.Tensor, str, float, bool]]: invalid_depth = np.ones((height, width), dtype=np.float32) invalid_instance = { 'intrinsics': np.array([[1.0, 0.0, 0.5], [0.0, 1.0, 0.5], [0.0, 0.0, 1.0]], dtype=np.float32), 'image': np.zeros((3, height, width), dtype=np.float32), 'normal': np.zeros((height, width, 3), dtype=np.float32), 'depth': invalid_depth, 'depth_mask_fin': np.isfinite(invalid_depth), 'depth_mask_inf': np.isinf(invalid_depth), 'label_type': 'invalid', 'is_metric': False, } if gen_info: invalid_instance.update({ 'width': width, 'height': height, 'dataset': 'invalid', 'filename': 'invalid', 'path': 'invalid', }) if torch_instance: return {k: torch.from_numpy(v) if isinstance(v, np.ndarray) else v for k, v in invalid_instance.items()} else: return invalid_instance def _sample_batch(self): rng = self._rng batch_id = 0 last_area = None while True: # Depending on the sample strategy, choose a dataset and a filename batch_id += 1 batch = [] # Sample instances for _ in range(self.batch_size): dataset_name = rng.choices(self.dataset_names, weights=self.dataset_weights)[0] filename = rng.choice(self.datasets[dataset_name]['filenames']) self.dataset_sample_count[dataset_name] += 1 path = Path(self.datasets[dataset_name]['path'], filename) instance = { 'batch_id': batch_id, 'seed': rng.randint(0, 2 ** 32 - 1), 'dataset': dataset_name, 'filename': filename, 'path': path, 'label_type': self.datasets[dataset_name]['label_type'], } batch.append(instance) # Decide the image size for this batch if self.image_size_strategy == 'fixed': width, height = rng.choice(self.config['image_sizes']) elif self.image_size_strategy == 'aspect_area': area = rng.uniform(*self.area_range) aspect_ratio_ranges = [self.datasets[instance['dataset']].get('aspect_ratio_range', self.aspect_ratio_range) for instance in batch] aspect_ratio_range = (min(r[0] for r in aspect_ratio_ranges), max(r[1] for r in aspect_ratio_ranges)) aspect_ratio = rng.uniform(*aspect_ratio_range) width, height = int((area * aspect_ratio) ** 0.5), int((area / aspect_ratio) ** 0.5) else: raise ValueError('Invalid image size strategy') for instance in batch: instance['width'], instance['height'] = width, height yield batch def _load_instance(self, instance: dict): try: if instance is None: return None image = read_image(Path(instance['path'], 'image.jpg')) depth = read_depth(Path(instance['path'], self.datasets[instance['dataset']].get('depth', 'depth.png'))) meta = read_json(Path(instance['path'], 'meta.json')) intrinsics = np.array(meta['intrinsics'], dtype=np.float32) has_metric_annotation = False if "metric_scale" in meta: depth *= meta["metric_scale"] has_metric_annotation = True elif "depth_scale" in meta: depth *= meta["depth_scale"] has_metric_annotation = True data = { 'image': image, 'depth': depth, 'intrinsics': intrinsics, 'path': str(instance['path']), 'has_metric_annotation': has_metric_annotation, } instance.update({ **data, }) except Exception as e: traceback.print_exc() print(f"Failed to load instance {instance['dataset']}/{instance['filename']} because of exception:", e) instance.update(self._get_invalid_instance()) return instance def _process_instance(self, instance: Dict[str, Union[np.ndarray, str, float, bool]]): try: if instance is None: return self._get_invalid_instance(torch_instance=True, gen_info=True) if instance['label_type'] == 'invalid': instance.update(self._get_invalid_instance(torch_instance=True)) return instance raw_image, raw_depth, raw_intrinsics = instance['image'], instance['depth'], instance['intrinsics'] raw_normal, raw_normal_mask = utils3d.np.depth_map_to_normal_map(raw_depth, intrinsics=raw_intrinsics, mask=np.isfinite(raw_depth), edge_threshold=88) raw_normal = np.where(raw_normal_mask[..., None], raw_normal, np.nan) depth_unit = self.datasets[instance['dataset']].get('depth_unit', None) tgt_width, tgt_height = instance['width'], instance['height'] tgt_aspect = tgt_width / tgt_height rng = np.random.default_rng(instance['seed']) # Sample perspective transformation with reject-and-resample on the # coarse warped finite-depth ratio. This avoids spending the # expensive full-res warp budget on viewpoints that land entirely # in NaN regions (sky / empty background). ds_cfg = self.datasets[instance['dataset']] min_valid_after_warp = ds_cfg.get('min_valid_after_warp', self.min_valid_after_warp) max_resample_retries = ds_cfg.get('resample_max_retries', self.resample_max_retries) raw_finite_mask = np.isfinite(raw_depth) probe_h = 64 probe_w = max(1, int(round(probe_h * tgt_aspect))) best = {'ratio': -1.0, 'tgt_intrinsics': None, 'R': None, 'transform': None} for _attempt in range(max_resample_retries + 1): tgt_intrinsics_try, R_try = sample_perspective( raw_intrinsics, tgt_aspect=tgt_aspect, center_augmentation=ds_cfg.get('center_augmentation', self.center_augmentation), fov_range_absolute=ds_cfg.get('fov_range_absolute', self.fov_range_absolute), fov_range_relative=ds_cfg.get('fov_range_relative', self.fov_range_relative), rng=rng, ) transform_try = tgt_intrinsics_try @ R_try @ np.linalg.inv(raw_intrinsics) probe = warp_perspective( raw_finite_mask.astype(np.uint8), transform_try, (probe_h, probe_w), interpolation='nearest', ) ratio = float(probe.mean()) if ratio > best['ratio']: best.update(ratio=ratio, tgt_intrinsics=tgt_intrinsics_try, R=R_try, transform=transform_try) if ratio >= min_valid_after_warp: break tgt_intrinsics, R, transform = best['tgt_intrinsics'], best['R'], best['transform'] # Warp # - Warp image tgt_image = warp_perspective(raw_image, transform, tgt_size=(tgt_height, tgt_width), interpolation='lanczos') # - Warp depth depth_edge_mask = utils3d.np.depth_map_edge(raw_depth, mask=np.isfinite(raw_depth), kernel_size=5, ltol=0.01) depth_bilinear_mask = np.isfinite(raw_depth) & ~depth_edge_mask warped_depth_bilinear_mask = warp_perspective(depth_bilinear_mask.astype(np.float32), transform, (tgt_height, tgt_width), interpolation='bilinear') warped_depth_nearest = warp_perspective(raw_depth, transform, (tgt_height, tgt_width), interpolation='nearest', sparse_mask=~np.isnan(raw_depth)) warped_depth_bilinear = 1 / warp_perspective(1 / raw_depth, transform, (tgt_height, tgt_width), interpolation='bilinear') # NOTE: Bilinear intepolation in disparity space maintains planar surfaces. warped_depth = np.where(warped_depth_bilinear_mask == 1., warped_depth_bilinear, warped_depth_nearest) # check if there is any zero in warped depth if np.any(warped_depth == 0): try: print(f"Zero depth encountered for instance {instance['path']}. Dumping data.") dump_path = self.workspace / 'failed_warp_dumps' / f"{instance['dataset']}_{instance['filename'].replace('/', '_')}.npz" dump_path.parent.mkdir(parents=True, exist_ok=True) np.savez_compressed(dump_path, raw_image=raw_image, raw_depth=raw_depth, transform=transform, tgt_size=(tgt_height, tgt_width), warped_depth=warped_depth) except Exception as e: print("Failed to dump warp data because of:", e) # fix zeros warped_depth = np.where(warped_depth > 0, warped_depth, np.nan) tgt_uvhomo = np.concatenate([utils3d.np.uv_map((tgt_height, tgt_width)), np.ones((tgt_height, tgt_width, 1), dtype=np.float32)], axis=-1) tgt_depth = warped_depth / np.dot(tgt_uvhomo, np.linalg.inv(transform)[2, :]) # - Warp normal warped_normal = warp_perspective(raw_normal, transform, (tgt_height, tgt_width), interpolation='bilinear') tgt_normal = warped_normal @ R.T # always make sure that mask is not empty if np.isfinite(tgt_depth).sum() / tgt_depth.size < 0.001: tgt_depth = np.ones_like(tgt_depth) instance['label_type'] = 'invalid' # try: # print(f"Insufficient valid depth after warping for instance {instance['path']}.") # dump_path = self.workspace / 'insufficient_depths' / f"{instance['dataset']}_{instance['filename'].replace('/', '_')}.npz" # dump_path.parent.mkdir(parents=True, exist_ok=True) # np.savez_compressed(dump_path, raw_image=raw_image, raw_depth=raw_depth, transform=transform, tgt_size=(tgt_height, tgt_width), tgt_depth=tgt_depth, tgt_image=tgt_image) # except Exception as e: # print("Failed to dump insufficient depth data because of:", e) # Flip augmentation if rng.choice([True, False]): tgt_image = np.flip(tgt_image, axis=1).copy() tgt_depth = np.flip(tgt_depth, axis=1).copy() tgt_normal = np.flip(tgt_normal, axis=1).copy() * [-1, 1, 1] # NOTE: if cx != 0.5, flip intrinsics accordingly. # Color augmentation image_augmentation = self.datasets[instance['dataset']].get('image_augmentation', self.image_augmentation) tgt_image = image_color_augmentation( tgt_image, augmentations=image_augmentation, rng=rng, depth=tgt_depth, ) # Set metric flag. # - Default: a dataset is metric iff it declares a `depth_unit` (depth is in a known # metric unit), and `depth_unit` scales the raw depth to meters. # - `metric_from_meta`: decide per-sample — only instances whose meta.json carried a # `metric_scale`/`depth_scale` annotation (already applied in `_load_instance`) are # metric; the rest are relative-scale (e.g. MegaDepth SfM samples without a recovered # scale). `depth_unit`, if present, still applies as a unit conversion to all samples. if depth_unit is not None: tgt_depth *= depth_unit if self.datasets[instance['dataset']].get('metric_from_meta', False): instance['is_metric'] = bool(instance.get('has_metric_annotation', False)) else: instance['is_metric'] = depth_unit is not None # Clip maximum depth max_depth = np.nanquantile(np.where(np.isfinite(tgt_depth), tgt_depth, np.nan), 0.01) * self.clamp_max_depth tgt_depth = np.where(np.isfinite(tgt_depth), np.clip(tgt_depth, 0, max_depth), tgt_depth) tgt_depth_mask_inf = np.isinf(tgt_depth) if self.datasets[instance['dataset']].get('finite_depth_mask', None) == "only_known": tgt_depth_mask_fin = np.isfinite(tgt_depth) else: tgt_depth_mask_fin = ~tgt_depth_mask_inf instance.update({ 'image': torch.from_numpy(tgt_image.astype(np.float32) / 255.0).permute(2, 0, 1), 'depth': torch.from_numpy(tgt_depth).float(), 'depth_mask_fin': torch.from_numpy(tgt_depth_mask_fin).bool(), 'depth_mask_inf': torch.from_numpy(tgt_depth_mask_inf).bool(), "normal": torch.from_numpy(tgt_normal).float(), 'intrinsics': torch.from_numpy(tgt_intrinsics).float(), }) except Exception as e: traceback.print_exc() print(f"Failed to process instance {instance['path']}: {e}") instance.update(self._get_invalid_instance(torch_instance=True)) return instance def _collate_batch(self, instances: List[Dict[str, Any]]): try: inst_w, inst_h = 256, 256 for instance in instances: if instance['label_type'] != 'invalid': inst_w, inst_h = instance['width'], instance['height'] break for i in range(len(instances)): if instances[i]['label_type'] == 'invalid': print(f"Replacing invalid instance at index {i} with size ({inst_w}, {inst_h}). Instance: dataset={instances[i].get('dataset', 'N/A')}, filename={instances[i].get('filename', 'N/A')}") instances[i].update(self._get_invalid_instance(torch_instance=True, height=inst_h, width=inst_w)) batch = {k: torch.stack([instance[k] for instance in instances], dim=0) for k in ['image', 'depth', 'depth_mask_fin', 'depth_mask_inf', 'normal', 'intrinsics']} batch = { 'label_type': [instance['label_type'] for instance in instances], 'is_metric': [instance['is_metric'] for instance in instances], 'info': [{'dataset': instance['dataset'], 'filename': instance['filename']} for instance in instances], **batch, } return batch except Exception as e: traceback.print_exc() print(f"Failed to collate batch: {e}") # show batch info and dump instances try: for instance in instances: print(f" - Instance: dataset={instance.get('dataset', 'N/A')}, filename={instance.get('filename', 'N/A')}, label_type={instance.get('label_type', 'N/A')}") # save dump_path = Path('failed_instance_dumps', f"{instance.get('dataset', 'N/A')}_{instance.get('filename', 'N/A').replace('/', '_')}.npz") dump_path.parent.mkdir(parents=True, exist_ok=True) np.savez_compressed(dump_path, **{k: v.numpy() if isinstance(v, torch.Tensor) else v for k, v in instance.items()}) except Exception as e2: traceback.print_exc() print("Also failed to dump instances because of:", e2) # return batch of invalid instances batch_size = len(instances) batch = {k: torch.stack([self._get_invalid_instance(torch_instance=True, height=inst_h, width=inst_w, gen_info=True)[k] for _ in range(batch_size)], dim=0) for k in ['image', 'depth', 'depth_mask_fin', 'depth_mask_inf', 'normal', 'intrinsics']} batch = { 'label_type': ['invalid'] * batch_size, 'is_metric': [False] * batch_size, 'info': [{'dataset': 'invalid', 'filename': 'invalid'} for _ in range(batch_size)], **batch, } return batch def get(self) -> Dict[str, Union[torch.Tensor, str]]: return self.pipeline.get() def profile(self) -> str: text = self.pipeline.profile() grouper = getattr(self, '_batch_grouper', None) if grouper is not None and grouper.num_incomplete_batches > 0: text += ( f'\nDropped {grouper.num_incomplete_batches} incomplete batches ' f'({grouper.num_dropped_instances} instances) whose siblings were invalid' ) return text def start(self): self.pipeline.start() def stop(self): self.pipeline.stop() def __enter__(self): self.start() return self def __exit__(self, exc_type, exc_value, traceback): self.pipeline.stop() return False def get_dataset_sample_count(self) -> Dict[str, int]: return self.dataset_sample_count