Heng Zhou1* · Shuhong Liu1,2* · Yonghao He1† · Bohao Zhang1 · Fa Fu1
Chenhui Hou1 · Xianbao Hou1,3 · Lijun Han1 · Cong Yang3 · Wei Sui1†✉
1D-Robotics 2The University of Tokyo 3Soochow University
*Equal Contribution †Project Lead ✉Corresponding Author
X-Lens recovers dense metric depth and a fused 3D point cloud from an arbitrary set of views captured by an arbitrary mix of cameras — perspective pinhole, wide-FoV fisheye, or a heterogeneous rig that combines both in a single pass. Built on an alternating (frame ⇄ global) cross-view attention backbone, it reasons jointly across views and camera models, so a fisheye surround-view scene and a set of pinhole cameras are handled by one model, one forward.
💎 One model, any camera. Fisheye and pinhole views are consumed side-by-side through a per-pixel ray representation + camera-type conditioning — no rectification, no per-camera retraining.
✨ Metric & geometry-aware. Predicts scale factor, depth, and a confidence map, unprojected to a single consistent point cloud.
- [07-15-2026] 📦 OmniScene dataset released on Hugging Face
- [07-15-2026] 🎉 Initial release: inference code + XLens-S checkpoints for all three camera modes.
- [07-15-2026] 📄 Paper released on arXiv.
- Heterogeneous multi-view. Mix fisheye and pinhole views in the same scene; camera type is injected per view via calibration tokens + a geometric distortion bias.
- Native fisheye. Wide-FoV cameras are handled directly through a per-pixel unit-ray LUT and ray-angle rotary position encoding — no undistortion to a lossy pinhole crop.
- Metric depth + point cloud. A learned scale head produces metric depth; every view unprojects into one fused, view-0-canonical world point cloud.
git clone https://github.com/zhouhengamerica/XLens.git xlens
cd xlens
conda create -n xlens python=3.10 -y
conda activate xlens
pip install -r requirements.txt
pip install -e . # exposes the `xlens` packageDownload a checkpoint into checkpoints/ (see the Model Zoo):
mkdir -p checkpoints
hf download henryzhou998/X-Lens --local-dir checkpointsThe DINOv2 backbone weights are bundled inside the checkpoint — no separate download needed for inference.
A scene is a small JSON manifest listing, per view, an image and its calibration
(pinhole K, per-pixel dcam, or fisheye lut) plus an optional c2w pose. Three
ready-to-run demo scenes live in examples/.
python inference.py \
--ckpt checkpoints/xlens.pth \
--manifest examples/hetero_loft/scene.json \
--out out/hetero --max-depth 25 --conf-drop-pct 10
# -> out/hetero/depth_metric.npy (6, 504, 798) metric depth
# -> out/hetero/depth_preview.png per-view depth mosaic
# -> out/hetero/points.ply fused world-frame point cloudAll three modes use the same checkpoint. The mode is selected purely by what each view carries in the manifest — the CLI assigns the camera type automatically. Each demo below is a self-contained, ready-to-run bundle (RGB images + intrinsics/extrinsics + masks).
| Mode | Per-view intrinsics | Per-view extrinsics | Bundled demo |
|---|---|---|---|
| Pure pinhole (6-cam) | 3x3 intrinsics K |
4x4 camera world pose c2w(relative, view-0 canonical) |
pinhole_4f_ba1/ - OmniOCC real capture |
| Pure fisheye (4-cam) | per-pixel unit-ray dcam |
fisheye_fuelbar/ - FS3 FuelBar |
|
| Heterogeneous (6-cam) | mix of K and dcam |
hetero_loft/ - 4 fisheye + 2 pinhole |
# Pure pinhole — 6-camera OmniOCC rig
python inference.py --ckpt checkpoints/xlens.pth --manifest examples/pinhole_4f_ba1/scene.json --out out/pinhole --max-depth 15
# Pure fisheye — 4 fisheye cameras (fisheye cleanup applied automatically)
python inference.py --ckpt checkpoints/xlens.pth --manifest examples/fisheye_fuelbar/scene.json --out out/fisheye --max-depth 25 --fov-max 85 --conf-drop-pct 8
# Heterogeneous — 4 fisheye + 2 pinhole in one scene (fisheye cleanup applied automatically)
python inference.py --ckpt checkpoints/xlens.pth --manifest examples/hetero_loft/scene.json --out out/hetero --max-depth 25 --fov-max 85 --conf-drop-pct 8Manifest format. Each view carries an image, a c2w pose, an optional mask, and its
calibration — one of: K (3×3 pinhole intrinsics), dcam (a .npy of per-pixel unit rays, for
any camera model), or lut (a fisheye ray LUT, see below). cam_type (0 fisheye / 1 pinhole) is
inferred from the calibration or set explicitly.
Near a fisheye lens's edge, distortion is extreme and per-pixel depth becomes unreliable — those peripheral rays unproject into stray points that shoot outside the scene. We strongly recommend cleaning them up. When a scene contains any fisheye view,
inference.pydoes this automatically: it trims rays more than 85° off the optical axis (--fov-max 85) and drops the lowest 8% of points by confidence (--conf-drop-pct 8). In our tests this collapses the outlier tail (e.g. max point range 25 m → ~14 m on pure fisheye, and 25 m → ~5 m on a mixed rig) while keeping ~90% of points.
Override or disable:
--fov-max 80 --conf-drop-pct 15 # stronger cleanup
--no-fisheye-clean # turn the automatic cleanup offOther point-cloud filters (any mode): --max-depth clips by euclidean range (m); --conf-thresh
sets an absolute confidence cutoff.
Python API (build inputs yourself)
import numpy as np
from xlens.inference import XLensInference
from xlens.inference.preprocess import pinhole_d_cam, load_fisheye_lut, assemble_batch
from xlens.inference.geometry import fuse_point_cloud, save_ply
model = XLensInference("checkpoints/xlens.pth", device="cuda")
images = [img0, img1] # list of (H, W, 3) uint8 RGB
d_cams = [pinhole_d_cam(K0, H, W), # pinhole view -> unit rays from K
load_fisheye_lut("cam1_lut.npy", H, W)] # fisheye view -> unit rays from LUT
cam_types = [1, 0] # 1 = pinhole, 0 = fisheye
c2w = np.stack([c2w0, c2w1]) # (S, 4, 4), optional (enables point-cloud fusion)
batch = assemble_batch(images, d_cams, cam_types, c2w=c2w, device="cuda")
out = model(batch)
depth = out["depth_metric"][0].numpy() # (S, H, W) metric depth
pts, col = fuse_point_cloud(depth, batch["d_cam"][0].cpu().numpy().transpose(0,2,3,1),
c2w, rgb=np.stack(images), conf=out["depth_conf"][0].numpy())
save_ply("points.ply", pts, col)Fisheye calibration → LUT
A fisheye view is fed to the model through a look-up table (LUT): a (H, W, 3) array giving,
for every pixel, the OpenCV camera-frame unit viewing direction (X right, Y down, Z forward).
Precompute one LUT per physical camera from your calibration (OpenCV cv2.fisheye, Kannala–Brandt,
Mei, …) and save it as .npy:
import cv2, numpy as np
H, W = 504, 798
u, v = np.meshgrid(np.arange(W), np.arange(H))
pts = np.stack([u, v], -1).astype(np.float32).reshape(-1, 1, 2)
undist = cv2.fisheye.undistortPoints(pts, K, D).reshape(H, W, 2) # normalized image plane
d = np.concatenate([undist, np.ones((H, W, 1), np.float32)], -1)
d /= np.linalg.norm(d, axis=-1, keepdims=True)
np.save("cam0_lut.npy", d.astype(np.float32))The LUT resolution must match the images you feed the model.
ONNX export (deploy on a fixed camera rig, full geometry baked)
Once your rig is final (view count, resolution, and the fixed per-camera calibration), export to ONNX
with all geometry baked in and run it with ONNX Runtime / TensorRT. S, H, W and the whole
calibration are frozen at export time — re-export when the rig changes.
xlens/tools/export_onnx_geo.py takes the same scene.json manifest as inference.py and bakes the
network's entire geometric input — ray_map (as a precomputed ray_feat constant), per-pixel d_cam
(ray-angle RoPE), cam_types, and the calibration-token attention masks — into the graph as constants. This
covers pinhole, fisheye, and heterogeneous rigs identically. The only runtime input is images;
camera extrinsics are used only for optional point-cloud fusion, outside ONNX.
python -m xlens.tools.export_onnx_geo \
--ckpt checkpoints/xlens_vits.safetensors --config configs/xlens_vits.yaml \
--manifest examples/hetero_loft/scene.json \
--out onnx/xlens_hetero_loft.onnx --device cuda
# bakes geometry -> single-file ONNX, then runs a PyTorch-vs-ONNXRuntime check on the real imagesRun the exported model (mirrors inference.py, but only feeds images to the graph):
python -m xlens.tools.infer_onnx \
--onnx onnx/xlens_hetero_loft.onnx \
--manifest examples/hetero_loft/scene.json \
--out out/hetero_onnx --provider cuda --max-depth 25
# writes depth_metric.npy + depth_preview.png + points.ply. Use --provider cpu on a
# small GPU: 6-view global attention (L≈12k) needs a large-memory GPU under ONNX Runtime.Or drive it from your own code:
import numpy as np, onnxruntime as ort
from xlens.inference.preprocess import assemble_batch
from xlens.inference.geometry import fuse_point_cloud, save_ply
sess = ort.InferenceSession("onnx/xlens_hetero_loft.onnx", providers=["CUDAExecutionProvider"])
# only `images` is needed at runtime (ImageNet-normalized, fixed view order + resolution)
batch = assemble_batch(images, d_cams, cam_types, c2w=c2w)
depth, depth_metric, depth_conf, scale, mask = sess.run(
None, {"images": batch["images"].cpu().numpy().astype(np.float32)})
# depth maps are the model output; fusing to a world point cloud still needs d_cam + c2w (outside ONNX)
pts, col = fuse_point_cloud(depth_metric[0], batch["d_cam"][0].cpu().numpy().transpose(0, 2, 3, 1), c2w)
save_ply("points.ply", pts, col)Outputs: depth, depth_metric, depth_conf, metric_scaling_factor, mask. Options: --opset 17
· --no_fp16_mask keeps the baked attention masks in fp32 (default is fp16 — bit-identical after
softmax; masks are also head-squeezed, keeping the file well under the 2 GiB ONNX limit). ImageNet
normalization and depth→point-cloud unprojection stay outside the graph. Camera extrinsics are never
part of the network — they are used only for point-cloud fusion.
| Model | Backbone | Params | Pinhole | Fisheye | Heterogeneous | Download |
|---|---|---|---|---|---|---|
xlens_vits |
ViT-S/14 | 0.04B | ✅ | ✅ | ✅ | 🤗 HF |
xlens_vitl |
ViT-L/14 | 0.54B | ✅ | ✅ | ✅ | 🔜 upcoming |
The checkpoint is trained through a pinhole → fisheye → mixed curriculum, so a single model serves all three modes. Mode is chosen at inference time by the inputs, not the weights.
Two backbones, one trade-off.
xlens_vitsis the fast, real-time-friendly default.xlens_vitlis an upcoming higher-accuracy variant for users who prioritize reconstruction quality over latency — same interface and checkpoint format, just a larger backbone.
xlens/
├── inference.py # CLI: manifest -> depth + point cloud
├── configs/xlens_vits.yaml # architecture reference (loader also reads arch from the ckpt)
├── examples/ # scene manifests for the three modes
├── xlens/
│ ├── models/ # the model (self-contained: backbone + heads)
│ ├── inference/ # preprocess (d_cam / ray_map / cam_types), geometry, pipeline
│ └── tools/ # export_onnx_geo.py (bake geometry -> ONNX) + infer_onnx.py (run it)
├── evaluation/ # depth eval: eval_pinhole / eval_fisheye / eval_heterogeneous
└── examples/ # ready-to-run demo scenes (pinhole / fisheye / heterogeneous)
We open-source OmniScene, the photorealistic synthetic dataset (rendered in NVIDIA Isaac Sim) used to train X-Lens. Each scene is captured by a 6-camera omnidirectional rig with metric ground-truth depth, so a single sample already contains the pinhole / fisheye / heterogeneous mix the model is designed for.
🤗 Download: huggingface.co/datasets/henryzhou998/OmniScene
| View | Model | Notes |
|---|---|---|
CAM_A CAM_B CAM_C CAM_D |
fisheye — omnidirectional (Mei / unified-sphere: xi + radtan distortion) |
4 side fisheye cameras |
CAM_Front CAM_Back |
pinhole — perspective (3×3 K) |
forward / backward views |
On the Hub, each sequence is packaged as a single .tar,
which keeps the repo well under the Hub's file-count limit:
OmniScene/ # Hugging Face repo
├── README.md
├── DEPTH_FORMAT.txt # depth decoding note (see below)
├── train/<scene>.tar # e.g. taobao02_AIUE_V01_001_100_10_60.tar
├── valid/<scene>.tar
├── test/<scene>.tar
└── texture/ # shared fisheye ray LUTs (CAM_*_rayEnterDirection.exr) — used by --lut_dir
Each tar extracts to the per-scene layout:
<scene>/
├── rgb/ CAM_*/<frame>.jpg # 8-bit RGB
├── depth/ CAM_*/<frame>.png # 16-bit metric depth (see decoding)
├── mask/ CAM_*_mask.png # static per-camera valid-lens mask
├── sky_mask/ _meta.json (+ per-frame masks when sky is present)
└── common/ <frame>.npy # per-frame camera parameters (all 6 views)
common/<frame>.npy is a pickled dict keyed by camera name; each entry provides:
intrinsics— 3×3 pinholeK(the calibration used for theCAM_Front/Backpinhole views)extrinsics_world— 4×4 camera-to-world (OpenCV convention: X right, Y down, Z forward)intrinsics_full— the full source calibration: theomnifisheye model (xi,fx/fy/cx/cy,radtancoeffs) forCAM_A/B/C/D, plus the camera-to-camera extrinsics to the other views. (CAM_Front/Backalso carry their Isaac Sim source metadata here, but are consumed as pinhole viaK.)
Depth is stored as 16-bit PNG; recover metres by dividing by 256. A 0 value means
invalid / sky (pair it with sky_mask). Because the raw 16-bit values are small, the PNGs look
almost black in a normal image viewer — that is expected, load them numerically:
import cv2, numpy as np
png = cv2.imread("depth/CAM_A/0000_0000.png", cv2.IMREAD_UNCHANGED) # MUST be UNCHANGED (keep 16-bit)
depth_m = png.astype(np.float32) / 256.0 # metres
valid = png > 0 # 0 = invalid / sky(depth_meters = png_value / 256, 0 = invalid/sky, max range 256 m — also recorded in DEPTH_FORMAT.txt.)
from huggingface_hub import hf_hub_download, snapshot_download
import tarfile
# one scene
p = hf_hub_download("henryzhou998/OmniScene", "test/<scene>.tar", repo_type="dataset")
tarfile.open(p).extractall("omniscene/") # -> omniscene/<scene>/{rgb,depth,mask,...}
# a whole split (downloads the tars; extract each as above)
snapshot_download("henryzhou998/OmniScene", repo_type="dataset",
allow_patterns="test/*", local_dir="omniscene_tars")Or grab the tars with the CLI, then extract:
hf download henryzhou998/OmniScene --repo-type dataset --include "test/*" --local-dir ./omniscene_tars
for f in ./omniscene_tars/test/*.tar; do tar -xf "$f" -C ./omniscene/; doneOmniScene is released for non-commercial research use under CC BY-NC 4.0.
Reproduce the X-Lens depth metrics across all three camera regimes. Each track loads the released
checkpoint with the shipped arch config (configs/xlens_vits.yaml) and runs self-contained —
no training code, no external model repos.
Baselines are not included. The open-source evaluation ships only the X-Lens model. The baseline comparisons in the paper (DAC · UniDAC · MapAnything · VGGT · …) are third-party and are not redistributed here.
| Track | Data | Entry point |
|---|---|---|
| Pinhole | OmniOCC (6-camera) + WAI (ETH3D / ScanNet++)1 | evaluation.eval_pinhole.run_eval |
| Fisheye | OmniScene 4-camera fisheye · KITTI-360 (monocular fisheye) | evaluation.eval_fisheye.{eval_fisheye, eval_kitti360} |
| Heterogeneous | OmniScene 6-camera (4 fisheye + 2 pinhole) | evaluation.eval_heterogeneous.eval_calib_compare |
Each track reads its paths/options from a small YAML — edit the /path/to/... placeholders first.
# Pinhole — OmniOCC + WAI depth
python -m evaluation.eval_pinhole.run_eval \
--config evaluation/eval_pinhole/eval_config.yaml
# Fisheye — 4-camera omni
python -m evaluation.eval_fisheye.eval_fisheye \
--config evaluation/eval_fisheye/eval_fisheye_config.yaml
# Fisheye — KITTI-360 (monocular fisheye, motion-stereo)
python -m evaluation.eval_fisheye.eval_kitti360 \
--stage3_ckpt checkpoints/model.safetensors \
--config configs/xlens_vits.yaml \
--data_root /path/to/KITTI-360
# Heterogeneous — 6-camera (4 fisheye + 2 pinhole)
python -m evaluation.eval_heterogeneous.eval_calib_compare \
--stage3_ckpt checkpoints/model.safetensors \
--config configs/xlens_vits.yaml \
--data_root /path/to/foundationstage3/test \
--lut_dir /path/to/OmniScene/texture # fisheye ray LUTs, shipped with the OmniScene datasetFor the released
.safetensors(weights only) always pass--config configs/xlens_vits.yaml. A.pthcheckpoint embeds its own arch config, which then takes priority.
Reading the metrics. Fisheye and pinhole views are reported separately and pooled (overall):
scale_absrel— unaligned metric-scale error|mean(pred) − mean(gt)| / mean(gt)(absolute metric accuracy).depth_absrel·rmse·δ1@1.25— per-view median-aligned, scale-invariant relative-depth quality.
Can I run a single pinhole image? The model is multi-view; one view should duplicate the view.
Built on DINOv2. WAI dataset handling for the pinhole evaluation follows MapAnything.
@article{zhou2026xlens,
title = {X-Lens: Real-Time Metric Depth Estimation with Heterogeneous Cameras},
author = {Zhou, Heng and Liu, Shuhong and He, Yonghao and Zhang, Bohao and Fu, Fa and
Hou, Chenhui and Hou, Xianbao and Han, Lijun and Yang, Cong and Sui, Wei},
journal = {arXiv preprint arXiv:2607.12993},
year = {2026},
}The X-Lens software is released under the Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0) for non-commercial use only. Commercial use requires separate permission from the copyright holders.
Footnotes
-
WAI data layout and loading follow MapAnything. ↩
