| |
| """ |
| Convert an ONNX model (e.g. 2xNomosUni_compact_otf_medium.onnx) to float16, |
| optimized for browser deployment via onnxruntime-web with the WebGPU |
| execution provider. |
| |
| Why fp16 and not int8 for WebGPU: |
| Most modern GPUs support FP16 natively -> ~2x size/memory reduction with |
| near-equivalent throughput. INT8/INT4 support in WebGPU compute shaders |
| is inconsistent across GPU/driver/ORT-web versions, with common gaps in |
| fused kernels and integer ops. FP16 is the safe, fast, broadly-supported |
| path for WebGPU specifically (as opposed to WASM, where INT8 wins). |
| |
| What this script does: |
| 1. Loads and validates the source ONNX model. |
| 2. Converts weights + compute graph to float16 (keeping a few numerically |
| sensitive ops like Resize/Softmax in fp32 via keep_io_types / |
| op_block_list, which is standard practice to avoid artifacts). |
| 3. Verifies the converted model still passes onnx.checker. |
| 4. Runs a quick numerical sanity check: same input through fp32 and fp16 |
| models, reports PSNR between the two outputs so you know how close |
| the fp16 version is before you ship it. |
| 5. Saves the result and prints the size comparison. |
| |
| Usage: |
| python convert_fp16_onnx.py \ |
| --model /Users/emay/Downloads/ONNXmodels/models/2xNomosUni_compact_otf_medium.onnx \ |
| --out /Users/emay/Downloads/ONNXmodels/models/2xNomosUni_compact_otf_medium.fp16.onnx |
| """ |
|
|
| import argparse |
| import os |
| import sys |
|
|
| import numpy as np |
| import onnx |
|
|
| try: |
| import onnxruntime as ort |
| except ImportError: |
| print("onnxruntime is required: pip install onnxruntime", file=sys.stderr) |
| raise |
|
|
| try: |
| from onnxconverter_common import float16 |
| except ImportError: |
| print( |
| "onnxconverter-common is required: pip install onnxconverter-common", |
| file=sys.stderr, |
| ) |
| raise |
|
|
|
|
| def psnr(a: np.ndarray, b: np.ndarray, max_val: float = 1.0) -> float: |
| mse = np.mean((a.astype(np.float64) - b.astype(np.float64)) ** 2) |
| if mse == 0: |
| return float("inf") |
| return 10 * np.log10((max_val ** 2) / mse) |
|
|
|
|
| def inspect(model_path: str) -> onnx.ModelProto: |
| print(f"\n{'='*70}\nSTEP 1: Loading & inspecting source model\n{'='*70}") |
| model = onnx.load(model_path) |
| onnx.checker.check_model(model) |
|
|
| size_mb = os.path.getsize(model_path) / 1e6 |
| print(f"File: {model_path}") |
| print(f"Size: {size_mb:.2f} MB") |
| print(f"Input: {model.graph.input[0].name}") |
| print(f"Output: {model.graph.output[0].name}") |
|
|
| op_counts = {} |
| for node in model.graph.node: |
| op_counts[node.op_type] = op_counts.get(node.op_type, 0) + 1 |
| print("Ops:", ", ".join(f"{k}x{v}" for k, v in sorted(op_counts.items()))) |
| print(f"{'='*70}\n") |
| return model |
|
|
|
|
| def convert_to_fp16(model: onnx.ModelProto) -> onnx.ModelProto: |
| print(f"{'='*70}\nSTEP 2: Converting to float16\n{'='*70}") |
| |
| |
| |
| |
| |
| |
| fp16_model = float16.convert_float_to_float16( |
| model, |
| keep_io_types=True, |
| disable_shape_infer=False, |
| op_block_list=["Resize"], |
| ) |
| onnx.checker.check_model(fp16_model) |
| print("Conversion complete, model passes onnx.checker.") |
| print(f"{'='*70}\n") |
| return fp16_model |
|
|
|
|
| def sanity_check(orig_model: onnx.ModelProto, fp16_model: onnx.ModelProto, tile: int = 128): |
| print(f"{'='*70}\nSTEP 3: Numerical sanity check (fp32 vs fp16 output)\n{'='*70}") |
|
|
| input_name = orig_model.graph.input[0].name |
| x = np.random.rand(1, 3, tile, tile).astype(np.float32) |
|
|
| sess_orig = ort.InferenceSession(orig_model.SerializeToString(), providers=["CPUExecutionProvider"]) |
| sess_fp16 = ort.InferenceSession(fp16_model.SerializeToString(), providers=["CPUExecutionProvider"]) |
|
|
| out_orig = sess_orig.run(None, {input_name: x})[0] |
| out_fp16 = sess_fp16.run(None, {input_name: x})[0] |
|
|
| p = psnr(out_orig, out_fp16) |
| print(f"PSNR (fp32 vs fp16 output, random calibration tile): {p:.1f} dB") |
| if p < 40: |
| print("NOTE: PSNR below 40dB -- inspect the fp16 output on a real image " |
| "before shipping. This is a synthetic random tile, so also test " |
| "with an actual photo for a trustworthy read.") |
| else: |
| print("Looks good -- fp16 output is numerically very close to fp32.") |
| print(f"{'='*70}\n") |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| ap.add_argument("--model", required=True, help="Path to input .onnx model (fp32)") |
| ap.add_argument("--out", required=True, help="Path to write the fp16 .onnx model") |
| ap.add_argument("--tile", type=int, default=128, help="Tile size for the sanity-check input") |
| ap.add_argument("--skip-sanity-check", action="store_true") |
| args = ap.parse_args() |
|
|
| if not os.path.isfile(args.model): |
| print(f"Model not found: {args.model}", file=sys.stderr) |
| sys.exit(1) |
|
|
| orig_model = inspect(args.model) |
| fp16_model = convert_to_fp16(orig_model) |
|
|
| if not args.skip_sanity_check: |
| sanity_check(orig_model, fp16_model, tile=args.tile) |
|
|
| onnx.save(fp16_model, args.out) |
|
|
| orig_size = os.path.getsize(args.model) / 1e6 |
| new_size = os.path.getsize(args.out) / 1e6 |
| print(f"Saved: {args.out}") |
| print(f"Size: {orig_size:.2f} MB -> {new_size:.2f} MB " |
| f"({(1 - new_size/orig_size)*100:.0f}% smaller)") |
| print("\nFor WebGPU in onnxruntime-web, load this model with:\n" |
| " const session = await ort.InferenceSession.create(url, {\n" |
| " executionProviders: ['webgpu']\n" |
| " });\n" |
| "Inputs/outputs stay float32 at the JS boundary (keep_io_types=True),\n" |
| "so your existing pre/post-processing code doesn't need to change.") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|