a4rtx pearsonkyle commited on
Commit
c5d3120
·
0 Parent(s):

Duplicate from pearsonkyle/Sharp-onnx

Browse files

Co-authored-by: Kyle Pearson <pearsonkyle@users.noreply.huggingface.co>

Files changed (7) hide show
  1. .gitattributes +3 -0
  2. .gitignore +7 -0
  3. README.md +121 -0
  4. convert_onnx.py +1048 -0
  5. inference_onnx.py +332 -0
  6. sharp_fp16.onnx +3 -0
  7. viewer.gif +3 -0
.gitattributes ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ sharp_fp16.onnx filter=lfs diff=lfs merge=lfs -text
2
+ viewer.giff filter=lfs diff=lfs merge=lfs -text
3
+ viewer.gif filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ __pycache__/
3
+ onnx__*
4
+ monodepth_*
5
+ feature_model*
6
+ _Constant_*
7
+ _init_model_*
README.md ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apple-amlr
3
+ library_name: ml-sharp
4
+ pipeline_tag: image-to-3d
5
+ base_model: apple/Sharp
6
+ tags:
7
+ - onnx
8
+ - monocular-view-synthesis
9
+ - gaussian-splatting
10
+ - quantization
11
+ - fp16
12
+ ---
13
+
14
+
15
+ # Sharp Monocular View Synthesis in Less Than a Second (ONNX Edition)
16
+
17
+ [![Project Page](https://img.shields.io/badge/Project-Page-green)](https://apple.github.io/ml-sharp/)
18
+ [![arXiv](https://img.shields.io/badge/arXiv-2512.10685-b31b1b.svg)](https://arxiv.org/abs/2512.10685)
19
+
20
+
21
+ This software project is a communnity contribution and not affiliated with the original the research paper:
22
+
23
+
24
+ > _Sharp Monocular View Synthesis in Less Than a Second_ by _Lars Mescheder, Wei Dong, Shiwei Li, Xuyang Bai, Marcel Santos, Peiyun Hu, Bruno Lecouat, Mingmin Zhen, Amaël Delaunoy, Tian Fang, Yanghai Tsin, Stephan Richter and Vladlen Koltun_.
25
+
26
+ > We present SHARP, an approach to photorealistic view synthesis from a single image. Given a single photograph, SHARP regresses the parameters of a 3D Gaussian representation of the depicted scene. This is done in less than a second on a standard GPU via a single feedforward pass through a neural network. The 3D Gaussian representation produced by SHARP can then be rendered in real time, yielding high-resolution photorealistic images for nearby views. The representation is metric, with absolute scale, supporting metric camera movements.
27
+
28
+ #### This release includes fully validated **ONNX** versions of SHARP (FP32 and FP16), optimized for cross-platform inference on Windows, Linux, and macOS.
29
+
30
+ ![](viewer.gif)
31
+
32
+ Rendered using [Splat Viewer](https://huggingface.co/spaces/pearsonkyle/Gaussian-Splat-Viewer)
33
+
34
+ ## Getting started
35
+
36
+ ### 🚀 Run Inference
37
+
38
+ Use the provided [inference_onnx.py](inference_onnx.py) script to run SHARP inference:
39
+
40
+ ```bash
41
+ # Run inference with FP16 model (faster, smaller)
42
+ python inference_onnx.py -m sharp_fp16.onnx -i test.png -o test.ply -d 0.5
43
+ ```
44
+
45
+ **CLI Options:**
46
+ - `-m, --model`: Path to ONNX model file
47
+ - `-i, --input`: Path to input image (PNG, JPEG, etc.)
48
+ - `-o, --output`: Path for output PLY file
49
+ - `-d, --decimate`: Decimation ratio 0.0-1.0 (default: 1.0 = keep all)
50
+ - `--disparity-factor`: Depth scale factor (default: 1.0)
51
+ - `--depth-scale`: Depth exaggeration factor (default: 1.0)
52
+
53
+ **Features:**
54
+ - Cross-platform ONNX Runtime inference (CPU/GPU)
55
+ - Automatic image preprocessing and resizing
56
+ - Gaussian decimation for reduced file sizes
57
+ - PLY output compatible with all major 3D Gaussian viewers
58
+
59
+ ## Model Input and Output
60
+
61
+ ### 📥 Input
62
+ The ONNX model accepts two inputs:
63
+
64
+ - **`image`**: A 3-channel RGB image in `float32` format with shape `(1, 3, H, W)`.
65
+ - Values expected in range `[0, 1]` (normalized RGB).
66
+ - Recommended resolution: `1536×1536` (matches training size).
67
+ - Aspect ratio preserved; input resized internally if needed.
68
+
69
+ - **`disparity_factor`**: A scalar tensor of shape `(1,)` representing the ratio `focal_length / image_width`.
70
+ - Use `1.0` for standard cameras (e.g., typical smartphone or DSLR).
71
+ - Adjust to control depth scale: higher values = closer objects, lower values = farther scenes.
72
+
73
+ ### 📤 Output
74
+ The model outputs five tensors representing a 3D Gaussian splat representation:
75
+
76
+ | Output | Shape | Description |
77
+ |--------|-------|-------------|
78
+ | `mean_vectors_3d_positions` | `(1, N, 3)` | 3D positions in Normalized Device Coordinates (NDC) — x, y, z. |
79
+ | `singular_values_scales` | `(1, N, 3)` | Scale parameters along each principal axis (width, height, depth). |
80
+ | `quaternions_rotations` | `(1, N, 4)` | Unit quaternions `[w, x, y, z]` encoding orientation of each Gaussian. |
81
+ | `colors_rgb_linear` | `(1, N, 3)` | Linear RGB color values in range `[0, 1]` (no gamma correction). |
82
+ | `opacities_alpha_channel` | `(1, N)` | Opacity (alpha) values per Gaussian, in range `[0, 1]`. |
83
+
84
+ The total number of Gaussians `N` is approximately 1,179,648 for the default model.
85
+
86
+ ## Model Conversion
87
+
88
+ To convert SHARP from PyTorch to ONNX, use the provided conversion script:
89
+
90
+ ```bash
91
+ # Convert to FP32 ONNX (higher precision)
92
+ python convert_onnx.py -o sharp.onnx --validate
93
+
94
+ # Convert to FP16 ONNX (faster inference, smaller model)
95
+ python convert_onnx.py -o sharp_fp16.onnx -q fp16 --validate
96
+ ```
97
+
98
+ **Conversion Options:**
99
+ - `-c, --checkpoint`: Path to PyTorch checkpoint (downloads from Apple if not provided)
100
+ - `-o, --output`: Output ONNX model path
101
+ - `-q, --quantize`: Quantization type (`fp16` for half-precision)
102
+ - `--validate`: Validate converted model against PyTorch reference
103
+ - `--input-image`: Path to test image for validation
104
+
105
+ **Requirements:**
106
+ - PyTorch and ml-sharp source code (automatically downloaded)
107
+ - ONNX and ONNX Runtime for validation
108
+
109
+ ## Citation
110
+
111
+ If you find this work useful, please cite the original paper:
112
+
113
+ ```bibtex
114
+ @inproceedings{Sharp2025:arxiv,
115
+ title = {Sharp Monocular View Synthesis in Less Than a Second},
116
+ author = {Lars Mescheder and Wei Dong and Shiwei Li and Xuyang Bai and Marcel Santos and Peiyun Hu and Bruno Lecouat and Mingmin Zhen and Ama\"{e}l Delaunoy and Tian Fang and Yanghai Tsin and Stephan R. Richter and Vladlen Koltun},
117
+ journal = {arXiv preprint arXiv:2512.10685},
118
+ year = {2025},
119
+ url = {https://arxiv.org/abs/2512.10685},
120
+ }
121
+ ```
convert_onnx.py ADDED
@@ -0,0 +1,1048 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert SHARP PyTorch model to ONNX format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import onnx
12
+ import onnxruntime as ort
13
+ import torch
14
+ import torch.nn as nn
15
+ import torch.nn.functional as F
16
+
17
+ from sharp.models import PredictorParams, create_predictor
18
+ from sharp.models.predictor import RGBGaussianPredictor
19
+ from sharp.utils import io
20
+
21
+ LOGGER = logging.getLogger(__name__)
22
+ DEFAULT_MODEL_URL = "https://ml-site.cdn-apple.com/models/sharp/sharp_2572gikvuh.pt"
23
+
24
+ OUTPUT_NAMES = [
25
+ "mean_vectors_3d_positions",
26
+ "singular_values_scales",
27
+ "quaternions_rotations",
28
+ "colors_rgb_linear",
29
+ "opacities_alpha_channel",
30
+ ]
31
+
32
+
33
+ @dataclass
34
+ class ToleranceConfig:
35
+ random_tolerances: dict = None
36
+ image_tolerances: dict = None
37
+ angular_tolerances_random: dict = None
38
+ angular_tolerances_image: dict = None
39
+ # FP16-specific tolerances (looser due to reduced precision)
40
+ fp16_random_tolerances: dict = None
41
+ fp16_angular_tolerances_random: dict = None
42
+ fp16_image_tolerances: dict = None
43
+ fp16_angular_tolerances_image: dict = None
44
+
45
+ def __post_init__(self):
46
+ if self.random_tolerances is None:
47
+ self.random_tolerances = {
48
+ "mean_vectors_3d_positions": 0.001,
49
+ "singular_values_scales": 0.0001,
50
+ "quaternions_rotations": 2.0, # Increased for ONNX numerical precision
51
+ "colors_rgb_linear": 0.002,
52
+ "opacities_alpha_channel": 0.005,
53
+ }
54
+ if self.image_tolerances is None:
55
+ self.image_tolerances = {
56
+ "mean_vectors_3d_positions": 3.5,
57
+ "singular_values_scales": 0.035,
58
+ "quaternions_rotations": 2.0, # Increased for ONNX numerical precision
59
+ "colors_rgb_linear": 0.01,
60
+ "opacities_alpha_channel": 0.05,
61
+ }
62
+ if self.angular_tolerances_random is None:
63
+ self.angular_tolerances_random = {"mean": 0.01, "p99": 0.1, "p99_9": 1.0, "max": 10.0}
64
+ if self.angular_tolerances_image is None:
65
+ self.angular_tolerances_image = {"mean": 0.2, "p99": 2.0, "p99_9": 5.0, "max": 25.0}
66
+ # FP16 tolerances - much looser due to float16 precision (~3-4 decimal digits)
67
+ # These are empirically tuned based on actual FP16 vs FP32 differences
68
+ # Large models with many layers accumulate FP16 rounding errors
69
+ if self.fp16_random_tolerances is None:
70
+ self.fp16_random_tolerances = {
71
+ "mean_vectors_3d_positions": 20.0, # Depth errors can be ~10 units for far objects
72
+ "singular_values_scales": 0.2, # Scale can have ~0.16 max diff
73
+ "quaternions_rotations": 2.0, # Validated separately via angular metrics
74
+ "colors_rgb_linear": 0.25, # sRGB2linearRGB power func is precision-sensitive
75
+ "opacities_alpha_channel": 1.0, # Opacity can have ~0.94 max diff
76
+ }
77
+ if self.fp16_angular_tolerances_random is None:
78
+ # Quaternion angular error is high due to accumulated FP16 precision loss
79
+ # 180 degree errors can occur when quaternion nearly flips sign
80
+ self.fp16_angular_tolerances_random = {"mean": 15.0, "p99": 75.0, "p99_9": 120.0, "max": 180.0}
81
+ # FP16 image tolerances - based on actual test.png validation results
82
+ if self.fp16_image_tolerances is None:
83
+ self.fp16_image_tolerances = {
84
+ "mean_vectors_3d_positions": 20.0, # Observed ~18.3 max diff
85
+ "singular_values_scales": 0.3, # Observed ~0.27 max diff
86
+ "quaternions_rotations": 2.0, # Validated separately via angular metrics
87
+ "colors_rgb_linear": 0.25, # sRGB2linearRGB power func is precision-sensitive
88
+ "opacities_alpha_channel": 1.0, # Observed ~0.79 max diff
89
+ }
90
+ if self.fp16_angular_tolerances_image is None:
91
+ self.fp16_angular_tolerances_image = {"mean": 1.0, "p99": 10.0, "p99_9": 60.0, "max": 180.0}
92
+
93
+
94
+ class QuaternionValidator:
95
+ def __init__(self, angular_tolerances=None, enable_outlier_analysis=True, outlier_thresholds=None):
96
+ self.angular_tolerances = angular_tolerances or {"mean": 0.01, "p99": 0.5, "p99_9": 2.0, "max": 15.0}
97
+ self.enable_outlier_analysis = enable_outlier_analysis
98
+ self.outlier_thresholds = outlier_thresholds or [5.0, 10.0, 15.0]
99
+
100
+ @staticmethod
101
+ def canonicalize_quaternion(q):
102
+ """Canonicalize quaternions by ensuring the largest-magnitude component is positive.
103
+
104
+ This resolves the q/-q sign ambiguity. For edge cases where components have
105
+ similar magnitudes, we use a stable tie-breaking strategy.
106
+ """
107
+ abs_q = np.abs(q)
108
+ max_idx = np.argmax(abs_q, axis=-1, keepdims=True)
109
+
110
+ # Get the value at the max index
111
+ max_val = np.take_along_axis(q, max_idx, axis=-1)
112
+
113
+ # Flip sign if the largest component is negative
114
+ sign_flip = np.where(max_val < 0, -1.0, 1.0)
115
+ return q * sign_flip
116
+
117
+ @staticmethod
118
+ def compute_angular_differences(quats1, quats2):
119
+ """Compute angular differences between quaternion pairs.
120
+
121
+ This accounts for the q/-q equivalence by taking the minimum angle
122
+ between the two possible orientations.
123
+ """
124
+ n1 = np.linalg.norm(quats1, axis=-1, keepdims=True)
125
+ n2 = np.linalg.norm(quats2, axis=-1, keepdims=True)
126
+ q1 = quats1 / np.clip(n1, 1e-12, None)
127
+ q2 = quats2 / np.clip(n2, 1e-12, None)
128
+
129
+ # Compute dot product for both sign options
130
+ dots = np.sum(q1 * q2, axis=-1)
131
+
132
+ # Use absolute value of dot product - handles sign ambiguity directly
133
+ # This is more robust than canonicalization which can fail at boundaries
134
+ dots = np.abs(dots)
135
+ dots = np.clip(dots, 0.0, 1.0)
136
+ ang_rad = 2.0 * np.arccos(dots)
137
+ ang_deg = np.degrees(ang_rad)
138
+ return ang_deg, {
139
+ "mean": float(np.mean(ang_deg)),
140
+ "std": float(np.std(ang_deg)),
141
+ "max": float(np.max(ang_deg)),
142
+ "p99": float(np.percentile(ang_deg, 99)),
143
+ "p99_9": float(np.percentile(ang_deg, 99.9)),
144
+ }
145
+
146
+ def validate(self, pt_quats, onnx_quats, image_name="Unknown"):
147
+ diff, stats = self.compute_angular_differences(pt_quats, onnx_quats)
148
+ passed = True
149
+ reasons = []
150
+ for k, t in self.angular_tolerances.items():
151
+ if k in stats and stats[k] > t:
152
+ passed = False
153
+ reasons.append(f"{k} angular {stats[k]:.4f} > {t:.4f}")
154
+ return {"image": image_name, "passed": passed, "failure_reasons": reasons, "stats": stats}
155
+
156
+
157
+ class SharpModelTraceable(nn.Module):
158
+ def __init__(self, predictor):
159
+ super().__init__()
160
+ self.init_model = predictor.init_model
161
+ self.feature_model = predictor.feature_model
162
+ self.monodepth_model = predictor.monodepth_model
163
+ self.prediction_head = predictor.prediction_head
164
+ self.gaussian_composer = predictor.gaussian_composer
165
+ self.depth_alignment = predictor.depth_alignment
166
+
167
+ def forward(self, image, disparity_factor):
168
+ monodepth_out = self.monodepth_model(image)
169
+ disp = monodepth_out.disparity
170
+ disp_factor = disparity_factor[:, None, None, None]
171
+ disp_clamped = disp.clamp(min=1e-4, max=1e4)
172
+ depth = disp_factor / disp_clamped
173
+ depth, _ = self.depth_alignment(depth, None, monodepth_out.decoder_features)
174
+ init_out = self.init_model(image, depth)
175
+ feats = self.feature_model(init_out.feature_input, encodings=monodepth_out.output_features)
176
+ deltas = self.prediction_head(feats)
177
+ gaussians = self.gaussian_composer(deltas, init_out.gaussian_base_values, init_out.global_scale)
178
+ quats = gaussians.quaternions
179
+ # Normalize quaternions to unit length
180
+ qnorm = torch.sqrt(torch.clamp(torch.sum(quats * quats, dim=-1, keepdim=True), min=1e-12))
181
+ quats = quats / qnorm
182
+ # NOTE: We intentionally do NOT canonicalize quaternions here.
183
+ # Canonicalization (ensuring largest component is positive) uses argmax which is
184
+ # inherently unstable when components have similar magnitudes. With FP16, tiny
185
+ # precision differences can flip which component is "largest", causing 180° sign flips.
186
+ # Since q and -q represent the same rotation, renderers handle this correctly.
187
+ # Validation uses |dot product| to compare quaternions regardless of sign.
188
+ return (gaussians.mean_vectors, gaussians.singular_values, quats.float(), gaussians.colors, gaussians.opacities)
189
+
190
+
191
+ # Ops that are numerically sensitive and should remain in FP32
192
+ # These operations are critical for accurate depth estimation and Gaussian rendering
193
+ FP16_OP_BLOCK_LIST = [
194
+ # Depth computation ops - critical for global_scale and depth normalization
195
+ 'ReduceMin', # Used in _rescale_depth to find min depth - critical for global_scale
196
+ 'ReduceMax', # May be used in depth clamping operations
197
+ 'Div', # Division (disparity_factor/depth, 1/depth_factor) accumulates errors
198
+
199
+ # Activation functions - inverse depth uses softplus(inverse_softplus(a) + b)
200
+ 'Softplus', # Used in inverse depth activation - sensitive to small values
201
+ 'Sigmoid', # Used in inverse_softplus and scale activation
202
+ 'Log', # Used in inverse_softplus - can underflow near zero
203
+ 'Exp', # Used in various activations - can overflow
204
+
205
+ # Arithmetic ops that amplify precision errors
206
+ 'Reciprocal', # 1/x is sensitive to precision for small x values
207
+ 'Pow', # Power operations amplify precision errors
208
+ 'Sqrt', # Square root in quaternion normalization
209
+ 'Sub', # Subtraction in normalizations can cause catastrophic cancellation
210
+ 'Add', # Addition in depth composition (inverse_softplus + delta)
211
+ 'Mul', # Multiplication for global_scale application - critical for depth
212
+
213
+ # Normalization layers need FP32 for numerical stability
214
+ 'ReduceMean', # Used in normalization - needs FP32 precision
215
+ 'LayerNormalization',
216
+ 'InstanceNormalization',
217
+ 'BatchNormalization',
218
+ 'GroupNormalization', # Used extensively in UNet decoder
219
+
220
+ # Clamp operations affect depth range computation
221
+ 'Clip', # Used in depth clamping (clamp(min=1e-4, max=1e4))
222
+ 'Min', # Element-wise min operations
223
+ 'Max', # Element-wise max operations
224
+
225
+ # Shape/reshape ops that can affect tensor interpretations
226
+ 'Flatten', # Used in depth min computation
227
+ 'Reshape', # Can affect numerical precision during reshaping
228
+
229
+ # Concatenation used in feature preparation
230
+ 'Concat', # Concatenating depth features
231
+ ]
232
+
233
+
234
+ def remove_spurious_fp16_casts(model, blocked_node_names):
235
+ """Remove Cast nodes that convert blocked node outputs back to FP16.
236
+
237
+ The float16 converter inserts Cast nodes at the boundary between FP32 and FP16
238
+ regions. For blocked nodes, it adds:
239
+ - Cast(input, to=FP32) before the blocked node
240
+ - Cast(output, to=FP16) after the blocked node
241
+
242
+ The output Cast defeats our purpose since downstream ops then receive FP16 data.
243
+ This function removes the output Cast nodes and updates downstream references.
244
+
245
+ Args:
246
+ model: ONNX model (modified in place)
247
+ blocked_node_names: List of node names that were blocked from FP16 conversion
248
+
249
+ Returns:
250
+ Modified ONNX model
251
+ """
252
+ from onnx import TensorProto
253
+
254
+ # Build set of blocked node name prefixes for matching Cast names
255
+ # Cast nodes are named like: /init_model/ReduceMin_output_cast0
256
+ blocked_prefixes = set()
257
+ for name in blocked_node_names:
258
+ # Extract prefix for matching cast nodes
259
+ # e.g., /init_model/ReduceMin -> matches /init_model/ReduceMin_output_cast0
260
+ blocked_prefixes.add(name)
261
+
262
+ # Find Cast-to-FP16 nodes that follow blocked nodes
263
+ cast_nodes_to_remove = []
264
+ cast_output_mapping = {} # Maps cast output to original output
265
+
266
+ for node in model.graph.node:
267
+ if node.op_type == 'Cast':
268
+ # Check if this Cast outputs FP16
269
+ is_cast_to_fp16 = False
270
+ for attr in node.attribute:
271
+ if attr.name == 'to' and attr.i == TensorProto.FLOAT16:
272
+ is_cast_to_fp16 = True
273
+ break
274
+
275
+ if is_cast_to_fp16:
276
+ # Check if this Cast is on the output of a blocked node
277
+ # Cast names follow the pattern: /original_node_name_output_cast0
278
+ cast_name = node.name
279
+ for prefix in blocked_prefixes:
280
+ # Match patterns like:
281
+ # Blocked: /init_model/ReduceMin
282
+ # Cast: /init_model/ReduceMin_output_cast0
283
+ if cast_name.startswith(prefix + '_output_cast'):
284
+ cast_nodes_to_remove.append(node)
285
+ # Map the cast output back to its input
286
+ cast_output_mapping[node.output[0]] = node.input[0]
287
+ break
288
+
289
+ if not cast_nodes_to_remove:
290
+ LOGGER.info(" No spurious FP16 cast nodes found to remove")
291
+ return model
292
+
293
+ LOGGER.info(f" Removing {len(cast_nodes_to_remove)} spurious Cast-to-FP16 nodes")
294
+
295
+ # Update all nodes that consume Cast outputs to consume the original outputs instead
296
+ for node in model.graph.node:
297
+ new_inputs = []
298
+ for inp in node.input:
299
+ if inp in cast_output_mapping:
300
+ new_inputs.append(cast_output_mapping[inp])
301
+ else:
302
+ new_inputs.append(inp)
303
+ # Clear and reassign inputs
304
+ del node.input[:]
305
+ node.input.extend(new_inputs)
306
+
307
+ # Also update graph outputs if they reference cast outputs
308
+ for out in model.graph.output:
309
+ if out.name in cast_output_mapping:
310
+ out.name = cast_output_mapping[out.name]
311
+
312
+ # Remove the Cast nodes from the graph
313
+ cast_names_to_remove = {n.name for n in cast_nodes_to_remove}
314
+ new_nodes = [n for n in model.graph.node if n.name not in cast_names_to_remove]
315
+
316
+ # Clear and reassign nodes
317
+ del model.graph.node[:]
318
+ model.graph.node.extend(new_nodes)
319
+
320
+ # Update value_info for the remapped tensors (change from FP16 to FP32)
321
+ for val in model.graph.value_info:
322
+ if val.name in cast_output_mapping.values():
323
+ # This tensor should remain FP32
324
+ val.type.tensor_type.elem_type = TensorProto.FLOAT
325
+
326
+ return model
327
+
328
+
329
+ def fix_depth_precision(model):
330
+ """Fix depth computation precision by ensuring FP32 flow through critical ops.
331
+
332
+ The float16 converter inserts Cast nodes at FP32/FP16 boundaries, causing
333
+ depth values to undergo FP32→FP16→FP32 round-trips that lose precision.
334
+
335
+ This function identifies and removes spurious FP16 Cast chains:
336
+ Cast(FP32->FP16) followed by Cast(FP16->FP32)
337
+
338
+ These chains are lossy and can be replaced with direct FP32 connections.
339
+ """
340
+ from onnx import TensorProto
341
+
342
+ # Build maps for efficient lookup
343
+ node_by_output = {} # tensor_name -> node that produces it
344
+ consumers_by_input = {} # tensor_name -> list of nodes that consume it
345
+
346
+ for node in model.graph.node:
347
+ for out in node.output:
348
+ node_by_output[out] = node
349
+ for inp in node.input:
350
+ if inp not in consumers_by_input:
351
+ consumers_by_input[inp] = []
352
+ consumers_by_input[inp].append(node)
353
+
354
+ # Find Cast-to-FP16 -> Cast-to-FP32 chains and remove them
355
+ # These are precision-losing round-trips
356
+ fp16_casts = [] # (cast_to_fp16_node, cast_to_fp32_node)
357
+
358
+ for node in model.graph.node:
359
+ if node.op_type != 'Cast':
360
+ continue
361
+
362
+ # Check if this is a Cast-to-FP16
363
+ is_to_fp16 = False
364
+ for attr in node.attribute:
365
+ if attr.name == 'to' and attr.i == TensorProto.FLOAT16:
366
+ is_to_fp16 = True
367
+ break
368
+
369
+ if not is_to_fp16:
370
+ continue
371
+
372
+ fp16_output = node.output[0]
373
+ fp32_input = node.input[0]
374
+
375
+ # Check if the only consumer of this FP16 output is a Cast-to-FP32
376
+ consumers = consumers_by_input.get(fp16_output, [])
377
+ if len(consumers) != 1:
378
+ continue
379
+
380
+ consumer = consumers[0]
381
+ if consumer.op_type != 'Cast':
382
+ continue
383
+
384
+ is_to_fp32 = False
385
+ for attr in consumer.attribute:
386
+ if attr.name == 'to' and attr.i == TensorProto.FLOAT:
387
+ is_to_fp32 = True
388
+ break
389
+
390
+ if is_to_fp32:
391
+ # Found a chain: Cast(FP32->FP16) -> Cast(FP16->FP32)
392
+ # The FP32 output of the second Cast should just use the original FP32 input
393
+ fp16_casts.append((node, consumer, fp32_input, consumer.output[0]))
394
+
395
+ if not fp16_casts:
396
+ LOGGER.info(" No FP16 round-trip casts to fix")
397
+ return model
398
+
399
+ LOGGER.info(f" Found {len(fp16_casts)} FP16 round-trip cast chains to eliminate")
400
+
401
+ # Build mapping from old output to new output (bypassing the chain)
402
+ output_mapping = {} # old_fp32_output -> original_fp32_input
403
+ nodes_to_remove = set()
404
+
405
+ for cast_to_fp16, cast_to_fp32, original_fp32, final_fp32 in fp16_casts:
406
+ output_mapping[final_fp32] = original_fp32
407
+ nodes_to_remove.add(cast_to_fp16.name)
408
+ nodes_to_remove.add(cast_to_fp32.name)
409
+
410
+ # Update all nodes to use the original FP32 values instead of the round-tripped ones
411
+ for node in model.graph.node:
412
+ if node.name in nodes_to_remove:
413
+ continue
414
+ new_inputs = list(node.input)
415
+ for i, inp in enumerate(new_inputs):
416
+ if inp in output_mapping:
417
+ new_inputs[i] = output_mapping[inp]
418
+ del node.input[:]
419
+ node.input.extend(new_inputs)
420
+
421
+ # Update graph outputs if they reference the round-tripped values
422
+ for out in model.graph.output:
423
+ if out.name in output_mapping:
424
+ LOGGER.info(f" Updating graph output {out.name} -> {output_mapping[out.name]}")
425
+ out.name = output_mapping[out.name]
426
+
427
+ # Remove the cast chain nodes
428
+ new_nodes = [n for n in model.graph.node if n.name not in nodes_to_remove]
429
+ del model.graph.node[:]
430
+ model.graph.node.extend(new_nodes)
431
+
432
+ LOGGER.info(f" Removed {len(nodes_to_remove)} Cast nodes from round-trip chains")
433
+
434
+ return model
435
+
436
+
437
+ def convert_to_onnx_fp16(
438
+ predictor: RGBGaussianPredictor,
439
+ output_path: Path,
440
+ input_shape: tuple = (1536, 1536),
441
+ ) -> Path:
442
+ """Convert SHARP model to ONNX with FP16 quantization.
443
+
444
+ Uses ONNX-native post-export FP16 conversion which is faster and more reliable
445
+ than PyTorch-level quantization. The conversion:
446
+ - Keeps inputs/outputs as FP32 for compatibility with existing inference code
447
+ - Preserves numerically sensitive ops (Softplus, Log, Exp, etc.) in FP32
448
+ - Keeps init_model and gaussian_composer in FP32 for accurate depth scaling
449
+ - Converts compute-heavy ops (Conv, MatMul, etc.) to FP16 for speed
450
+
451
+ Args:
452
+ predictor: The SHARP predictor model
453
+ output_path: Output path for ONNX model
454
+ input_shape: Input image shape (height, width)
455
+
456
+ Returns:
457
+ Path to the exported ONNX model
458
+ """
459
+ # Import the onnxruntime.transformers float16 converter which works with paths
460
+ from onnxruntime.transformers.float16 import convert_float_to_float16
461
+
462
+ LOGGER.info("Converting to ONNX with FP16 quantization (ONNX-native approach)...")
463
+
464
+ # First export to FP32 ONNX using a temporary file
465
+ temp_fp32_path = output_path.parent / f"{output_path.stem}_temp_fp32.onnx"
466
+
467
+ try:
468
+ # Export FP32 model first
469
+ LOGGER.info("Step 1/4: Exporting FP32 ONNX model...")
470
+ convert_to_onnx(predictor, temp_fp32_path, input_shape=input_shape, use_external_data=False)
471
+
472
+ # Load the FP32 model to get node names for blocking
473
+ LOGGER.info("Step 2/4: Analyzing model and preparing node block list...")
474
+ model_fp32 = onnx.load(str(temp_fp32_path), load_external_data=True)
475
+
476
+ # Build a node block list for nodes in critical paths:
477
+ # - /init_model/* : depth normalization and global_scale computation
478
+ # - /gaussian_composer/* : final Gaussian parameter composition with global_scale
479
+ # - Root-level depth/disparity ops: /Clip, /Div, /Mul that operate on depth
480
+ node_block_list = []
481
+ for node in model_fp32.graph.node:
482
+ node_name = node.name
483
+ # Block all init_model nodes (depth normalization, global_scale)
484
+ if '/init_model/' in node_name:
485
+ node_block_list.append(node_name)
486
+ # Block all gaussian_composer nodes (applies global_scale to outputs)
487
+ elif '/gaussian_composer/' in node_name:
488
+ node_block_list.append(node_name)
489
+ # Block ALL prediction_head nodes - quaternion/color/opacity deltas need FP32 precision
490
+ # FP16 precision loss here directly affects output quality
491
+ elif '/prediction_head/' in node_name:
492
+ node_block_list.append(node_name)
493
+ # Block feature_model decoder's final layers (feed into prediction_head)
494
+ elif '/feature_model/' in node_name and any(x in node_name for x in ['decoder/out', 'decoder/up_4', 'decoder/up_3']):
495
+ node_block_list.append(node_name)
496
+ # Block root-level ops that operate on depth (between monodepth and init_model)
497
+ elif node_name.startswith('/Clip') or node_name.startswith('/Div') or node_name.startswith('/Mul'):
498
+ node_block_list.append(node_name)
499
+ # Block final output processing ops (quaternion normalization)
500
+ elif node_name.startswith('/Sqrt') or node_name.startswith('/Clamp'):
501
+ node_block_list.append(node_name)
502
+ # Block Pow operations (used in sRGB2linearRGB conversion - power 2.4 is precision-sensitive)
503
+ elif 'Pow' in node_name:
504
+ node_block_list.append(node_name)
505
+
506
+ LOGGER.info(f" Blocking {len(node_block_list)} nodes from FP16 conversion")
507
+ if node_block_list:
508
+ LOGGER.info(f" Sample blocked nodes: {node_block_list[:5]}...")
509
+
510
+ # Clean up loaded model
511
+ del model_fp32
512
+
513
+ # Convert to FP16 using ONNX-native conversion
514
+ # Use INVERSE APPROACH: Block ALL ops EXCEPT compute-heavy ones
515
+ # Only Conv, MatMul, Gemm get FP16 - everything else stays FP32
516
+ LOGGER.info("Step 3/4: Converting to FP16 (inverse approach - only compute ops)...")
517
+
518
+ # Reload model for analysis
519
+ model_fp32 = onnx.load(str(temp_fp32_path), load_external_data=True)
520
+
521
+ # Get all unique op types in the model
522
+ op_types_in_model = set()
523
+ for node in model_fp32.graph.node:
524
+ op_types_in_model.add(node.op_type)
525
+
526
+ # Define ops that are SAFE for FP16 (compute-heavy, numerically stable)
527
+ FP16_SAFE_OPS = {'Conv', 'MatMul', 'Gemm', 'ConvTranspose'}
528
+
529
+ # Block all ops EXCEPT the safe ones
530
+ op_block_list_all = list(op_types_in_model - FP16_SAFE_OPS)
531
+
532
+ LOGGER.info(f" Model has {len(op_types_in_model)} unique op types")
533
+ LOGGER.info(f" FP16 ops: {FP16_SAFE_OPS & op_types_in_model}")
534
+ LOGGER.info(f" FP32 ops: {len(op_block_list_all)} op types blocked")
535
+
536
+ del model_fp32
537
+
538
+ model_fp16 = convert_float_to_float16(
539
+ str(temp_fp32_path), # Pass path string, not model object!
540
+ keep_io_types=True, # Keep inputs/outputs as FP32
541
+ op_block_list=op_block_list_all, # Block everything except compute ops
542
+ node_block_list=node_block_list, # Still block critical nodes
543
+ )
544
+
545
+ LOGGER.info(f" Converted model has {len(model_fp16.graph.node)} nodes")
546
+
547
+ # Post-process to fix the FP32 depth path
548
+ # Remove spurious FP16 casts that break the depth computation chain
549
+ model_fp16 = fix_depth_precision(model_fp16)
550
+
551
+ LOGGER.info(f" After depth precision fix: {len(model_fp16.graph.node)} nodes")
552
+
553
+ # Clean up output path before saving
554
+ cleanup_onnx_files(output_path)
555
+
556
+ # Save the FP16 model
557
+ LOGGER.info("Step 4/4: Saving FP16 model...")
558
+ onnx.save(model_fp16, str(output_path))
559
+
560
+ # Report file size
561
+ if output_path.exists():
562
+ file_size_mb = output_path.stat().st_size / (1024**2)
563
+ LOGGER.info(f"FP16 ONNX model saved: {output_path} ({file_size_mb:.2f} MB)")
564
+
565
+ # Compare with FP32 size
566
+ if temp_fp32_path.exists():
567
+ fp32_size_mb = temp_fp32_path.stat().st_size / (1024**2)
568
+ reduction = (1 - file_size_mb / fp32_size_mb) * 100
569
+ LOGGER.info(f" Size reduction: {fp32_size_mb:.2f} MB -> {file_size_mb:.2f} MB ({reduction:.1f}% smaller)")
570
+
571
+ return output_path
572
+
573
+ finally:
574
+ # Clean up temporary FP32 file
575
+ cleanup_onnx_files(temp_fp32_path)
576
+
577
+
578
+ def cleanup_onnx_files(onnx_path):
579
+ """Clean up ONNX model files including external data files."""
580
+ try:
581
+ if onnx_path.exists():
582
+ onnx_path.unlink()
583
+ #LOGGER.info(f"Removed {onnx_path}")
584
+ except Exception as e:
585
+ LOGGER.warning(f"Could not remove {onnx_path}: {e}")
586
+
587
+ # Also clean up external data file with .onnx.data suffix
588
+ data_path = onnx_path.with_suffix('.onnx.data')
589
+ try:
590
+ if data_path.exists():
591
+ data_path.unlink()
592
+ #LOGGER.info(f"Removed {data_path}")
593
+ except Exception as e:
594
+ LOGGER.warning(f"Could not remove {data_path}: {e}")
595
+
596
+ # Clean up any temporary files from conversion
597
+ temp_patterns = ["onnx__*", "monodepth_*", "feature_model*", "_Constant_*", "_init_model_*"]
598
+ import glob
599
+ for pattern in temp_patterns:
600
+ for f in glob.glob(pattern):
601
+ try:
602
+ Path(f).unlink()
603
+ #LOGGER.info(f"Removed temporary file {f}")
604
+ except Exception:
605
+ pass
606
+
607
+
608
+ def cleanup_extraneous_files():
609
+ import glob
610
+ import os
611
+ patterns = ["onnx__*", "monodepth_*", "feature_model*", "_Constant_*", "_init_model_*"]
612
+ for p in patterns:
613
+ for f in glob.glob(p):
614
+ try:
615
+ os.remove(f)
616
+ except Exception:
617
+ pass
618
+
619
+
620
+ def load_sharp_model(checkpoint_path=None):
621
+ if checkpoint_path is None:
622
+ LOGGER.info(f"Downloading model from {DEFAULT_MODEL_URL}")
623
+ state_dict = torch.hub.load_state_dict_from_url(DEFAULT_MODEL_URL, progress=True)
624
+ else:
625
+ LOGGER.info(f"Loading checkpoint from {checkpoint_path}")
626
+ state_dict = torch.load(checkpoint_path, weights_only=True, map_location="cpu")
627
+ predictor = create_predictor(PredictorParams())
628
+ predictor.load_state_dict(state_dict)
629
+ predictor.eval()
630
+ return predictor
631
+
632
+
633
+ def convert_to_onnx(predictor, output_path, input_shape=(1536, 1536), use_external_data=True):
634
+ LOGGER.info("Exporting to ONNX format...")
635
+ predictor.depth_alignment.scale_map_estimator = None
636
+ model = SharpModelTraceable(predictor)
637
+ model.eval()
638
+
639
+ LOGGER.info("Pre-warming model...")
640
+ with torch.no_grad():
641
+ for _ in range(3):
642
+ _ = model(torch.randn(1, 3, input_shape[0], input_shape[1]), torch.tensor([1.0]))
643
+
644
+ cleanup_onnx_files(output_path)
645
+
646
+ h, w = input_shape
647
+ torch.manual_seed(42)
648
+ example_image = torch.randn(1, 3, h, w)
649
+ example_disparity = torch.tensor([1.0])
650
+
651
+ LOGGER.info(f"Exporting to ONNX: {output_path} (external_data={use_external_data})")
652
+
653
+ dynamic_axes = {}
654
+ for name in OUTPUT_NAMES:
655
+ if name == "opacities_alpha_channel":
656
+ dynamic_axes[name] = {0: 'batch', 1: 'num_gaussians'}
657
+ else:
658
+ dynamic_axes[name] = {0: 'batch', 1: 'num_gaussians'}
659
+
660
+ # For large models (>2GB), PyTorch ONNX export creates external data files
661
+ # regardless of the external_data flag. We always use external data during export
662
+ # and then optionally convert to a single file afterward.
663
+ temp_path = output_path.parent / f"{output_path.stem}_export_temp.onnx"
664
+
665
+ torch.onnx.export(
666
+ model, (example_image, example_disparity), str(temp_path),
667
+ export_params=True, verbose=False,
668
+ input_names=['image', 'disparity_factor'],
669
+ output_names=OUTPUT_NAMES,
670
+ dynamic_axes=dynamic_axes,
671
+ opset_version=15,
672
+ # Always use external data for large models to avoid proto buffer limit
673
+ external_data=True,
674
+ )
675
+
676
+ # Load and re-save with proper handling
677
+ LOGGER.info("Loading exported model and consolidating weights...")
678
+ model_proto = onnx.load(str(temp_path), load_external_data=True)
679
+
680
+ # Clean up temp files before saving final output
681
+ cleanup_onnx_files(temp_path)
682
+
683
+ if use_external_data:
684
+ # Save with external data file
685
+ data_path = output_path.with_suffix('.onnx.data')
686
+ onnx.save_model(
687
+ model_proto,
688
+ str(output_path),
689
+ save_as_external_data=True,
690
+ all_tensors_to_one_file=True,
691
+ location=data_path.name,
692
+ size_threshold=0, # Save all tensors externally
693
+ )
694
+ if data_path.exists():
695
+ data_size_gb = data_path.stat().st_size / (1024**3)
696
+ LOGGER.info(f"External data file saved: {data_path} ({data_size_gb:.2f} GB)")
697
+ else:
698
+ # For models >2GB, we must use external data due to protobuf limits
699
+ # Check estimated size and force external data if needed
700
+ estimated_size = sum(t.ByteSize() if hasattr(t, 'ByteSize') else 0 for t in model_proto.graph.initializer)
701
+ if estimated_size > 2 * 1024**3: # 2GB limit
702
+ LOGGER.info("Model exceeds 2GB protobuf limit, using external data format...")
703
+ data_path = output_path.with_suffix('.onnx.data')
704
+ onnx.save_model(
705
+ model_proto,
706
+ str(output_path),
707
+ save_as_external_data=True,
708
+ all_tensors_to_one_file=True,
709
+ location=data_path.name,
710
+ size_threshold=0,
711
+ )
712
+ if data_path.exists():
713
+ data_size_gb = data_path.stat().st_size / (1024**3)
714
+ LOGGER.info(f"External data file saved: {data_path} ({data_size_gb:.2f} GB)")
715
+ else:
716
+ # Convert external data to internal (inline) - this works for models <2GB
717
+ try:
718
+ onnx.save_model(model_proto, str(output_path))
719
+ file_size_gb = output_path.stat().st_size / (1024**3)
720
+ LOGGER.info(f"Inline model saved: {file_size_gb:.2f} GB")
721
+ except Exception as e:
722
+ LOGGER.warning(f"Could not save inline model: {e}")
723
+ LOGGER.info("Falling back to external data format...")
724
+ data_path = output_path.with_suffix('.onnx.data')
725
+ onnx.save_model(
726
+ model_proto,
727
+ str(output_path),
728
+ save_as_external_data=True,
729
+ all_tensors_to_one_file=True,
730
+ location=data_path.name,
731
+ size_threshold=0,
732
+ )
733
+
734
+ LOGGER.info(f"ONNX model saved to {output_path}")
735
+ return output_path
736
+
737
+
738
+ def find_onnx_output_key(name, onnx_outputs):
739
+ if name in onnx_outputs:
740
+ return name
741
+ for key in onnx_outputs:
742
+ if name.split('_')[0] in key.lower():
743
+ return key
744
+ return list(onnx_outputs.keys())[OUTPUT_NAMES.index(name) if name in OUTPUT_NAMES else 0]
745
+
746
+
747
+ def load_and_preprocess_image(image_path, target_size=(1536, 1536)):
748
+ LOGGER.info(f"Loading image from {image_path}")
749
+ image_np, orig_size, f_px = io.load_rgb(image_path)
750
+ # Fallback to getting size from array if orig_size is None
751
+ if orig_size is None:
752
+ orig_size = (image_np.shape[1], image_np.shape[0])
753
+ LOGGER.info(f"Original size: {orig_size}, focal: {f_px:.2f}px")
754
+ tensor = torch.from_numpy(image_np.copy()).float() / 255.0
755
+ tensor = tensor.permute(2, 0, 1)
756
+ if (orig_size[0], orig_size[1]) != (target_size[1], target_size[0]):
757
+ LOGGER.info(f"Resizing to {target_size[1]}x{target_size[0]}")
758
+ tensor = F.interpolate(tensor.unsqueeze(0), size=target_size, mode="bilinear", align_corners=True).squeeze(0)
759
+ tensor = tensor.unsqueeze(0)
760
+ LOGGER.info(f"Preprocessed shape: {tensor.shape}, range: [{tensor.min():.4f}, {tensor.max():.4f}]")
761
+ return tensor, f_px, orig_size
762
+
763
+
764
+ def run_inference_pair(pytorch_model, onnx_path, image_tensor, disparity_factor=1.0, log_internals=False):
765
+ wrapper = SharpModelTraceable(pytorch_model)
766
+ wrapper.eval()
767
+ image_tensor = image_tensor.float()
768
+ disp_pt = torch.tensor([disparity_factor], dtype=torch.float32)
769
+ with torch.no_grad():
770
+ pt_outputs = wrapper(image_tensor, disp_pt)
771
+
772
+ pt_np = [o.numpy() for o in pt_outputs]
773
+
774
+ session = ort.InferenceSession(str(onnx_path), providers=['CPUExecutionProvider'])
775
+ onnx_inputs = {"image": image_tensor.numpy(), "disparity_factor": np.array([disparity_factor], dtype=np.float32)}
776
+ onnx_raw = session.run(None, onnx_inputs)
777
+
778
+ LOGGER.info(f"ONNX raw outputs count: {len(onnx_raw)}, first shape: {onnx_raw[0].shape if len(onnx_raw) > 0 else 'N/A'}")
779
+
780
+ # Check if outputs are already separated
781
+ if len(onnx_raw) == 5:
782
+ # ONNX returns separate outputs
783
+ onnx_splits = list(onnx_raw)
784
+ elif len(onnx_raw) == 1:
785
+ # ONNX returns concatenated output - split it
786
+ total_size = onnx_raw[0].shape[-1]
787
+ LOGGER.info(f"ONNX single output total size: {total_size}")
788
+
789
+ # Cumulative sizes: positions(3) + scales(3) + quats(4) + colors(3) + opacities(1) = 14
790
+ sizes = [3, 3, 4, 3, 1]
791
+ start = 0
792
+ onnx_splits = []
793
+ for i, size in enumerate(sizes):
794
+ onnx_splits.append(onnx_raw[0][:, :, start:start+size])
795
+ start += size
796
+ else:
797
+ onnx_splits = list(onnx_raw)
798
+
799
+ return pt_np, onnx_splits
800
+
801
+
802
+ def format_validation_table(results, image_name="", include_image=False):
803
+ lines = []
804
+ if include_image:
805
+ lines.append("| Image | Output | Max Diff | Mean Diff | P99 Diff | Status |")
806
+ lines.append("|-------|--------|----------|-----------|----------|--------|")
807
+ for r in results:
808
+ name = r["output"].replace("_", " ").title()
809
+ status = "PASS" if r["passed"] else "FAIL"
810
+ lines.append(f"| {image_name} | {name} | {r['max_diff']} | {r['mean_diff']} | {r['p99_diff']} | {status} |")
811
+ else:
812
+ lines.append("| Output | Max Diff | Mean Diff | P99 Diff | Status |")
813
+ lines.append("|--------|----------|-----------|----------|--------|")
814
+ for r in results:
815
+ name = r["output"].replace("_", " ").title()
816
+ status = "PASS" if r["passed"] else "FAIL"
817
+ lines.append(f"| {name} | {r['max_diff']} | {r['mean_diff']} | {r['p99_diff']} | {status} |")
818
+ return "\n".join(lines)
819
+
820
+
821
+ def validate_with_image(onnx_path, pytorch_model, image_path, input_shape=(1536, 1536), is_fp16_model=False):
822
+ LOGGER.info(f"Validating with image: {image_path}")
823
+ test_image, f_px, (w, h) = load_and_preprocess_image(image_path, input_shape)
824
+ disparity_factor = f_px / w
825
+ LOGGER.info(f"Using disparity_factor = {disparity_factor:.6f}")
826
+
827
+ pt_outputs, onnx_out = run_inference_pair(pytorch_model, onnx_path, test_image, disparity_factor)
828
+
829
+ LOGGER.info(f"PyTorch outputs shapes: {[o.shape for o in pt_outputs]}")
830
+ LOGGER.info(f"ONNX output shapes: {[o.shape for o in onnx_out]}")
831
+
832
+ tolerance_config = ToleranceConfig()
833
+ if is_fp16_model:
834
+ tolerances = tolerance_config.fp16_image_tolerances
835
+ quat_validator = QuaternionValidator(angular_tolerances=tolerance_config.fp16_angular_tolerances_image)
836
+ LOGGER.info("Using FP16 validation tolerances (comparing FP16 ONNX vs FP32 PyTorch reference)")
837
+ else:
838
+ tolerances = tolerance_config.image_tolerances
839
+ quat_validator = QuaternionValidator(angular_tolerances=tolerance_config.angular_tolerances_image)
840
+
841
+ all_passed = True
842
+ results = []
843
+
844
+ for i, name in enumerate(OUTPUT_NAMES):
845
+ pt_out = pt_outputs[i]
846
+ onnx_output = onnx_out[i]
847
+
848
+ result = {"output": name, "passed": True, "failure_reason": ""}
849
+
850
+ if name == "quaternions_rotations":
851
+ quat_result = quat_validator.validate(pt_out, onnx_output, image_path.name)
852
+ result.update({
853
+ "max_diff": f"{quat_result['stats']['max']:.6f}",
854
+ "mean_diff": f"{quat_result['stats']['mean']:.6f}",
855
+ "p99_diff": f"{quat_result['stats']['p99']:.6f}",
856
+ "passed": quat_result["passed"],
857
+ "failure_reason": "; ".join(quat_result["failure_reasons"]),
858
+ })
859
+ if not quat_result["passed"]:
860
+ all_passed = False
861
+ else:
862
+ diff = np.abs(pt_out - onnx_output)
863
+ tol = tolerances.get(name, 0.01)
864
+ result.update({
865
+ "max_diff": f"{np.max(diff):.6f}",
866
+ "mean_diff": f"{np.mean(diff):.6f}",
867
+ "p99_diff": f"{np.percentile(diff, 99):.6f}",
868
+ })
869
+ if np.max(diff) > tol:
870
+ result["passed"] = False
871
+ result["failure_reason"] = f"max diff {np.max(diff):.6f} > tol {tol:.6f}"
872
+ all_passed = False
873
+
874
+ results.append(result)
875
+
876
+ LOGGER.info(f"\n### Validation Results: {image_path.name}\n")
877
+ LOGGER.info(format_validation_table(results, image_path.name, include_image=True))
878
+ LOGGER.info("")
879
+
880
+ return all_passed
881
+
882
+
883
+ def validate_onnx_model(onnx_path, pytorch_model, input_shape=(1536, 1536), angular_tolerances=None, is_fp16_model=False):
884
+ LOGGER.info("Validating ONNX model against PyTorch...")
885
+ np.random.seed(42)
886
+ torch.manual_seed(42)
887
+
888
+ # Always use FP32 inputs - FP16 models with keep_io_types=True accept FP32 inputs
889
+ # and we compare against FP32 PyTorch reference for meaningful accuracy measurement
890
+ test_image_np = np.random.rand(1, 3, input_shape[0], input_shape[1]).astype(np.float32)
891
+ test_disp_np = np.array([1.0], dtype=np.float32)
892
+
893
+ # Create a wrapper for PyTorch model - always use FP32 as reference
894
+ wrapper = SharpModelTraceable(pytorch_model)
895
+ wrapper.eval()
896
+
897
+ test_image = torch.from_numpy(test_image_np)
898
+ test_disp = torch.from_numpy(test_disp_np)
899
+
900
+ with torch.no_grad():
901
+ pt_out = wrapper(test_image, test_disp)
902
+
903
+ # ONNX inference - always use FP32 inputs (FP16 model handles conversion internally)
904
+ session = ort.InferenceSession(str(onnx_path), providers=['CPUExecutionProvider'])
905
+ onnx_raw = session.run(None, {"image": test_image_np, "disparity_factor": test_disp_np})
906
+
907
+ # Use same splitting logic as run_inference_pair
908
+ if len(onnx_raw) == 5:
909
+ onnx_splits = list(onnx_raw)
910
+ elif len(onnx_raw) == 1:
911
+ sizes = [3, 3, 4, 3, 1]
912
+ start = 0
913
+ onnx_splits = []
914
+ for size in sizes:
915
+ onnx_splits.append(onnx_raw[0][:, :, start:start+size])
916
+ start += size
917
+ else:
918
+ onnx_splits = list(onnx_raw)
919
+
920
+ tolerance_config = ToleranceConfig()
921
+ # Use FP16 tolerances if validating FP16 model (compared against FP32 PyTorch reference)
922
+ if is_fp16_model:
923
+ tolerances = tolerance_config.fp16_random_tolerances
924
+ quat_validator = QuaternionValidator(angular_tolerances=angular_tolerances or tolerance_config.fp16_angular_tolerances_random)
925
+ LOGGER.info("Using FP16 validation tolerances (comparing FP16 ONNX vs FP32 PyTorch reference)")
926
+ else:
927
+ tolerances = tolerance_config.random_tolerances
928
+ quat_validator = QuaternionValidator(angular_tolerances=angular_tolerances or tolerance_config.angular_tolerances_random)
929
+
930
+ all_passed = True
931
+ results = []
932
+
933
+ for i, name in enumerate(OUTPUT_NAMES):
934
+ pt_o = pt_out[i].numpy()
935
+ onnx_o = onnx_splits[i]
936
+ result = {"output": name, "passed": True, "failure_reason": ""}
937
+
938
+ if name == "quaternions_rotations":
939
+ qr = quat_validator.validate(pt_o, onnx_o, "Random")
940
+ result.update({
941
+ "max_diff": f"{qr['stats']['max']:.6f}",
942
+ "mean_diff": f"{qr['stats']['mean']:.6f}",
943
+ "p99_diff": f"{qr['stats']['p99']:.6f}",
944
+ "passed": qr["passed"],
945
+ "failure_reason": "; ".join(qr["failure_reasons"]),
946
+ })
947
+ if not qr["passed"]:
948
+ all_passed = False
949
+ else:
950
+ diff = np.abs(pt_o - onnx_o)
951
+ tol = tolerances.get(name, 0.01)
952
+ result.update({
953
+ "max_diff": f"{np.max(diff):.6f}",
954
+ "mean_diff": f"{np.mean(diff):.6f}",
955
+ "p99_diff": f"{np.percentile(diff, 99):.6f}",
956
+ })
957
+ if np.max(diff) > tol:
958
+ result["passed"] = False
959
+ result["failure_reason"] = f"max diff {np.max(diff):.6f} > tol {tol:.6f}"
960
+ all_passed = False
961
+
962
+ results.append(result)
963
+
964
+ LOGGER.info("\n### Random Validation Results\n")
965
+ LOGGER.info(format_validation_table(results))
966
+ LOGGER.info("")
967
+
968
+ return all_passed
969
+
970
+
971
+ def main():
972
+ parser = argparse.ArgumentParser(description="Convert SHARP PyTorch model to ONNX format")
973
+ parser.add_argument("-c", "--checkpoint", type=Path, default=None, help="Path to PyTorch checkpoint")
974
+ parser.add_argument("-o", "--output", type=Path, default=Path("sharp.onnx"), help="Output path for ONNX model")
975
+ parser.add_argument("-q", "--quantize", type=str, default=None, choices=["fp16"], help="Quantization type (fp16 for float16)")
976
+ parser.add_argument("--height", type=int, default=1536, help="Input image height")
977
+ parser.add_argument("--width", type=int, default=1536, help="Input image width")
978
+ parser.add_argument("--validate", action="store_true", help="Validate ONNX model against PyTorch")
979
+ parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging")
980
+ parser.add_argument("--input-image", type=Path, default=None, action="append", help="Path to input image for validation")
981
+ parser.add_argument("--no-external-data", action="store_true", help="Save model with inline data (no .onnx.data file needed)")
982
+ parser.add_argument("--tolerance-mean", type=float, default=None, help="Custom mean angular tolerance for quaternion validation")
983
+ parser.add_argument("--tolerance-p99", type=float, default=None, help="Custom p99 angular tolerance for quaternion validation")
984
+ parser.add_argument("--tolerance-max", type=float, default=None, help="Custom max angular tolerance for quaternion validation")
985
+
986
+ args = parser.parse_args()
987
+
988
+ logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
989
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
990
+
991
+ LOGGER.info("Loading SHARP model...")
992
+ predictor = load_sharp_model(args.checkpoint)
993
+
994
+ input_shape = (args.height, args.width)
995
+
996
+ LOGGER.info(f"Converting to ONNX: {args.output}")
997
+
998
+ # Handle quantization
999
+ if args.quantize == "fp16":
1000
+ LOGGER.info("Using FP16 quantization (ONNX-native post-export conversion)...")
1001
+ convert_to_onnx_fp16(
1002
+ predictor,
1003
+ args.output,
1004
+ input_shape=input_shape,
1005
+ )
1006
+ else:
1007
+ # Standard float32 conversion
1008
+ convert_to_onnx(predictor, args.output, input_shape=input_shape, use_external_data=False)
1009
+
1010
+ LOGGER.info(f"ONNX model saved to {args.output}")
1011
+
1012
+ is_fp16 = args.quantize == "fp16"
1013
+
1014
+ if args.validate:
1015
+ if args.input_image:
1016
+ for img_path in args.input_image:
1017
+ if not img_path.exists():
1018
+ LOGGER.error(f"Image not found: {img_path}")
1019
+ return 1
1020
+ passed = validate_with_image(args.output, predictor, img_path, input_shape, is_fp16_model=is_fp16)
1021
+ if not passed:
1022
+ LOGGER.error(f"Validation failed for {img_path}")
1023
+ return 1
1024
+ else:
1025
+ angular_tolerances = None
1026
+ if args.tolerance_mean or args.tolerance_p99 or args.tolerance_max:
1027
+ angular_tolerances = {
1028
+ "mean": args.tolerance_mean if args.tolerance_mean else 0.01,
1029
+ "p99": args.tolerance_p99 if args.tolerance_p99 else 0.5,
1030
+ "p99_9": 2.0,
1031
+ "max": args.tolerance_max if args.tolerance_max else 15.0,
1032
+ }
1033
+ # Use FP16 tolerances for FP16 model validation (still uses FP32 inputs)
1034
+ is_fp16_model = args.quantize == "fp16"
1035
+ passed = validate_onnx_model(args.output, predictor, input_shape, angular_tolerances=angular_tolerances, is_fp16_model=is_fp16_model)
1036
+ if passed:
1037
+ LOGGER.info("Validation passed!")
1038
+ else:
1039
+ LOGGER.error("Validation failed!")
1040
+ return 1
1041
+
1042
+ cleanup_extraneous_files()
1043
+ LOGGER.info("Conversion complete!")
1044
+ return 0
1045
+
1046
+
1047
+ if __name__ == "__main__":
1048
+ exit(main())
inference_onnx.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """ONNX Inference Script for SHARP Model.
3
+
4
+ Loads an ONNX model (fp32 or fp16), runs inference on an input image,
5
+ and exports the result as a PLY file.
6
+
7
+ Usage:
8
+ # Convert and validate FP16 model
9
+ python convert_onnx.py -o sharp_fp16.onnx -q fp16 --validate
10
+
11
+ # Run inference with FP16 model
12
+ python inference_onnx.py -m sharp_fp16.onnx -i test.png -o test.ply -d 0.5
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import logging
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+ import onnxruntime as ort
23
+ from PIL import Image
24
+ from plyfile import PlyData, PlyElement
25
+
26
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
27
+ LOGGER = logging.getLogger(__name__)
28
+
29
+ DEFAULT_HEIGHT = 1536
30
+ DEFAULT_WIDTH = 1536
31
+
32
+
33
+ def linear_to_srgb(linear: float) -> float:
34
+ if linear <= 0.0031308:
35
+ return linear * 12.92
36
+ return 1.055 * pow(linear, 1.0 / 2.4) - 0.055
37
+
38
+
39
+ def rgb_to_sh(rgb: float) -> float:
40
+ coeff_degree0 = 1.0 / np.sqrt(4.0 * np.pi)
41
+ return (rgb - 0.5) / coeff_degree0
42
+
43
+
44
+ def inverse_sigmoid(x: float) -> float:
45
+ x = np.clip(x, 1e-6, 1.0 - 1e-6)
46
+ return np.log(x / (1.0 - x))
47
+
48
+
49
+ def preprocess_image(image_path: str | Path, target_size: tuple[int, int] = (DEFAULT_HEIGHT, DEFAULT_WIDTH)):
50
+ """Load and preprocess an image for ONNX inference."""
51
+ image_path = Path(image_path)
52
+ target_h, target_w = target_size
53
+
54
+ img = Image.open(image_path)
55
+ original_size = img.size
56
+ focal_length_px = original_size[0]
57
+
58
+ if img.size != (target_w, target_h):
59
+ img = img.resize((target_w, target_h), Image.BILINEAR)
60
+
61
+ img_np = np.array(img, dtype=np.float32) / 255.0
62
+
63
+ if img_np.shape[2] == 4:
64
+ img_np = img_np[:, :, :3]
65
+
66
+ img_np = np.transpose(img_np, (2, 0, 1))
67
+ img_np = np.expand_dims(img_np, axis=0)
68
+
69
+ LOGGER.info(f"Loaded image: {image_path}, original size: {original_size}")
70
+ LOGGER.info(f"Preprocessed shape: {img_np.shape}, range: [{img_np.min():.4f}, {img_np.max():.4f}]")
71
+
72
+ return img_np, float(focal_length_px), original_size
73
+
74
+
75
+ def run_inference(onnx_path: str | Path, image: np.ndarray, disparity_factor: float = 1.0) -> dict[str, np.ndarray]:
76
+ """Run ONNX inference on the preprocessed image."""
77
+ onnx_path = Path(onnx_path)
78
+
79
+ LOGGER.info(f"Loading ONNX model: {onnx_path}")
80
+
81
+ # Configure session to suppress constant folding warnings for FP16 ops
82
+ # These warnings are benign - FP16 Sqrt/Tile ops run correctly but can't be pre-folded
83
+ sess_options = ort.SessionOptions()
84
+ sess_options.log_severity_level = 3 # 0=Verbose, 1=Info, 2=Warning, 3=Error, 4=Fatal
85
+
86
+ # Use CPUExecutionProvider for universal compatibility
87
+ # Works on all platforms and handles large models with external data files
88
+ session = ort.InferenceSession(str(onnx_path), sess_options, providers=['CPUExecutionProvider'])
89
+ LOGGER.info("Using CPUExecutionProvider for inference")
90
+
91
+ input_names = [inp.name for inp in session.get_inputs()]
92
+ output_names = [out.name for out in session.get_outputs()]
93
+
94
+ LOGGER.info(f"Input names: {input_names}")
95
+ LOGGER.info(f"Output names: {output_names}")
96
+
97
+ inputs = {
98
+ "image": image.astype(np.float32),
99
+ "disparity_factor": np.array([disparity_factor], dtype=np.float32)
100
+ }
101
+
102
+ LOGGER.info("Running inference...")
103
+ raw_outputs = session.run(None, inputs)
104
+
105
+ outputs = {}
106
+
107
+ if len(raw_outputs) == 1:
108
+ concat = raw_outputs[0]
109
+ sizes = [3, 3, 4, 3, 1]
110
+ names = [
111
+ "mean_vectors_3d_positions",
112
+ "singular_values_scales",
113
+ "quaternions_rotations",
114
+ "colors_rgb_linear",
115
+ "opacities_alpha_channel"
116
+ ]
117
+ start = 0
118
+ for name, size in zip(names, sizes):
119
+ outputs[name] = concat[:, :, start:start + size]
120
+ start += size
121
+ elif len(raw_outputs) == 5:
122
+ names = [
123
+ "mean_vectors_3d_positions",
124
+ "singular_values_scales",
125
+ "quaternions_rotations",
126
+ "colors_rgb_linear",
127
+ "opacities_alpha_channel"
128
+ ]
129
+ for name, out in zip(names, raw_outputs):
130
+ outputs[name] = out
131
+ else:
132
+ for name, out in zip(output_names, raw_outputs):
133
+ outputs[name] = out
134
+
135
+ for name, arr in outputs.items():
136
+ LOGGER.info(f" {name}: shape {arr.shape}")
137
+
138
+ return outputs
139
+
140
+
141
+ def export_ply(outputs: dict[str, np.ndarray], output_path: str | Path,
142
+ focal_length_px: float, image_shape: tuple[int, int],
143
+ decimation: float = 1.0, depth_scale: float = 1.0) -> None:
144
+ """Export Gaussians to PLY file format."""
145
+ output_path = Path(output_path)
146
+
147
+ mean_vectors = outputs["mean_vectors_3d_positions"]
148
+ singular_values = outputs["singular_values_scales"]
149
+ quaternions = outputs["quaternions_rotations"]
150
+ colors = outputs["colors_rgb_linear"]
151
+ opacities = outputs["opacities_alpha_channel"]
152
+
153
+ mean_vectors = mean_vectors[0]
154
+ singular_values = singular_values[0]
155
+ quaternions = quaternions[0]
156
+ colors = colors[0]
157
+ opacities = opacities[0]
158
+
159
+ num_gaussians = mean_vectors.shape[0]
160
+ LOGGER.info(f"Exporting {num_gaussians} Gaussians to PLY")
161
+
162
+ if decimation < 1.0:
163
+ log_scales = np.log(np.maximum(singular_values, 1e-10))
164
+ scale_product = np.exp(np.sum(log_scales, axis=1))
165
+ importance = scale_product * opacities
166
+
167
+ indices = np.argsort(-importance)
168
+ keep_count = max(1, int(num_gaussians * decimation))
169
+ keep_indices = indices[:keep_count]
170
+ keep_indices.sort()
171
+
172
+ LOGGER.info(f"Decimating: keeping {keep_count} of {num_gaussians} ({decimation * 100:.1f}%)")
173
+
174
+ mean_vectors = mean_vectors[keep_indices]
175
+ singular_values = singular_values[keep_indices]
176
+ quaternions = quaternions[keep_indices]
177
+ colors = colors[keep_indices]
178
+ opacities = opacities[keep_indices]
179
+ num_gaussians = keep_count
180
+
181
+ vertex_data = np.zeros(num_gaussians, dtype=[
182
+ ('x', 'f4'), ('y', 'f4'), ('z', 'f4'),
183
+ ('f_dc_0', 'f4'), ('f_dc_1', 'f4'), ('f_dc_2', 'f4'),
184
+ ('opacity', 'f4'),
185
+ ('scale_0', 'f4'), ('scale_1', 'f4'), ('scale_2', 'f4'),
186
+ ('rot_0', 'f4'), ('rot_1', 'f4'), ('rot_2', 'f4'), ('rot_3', 'f4')
187
+ ])
188
+
189
+ # Model outputs [z*x_ndc, z*y_ndc, z] where z is normalized depth and x_ndc, y_ndc ∈ [-1, 1]
190
+ # The model's depth is scale-invariant and normalized to a small range (typically ~0.5-0.7)
191
+ # We need to:
192
+ # 1. Expand the depth range for proper 3D relief
193
+ # 2. Convert projective coords to camera space: x_cam = (z*x_ndc) / focal_ndc
194
+
195
+ img_h, img_w = image_shape
196
+ z_raw = mean_vectors[:, 2]
197
+
198
+ # Normalize depth to start at 1.0 and scale for better 3D relief
199
+ # depth_scale > 1.0 exaggerates depth differences (useful for flat scenes)
200
+ z_min = np.min(z_raw)
201
+ z_normalized = z_raw / z_min # Now min depth = 1.0
202
+
203
+ # Apply depth scale to exaggerate depth differences around the median
204
+ if depth_scale != 1.0:
205
+ z_median = np.median(z_normalized)
206
+ z_normalized = z_median + (z_normalized - z_median) * depth_scale
207
+
208
+ # Scale factor to convert from NDC to camera space
209
+ # For a camera with focal length f and image width w: focal_ndc = 2*f/w
210
+ # With f = w (90° FOV assumption): focal_ndc = 2.0
211
+ focal_ndc = 2.0 * focal_length_px / img_w
212
+
213
+ # Compute camera-space coordinates
214
+ # The projective values need to be scaled by the same depth normalization
215
+ scale_factor = 1.0 / (z_min * focal_ndc)
216
+
217
+ vertex_data['x'] = mean_vectors[:, 0] * scale_factor
218
+ vertex_data['y'] = mean_vectors[:, 1] * scale_factor
219
+ vertex_data['z'] = z_normalized
220
+
221
+ LOGGER.info(f"Depth range: {z_raw.min():.3f} - {z_raw.max():.3f} -> normalized: 1.0 - {z_normalized.max():.3f}")
222
+
223
+ for i in range(num_gaussians):
224
+ r, g, b = colors[i]
225
+ srgb_r = linear_to_srgb(float(r))
226
+ srgb_g = linear_to_srgb(float(g))
227
+ srgb_b = linear_to_srgb(float(b))
228
+
229
+ vertex_data['f_dc_0'][i] = rgb_to_sh(srgb_r)
230
+ vertex_data['f_dc_1'][i] = rgb_to_sh(srgb_g)
231
+ vertex_data['f_dc_2'][i] = rgb_to_sh(srgb_b)
232
+
233
+ vertex_data['opacity'] = inverse_sigmoid(opacities)
234
+
235
+ # Scale the Gaussian sizes to match the transformed coordinate space
236
+ vertex_data['scale_0'] = np.log(np.maximum(singular_values[:, 0] * scale_factor, 1e-10))
237
+ vertex_data['scale_1'] = np.log(np.maximum(singular_values[:, 1] * scale_factor, 1e-10))
238
+ vertex_data['scale_2'] = np.log(np.maximum(singular_values[:, 2] / z_min, 1e-10)) # Z scale uses depth normalization
239
+
240
+ vertex_data['rot_0'] = quaternions[:, 0]
241
+ vertex_data['rot_1'] = quaternions[:, 1]
242
+ vertex_data['rot_2'] = quaternions[:, 2]
243
+ vertex_data['rot_3'] = quaternions[:, 3]
244
+
245
+ vertex_element = PlyElement.describe(vertex_data, 'vertex')
246
+
247
+ # Extrinsic: 4x4 identity matrix as 16 separate properties
248
+ extrinsic_data = np.zeros(1, dtype=[('extrinsic', 'f4', (16,))])
249
+ extrinsic_data['extrinsic'][0] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
250
+ extrinsic_element = PlyElement.describe(extrinsic_data, 'extrinsic')
251
+
252
+ img_h, img_w = image_shape
253
+ # Intrinsic: 3x3 matrix as 9 separate properties
254
+ intrinsic_data = np.zeros(1, dtype=[('intrinsic', 'f4', (9,))])
255
+ intrinsic_data['intrinsic'][0] = [focal_length_px, 0, img_w / 2, 0, focal_length_px, img_h / 2, 0, 0, 1]
256
+ intrinsic_element = PlyElement.describe(intrinsic_data, 'intrinsic')
257
+
258
+ # Image size: 2 separate uint32 properties
259
+ image_size_data = np.zeros(1, dtype=[('image_size', 'u4', (2,))])
260
+ image_size_data['image_size'][0] = [img_w, img_h]
261
+ image_size_element = PlyElement.describe(image_size_data, 'image_size')
262
+
263
+ # Frame: 2 separate int32 properties
264
+ frame_data = np.zeros(1, dtype=[('frame', 'i4', (2,))])
265
+ frame_data['frame'][0] = [1, num_gaussians]
266
+ frame_element = PlyElement.describe(frame_data, 'frame')
267
+
268
+ z_values = mean_vectors[:, 2]
269
+ z_safe = np.maximum(z_values, 1e-6)
270
+ disparities = 1.0 / z_safe
271
+ disparities.sort()
272
+ disparity_10 = disparities[int(len(disparities) * 0.1)] if len(disparities) > 0 else 0.0
273
+ disparity_90 = disparities[int(len(disparities) * 0.9)] if len(disparities) > 0 else 1.0
274
+ disparity_data = np.zeros(1, dtype=[('disparity', 'f4', (2,))])
275
+ disparity_data['disparity'][0] = [disparity_10, disparity_90]
276
+ disparity_element = PlyElement.describe(disparity_data, 'disparity')
277
+
278
+ # Color space: single uchar property
279
+ color_space_data = np.zeros(1, dtype=[('color_space', 'u1')])
280
+ color_space_data['color_space'][0] = 1
281
+ color_space_element = PlyElement.describe(color_space_data, 'color_space')
282
+
283
+ # Version: 3 uchar properties
284
+ version_data = np.zeros(1, dtype=[('version', 'u1', (3,))])
285
+ version_data['version'][0] = [1, 5, 0]
286
+ version_element = PlyElement.describe(version_data, 'version')
287
+
288
+ PlyData([
289
+ vertex_element,
290
+ extrinsic_element,
291
+ intrinsic_element,
292
+ image_size_element,
293
+ frame_element,
294
+ disparity_element,
295
+ color_space_element,
296
+ version_element
297
+ ], text=False).write(str(output_path))
298
+
299
+ LOGGER.info(f"Saved PLY with {num_gaussians} Gaussians to {output_path}")
300
+
301
+
302
+ def main():
303
+ parser = argparse.ArgumentParser(
304
+ description="ONNX Inference for SHARP - Generate 3D Gaussians from an image"
305
+ )
306
+ parser.add_argument("-m", "--model", type=str, required=True,
307
+ help="Path to ONNX model file")
308
+ parser.add_argument("-i", "--input", type=str, required=True,
309
+ help="Path to input image")
310
+ parser.add_argument("-o", "--output", type=str, required=True,
311
+ help="Path to output file (.ply)")
312
+ parser.add_argument("-d", "--decimate", type=float, default=1.0,
313
+ help="Decimation ratio 0.0-1.0 (default: 1.0 = keep all)")
314
+ parser.add_argument("--disparity-factor", type=float, default=1.0,
315
+ help="Disparity factor for depth conversion (default: 1.0)")
316
+ parser.add_argument("--depth-scale", type=float, default=1.0,
317
+ help="Depth exaggeration factor (>1.0 increases 3D relief, default: 1.0)")
318
+
319
+ args = parser.parse_args()
320
+
321
+ # Preprocess image
322
+ image, focal_length_px, image_shape = preprocess_image(args.input)
323
+
324
+ # Run inference
325
+ outputs = run_inference(args.model, image, args.disparity_factor)
326
+
327
+ # Export to PLY
328
+ export_ply(outputs, args.output, focal_length_px, image_shape, args.decimate, args.depth_scale)
329
+
330
+
331
+ if __name__ == "__main__":
332
+ main()
sharp_fp16.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a45328f6c1b7f7d57d3a19c1d1b71647bff72a42a532814e826cf86d1e8a4040
3
+ size 1319661752
viewer.gif ADDED

Git LFS Details

  • SHA256: dc08d861335fcf8f3df546b29a941e67890fd647cfa0cd5c2d2a28691ea7a50f
  • Pointer size: 132 Bytes
  • Size of remote file: 8.76 MB