diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000000000000000000000000000000000..ffb4a91088fc5d19581b395bdcc910520b5472c0
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,86 @@
+*.7z filter=lfs diff=lfs merge=lfs -text
+*.arrow filter=lfs diff=lfs merge=lfs -text
+*.bin filter=lfs diff=lfs merge=lfs -text
+*.bz2 filter=lfs diff=lfs merge=lfs -text
+*.ckpt filter=lfs diff=lfs merge=lfs -text
+*.ftz filter=lfs diff=lfs merge=lfs -text
+*.gz filter=lfs diff=lfs merge=lfs -text
+*.h5 filter=lfs diff=lfs merge=lfs -text
+*.joblib filter=lfs diff=lfs merge=lfs -text
+*.lfs.* filter=lfs diff=lfs merge=lfs -text
+*.mlmodel filter=lfs diff=lfs merge=lfs -text
+*.model filter=lfs diff=lfs merge=lfs -text
+*.msgpack filter=lfs diff=lfs merge=lfs -text
+*.npy filter=lfs diff=lfs merge=lfs -text
+*.npz filter=lfs diff=lfs merge=lfs -text
+*.onnx filter=lfs diff=lfs merge=lfs -text
+*.ot filter=lfs diff=lfs merge=lfs -text
+*.parquet filter=lfs diff=lfs merge=lfs -text
+*.pb filter=lfs diff=lfs merge=lfs -text
+*.pickle filter=lfs diff=lfs merge=lfs -text
+*.pkl filter=lfs diff=lfs merge=lfs -text
+*.pt filter=lfs diff=lfs merge=lfs -text
+*.pth filter=lfs diff=lfs merge=lfs -text
+*.rar filter=lfs diff=lfs merge=lfs -text
+*.safetensors filter=lfs diff=lfs merge=lfs -text
+saved_model/**/* filter=lfs diff=lfs merge=lfs -text
+*.tar.* filter=lfs diff=lfs merge=lfs -text
+*.tar filter=lfs diff=lfs merge=lfs -text
+*.tflite filter=lfs diff=lfs merge=lfs -text
+*.tgz filter=lfs diff=lfs merge=lfs -text
+*.wasm filter=lfs diff=lfs merge=lfs -text
+*.xz filter=lfs diff=lfs merge=lfs -text
+*.zip filter=lfs diff=lfs merge=lfs -text
+*.zst filter=lfs diff=lfs merge=lfs -text
+*tfevents* filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/Depth[[:space:]](disparity,[[:space:]]magma)/5e96afc67581e463f100/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/Depth[[:space:]](disparity,[[:space:]]magma)/9220cd48d295ca353a6b/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/Depth[[:space:]](disparity,[[:space:]]magma)/c8838412e5dee9697701/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/Depth[[:space:]](disparity,[[:space:]]magma)/f7095c71dfb49f7e3a2a/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/a2b2ed4415d6c0310a36/cloud_05cfcce5d5e6.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/b72cecf121cf47496c82/cloud_394dafbecc42.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/b8c85f926a8d6d09ee32/cloud_dbf7146aa8d6.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/bb6dcdbd8cf58c73cd2c/cloud_a05855c7716b.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/c3390d29364b0fab5237/cloud_14b821bf5517.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/f90705e1df43a796cd63/cloud_41032084d765.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/RGB[[:space:]]image/02ab28426d5807f6943a/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/RGB[[:space:]]image/179b01cce83461a9a19a/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/RGB[[:space:]]image/1965504ccdd23a0da632/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/RGB[[:space:]]image/3f5824c31471ef256471/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/RGB[[:space:]]image/7ebf7b4b6db056927956/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/30/RGB[[:space:]]image/dba880c3bec4afd1a9ff/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Depth[[:space:]](disparity,[[:space:]]magma)/b8e54b0d5e39f055f164/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Depth[[:space:]](disparity,[[:space:]]magma)/e3b3eed2144fe2f212bb/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/7b2796d4a9ff918e3ff0/cloud_16bed495fda3.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/d21fe0e76f4b13cc03c4/cloud_b91d63f1da9e.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/ff979f9418cc0388e99b/cloud_006af054b659.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/RGB[[:space:]]image/1a6ed5112df1d233314c/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/RGB[[:space:]]image/c14e4a0757ca88e19135/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/RGB[[:space:]]image/e858eac28e2a39b56e43/image.png filter=lfs diff=lfs merge=lfs -text
+assets/examples/alley.png filter=lfs diff=lfs merge=lfs -text
+assets/examples/castle.png filter=lfs diff=lfs merge=lfs -text
+assets/examples/yosemite.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Depth[[:space:]](disparity,[[:space:]]magma)/00514d1d228e1970f9a5/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Depth[[:space:]](disparity,[[:space:]]magma)/282b8de47381433b20ba/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Depth[[:space:]](disparity,[[:space:]]magma)/77eb75a4ae32a2779670/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Depth[[:space:]](disparity,[[:space:]]magma)/b3a0c160552d198bcca2/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/6351a7c3b27ea01e3670/cloud_45fd4adef349.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/a42239d35b0256820e21/cloud_82bf2e710be0.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/a600814ba3ef00eacfeb/cloud_9a8d39949ba7.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/a6b517c351861e68aa8f/cloud_6f14c0ff2da6.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/a93a97926162980d549a/cloud_53c7df1dbc77.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/e208d09a8d563ea27323/cloud_a7b919815dba.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/RGB[[:space:]]image/4763ceeb1dd670d3f927/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/RGB[[:space:]]image/530eb79823e527aed6bf/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/RGB[[:space:]]image/65c25b8debef84e0027b/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/RGB[[:space:]]image/6d51368904340f3a7f23/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/RGB[[:space:]]image/91174cfa8f5a5909a013/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/31/RGB[[:space:]]image/d68b76efe33346efd7e3/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/32/Depth[[:space:]](disparity,[[:space:]]magma)/38d56c7397d95d524055/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/32/Depth[[:space:]](disparity,[[:space:]]magma)/9a36d2e382db4e333742/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/32/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/674110f2aecaaf5effa8/cloud_f9d62ed7d997.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/32/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/85ee5283a423852aa42d/cloud_8a6b0d1109ae.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/32/Interactive[[:space:]]3D[[:space:]]point[[:space:]]cloud/e7f9024213e4656b8beb/cloud_a6225bea9d7e.glb filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/32/RGB[[:space:]]image/3c2c4fa7881bb773646d/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/32/RGB[[:space:]]image/87aaba9c524a915404cd/image.png filter=lfs diff=lfs merge=lfs -text
+.gradio/cached_examples/32/RGB[[:space:]]image/e64a0aff7688d0635057/image.png filter=lfs diff=lfs merge=lfs -text
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..43a69dce9ed4a7f7f555284a201df17237e8487b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+__pycache__/
+*.pyc
+.venv/
+flux_rgbd_outputs/
+*.npy
+artifacts/
diff --git a/.gradio/cached_examples/31/Depth (disparity, magma)/00514d1d228e1970f9a5/image.png b/.gradio/cached_examples/31/Depth (disparity, magma)/00514d1d228e1970f9a5/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..964465199f087af741085e38c2149dff67896ffb
--- /dev/null
+++ b/.gradio/cached_examples/31/Depth (disparity, magma)/00514d1d228e1970f9a5/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:10879b58e6c03fde97b669315c00bf71bdeafb5d918ff9f25ac544564475aeef
+size 363844
diff --git a/.gradio/cached_examples/31/Depth (disparity, magma)/21da509194782c87b704/image.png b/.gradio/cached_examples/31/Depth (disparity, magma)/21da509194782c87b704/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..e4dc90e7889bd1304f88cf1e8007bea4b6be4594
Binary files /dev/null and b/.gradio/cached_examples/31/Depth (disparity, magma)/21da509194782c87b704/image.png differ
diff --git a/.gradio/cached_examples/31/Depth (disparity, magma)/282b8de47381433b20ba/image.png b/.gradio/cached_examples/31/Depth (disparity, magma)/282b8de47381433b20ba/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..cf09bd368319e3dd06775b3475fa6f0ebe925404
--- /dev/null
+++ b/.gradio/cached_examples/31/Depth (disparity, magma)/282b8de47381433b20ba/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9e6f89e921019d1bf9caa044e77785469ba45b2ed21d080f39be97f06cd701dd
+size 156685
diff --git a/.gradio/cached_examples/31/Depth (disparity, magma)/72f252326e89ba602db3/image.png b/.gradio/cached_examples/31/Depth (disparity, magma)/72f252326e89ba602db3/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..4ccd96c0277c0b7e0f5c6f0bc9379b988524c160
Binary files /dev/null and b/.gradio/cached_examples/31/Depth (disparity, magma)/72f252326e89ba602db3/image.png differ
diff --git a/.gradio/cached_examples/31/Depth (disparity, magma)/77eb75a4ae32a2779670/image.png b/.gradio/cached_examples/31/Depth (disparity, magma)/77eb75a4ae32a2779670/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..8f3953818835d9ef1dd3b20397f20e9596c83916
--- /dev/null
+++ b/.gradio/cached_examples/31/Depth (disparity, magma)/77eb75a4ae32a2779670/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4c8916e160ea6d6c6b792075a1f924eacf09840e44baab2df857b3bb04b26463
+size 102698
diff --git a/.gradio/cached_examples/31/Depth (disparity, magma)/b3a0c160552d198bcca2/image.png b/.gradio/cached_examples/31/Depth (disparity, magma)/b3a0c160552d198bcca2/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..63c9325706d159fca99b83d24940c514d7051c82
--- /dev/null
+++ b/.gradio/cached_examples/31/Depth (disparity, magma)/b3a0c160552d198bcca2/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6e6b73e6d887e1d46d16499daa723b390882e9bf839a5bc46177a202b2722687
+size 143670
diff --git a/.gradio/cached_examples/31/Interactive 3D point cloud/6351a7c3b27ea01e3670/cloud_45fd4adef349.glb b/.gradio/cached_examples/31/Interactive 3D point cloud/6351a7c3b27ea01e3670/cloud_45fd4adef349.glb
new file mode 100644
index 0000000000000000000000000000000000000000..42c5fbfa6871cb8dca96ddcec386fca249f57122
--- /dev/null
+++ b/.gradio/cached_examples/31/Interactive 3D point cloud/6351a7c3b27ea01e3670/cloud_45fd4adef349.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:318e55582668f25686e92a4db1b6c5d3f75434d047ffd58c1de526abd12473c4
+size 3068480
diff --git a/.gradio/cached_examples/31/Interactive 3D point cloud/a42239d35b0256820e21/cloud_82bf2e710be0.glb b/.gradio/cached_examples/31/Interactive 3D point cloud/a42239d35b0256820e21/cloud_82bf2e710be0.glb
new file mode 100644
index 0000000000000000000000000000000000000000..587988fc4daf49cd4985f30eb755e135d37b6022
--- /dev/null
+++ b/.gradio/cached_examples/31/Interactive 3D point cloud/a42239d35b0256820e21/cloud_82bf2e710be0.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:da22a39f84033e0f93e395d90715aedd4c15de191ac5740704e8036a3c2238f2
+size 3839248
diff --git a/.gradio/cached_examples/31/Interactive 3D point cloud/a600814ba3ef00eacfeb/cloud_9a8d39949ba7.glb b/.gradio/cached_examples/31/Interactive 3D point cloud/a600814ba3ef00eacfeb/cloud_9a8d39949ba7.glb
new file mode 100644
index 0000000000000000000000000000000000000000..db70130baccbe10c33545c5ba762b5d8044c7264
--- /dev/null
+++ b/.gradio/cached_examples/31/Interactive 3D point cloud/a600814ba3ef00eacfeb/cloud_9a8d39949ba7.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c9c9c01e77f9425692ccb77dcacfd10690f42aa2f59f5086df11e313f98bca4a
+size 1557100
diff --git a/.gradio/cached_examples/31/Interactive 3D point cloud/a6b517c351861e68aa8f/cloud_6f14c0ff2da6.glb b/.gradio/cached_examples/31/Interactive 3D point cloud/a6b517c351861e68aa8f/cloud_6f14c0ff2da6.glb
new file mode 100644
index 0000000000000000000000000000000000000000..c74ddb2893de5051c3b5ca7ee8b5f689f8050ab2
--- /dev/null
+++ b/.gradio/cached_examples/31/Interactive 3D point cloud/a6b517c351861e68aa8f/cloud_6f14c0ff2da6.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e8143c842b204228b93431b2c937d2e531d477854a1177c4a23076d027ae63e7
+size 4057104
diff --git a/.gradio/cached_examples/31/Interactive 3D point cloud/a93a97926162980d549a/cloud_53c7df1dbc77.glb b/.gradio/cached_examples/31/Interactive 3D point cloud/a93a97926162980d549a/cloud_53c7df1dbc77.glb
new file mode 100644
index 0000000000000000000000000000000000000000..bea89da5e58b0385ccc550df2ac9d582dc4dd111
--- /dev/null
+++ b/.gradio/cached_examples/31/Interactive 3D point cloud/a93a97926162980d549a/cloud_53c7df1dbc77.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6f7e2b84bf61c50398f8fdb3b0f84ae218d5d3991c268f96f3b6184bf02a021d
+size 4050768
diff --git a/.gradio/cached_examples/31/Interactive 3D point cloud/e208d09a8d563ea27323/cloud_a7b919815dba.glb b/.gradio/cached_examples/31/Interactive 3D point cloud/e208d09a8d563ea27323/cloud_a7b919815dba.glb
new file mode 100644
index 0000000000000000000000000000000000000000..4f2c151a05e52515afa2f26af30d5eedcd8ef451
--- /dev/null
+++ b/.gradio/cached_examples/31/Interactive 3D point cloud/e208d09a8d563ea27323/cloud_a7b919815dba.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a2e5928c97bf15bc99c66232dbcd8fd09b9dcb5401ad945409c5e08694314b5d
+size 3992720
diff --git a/.gradio/cached_examples/31/RGB image/4763ceeb1dd670d3f927/image.png b/.gradio/cached_examples/31/RGB image/4763ceeb1dd670d3f927/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..3e023fa7ab22edf4d1b19c8219ab6ed7a896bfbc
--- /dev/null
+++ b/.gradio/cached_examples/31/RGB image/4763ceeb1dd670d3f927/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f216cef36f008e9aebbd1a42fd3e8a2ea7f750120dd7da53c73e29603095cbbb
+size 431043
diff --git a/.gradio/cached_examples/31/RGB image/530eb79823e527aed6bf/image.png b/.gradio/cached_examples/31/RGB image/530eb79823e527aed6bf/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..bf9dcc8b37e78d350d7c6e057862f62be8c3b148
--- /dev/null
+++ b/.gradio/cached_examples/31/RGB image/530eb79823e527aed6bf/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d3703a4031f55b4fccaa71f38375896c2f165c8932ac37064bd8ac81ce46ce76
+size 467113
diff --git a/.gradio/cached_examples/31/RGB image/65c25b8debef84e0027b/image.png b/.gradio/cached_examples/31/RGB image/65c25b8debef84e0027b/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..914537fadfe4d0ea42b3f2914fda66a4fa044161
--- /dev/null
+++ b/.gradio/cached_examples/31/RGB image/65c25b8debef84e0027b/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:24647b9483e60b0f5f38fc1bf2d47e8504ab9d03484817a58a2ee869d4559c59
+size 374322
diff --git a/.gradio/cached_examples/31/RGB image/6d51368904340f3a7f23/image.png b/.gradio/cached_examples/31/RGB image/6d51368904340f3a7f23/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..b222301d7409670677108a484ef058c6aa8f1cc3
--- /dev/null
+++ b/.gradio/cached_examples/31/RGB image/6d51368904340f3a7f23/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4fc5d7fd792da9837b7e716cd6b35e01c628db0056eeb5fe807f1c93c87e8a51
+size 471512
diff --git a/.gradio/cached_examples/31/RGB image/91174cfa8f5a5909a013/image.png b/.gradio/cached_examples/31/RGB image/91174cfa8f5a5909a013/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..a6f8b4be199a692bb2e7ec0bb557a0c7c5ef1c3d
--- /dev/null
+++ b/.gradio/cached_examples/31/RGB image/91174cfa8f5a5909a013/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6ce3a9428a12cf898421b3ab2f1cbed13e7cb87874ebf238845111ca32293fdc
+size 331712
diff --git a/.gradio/cached_examples/31/RGB image/d68b76efe33346efd7e3/image.png b/.gradio/cached_examples/31/RGB image/d68b76efe33346efd7e3/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..754d4590fe8d16c16f4f61007c43a140c0ab26c3
--- /dev/null
+++ b/.gradio/cached_examples/31/RGB image/d68b76efe33346efd7e3/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7f5bd27a66d93922348c7782de1dc9c0a1b700591e30e8549a0c683bc435b5a1
+size 410039
diff --git a/.gradio/cached_examples/31/indices.csv b/.gradio/cached_examples/31/indices.csv
new file mode 100644
index 0000000000000000000000000000000000000000..e8371f00609f33a59378dd2f6bb4385a7df8bd63
--- /dev/null
+++ b/.gradio/cached_examples/31/indices.csv
@@ -0,0 +1,6 @@
+0
+1
+2
+3
+4
+5
diff --git a/.gradio/cached_examples/31/log.csv b/.gradio/cached_examples/31/log.csv
new file mode 100644
index 0000000000000000000000000000000000000000..a6f173c96e50d6160be62b9aafee787673362702
--- /dev/null
+++ b/.gradio/cached_examples/31/log.csv
@@ -0,0 +1,7 @@
+RGB image,"Depth (disparity, magma)",Interactive 3D point cloud,Status,timestamp
+"{""path"": "".gradio/cached_examples/31/RGB image/91174cfa8f5a5909a013/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/dfd5ecf1e795fbfd7f794edd94c8f06e7dc0f0008a15814286a7a11b0e3bda0a/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/31/Depth (disparity, magma)/72f252326e89ba602db3/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/7111df1c084d7d80e87ed0ba942ef806ebb82b0ea3358242a9471e87ed025aca/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/31/Interactive 3D point cloud/e208d09a8d563ea27323/cloud_a7b919815dba.glb"", ""url"": ""/gradio_api/file=/tmp/gradio/b7842ce128885af0de98f62956ea71483eb3ca75a5af89df81202f0c19de2502/cloud_a7b919815dba.glb"", ""size"": null, ""orig_name"": ""cloud_a7b919815dba.glb"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","joint · 17.0 s · depth median=1.06 p5=0.55 p95=1.39 · 249,485 points",2026-06-12 07:14:17.591889
+"{""path"": "".gradio/cached_examples/31/RGB image/d68b76efe33346efd7e3/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/db51824edbde88fd4de0749ffb077c98b27d9ee96017d42db904e2c763579606/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/31/Depth (disparity, magma)/21da509194782c87b704/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/e3e01501ae08e1438d11238cf43d427406c012cf0b287f1820975b5cc461f224/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/31/Interactive 3D point cloud/a93a97926162980d549a/cloud_53c7df1dbc77.glb"", ""url"": ""/gradio_api/file=/tmp/gradio/96342e925c3ba767021690b60f3715015db850198dccd2fd3e5245468dbcafee/cloud_53c7df1dbc77.glb"", ""size"": null, ""orig_name"": ""cloud_53c7df1dbc77.glb"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","joint · 14.8 s · depth median=1.04 p5=0.57 p95=1.37 · 253,113 points",2026-06-12 07:14:32.666816
+"{""path"": "".gradio/cached_examples/31/RGB image/6d51368904340f3a7f23/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/9919ff6eef6ba9aa87da068492e8a80dd0e5f62f7f27dcc20cb3de5e975ef2b0/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/31/Depth (disparity, magma)/00514d1d228e1970f9a5/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/0ffd5b599e783b31637214af5bfb99c6fea27cce8df77021893d3f63e9e1f5aa/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/31/Interactive 3D point cloud/a600814ba3ef00eacfeb/cloud_9a8d39949ba7.glb"", ""url"": ""/gradio_api/file=/tmp/gradio/3e1f8dedd7baf5f8977d2f5e7488413ca6499045d5939ee6371c86fb2b039e54/cloud_9a8d39949ba7.glb"", ""size"": null, ""orig_name"": ""cloud_9a8d39949ba7.glb"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","joint · 14.9 s · depth median=0.84 p5=0.37 p95=2.21 · 97,259 points",2026-06-12 07:14:47.729293
+"{""path"": "".gradio/cached_examples/31/RGB image/530eb79823e527aed6bf/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/0bbd8ccaaeccd0e353685be2f1146bbbdd4e2a42d5350fe13bd182a27f505a61/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/31/Depth (disparity, magma)/b3a0c160552d198bcca2/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/91abce7d476b85c56d457cb3526e279dbf126ff0d36d266cbde7b0ba97d0714f/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/31/Interactive 3D point cloud/a42239d35b0256820e21/cloud_82bf2e710be0.glb"", ""url"": ""/gradio_api/file=/tmp/gradio/fff39c18ed5428d50126dee01f175fe9339a93dedb01c226549e19234c3a18d3/cloud_82bf2e710be0.glb"", ""size"": null, ""orig_name"": ""cloud_82bf2e710be0.glb"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","joint · 14.9 s · depth median=0.98 p5=0.65 p95=1.38 · 239,893 points",2026-06-12 07:15:02.835368
+"{""path"": "".gradio/cached_examples/31/RGB image/65c25b8debef84e0027b/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/eed4a59e42c6c0c9e9cd120b9f5900522c0f4006c54aec5e3466300cce5f5c3d/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/31/Depth (disparity, magma)/77eb75a4ae32a2779670/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/c9434cd1a2b855cdf7615290ea2d29c8c5418e0793a3cd859e55150891bc495a/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/31/Interactive 3D point cloud/a6b517c351861e68aa8f/cloud_6f14c0ff2da6.glb"", ""url"": ""/gradio_api/file=/tmp/gradio/3fe22788a5f8d0a6d274d5b367522db4306e5dca95dbfe1bd1cfb1c2f1c4c6f0/cloud_6f14c0ff2da6.glb"", ""size"": null, ""orig_name"": ""cloud_6f14c0ff2da6.glb"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","joint · 14.8 s · depth median=0.90 p5=0.63 p95=1.65 · 253,509 points",2026-06-12 07:15:17.944777
+"{""path"": "".gradio/cached_examples/31/RGB image/4763ceeb1dd670d3f927/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/71b22a185a03aa169cdca016381da0b4179ed403b377b7fe4cb005c97797b8ee/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/31/Depth (disparity, magma)/282b8de47381433b20ba/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/9ef7335cc9a72e4364a920bf4431a69841eff7326a683f601376302cc1ddaa24/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/31/Interactive 3D point cloud/6351a7c3b27ea01e3670/cloud_45fd4adef349.glb"", ""url"": ""/gradio_api/file=/tmp/gradio/92aa7dea6a27cd503ba5c2235856b6e28f283700cbfc51764f794167491464e4/cloud_45fd4adef349.glb"", ""size"": null, ""orig_name"": ""cloud_45fd4adef349.glb"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","joint · 14.8 s · depth median=0.82 p5=0.07 p95=2.22 · 191,720 points",2026-06-12 07:15:32.973544
diff --git a/.gradio/cached_examples/32/Depth (disparity, magma)/0891e8543770d0db2151/image.png b/.gradio/cached_examples/32/Depth (disparity, magma)/0891e8543770d0db2151/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..795590d1a39d6afb7b2b5aab380300deaebe2100
Binary files /dev/null and b/.gradio/cached_examples/32/Depth (disparity, magma)/0891e8543770d0db2151/image.png differ
diff --git a/.gradio/cached_examples/32/Depth (disparity, magma)/38d56c7397d95d524055/image.png b/.gradio/cached_examples/32/Depth (disparity, magma)/38d56c7397d95d524055/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..5a647e817008976689c4be61b1dbcbbc80f85c6a
--- /dev/null
+++ b/.gradio/cached_examples/32/Depth (disparity, magma)/38d56c7397d95d524055/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:01e1fef5b2755c3e03dddc46962f991d6afe86ba360bbac4f5cf6938a3a1b0f1
+size 108445
diff --git a/.gradio/cached_examples/32/Depth (disparity, magma)/9a36d2e382db4e333742/image.png b/.gradio/cached_examples/32/Depth (disparity, magma)/9a36d2e382db4e333742/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..29dfc9860404d34e4c23cc32bd13aa0b546a9daf
--- /dev/null
+++ b/.gradio/cached_examples/32/Depth (disparity, magma)/9a36d2e382db4e333742/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5f4d00494d4feaa228b35900730ea1dfbd407f72bdfe494c606c42145935557f
+size 180680
diff --git a/.gradio/cached_examples/32/Interactive 3D point cloud/674110f2aecaaf5effa8/cloud_f9d62ed7d997.glb b/.gradio/cached_examples/32/Interactive 3D point cloud/674110f2aecaaf5effa8/cloud_f9d62ed7d997.glb
new file mode 100644
index 0000000000000000000000000000000000000000..a5d0819b3cd3d4b990f88cd7b5ada8acd5e1fd11
--- /dev/null
+++ b/.gradio/cached_examples/32/Interactive 3D point cloud/674110f2aecaaf5effa8/cloud_f9d62ed7d997.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c5d3183aec16883d35c85a8d6826d8c437cb1630c9d10ff08a5f67116dd3fd65
+size 3730060
diff --git a/.gradio/cached_examples/32/Interactive 3D point cloud/85ee5283a423852aa42d/cloud_8a6b0d1109ae.glb b/.gradio/cached_examples/32/Interactive 3D point cloud/85ee5283a423852aa42d/cloud_8a6b0d1109ae.glb
new file mode 100644
index 0000000000000000000000000000000000000000..c93d24ff777c259eeac61fde73600ac60b767c73
--- /dev/null
+++ b/.gradio/cached_examples/32/Interactive 3D point cloud/85ee5283a423852aa42d/cloud_8a6b0d1109ae.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:98a8873b3e5893e5a7dc8afdaca3c8f92484cc6981f493bb834bfed6ca019caf
+size 1768556
diff --git a/.gradio/cached_examples/32/Interactive 3D point cloud/e7f9024213e4656b8beb/cloud_a6225bea9d7e.glb b/.gradio/cached_examples/32/Interactive 3D point cloud/e7f9024213e4656b8beb/cloud_a6225bea9d7e.glb
new file mode 100644
index 0000000000000000000000000000000000000000..62a58c6db1544ab1002b7eb7acf538f9b10ac77a
--- /dev/null
+++ b/.gradio/cached_examples/32/Interactive 3D point cloud/e7f9024213e4656b8beb/cloud_a6225bea9d7e.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4ca71c04f12756b0bfb26002b18e7221bf22ea36a5e23824a0a01cf753806f9e
+size 3742080
diff --git a/.gradio/cached_examples/32/RGB image/3c2c4fa7881bb773646d/image.png b/.gradio/cached_examples/32/RGB image/3c2c4fa7881bb773646d/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..0b75be4f7b9ea136d47f9ab4458ac26a78e5cea7
--- /dev/null
+++ b/.gradio/cached_examples/32/RGB image/3c2c4fa7881bb773646d/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a125ca90ef87e753901fde94cd0befefb05c4cff1c4c856ade115aa91327b8f1
+size 434275
diff --git a/.gradio/cached_examples/32/RGB image/87aaba9c524a915404cd/image.png b/.gradio/cached_examples/32/RGB image/87aaba9c524a915404cd/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..8d4b3f013a3e1eeed20521b6db6ab359f8dd2f8b
--- /dev/null
+++ b/.gradio/cached_examples/32/RGB image/87aaba9c524a915404cd/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f66d7a4e1eaed6bfa4746a058decca0362b186f6fa43ecd6e4b2940640a1d227
+size 468533
diff --git a/.gradio/cached_examples/32/RGB image/e64a0aff7688d0635057/image.png b/.gradio/cached_examples/32/RGB image/e64a0aff7688d0635057/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..37811600f27cd6f01b5484d8a8e7dbaa9d6e1d3d
--- /dev/null
+++ b/.gradio/cached_examples/32/RGB image/e64a0aff7688d0635057/image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f6f5edfd3b450ddb42a83bccbb9ecbecb9c6125f34e8863f89066b2f0a5b52b8
+size 371582
diff --git a/.gradio/cached_examples/32/indices.csv b/.gradio/cached_examples/32/indices.csv
new file mode 100644
index 0000000000000000000000000000000000000000..4539bbf2d22d5c3b4227859ff854eda060a687fb
--- /dev/null
+++ b/.gradio/cached_examples/32/indices.csv
@@ -0,0 +1,3 @@
+0
+1
+2
diff --git a/.gradio/cached_examples/32/log.csv b/.gradio/cached_examples/32/log.csv
new file mode 100644
index 0000000000000000000000000000000000000000..e6b182b6ca7abc8204d20fc561e21bc5741c3c30
--- /dev/null
+++ b/.gradio/cached_examples/32/log.csv
@@ -0,0 +1,4 @@
+RGB image,"Depth (disparity, magma)",Interactive 3D point cloud,Status,timestamp
+"{""path"": "".gradio/cached_examples/32/RGB image/3c2c4fa7881bb773646d/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/1ed5fba88325554dd8fde55f43d90e4b52d96bd770ae9abb55558a721d2ad4dc/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/32/Depth (disparity, magma)/38d56c7397d95d524055/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/675d608d8081b140ee360ac99a11615574a95a1f575dd55ceb8941f405daac0f/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/32/Interactive 3D point cloud/674110f2aecaaf5effa8/cloud_f9d62ed7d997.glb"", ""url"": ""/gradio_api/file=/tmp/gradio/6396a2958022a63007f08ff0882cd34405d96b6cee56b1a69444e1ef08649811/cloud_f9d62ed7d997.glb"", ""size"": null, ""orig_name"": ""cloud_f9d62ed7d997.glb"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","i2d · 5.4 s · depth median=0.65 p5=0.37 p95=3.31 · 233,069 points",2026-06-12 07:15:38.629118
+"{""path"": "".gradio/cached_examples/32/RGB image/e64a0aff7688d0635057/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/3a7962dcf7f6b1d7db2e1a0e7a625daecd56aebbac87a934bd805b05c026ee6c/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/32/Depth (disparity, magma)/9a36d2e382db4e333742/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/647b0d2a6553dedf70711720e7a0dd7c2d0c1778bbbc29c066ebbdb74137ba43/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/32/Interactive 3D point cloud/85ee5283a423852aa42d/cloud_8a6b0d1109ae.glb"", ""url"": ""/gradio_api/file=/tmp/gradio/9b0b10012948a25b48290aefa846f9ddb57792cae0bccf98ead298a0a18baf6e/cloud_8a6b0d1109ae.glb"", ""size"": null, ""orig_name"": ""cloud_8a6b0d1109ae.glb"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","i2d · 5.0 s · depth median=1.18 p5=0.36 p95=2.33 · 110,475 points",2026-06-12 07:15:43.778321
+"{""path"": "".gradio/cached_examples/32/RGB image/87aaba9c524a915404cd/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/782693cd71c502c69e559adc17a82d06dc75ece451c4745b286f74f8be98284d/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/32/Depth (disparity, magma)/0891e8543770d0db2151/image.png"", ""url"": ""/gradio_api/file=/tmp/gradio/112070edef52867273e2552aa71313e0447940a6eda5b9215a5202cc82c501a2/image.png"", ""size"": null, ""orig_name"": ""image.png"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","{""path"": "".gradio/cached_examples/32/Interactive 3D point cloud/e7f9024213e4656b8beb/cloud_a6225bea9d7e.glb"", ""url"": ""/gradio_api/file=/tmp/gradio/bd563503321e9ee362fcadf84021c707d2ddfb1ae60357b595f562a830465900/cloud_a6225bea9d7e.glb"", ""size"": null, ""orig_name"": ""cloud_a6225bea9d7e.glb"", ""mime_type"": null, ""is_stream"": false, ""meta"": {""_type"": ""gradio.FileData""}}","i2d · 5.0 s · depth median=0.72 p5=0.08 p95=2.41 · 233,820 points",2026-06-12 07:15:49.021708
diff --git a/.ruff_cache/.gitignore b/.ruff_cache/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..b100327770759f0efb7859fe78aca4801d69ed61
--- /dev/null
+++ b/.ruff_cache/.gitignore
@@ -0,0 +1,2 @@
+# Automatically created by ruff.
+*
diff --git a/.ruff_cache/0.5.5/12279076348749595670 b/.ruff_cache/0.5.5/12279076348749595670
new file mode 100644
index 0000000000000000000000000000000000000000..84d4c0a41525257d39825df887c3ebb5ec65b08e
Binary files /dev/null and b/.ruff_cache/0.5.5/12279076348749595670 differ
diff --git a/.ruff_cache/CACHEDIR.TAG b/.ruff_cache/CACHEDIR.TAG
new file mode 100644
index 0000000000000000000000000000000000000000..bc1ecb967a482524e7736038de0df6e08f9ee452
--- /dev/null
+++ b/.ruff_cache/CACHEDIR.TAG
@@ -0,0 +1 @@
+Signature: 8a477f597d28d172789f06886806bc55
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..bf06034a40fdea706a8d1d1f6231f3d06461107b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2026 World Labs
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/LICENSE-WEIGHTS b/LICENSE-WEIGHTS
new file mode 100644
index 0000000000000000000000000000000000000000..5522c139389eae5700e9db3da2a99853f23af0f0
--- /dev/null
+++ b/LICENSE-WEIGHTS
@@ -0,0 +1,14 @@
+Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)
+
+Copyright (c) 2026 World Labs.
+
+The MODEL WEIGHTS distributed for this project (e.g. via the Hugging Face
+repository bartduis/modality_forcing) are licensed under the Creative Commons
+Attribution-NonCommercial 4.0 International License. You are free to share and
+adapt the weights for non-commercial purposes, provided you give appropriate
+credit. THE WEIGHTS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
+
+Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode
+
+The CODE in this repository is licensed separately under Apache-2.0 (see
+LICENSE).
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..613443ae67f8c45aa9a768fa0b8e66e531f338b4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,99 @@
+---
+title: Modality Forcing
+emoji: 🏢
+colorFrom: blue
+colorTo: gray
+sdk: gradio
+sdk_version: 6.16.0
+python_version: '3.12'
+app_file: app.py
+hardware: zero-h200
+pinned: false
+license: apache-2.0
+short_description: Text → RGB + depth + 3D point cloud.
+---
+
+
inside gr.HTML. */
+.mf-pub, .mf-pub *, .mf-intro, .mf-intro * { text-align: center !important; }
+.mf-pub { margin: 0.25rem auto 0.25rem; }
+.mf-pub-title {
+ font-weight: 600;
+ font-size: clamp(1.9rem, 4.2vw, 3rem);
+ line-height: 1.13; letter-spacing: -0.01em;
+ color: #363636; margin: 0 auto 0.7em; max-width: 900px;
+}
+.mf-authors {
+ font-size: clamp(1rem, 1.4vw, 1.25rem); line-height: 1.5;
+ color: #363636; margin: 0 auto;
+}
+.mf-authors a { color: #3273dc !important; text-decoration: none !important; }
+.mf-authors a:hover { text-decoration: underline !important; }
+.mf-authors .ab { white-space: nowrap; margin: 0 0.15em; }
+.mf-affil {
+ font-size: clamp(0.95rem, 1.3vw, 1.2rem); color: #363636;
+ margin: 0.5em auto 0;
+}
+.mf-affil .ab { margin: 0 0.6em; }
+.mf-logos {
+ display: flex; justify-content: center; align-items: center;
+ gap: 40px; flex-wrap: wrap; margin: 1.4em auto 0;
+}
+.mf-logos img { height: 78px; }
+.mf-venue { font-weight: 700; color: #363636; margin: 1em auto 0; }
+.mf-links {
+ display: flex; justify-content: center; gap: 12px;
+ flex-wrap: wrap; margin: 1.1em auto 0.25rem;
+}
+.mf-btn {
+ display: inline-flex; align-items: center; gap: 7px;
+ padding: 7px 18px; border-radius: 9999px;
+ background: #363636; color: #ffffff !important;
+ font-size: 14px; font-weight: 500; text-decoration: none !important;
+ transition: background .15s ease;
+}
+.mf-btn:hover { background: #4a4a4a; }
+.mf-pub-sub {
+ font-size: clamp(1rem, 1.3vw, 1.2rem); line-height: 1.5;
+ color: #4a4a4a; max-width: 720px; margin: 1.2em auto 0; font-weight: 400;
+}
+
+/* ---- Quiet helper line + section eyebrows ---- */
+.mf-intro {
+ text-align: center; max-width: 640px; margin: 0.25rem auto 1rem;
+ font-size: 14px; line-height: 1.6; color: #6b6b6b;
+}
+.mf-intro b { color: #111111; font-weight: 600; }
+.mf-sec {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 11px; font-weight: 500; letter-spacing: 0.18em;
+ text-transform: uppercase; color: #9a9a9a; margin: 4px 2px 2px;
+}
+
+/* ---- Primary button: full pill, ink-black ---- */
+.gradio-container button.primary,
+.gradio-container button.lg.primary {
+ background: #111111 !important; color: #ffffff !important;
+ border: 1px solid #111111 !important; border-radius: 9999px !important;
+ font-weight: 500 !important; letter-spacing: -0.005em !important;
+}
+.gradio-container button.primary:hover { background: #1f1f1f !important; }
+
+/* ---- Examples: borderless, quiet ---- */
+.gradio-container .examples table,
+.gradio-container .examples .tr-head { border: 0 !important; }
+.gradio-container .examples td {
+ border-color: rgba(0,0,0,0.06) !important; font-size: 13px !important;
+}
+
+/* ---- Footer ---- */
+.mf-footer {
+ text-align: center; margin-top: 2rem; padding-top: 1.1rem;
+ border-top: 1px solid rgba(0,0,0,0.08);
+}
+.mf-footer .mf-cta { font-size: 14px; color: #3d3d3d; }
+.mf-footer .mf-cta a {
+ color: #111111 !important; text-decoration: none !important;
+ border-bottom: 1px solid rgba(0,0,0,0.25);
+}
+.mf-footer .mf-credit {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
+ color: #9a9a9a; margin-top: 6px;
+}
+"""
+
+_HEADER_HTML = (
+ '
'
+ '
Modality Forcing for Scalable Spatial Generation
'
+ '
'
+ '
'
+ '1Carnegie Mellon University'
+ '2World Labs'
+ '
'
+ '
'
+ '

'
+ '

'
+ '
'
+ '
Preprint, 2026
'
+ '
'
+ '
Modality '
+ 'Forcing turns a pretrained text-to-image diffusion transformer into a '
+ 'joint image-depth generator with a simple post-training recipe.
'
+ '
'
+)
+
+_INTRO_HTML = (
+ '
Type a scene and press Generate, or upload an '
+ 'image to run image→depth mode instead.
'
+)
+
+_EXAMPLE_IMAGES = [
+ ["assets/examples/alley.png"],
+ ["assets/examples/yosemite.png"],
+ ["assets/examples/castle.png"],
+]
+
+_EXAMPLE_PROMPTS = [
+ [DEFAULT_PROMPT],
+ ["A sunlit Scandinavian living room with a linen sofa, a low oak coffee "
+ "table, and tall windows opening onto a snowy courtyard."],
+ ["A misty pine forest at dawn, shafts of golden light cutting between the "
+ "trunks and a narrow dirt trail winding into the distance."],
+ ["A cozy bookshop interior with floor-to-ceiling wooden shelves, a rolling "
+ "ladder, warm pendant lighting, and a worn leather reading chair."],
+ ["A still life on a marble countertop: a bowl of ripe lemons, a ceramic "
+ "pitcher, and a sprig of rosemary lit by soft side light."],
+ ["A coastal cliffside at golden hour overlooking a turquoise bay, with wild "
+ "grass in the foreground and distant sailboats on the water."],
+]
+
+with gr.Blocks(title="Modality Forcing — World Labs") as demo:
+ gr.HTML(_HEADER_HTML, elem_classes="mf-bare")
+ gr.HTML(_INTRO_HTML, elem_classes="mf-bare")
+ gr.HTML('
', elem_classes="mf-bare")
+
+ with gr.Row(equal_height=False):
+ with gr.Column(scale=2, min_width=320) as left_col:
+ gr.HTML('
Input
', elem_classes="mf-bare")
+ prompt = gr.Textbox(
+ value=DEFAULT_PROMPT,
+ lines=4,
+ label="Scene prompt",
+ placeholder="Describe a scene to generate…",
+ )
+ input_image = gr.Image(
+ label="Optional input image (switches to image→depth mode)",
+ type="numpy", height=200, sources=("upload", "clipboard"),
+ )
+ btn = gr.Button("Generate", variant="primary", size="lg")
+
+ with gr.Accordion("Advanced settings", open=False):
+ with gr.Row():
+ num_steps = gr.Slider(
+ 1, 80, value=50, step=1, label="Sampling steps")
+ cfg_scale = gr.Slider(
+ 1.0, 8.0, value=4.0, step=0.1,
+ label="Guidance (CFG) — text mode only")
+ log2_alpha = gr.Slider(
+ -5.0, 5.0, value=5.0, step=0.5,
+ label="log2(alpha) — depth trajectory (>0 rgb-first, "
+ "cleaner depth; 0 diagonal; <0 depth-first) — text mode")
+ edge_rtol = gr.Slider(
+ 0.0, 0.25, value=0.04, step=0.005,
+ label="Point-cloud depth-edge mask (rtol) — drop pixels at "
+ "depth jumps > this; lower = more aggressive, 0 = off")
+ sor_toggle = gr.Checkbox(
+ value=False,
+ label="Statistical outlier removal (point cloud) — drops "
+ "isolated floaters; can over-trim fine structures")
+ seed = gr.Number(value=0, precision=0, label="Seed")
+ refine_depth = gr.Checkbox(
+ value=True,
+ label="Refine depth: joint (CFG) → image→depth (CFG 1) "
+ "— text mode only",
+ )
+
+ status = gr.Textbox(label="Status", interactive=False)
+
+ with gr.Column(scale=3, min_width=380):
+ gr.HTML('
Output
', elem_classes="mf-bare")
+ with gr.Row():
+ rgb_out = gr.Image(
+ label="RGB image", type="numpy", height=320, format="png")
+ depth_out = gr.Image(
+ label="Depth (disparity, magma)",
+ type="numpy", height=320, format="png")
+ with gr.Group():
+ cloud_out = gr.Model3D(
+ label="Interactive 3D point cloud",
+ clear_color=(0.12, 0.12, 0.14, 1.0),
+ zoom_speed=0.5, pan_speed=0.5,
+ height=520,
+ )
+
+ def _run_text_example(prompt_text):
+ # Cached example runs pin the UI defaults so the cache stays valid.
+ return generate(prompt_text, None, 50, 4.0, 0, True, 5.0, 0.04, False)
+
+ def _run_image_example(image):
+ return generate("", image, 50, 4.0, 0, True, 5.0, 0.04, False)
+
+ # Defined after the output components exist, rendered back into the left
+ # column. Cached: clicking an example serves precomputed results instead
+ # of spending GPU time ("lazy" = no cache rebuild at startup; the
+ # committed cache ships with the Space repo).
+ _CACHE_MODE = os.environ.get("EXAMPLES_CACHE_MODE", "lazy")
+ with left_col:
+ gr.Examples(
+ examples=_EXAMPLE_PROMPTS,
+ inputs=[prompt],
+ outputs=[rgb_out, depth_out, cloud_out, status],
+ fn=_run_text_example,
+ cache_examples=True,
+ cache_mode=_CACHE_MODE,
+ label="Example prompts",
+ )
+ gr.Examples(
+ examples=_EXAMPLE_IMAGES,
+ inputs=[input_image],
+ outputs=[rgb_out, depth_out, cloud_out, status],
+ fn=_run_image_example,
+ cache_examples=True,
+ cache_mode=_CACHE_MODE,
+ label="Example images (image → depth)",
+ )
+
+ gr.HTML(
+ '',
+ elem_classes="mf-bare",
+ )
+
+ btn.click(
+ generate,
+ inputs=[prompt, input_image, num_steps, cfg_scale, seed, refine_depth,
+ log2_alpha, edge_rtol, sor_toggle],
+ outputs=[rgb_out, depth_out, cloud_out, status],
+ )
+
+ # Adding an image switches the demo into image→depth mode, where the prompt
+ # is optional — clear it so it defaults to empty. The user can retype one.
+ input_image.change(
+ lambda img: "" if img is not None else gr.update(),
+ inputs=[input_image],
+ outputs=[prompt],
+ )
+
+# The UI is designed light-only (paper-white cards, explicit light CSS). A
+# visitor whose OS is in dark mode otherwise gets gradio's dark text colors on
+# our light backgrounds — an unreadable mix. Redirect to ?__theme=light in
+# , before gradio boots, so every visitor gets the light theme.
+_FORCE_LIGHT_HEAD = """
+
+"""
+
+if __name__ == "__main__":
+ # Gradio 6 takes theme + css on launch() (not the Blocks constructor),
+ # so they must be passed here to actually apply on the Space.
+ demo.queue(max_size=4).launch(theme=_THEME, css=_CSS, head=_FORCE_LIGHT_HEAD)
diff --git a/assets/examples/alley.png b/assets/examples/alley.png
new file mode 100644
index 0000000000000000000000000000000000000000..bafd83e56c8ae253273e665b6a7da93b6f827467
--- /dev/null
+++ b/assets/examples/alley.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:12d47280f10a0f7e07077ddb595d18d3636e6ef3cbe63ccf281f193aca7f55e3
+size 1030212
diff --git a/assets/examples/castle.png b/assets/examples/castle.png
new file mode 100644
index 0000000000000000000000000000000000000000..3e594400b074b96a99b85ec56b1944102b1b05b0
--- /dev/null
+++ b/assets/examples/castle.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d94240486e851197441a2b63b02ee70eeab7d2e607d3568e34741c54ca55b94b
+size 1900625
diff --git a/assets/examples/yosemite.png b/assets/examples/yosemite.png
new file mode 100644
index 0000000000000000000000000000000000000000..6d12640ae253c83c3994b156433066dcaa584071
--- /dev/null
+++ b/assets/examples/yosemite.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:edeb3066dd0ac94309cb8ab2372c4b2c3474847cacf2055821dcde036faeca45
+size 1657947
diff --git a/flux_rgbd/__init__.py b/flux_rgbd/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..a8c9d585192a6562cce6e514255121592fb85df9
--- /dev/null
+++ b/flux_rgbd/__init__.py
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) 2026 World Labs.
+"""flux_rgbd — joint text → RGB + depth diffusion on top of FLUX.2."""
+
+__version__ = "0.1.0"
+__all__ = ["FluxRGBDRunner"]
+
+
+def __getattr__(name):
+ if name == "FluxRGBDRunner":
+ from flux_rgbd.runner import FluxRGBDRunner
+ return FluxRGBDRunner
+ raise AttributeError(name)
diff --git a/flux_rgbd/_flux2/__init__.py b/flux_rgbd/_flux2/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..ab58edae3e390e9e4d544a88b238b6c6a6bf1cb9
--- /dev/null
+++ b/flux_rgbd/_flux2/__init__.py
@@ -0,0 +1,11 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 Black Forest Labs.
+"""Vendored FLUX.2 reference implementation.
+
+This subpackage holds the unmodified FLUX.2 architecture (Apache-2.0,
+Black Forest Labs upstream). Do not edit these files; they are
+upstream-tracking. All modifications and extensions for RGB+D inference
+live in the parent ``flux_rgbd`` package.
+
+Upstream: https://github.com/black-forest-labs/flux2
+"""
diff --git a/flux_rgbd/_flux2/autoencoder.py b/flux_rgbd/_flux2/autoencoder.py
new file mode 100644
index 0000000000000000000000000000000000000000..9665928ad0be977356a92b9cb71e7f783182750f
--- /dev/null
+++ b/flux_rgbd/_flux2/autoencoder.py
@@ -0,0 +1,499 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 Black Forest Labs.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""FLUX.2 autoencoder.
+
+Adapted from the FLUX.2 codebase:
+ https://github.com/black-forest-labs/flux2
+"""
+
+import math
+from collections.abc import Sequence
+
+import torch
+from einops import rearrange
+from jaxtyping import Float
+from safetensors.torch import load_file as load_safetensors
+from torch import Tensor, nn
+
+__all__ = ["Flux2Encoder", "Flux2Decoder"]
+
+# FLUX.2-dev autoencoder weights, pre-split into encoder + decoder
+# safetensors. (BFL's canonical `ae.safetensors` is a combined file
+# we'd have to filter by key prefix — easier to host the split files
+# inside the model repo. Override via the FLUX_RGBD_LOCAL_AE_{...}
+# env vars if you want to skip the Hub fetch entirely.)
+DEFAULT_AE_REPO = "bartduis/modality_forcing"
+DEFAULT_ENCODER_FILE = "ae_encoder.safetensors"
+DEFAULT_DECODER_FILE = "ae_decoder.safetensors"
+
+DEFAULT_RESOLUTION = 256
+DEFAULT_IN_CHANNELS = 3
+DEFAULT_CH = 128
+DEFAULT_OUT_CHANNELS = 3
+DEFAULT_CH_MULT = (1, 2, 4, 4)
+DEFAULT_NUM_RES_BLOCKS = 2
+DEFAULT_Z_CHANNELS = 32
+
+
+def _hf_download(repo_id: str, filename: str) -> str:
+ """Resolve a HuggingFace Hub file path. Lazy-import hf_hub to keep the
+ base package importable in environments that pin huggingface_hub via
+ extras."""
+ from huggingface_hub import hf_hub_download
+
+ return hf_hub_download(repo_id=repo_id, filename=filename)
+
+
+def _load_weights(repo_id: str, filename: str) -> dict[str, Tensor]:
+ # Allow callers to point at a local file via env var. Useful for tests
+ # and air-gapped environments.
+ import os
+ env_key = f"FLUX_RGBD_LOCAL_{filename.upper().replace('.', '_')}"
+ override = os.environ.get(env_key)
+ if override:
+ if not os.path.isfile(override):
+ raise FileNotFoundError(f"{env_key}={override} is set but not a file")
+ return load_safetensors(override)
+ path = _hf_download(repo_id, filename)
+ return load_safetensors(path)
+
+
+def _swish(x: Tensor) -> Tensor:
+ return x * torch.sigmoid(x)
+
+
+class AttnBlock(nn.Module):
+ """Attention block for FLUX.2 autoencoder."""
+
+ def __init__(self, in_channels: int):
+ super().__init__()
+ self.in_channels = in_channels
+
+ self.norm = nn.GroupNorm(
+ num_groups=32, num_channels=in_channels, eps=1e-6, affine=True
+ )
+
+ self.q = nn.Conv2d(in_channels, in_channels, kernel_size=1)
+ self.k = nn.Conv2d(in_channels, in_channels, kernel_size=1)
+ self.v = nn.Conv2d(in_channels, in_channels, kernel_size=1)
+ self.proj_out = nn.Conv2d(in_channels, in_channels, kernel_size=1)
+
+ def attention(self, h_: Tensor) -> Tensor:
+ h_ = self.norm(h_)
+ q = self.q(h_)
+ k = self.k(h_)
+ v = self.v(h_)
+
+ b, c, h, w = q.shape
+ q = rearrange(q, "b c h w -> b 1 (h w) c").contiguous()
+ k = rearrange(k, "b c h w -> b 1 (h w) c").contiguous()
+ v = rearrange(v, "b c h w -> b 1 (h w) c").contiguous()
+ h_ = nn.functional.scaled_dot_product_attention(q, k, v)
+
+ return rearrange(h_, "b 1 (h w) c -> b c h w", h=h, w=w, c=c, b=b)
+
+ def forward(self, x: Tensor) -> Tensor:
+ return x + self.proj_out(self.attention(x))
+
+
+class ResnetBlock(nn.Module):
+ """Resnet block for FLUX.2 autoencoder."""
+
+ def __init__(self, in_channels: int, out_channels: int):
+ super().__init__()
+ self.in_channels = in_channels
+ out_channels = in_channels if out_channels is None else out_channels
+ self.out_channels = out_channels
+
+ self.norm1 = nn.GroupNorm(
+ num_groups=32, num_channels=in_channels, eps=1e-6, affine=True
+ )
+ self.conv1 = nn.Conv2d(
+ in_channels, out_channels, kernel_size=3, stride=1, padding=1
+ )
+ self.norm2 = nn.GroupNorm(
+ num_groups=32, num_channels=out_channels, eps=1e-6, affine=True
+ )
+ self.conv2 = nn.Conv2d(
+ out_channels, out_channels, kernel_size=3, stride=1, padding=1
+ )
+ if self.in_channels != self.out_channels:
+ self.nin_shortcut = nn.Conv2d(
+ in_channels, out_channels, kernel_size=1, stride=1, padding=0
+ )
+
+ def forward(self, x):
+ h = x
+ h = self.norm1(h)
+ h = _swish(h)
+ h = self.conv1(h)
+
+ h = self.norm2(h)
+ h = _swish(h)
+ h = self.conv2(h)
+
+ if self.in_channels != self.out_channels:
+ x = self.nin_shortcut(x)
+
+ return x + h
+
+
+class Downsample(nn.Module):
+ """Downsample block for FLUX.2 autoencoder."""
+
+ def __init__(self, in_channels: int):
+ super().__init__()
+ # no asymmetric padding in torch conv, must do it ourselves
+ self.conv = nn.Conv2d(
+ in_channels, in_channels, kernel_size=3, stride=2, padding=0
+ )
+
+ def forward(self, x: Tensor):
+ pad = (0, 1, 0, 1)
+ x = nn.functional.pad(x, pad, mode="constant", value=0)
+ x = self.conv(x)
+ return x
+
+
+class Upsample(nn.Module):
+ """Upsample block for FLUX.2 autoencoder."""
+
+ def __init__(self, in_channels: int):
+ super().__init__()
+ self.conv = nn.Conv2d(
+ in_channels, in_channels, kernel_size=3, stride=1, padding=1
+ )
+
+ def forward(self, x: Tensor):
+ x = nn.functional.interpolate(x, scale_factor=2.0, mode="nearest")
+ x = self.conv(x)
+ return x
+
+
+class Flux2Encoder(nn.Module):
+ """Encoder for FLUX.2 autoencoder."""
+
+ def __init__(
+ self,
+ resolution: int = DEFAULT_RESOLUTION,
+ in_channels: int = DEFAULT_IN_CHANNELS,
+ ch: int = DEFAULT_CH,
+ ch_mult: Sequence[int] = DEFAULT_CH_MULT,
+ num_res_blocks: int = DEFAULT_NUM_RES_BLOCKS,
+ z_channels: int = DEFAULT_Z_CHANNELS,
+ repo_id: str = DEFAULT_AE_REPO,
+ filename: str = DEFAULT_ENCODER_FILE,
+ ):
+ """Initialize the FLUX.2 encoder.
+
+ Args:
+ resolution: The resolution of the input images.
+ in_channels: The number of channels in the input images.
+ ch: The number of channels in the encoder.
+ ch_mult: The number of channels in the encoder at each resolution.
+ num_res_blocks: The number of ResNet blocks in each downsampling block.
+ z_channels: The number of channels in the latent space.
+ repo_id: HuggingFace Hub repository id from which to load weights.
+ filename: Filename of the safetensors weights within `repo_id`.
+ """
+ super().__init__()
+ self._repo_id = repo_id
+ self._filename = filename
+
+ self.quant_conv = torch.nn.Conv2d(2 * z_channels, 2 * z_channels, 1)
+ self.ch = ch
+ self.num_resolutions = len(ch_mult)
+ self.num_res_blocks = num_res_blocks
+ self.resolution = resolution
+ self.in_channels = in_channels
+ # downsampling
+ self.conv_in = nn.Conv2d(
+ in_channels, self.ch, kernel_size=3, stride=1, padding=1
+ )
+
+ curr_res = resolution
+ in_ch_mult = (1,) + tuple(ch_mult)
+ self.in_ch_mult = in_ch_mult
+ self.down = nn.ModuleList()
+ block_in = self.ch
+ for i_level in range(self.num_resolutions):
+ block = nn.ModuleList()
+ attn = nn.ModuleList()
+ block_in = ch * in_ch_mult[i_level]
+ block_out = ch * ch_mult[i_level]
+ for _ in range(self.num_res_blocks):
+ block.append(ResnetBlock(in_channels=block_in, out_channels=block_out))
+ block_in = block_out
+ down = nn.Module()
+ down.block = block
+ down.attn = attn
+ if i_level != self.num_resolutions - 1:
+ down.downsample = Downsample(block_in)
+ curr_res = curr_res // 2
+ self.down.append(down)
+
+ # middle
+ self.mid = nn.Module()
+ self.mid.block_1 = ResnetBlock(in_channels=block_in, out_channels=block_in)
+ self.mid.attn_1 = AttnBlock(block_in)
+ self.mid.block_2 = ResnetBlock(in_channels=block_in, out_channels=block_in)
+
+ # end
+ self.norm_out = nn.GroupNorm(
+ num_groups=32, num_channels=block_in, eps=1e-6, affine=True
+ )
+ self.conv_out = nn.Conv2d(
+ block_in, 2 * z_channels, kernel_size=3, stride=1, padding=1
+ )
+
+ self.bn_eps = 1e-4
+ self.bn_momentum = 0.1
+ self.ps = [2, 2]
+ self.bn = torch.nn.BatchNorm2d(
+ math.prod(self.ps) * z_channels,
+ eps=self.bn_eps,
+ momentum=self.bn_momentum,
+ affine=False,
+ track_running_stats=True,
+ )
+
+ def load_weights(self):
+ """Fetch and load weights from HuggingFace Hub."""
+ self.load_state_dict(_load_weights(self._repo_id, self._filename))
+
+ def _forward(self, x: Float[Tensor, "n cx hx wx"]) -> Float[Tensor, "n cz hz wz"]:
+ # downsampling
+ hs = [self.conv_in(x)]
+ for i_level in range(self.num_resolutions):
+ for i_block in range(self.num_res_blocks):
+ h = self.down[i_level].block[i_block](hs[-1])
+ if len(self.down[i_level].attn) > 0:
+ h = self.down[i_level].attn[i_block](h)
+ hs.append(h)
+ if i_level != self.num_resolutions - 1:
+ hs.append(self.down[i_level].downsample(hs[-1]))
+
+ # middle
+ h = hs[-1]
+ h = self.mid.block_1(h)
+ h = self.mid.attn_1(h)
+ h = self.mid.block_2(h)
+ # end
+ h = self.norm_out(h)
+ h = _swish(h)
+ h = self.conv_out(h)
+ h = self.quant_conv(h)
+ return h
+
+ def _normalize(self, z: Float[Tensor, "n cz hz wz"]) -> Float[Tensor, "n cz hz wz"]:
+ self.bn.eval()
+ return self.bn(z)
+
+ def forward(
+ self, x: Float[Tensor, "... hx wx cx"]
+ ) -> Float[Tensor, "... hz wz cz"]:
+ """Encode images to latents.
+
+ Args:
+ x: Images to encode.
+
+ Returns:
+ The encoded latents. The latents will be normalized using the running
+ statistics from training.
+ """
+ *batch_shape, _, _, _ = x.shape
+ # Convert to NCHW.
+ x = rearrange(x, "... h w c -> (...) c h w")
+
+ # Compute latents.
+ moments = self._forward(x)
+ mean = torch.chunk(moments, 2, dim=1)[0]
+ z = rearrange(
+ mean,
+ "... c (i pi) (j pj) -> ... (c pi pj) i j",
+ pi=self.ps[0],
+ pj=self.ps[1],
+ )
+ z = self._normalize(z)
+
+ # Convert back to NHWC and restore batch shape.
+ z = rearrange(z, "... c h w -> ... h w c")
+ return z.reshape(*batch_shape, *z.shape[-3:])
+
+
+class Flux2Decoder(nn.Module):
+ """Decoder for FLUX.2 autoencoder."""
+
+ def __init__(
+ self,
+ ch: int = DEFAULT_CH,
+ out_ch: int = DEFAULT_OUT_CHANNELS,
+ ch_mult: Sequence[int] = DEFAULT_CH_MULT,
+ num_res_blocks: int = DEFAULT_NUM_RES_BLOCKS,
+ in_channels: int = DEFAULT_IN_CHANNELS,
+ resolution: int = DEFAULT_RESOLUTION,
+ z_channels: int = DEFAULT_Z_CHANNELS,
+ repo_id: str = DEFAULT_AE_REPO,
+ filename: str = DEFAULT_DECODER_FILE,
+ ):
+ """Initialize the FLUX.2 decoder.
+
+ Args:
+ ch: The number of channels in the decoder.
+ out_ch: The number of channels in the output.
+ ch_mult: The number of channels in the decoder at each resolution.
+ num_res_blocks: The number of ResNet blocks in each upsampling block.
+ in_channels: The number of channels in the input images.
+ resolution: The resolution of the input images.
+ z_channels: The number of channels in the latent space.
+ repo_id: HuggingFace Hub repository id from which to load weights.
+ filename: Filename of the safetensors weights within `repo_id`.
+ """
+ super().__init__()
+ self._repo_id = repo_id
+ self._filename = filename
+
+ self.post_quant_conv = torch.nn.Conv2d(z_channels, z_channels, 1)
+ self.ch = ch
+ self.num_resolutions = len(ch_mult)
+ self.num_res_blocks = num_res_blocks
+ self.resolution = resolution
+ self.in_channels = in_channels
+ self.ffactor = 2 ** (self.num_resolutions - 1)
+
+ # compute in_ch_mult, block_in and curr_res at lowest res
+ block_in = ch * ch_mult[self.num_resolutions - 1]
+ curr_res = resolution // 2 ** (self.num_resolutions - 1)
+ self.z_shape = (1, z_channels, curr_res, curr_res)
+
+ # z to block_in
+ self.conv_in = nn.Conv2d(
+ z_channels, block_in, kernel_size=3, stride=1, padding=1
+ )
+
+ # middle
+ self.mid = nn.Module()
+ self.mid.block_1 = ResnetBlock(in_channels=block_in, out_channels=block_in)
+ self.mid.attn_1 = AttnBlock(block_in)
+ self.mid.block_2 = ResnetBlock(in_channels=block_in, out_channels=block_in)
+
+ # upsampling
+ self.up = nn.ModuleList()
+ for i_level in reversed(range(self.num_resolutions)):
+ block = nn.ModuleList()
+ attn = nn.ModuleList()
+ block_out = ch * ch_mult[i_level]
+ for _ in range(self.num_res_blocks + 1):
+ block.append(ResnetBlock(in_channels=block_in, out_channels=block_out))
+ block_in = block_out
+ up = nn.Module()
+ up.block = block
+ up.attn = attn
+ if i_level != 0:
+ up.upsample = Upsample(block_in)
+ curr_res = curr_res * 2
+ self.up.insert(0, up) # prepend to get consistent order
+
+ # end
+ self.norm_out = nn.GroupNorm(
+ num_groups=32, num_channels=block_in, eps=1e-6, affine=True
+ )
+ self.conv_out = nn.Conv2d(block_in, out_ch, kernel_size=3, stride=1, padding=1)
+
+ self.bn_eps = 1e-4
+ self.bn_momentum = 0.1
+ self.ps = [2, 2]
+ self.bn = torch.nn.BatchNorm2d(
+ math.prod(self.ps) * z_channels,
+ eps=self.bn_eps,
+ momentum=self.bn_momentum,
+ affine=False,
+ track_running_stats=True,
+ )
+
+ def load_weights(self):
+ """Fetch and load weights from HuggingFace Hub."""
+ self.load_state_dict(_load_weights(self._repo_id, self._filename))
+
+ def _forward(self, z: Float[Tensor, "n cz hz wz"]) -> Float[Tensor, "n cx hx wx"]:
+ z = self.post_quant_conv(z)
+
+ # get dtype for proper tracing
+ upscale_dtype = next(self.up.parameters()).dtype
+
+ # z to block_in
+ h = self.conv_in(z)
+
+ # middle
+ h = self.mid.block_1(h)
+ h = self.mid.attn_1(h)
+ h = self.mid.block_2(h)
+
+ # cast to proper dtype
+ h = h.to(upscale_dtype)
+ # upsampling
+ for i_level in reversed(range(self.num_resolutions)):
+ for i_block in range(self.num_res_blocks + 1):
+ h = self.up[i_level].block[i_block](h)
+ if len(self.up[i_level].attn) > 0:
+ h = self.up[i_level].attn[i_block](h)
+ if i_level != 0:
+ h = self.up[i_level].upsample(h)
+
+ # end
+ h = self.norm_out(h)
+ h = _swish(h)
+ h = self.conv_out(h)
+ return h
+
+ def _inv_normalize(
+ self, z: Float[Tensor, "n cz hz wz"]
+ ) -> Float[Tensor, "n cz hz wz"]:
+ self.bn.eval()
+ s = torch.sqrt(self.bn.running_var.view(1, -1, 1, 1) + self.bn_eps)
+ m = self.bn.running_mean.view(1, -1, 1, 1)
+ return z * s + m
+
+ def forward(
+ self, z: Float[Tensor, "... cz hz wz"]
+ ) -> Float[Tensor, "... cx hx wx"]:
+ """Decode latents to images.
+
+ Args:
+ z: Image latents normalized to have zero mean and unit variance.
+
+ Returns:
+ Decoded images.
+ """
+ *batch_shape, _, _, _ = z.shape
+
+ # Convert to NCHW.
+ z = rearrange(z, "... h w c -> (...) c h w")
+
+ # Denormalize and evaluate decoder.
+ z = self._inv_normalize(z)
+ z = rearrange(
+ z,
+ "... (c pi pj) i j -> ... c (i pi) (j pj)",
+ pi=self.ps[0],
+ pj=self.ps[1],
+ )
+ dec = self._forward(z)
+
+ # Convert back to NHWC and restore batch shape.
+ dec = rearrange(dec, "... c h w -> ... h w c")
+ dec = dec.reshape(*batch_shape, *dec.shape[-3:])
+ return dec
diff --git a/flux_rgbd/_flux2/constants.py b/flux_rgbd/_flux2/constants.py
new file mode 100644
index 0000000000000000000000000000000000000000..872d71ae9ab1d3300f8bb94156e3e819323e6a49
--- /dev/null
+++ b/flux_rgbd/_flux2/constants.py
@@ -0,0 +1,207 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 Black Forest Labs.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Adapted from the FLUX.2 codebase:
+# https://github.com/black-forest-labs/flux2
+"""
+Architecture parameters and other constants useful for FLUX.2 inference.
+
+This module centralizes all constants used across the FLUX.2 model implementation,
+including model architecture configurations for all public FLUX.2 variants, their
+text encoder parameters, and system prompts for prompt upsampling.
+"""
+
+from types import MappingProxyType
+
+__all__ = [
+ "FLUX2_DEV_PARAMS",
+ "FLUX2_KLEIN_4B_PARAMS",
+ "FLUX2_KLEIN_9B_PARAMS",
+ "OUTPUT_LAYERS_MISTRAL",
+ "OUTPUT_LAYERS_QWEN3",
+ "MAX_LENGTH",
+ "UPSAMPLING_MAX_IMAGE_SIZE",
+ "SYSTEM_MESSAGE",
+ "SYSTEM_MESSAGE_UPSAMPLING_T2I",
+ "SYSTEM_MESSAGE_UPSAMPLING_I2I",
+]
+
+# =============================================================================
+# Model Architecture Parameters
+# =============================================================================
+
+FLUX2_DEV_PARAMS = MappingProxyType(
+ {
+ "in_channels": 128,
+ "context_in_dim": 15360,
+ "hidden_size": 6144,
+ "num_heads": 48,
+ "depth": 8,
+ "depth_single_blocks": 48,
+ "axes_dim": (32, 32, 32, 32),
+ "theta": 2000,
+ "mlp_ratio": 3.0,
+ "use_guidance_embed": True,
+ }
+)
+"""
+Architecture config for FLUX.2 [dev] 32B parameter model (guidance distilled).
+"""
+
+FLUX2_KLEIN_4B_PARAMS = MappingProxyType(
+ {
+ "in_channels": 128,
+ "context_in_dim": 7680,
+ "hidden_size": 3072,
+ "num_heads": 24,
+ "depth": 5,
+ "depth_single_blocks": 20,
+ "axes_dim": (32, 32, 32, 32),
+ "theta": 2000,
+ "mlp_ratio": 3.0,
+ "use_guidance_embed": False,
+ }
+)
+"""
+Architecture config for FLUX.2 [klein] 4B parameter model (size distilled).
+"""
+
+FLUX2_KLEIN_9B_PARAMS = MappingProxyType(
+ {
+ "in_channels": 128,
+ "context_in_dim": 12288,
+ "hidden_size": 4096,
+ "num_heads": 32,
+ "depth": 8,
+ "depth_single_blocks": 24,
+ "axes_dim": (32, 32, 32, 32),
+ "theta": 2000,
+ "mlp_ratio": 3.0,
+ "use_guidance_embed": False,
+ }
+)
+"""
+Immutable architecture configuration for FLUX.2 [klein]-9B model.
+
+This is a mid-size 9B parameter model that balances quality and speed. It uses
+Qwen3-8B as text encoder and provides higher quality than the 4B variant while
+maintaining fast 4-step inference. The model is guidance-distilled.
+
+Key architectural features:
+ - 24 single-stream transformer blocks (between 4B and dev)
+ - 4096 hidden dimensions with 32 attention heads
+ - No guidance embeddings (guidance-distilled into weights)
+ - Context dimension 12288 matches concatenated Qwen3-8B outputs
+
+Default generation parameters (distilled variants):
+ - guidance: 1.0 (fixed, baked into weights)
+ - num_steps: 4 (fixed)
+
+Base variant generation parameters:
+ - guidance: 4.0 (adjustable)
+ - num_steps: 50 (adjustable)
+"""
+
+# =============================================================================
+# Text Encoder Configuration
+# =============================================================================
+
+OUTPUT_LAYERS_MISTRAL = (10, 20, 30)
+"""
+Layer indices to extract hidden states from Mistral-Small-3.2-24B text encoder.
+
+The FLUX.2 [dev] model uses Mistral-Small as its text encoder and concatenates
+hidden states from these three intermediate layers to form rich text embeddings.
+"""
+
+OUTPUT_LAYERS_QWEN3 = (9, 18, 27)
+"""
+Layer indices to extract hidden states from Qwen3 text encoder variants.
+
+The FLUX.2 [klein] models (4B and 9B) use Qwen3 text encoders and concatenate
+hidden states from these three intermediate layers to form text embeddings.
+"""
+
+MAX_LENGTH = 512
+"""
+Maximum sequence length for text encoder tokenization.
+
+Text prompts are truncated or padded to this length during encoding. This
+applies to both Mistral-Small and Qwen3 text encoders.
+"""
+
+UPSAMPLING_MAX_IMAGE_SIZE = 768**2
+"""
+Maximum pixel area for images during prompt upsampling.
+
+When images are provided as conditioning for prompt upsampling (image-to-image
+mode), they are resized to have at most this many pixels while preserving
+aspect ratio. This prevents out-of-memory errors when processing large images.
+"""
+
+# =============================================================================
+# System Messages for Prompt Upsampling
+# =============================================================================
+
+SYSTEM_MESSAGE = """You are an AI that reasons about image descriptions. You give \
+structured responses focusing on object relationships, object attribution and \
+actions without speculation."""
+"""
+Default system message for text encoder when generating image descriptions.
+
+This system message is used for general-purpose text encoding tasks where the
+model needs to reason about images in a structured, factual manner.
+"""
+
+SYSTEM_MESSAGE_UPSAMPLING_T2I = """You are an expert prompt engineer for FLUX.2 \
+by Black Forest Labs. Rewrite user prompts to be more descriptive while \
+strictly preserving their core subject and intent.
+
+Guidelines:
+1. Structure: Keep structured inputs structured (enhance within fields). \
+Convert natural language to detailed paragraphs.
+2. Details: Add concrete visual specifics - form, scale, textures, materials, \
+lighting (quality, direction, color), shadows, spatial relationships, and \
+environmental context.
+3. Text in Images: Put ALL text in quotation marks, matching the prompt's \
+language. Always provide explicit quoted text for objects that would contain \
+text in reality (signs, labels, screens, etc.) - without it, the model \
+generates gibberish.
+
+Output only the revised prompt and nothing else."""
+"""
+System message for text-to-image prompt upsampling.
+
+This prompt engineering template guides the text encoder to expand sparse user
+prompts into detailed, visually-rich descriptions that improve image generation
+quality. It emphasizes adding concrete visual details while preserving the
+original intent.
+"""
+
+SYSTEM_MESSAGE_UPSAMPLING_I2I = """You are FLUX.2 by Black Forest Labs, an \
+image-editing expert. You convert editing requests into one concise \
+instruction (50-80 words, ~30 for brief requests).
+
+Rules:
+- Single instruction only, no commentary
+- Use clear, analytical language (avoid "whimsical," "cascading," etc.)
+- Specify what changes AND what stays the same (face, lighting, composition)
+- Reference actual image elements
+- Turn negatives into positives ("don't change X" → "keep X")
+- Make abstractions concrete ("futuristic" → "glowing cyan neon, metallic panels")
+- Keep content PG-13
+
+Output only the final instruction in plain text and nothing else."""
+"""
+System message for image-to-image prompt upsampling.
+
+This prompt engineering template guides the text encoder to convert free-form
+image editing requests into concise, actionable instructions suitable for
+image-to-image generation tasks.
+"""
diff --git a/flux_rgbd/_flux2/model.py b/flux_rgbd/_flux2/model.py
new file mode 100644
index 0000000000000000000000000000000000000000..d1b0abfbbbace43c76b8c54f1338bb8fa61c9dff
--- /dev/null
+++ b/flux_rgbd/_flux2/model.py
@@ -0,0 +1,674 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 Black Forest Labs.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Adapted from the FLUX.2 codebase:
+# https://github.com/black-forest-labs/flux2
+"""
+FLUX.2 diffusion transformer architecture for image generation and editing.
+
+This module implements the core transformer architecture for FLUX.2 models from
+Black Forest Labs. The architecture uses dual-stream and single-stream transformer
+blocks to process text and image latents for text-to-image and image-to-image tasks.
+"""
+
+import math
+
+import einops
+import torch
+from torch import Tensor, nn
+
+
+class Flux2(nn.Module):
+ """
+ FLUX.2 diffusion transformer for image generation and editing.
+
+ This is a flow-matching diffusion model that uses a stack of dual-stream and
+ single-stream transformer blocks for text context and image latents. The
+ model supports text-to-image and image-to-image generation tasks.
+
+ Default parameter values match the FLUX.2 [klein] 4B architecture, which is
+ optimized for fast inference. For other model variants (klein-9B or dev),
+ use the parameters from :mod:`flux_rgbd._flux2.constants`.
+ """
+
+ def __init__(
+ self,
+ in_channels: int = 128,
+ context_in_dim: int = 7680,
+ hidden_size: int = 3072,
+ num_heads: int = 24,
+ depth: int = 5,
+ depth_single_blocks: int = 20,
+ axes_dim: tuple[int, int, int, int] = (32, 32, 32, 32),
+ theta: int = 2000,
+ mlp_ratio: float = 3.0,
+ use_guidance_embed: bool = False,
+ ):
+ """
+ Args:
+ in_channels: Number of input channels for image latents. Matches the
+ output dimension of the autoencoder used to encode images.
+ context_in_dim: Dimension of text context embeddings from the text
+ encoder. This should match the concatenated output dimension of
+ the text encoder being used (7680 for Qwen3-4B, 12288 for Qwen3-8B,
+ 15360 for Mistral-Small).
+ hidden_size: Hidden dimension size for transformer blocks. All
+ attention and MLP operations use this dimension internally.
+ num_heads: Number of attention heads in multi-head attention layers.
+ Must evenly divide `hidden_size`.
+ depth: Number of dual-stream transformer blocks. These blocks process
+ text and image streams separately with cross-attention.
+ depth_single_blocks: Number of single-stream transformer blocks. These
+ blocks process the concatenated text+image sequence.
+ axes_dim: Tuple of 4 integers specifying the dimensionality for each
+ axis in rotary embeddings. Must sum to `hidden_size / num_heads`.
+ theta: Base frequency for rotary position embeddings (RoPE). Higher
+ values result in slower position encoding rotation.
+ mlp_ratio: Expansion ratio for MLP hidden dimension relative to
+ `hidden_size`. MLP hidden dim = `hidden_size * mlp_ratio`.
+ use_guidance_embed: Whether to include guidance scale embeddings for
+ classifier-free guidance. Set to False for distilled models where
+ guidance is baked into weights.
+ """
+ super().__init__()
+
+ self.in_channels = in_channels
+ self.out_channels = in_channels
+ if hidden_size % num_heads != 0:
+ raise ValueError(
+ f"Hidden size {hidden_size} must be divisible by "
+ f"num_heads {num_heads}"
+ )
+ pe_dim = hidden_size // num_heads
+ if sum(axes_dim) != pe_dim:
+ raise ValueError(f"Got {axes_dim} but expected positional dim {pe_dim}")
+ self.hidden_size = hidden_size
+ self.num_heads = num_heads
+ self.pe_embedder = EmbedND(dim=pe_dim, theta=theta, axes_dim=axes_dim)
+ self.img_in = nn.Linear(self.in_channels, self.hidden_size, bias=False)
+ self.time_in = MLPEmbedder(
+ in_dim=256, hidden_dim=self.hidden_size, disable_bias=True
+ )
+ self.txt_in = nn.Linear(context_in_dim, self.hidden_size, bias=False)
+
+ self.use_guidance_embed = use_guidance_embed
+ if self.use_guidance_embed:
+ self.guidance_in = MLPEmbedder(
+ in_dim=256, hidden_dim=self.hidden_size, disable_bias=True
+ )
+
+ double_blocks = [
+ DoubleStreamBlock(self.hidden_size, self.num_heads, mlp_ratio=mlp_ratio)
+ for _ in range(depth)
+ ]
+ self.double_blocks = nn.ModuleList(double_blocks)
+
+ single_blocks = [
+ SingleStreamBlock(self.hidden_size, self.num_heads, mlp_ratio=mlp_ratio)
+ for _ in range(depth_single_blocks)
+ ]
+ self.single_blocks = nn.ModuleList(single_blocks)
+
+ self.double_stream_modulation_img = Modulation(
+ self.hidden_size, double=True, disable_bias=True
+ )
+ self.double_stream_modulation_txt = Modulation(
+ self.hidden_size, double=True, disable_bias=True
+ )
+ self.single_stream_modulation = Modulation(
+ self.hidden_size, double=False, disable_bias=True
+ )
+
+ self.final_layer = LastLayer(self.hidden_size, self.out_channels)
+
+ def forward(
+ self,
+ x: Tensor,
+ x_ids: Tensor,
+ timesteps: Tensor,
+ ctx: Tensor,
+ ctx_ids: Tensor,
+ guidance: Tensor | None,
+ ):
+ num_txt_tokens = ctx.shape[1]
+
+ timestep_emb = timestep_embedding(timesteps, 256)
+ vec = self.time_in(timestep_emb)
+ if self.use_guidance_embed:
+ guidance_emb = timestep_embedding(guidance, 256)
+ vec = vec + self.guidance_in(guidance_emb)
+
+ double_block_mod_img = self.double_stream_modulation_img(vec)
+ double_block_mod_txt = self.double_stream_modulation_txt(vec)
+ single_block_mod, _ = self.single_stream_modulation(vec)
+
+ img = self.img_in(x)
+ txt = self.txt_in(ctx)
+
+ pe_x = self.pe_embedder(x_ids)
+ pe_ctx = self.pe_embedder(ctx_ids)
+
+ for block in self.double_blocks:
+ img, txt = block(
+ img,
+ txt,
+ pe_x,
+ pe_ctx,
+ double_block_mod_img,
+ double_block_mod_txt,
+ )
+
+ img = torch.cat((txt, img), dim=1)
+ pe = torch.cat((pe_ctx, pe_x), dim=2)
+
+ for block in self.single_blocks:
+ img = block(
+ img,
+ pe,
+ single_block_mod,
+ )
+
+ img = img[:, num_txt_tokens:, ...]
+
+ img = self.final_layer(img, vec)
+ return img
+
+
+class SelfAttention(nn.Module):
+ """
+ Multi-head self-attention with QK normalization.
+
+ This module computes query, key, and value projections in a single linear layer,
+ applies RMS normalization to queries and keys, then performs attention and projects
+ the output back to the original dimension.
+ """
+
+ def __init__(self, dim: int, num_heads: int = 8):
+ """
+ Args:
+ dim: Hidden dimension size. Must be divisible by `num_heads`.
+ num_heads: Number of parallel attention heads.
+ """
+ super().__init__()
+ self.num_heads = num_heads
+ head_dim = dim // num_heads
+ self.qkv = nn.Linear(dim, dim * 3, bias=False)
+
+ self.norm = QKNorm(head_dim)
+ self.proj = nn.Linear(dim, dim, bias=False)
+
+
+class SiLUActivation(nn.Module):
+ """
+ Gated activation using SiLU (Swish) function.
+
+ This module splits the input tensor along the last dimension, applies SiLU to one
+ half, and element-wise multiplies it with the other half. This is commonly known
+ as SwiGLU when used in MLP layers.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.gate_fn = nn.SiLU()
+
+ def forward(self, x: Tensor) -> Tensor:
+ """
+ Args:
+ x: Input tensor of shape `(..., 2 * dim)` where the last dimension will
+ be split into two equal parts for gating.
+
+ Returns:
+ Gated output of shape `(..., dim)`.
+ """
+ x1, x2 = x.chunk(2, dim=-1)
+ return self.gate_fn(x1) * x2
+
+
+class Modulation(nn.Module):
+ """
+ Adaptive layer normalization (AdaLN) modulation layer.
+
+ This module generates scale, shift, and gate parameters for adaptive normalization
+ from timestep/guidance embeddings. For double-stream blocks, it produces two sets
+ of modulation parameters (one for each stream).
+ """
+
+ def __init__(self, dim: int, double: bool, disable_bias: bool = False):
+ """
+ Args:
+ dim: Hidden dimension size matching the transformer blocks.
+ double: If True, generates parameters for dual-stream blocks (6 params:
+ shift, scale, gate for each stream). If False, generates for
+ single-stream blocks (3 params: shift, scale, gate).
+ disable_bias: If True, the linear layer has no bias term.
+ """
+ super().__init__()
+ self.is_double = double
+ self.multiplier = 6 if double else 3
+ self.lin = nn.Linear(dim, self.multiplier * dim, bias=not disable_bias)
+
+ def forward(self, vec: Tensor):
+ """
+ Args:
+ vec: Timestep/guidance embedding of shape `(batch_size, dim)` or
+ `(batch_size, seq_len, dim)`.
+
+ Returns:
+ Tuple of modulation parameters. For single-stream: `(mod, None)` where
+ `mod` is a 3-tuple of (shift, scale, gate). For double-stream:
+ `(mod1, mod2)` where each is a 3-tuple for different streams.
+ """
+ out = self.lin(nn.functional.silu(vec))
+ if out.ndim == 2:
+ out = out[:, None, :]
+ out = out.chunk(self.multiplier, dim=-1)
+ return out[:3], out[3:] if self.is_double else None
+
+
+class LastLayer(nn.Module):
+ """
+ Final output layer with adaptive layer normalization.
+
+ This module applies AdaLN-modulated normalization followed by a linear projection
+ to map transformer hidden states back to the output space (image latent channels).
+ """
+
+ def __init__(self, hidden_size: int, out_channels: int):
+ """
+ Args:
+ hidden_size: Hidden dimension of transformer blocks.
+ out_channels: Output dimension (number of latent channels).
+ """
+ super().__init__()
+ self.norm_final = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
+ self.linear = nn.Linear(hidden_size, out_channels, bias=False)
+ self.adaLN_modulation = nn.Sequential( # pylint: disable=invalid-name
+ nn.SiLU(), nn.Linear(hidden_size, 2 * hidden_size, bias=False)
+ )
+
+ def forward(self, x: Tensor, vec: Tensor) -> Tensor:
+ """
+ Args:
+ x: Hidden states from transformer as a tensor of shape
+ `(batch_size, seq_len, hidden_size)`.
+ vec: Timestep embedding of shape `(batch_size, hidden_size)`.
+
+ Returns:
+ Output tensor of shape `(batch_size, seq_len, out_channels)`.
+ """
+ mod = self.adaLN_modulation(vec)
+ shift, scale = mod.chunk(2, dim=-1)
+ if shift.ndim == 2:
+ shift = shift[:, None, :]
+ scale = scale[:, None, :]
+ x = (1 + scale) * self.norm_final(x) + shift
+ x = self.linear(x)
+ return x
+
+
+class SingleStreamBlock(nn.Module):
+ """
+ Single-stream transformer block processing concatenated text+image tokens.
+
+ This block applies self-attention and MLP operations to a unified sequence of
+ text and image tokens. Both operations share pre-normalization and use adaptive
+ modulation from timestep embeddings.
+ """
+
+ def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float = 4.0):
+ """
+ Args:
+ hidden_size: Hidden dimension size for all linear projections.
+ num_heads: Number of attention heads. Must divide `hidden_size` evenly.
+ mlp_ratio: Ratio of MLP hidden dimension to `hidden_size`. The actual
+ MLP hidden dim is `int(hidden_size * mlp_ratio)`.
+ """
+ super().__init__()
+
+ self.hidden_dim = hidden_size
+ self.num_heads = num_heads
+ head_dim = hidden_size // num_heads
+ self.scale = head_dim**-0.5
+ self.mlp_hidden_dim = int(hidden_size * mlp_ratio)
+ self.mlp_mult_factor = 2
+
+ self.linear1 = nn.Linear(
+ hidden_size,
+ hidden_size * 3 + self.mlp_hidden_dim * self.mlp_mult_factor,
+ bias=False,
+ )
+ self.linear2 = nn.Linear(
+ hidden_size + self.mlp_hidden_dim, hidden_size, bias=False
+ )
+ self.norm = QKNorm(head_dim)
+
+ self.hidden_size = hidden_size
+ self.pre_norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
+
+ self.mlp_act = SiLUActivation()
+
+ def forward(self, x: Tensor, pe: Tensor, mod: tuple[Tensor, Tensor]) -> Tensor:
+ mod_shift, mod_scale, mod_gate = mod
+ x_mod = (1 + mod_scale) * self.pre_norm(x) + mod_shift
+
+ qkv, mlp = torch.split(
+ self.linear1(x_mod),
+ [3 * self.hidden_size, self.mlp_hidden_dim * self.mlp_mult_factor],
+ dim=-1,
+ )
+
+ q, k, v = einops.rearrange(
+ qkv, "B L (K H D) -> K B H L D", K=3, H=self.num_heads
+ )
+ q, k = self.norm(q, k, v)
+
+ attn = attention(q, k, v, pe)
+
+ # Compute activation in mlp stream, cat again and run second linear layer.
+ output = self.linear2(torch.cat((attn, self.mlp_act(mlp)), 2))
+ return x + mod_gate * output
+
+
+class DoubleStreamBlock(nn.Module):
+ """
+ Dual-stream transformer block processing text and image tokens separately.
+
+ This block maintains separate streams for text and image tokens, each with their
+ own self-attention and MLP sublayers. Cross-stream information exchange happens
+ through joint attention where Q, K, V from both streams are concatenated.
+ """
+
+ def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float):
+ """
+ Args:
+ hidden_size: Hidden dimension size for all linear projections.
+ num_heads: Number of attention heads. Must divide `hidden_size` evenly.
+ mlp_ratio: Ratio of MLP hidden dimension to `hidden_size`.
+ """
+ super().__init__()
+ mlp_hidden_dim = int(hidden_size * mlp_ratio)
+ self.num_heads = num_heads
+ assert (
+ hidden_size % num_heads == 0
+ ), f"{hidden_size=} must be divisible by {num_heads=}"
+
+ self.hidden_size = hidden_size
+ self.img_norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
+ self.mlp_mult_factor = 2
+
+ self.img_attn = SelfAttention(dim=hidden_size, num_heads=num_heads)
+
+ self.img_norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
+ self.img_mlp = nn.Sequential(
+ nn.Linear(hidden_size, mlp_hidden_dim * self.mlp_mult_factor, bias=False),
+ SiLUActivation(),
+ nn.Linear(mlp_hidden_dim, hidden_size, bias=False),
+ )
+
+ self.txt_norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
+ self.txt_attn = SelfAttention(dim=hidden_size, num_heads=num_heads)
+
+ self.txt_norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
+ self.txt_mlp = nn.Sequential(
+ nn.Linear(
+ hidden_size,
+ mlp_hidden_dim * self.mlp_mult_factor,
+ bias=False,
+ ),
+ SiLUActivation(),
+ nn.Linear(mlp_hidden_dim, hidden_size, bias=False),
+ )
+
+ def forward(
+ self,
+ img: Tensor,
+ txt: Tensor,
+ pe: Tensor,
+ pe_ctx: Tensor,
+ mod_img: tuple[Tensor, Tensor],
+ mod_txt: tuple[Tensor, Tensor],
+ ) -> tuple[Tensor, Tensor]:
+ img_mod1, img_mod2 = mod_img
+ txt_mod1, txt_mod2 = mod_txt
+
+ img_mod1_shift, img_mod1_scale, img_mod1_gate = img_mod1
+ img_mod2_shift, img_mod2_scale, img_mod2_gate = img_mod2
+ txt_mod1_shift, txt_mod1_scale, txt_mod1_gate = txt_mod1
+ txt_mod2_shift, txt_mod2_scale, txt_mod2_gate = txt_mod2
+
+ # Prepare image for attention.
+ img_modulated = self.img_norm1(img)
+ img_modulated = (1 + img_mod1_scale) * img_modulated + img_mod1_shift
+
+ img_qkv = self.img_attn.qkv(img_modulated)
+ img_q, img_k, img_v = einops.rearrange(
+ img_qkv, "B L (K H D) -> K B H L D", K=3, H=self.num_heads
+ )
+ img_q, img_k = self.img_attn.norm(img_q, img_k, img_v)
+
+ # Prepare txt for attention.
+ txt_modulated = self.txt_norm1(txt)
+ txt_modulated = (1 + txt_mod1_scale) * txt_modulated + txt_mod1_shift
+
+ txt_qkv = self.txt_attn.qkv(txt_modulated)
+ txt_q, txt_k, txt_v = einops.rearrange(
+ txt_qkv, "B L (K H D) -> K B H L D", K=3, H=self.num_heads
+ )
+ txt_q, txt_k = self.txt_attn.norm(txt_q, txt_k, txt_v)
+
+ q = torch.cat((txt_q, img_q), dim=2)
+ k = torch.cat((txt_k, img_k), dim=2)
+ v = torch.cat((txt_v, img_v), dim=2)
+
+ pe = torch.cat((pe_ctx, pe), dim=2)
+ attn = attention(q, k, v, pe)
+ txt_attn, img_attn = attn[:, : txt_q.shape[2]], attn[:, txt_q.shape[2] :]
+
+ # Calculate the img blocks.
+ img = img + img_mod1_gate * self.img_attn.proj(img_attn)
+ img = img + img_mod2_gate * self.img_mlp(
+ (1 + img_mod2_scale) * (self.img_norm2(img)) + img_mod2_shift
+ )
+
+ # Calculate the txt blocks.
+ txt = txt + txt_mod1_gate * self.txt_attn.proj(txt_attn)
+ txt = txt + txt_mod2_gate * self.txt_mlp(
+ (1 + txt_mod2_scale) * (self.txt_norm2(txt)) + txt_mod2_shift
+ )
+ return img, txt
+
+
+class MLPEmbedder(nn.Module):
+ """
+ Two-layer MLP for embedding timestep and guidance values.
+
+ This simple MLP transforms scalar timestep or guidance embeddings (after
+ sinusoidal encoding) into the transformer's hidden dimension space.
+ """
+
+ def __init__(self, in_dim: int, hidden_dim: int, disable_bias: bool = False):
+ """
+ Args:
+ in_dim: Input dimension (typically 256 for sinusoidal embeddings).
+ hidden_dim: Output hidden dimension matching transformer blocks.
+ disable_bias: If True, linear layers have no bias terms.
+ """
+ super().__init__()
+ self.in_layer = nn.Linear(in_dim, hidden_dim, bias=not disable_bias)
+ self.silu = nn.SiLU()
+ self.out_layer = nn.Linear(hidden_dim, hidden_dim, bias=not disable_bias)
+
+ def forward(self, x: Tensor) -> Tensor:
+ """
+ Args:
+ x: Input embeddings of shape `(batch_size, in_dim)`.
+
+ Returns:
+ Projected embeddings of shape `(batch_size, hidden_dim)`.
+ """
+ return self.out_layer(self.silu(self.in_layer(x)))
+
+
+class EmbedND(nn.Module):
+ """
+ N-dimensional rotary position embeddings (RoPE) for spatial-temporal tokens.
+
+ This module creates rotary embeddings for multi-dimensional position indices
+ (e.g., time, height, width, sequence). Each dimension gets its own embedding
+ component with configurable dimensions.
+ """
+
+ def __init__(self, dim: int, theta: int, axes_dim: list[int]):
+ """
+ Args:
+ dim: Total position embedding dimension. Should equal `sum(axes_dim)`.
+ theta: Base frequency for RoPE. Higher values give slower rotation.
+ axes_dim: Dimension allocation for each position axis.
+ """
+ super().__init__()
+ self.dim = dim
+ self.theta = theta
+ self.axes_dim = axes_dim
+
+ def forward(self, ids: Tensor) -> Tensor:
+ """
+ Args:
+ ids: Position indices of shape `(..., num_axes)` where `num_axes`
+ matches `len(axes_dim)`.
+
+ Returns:
+ Rotary embeddings of shape `(..., 1, sum(axes_dim), 2, 2)` suitable
+ for applying rotation to query and key tensors.
+ """
+ emb = torch.cat(
+ [
+ rope(ids[..., i], self.axes_dim[i], self.theta)
+ for i in range(len(self.axes_dim))
+ ],
+ dim=-3,
+ )
+
+ return emb.unsqueeze(1)
+
+
+def timestep_embedding(t: Tensor, dim, max_period=10000, time_factor: float = 1000.0):
+ """
+ Create sinusoidal timestep embeddings.
+ :param t: a 1-D Tensor of N indices, one per batch element.
+ These may be fractional.
+ :param dim: the dimension of the output.
+ :param max_period: controls the minimum frequency of the embeddings.
+ :return: an (N, D) Tensor of positional embeddings.
+ """
+ t = time_factor * t
+ half = dim // 2
+ freqs = torch.exp(
+ -math.log(max_period)
+ * torch.arange(start=0, end=half, device=t.device, dtype=torch.float32)
+ / half
+ )
+
+ args = t[:, None].float() * freqs[None]
+ embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
+ if dim % 2:
+ embedding = torch.cat([embedding, torch.zeros_like(embedding[:, :1])], dim=-1)
+ if torch.is_floating_point(t):
+ embedding = embedding.to(t)
+ return embedding
+
+
+class RMSNorm(torch.nn.Module):
+ """
+ Root Mean Square Layer Normalization.
+
+ RMSNorm normalizes using only the variance (RMS) without centering by mean,
+ providing a simpler and often equally effective alternative to LayerNorm.
+ """
+
+ def __init__(self, dim: int):
+ """
+ Args:
+ dim: Dimension to normalize over (last dimension of input).
+ """
+ super().__init__()
+ self.scale = nn.Parameter(torch.ones(dim))
+
+ def forward(self, x: Tensor):
+ """
+ Args:
+ x: Input tensor of shape `(..., dim)`.
+
+ Returns:
+ Normalized tensor of same shape as input.
+ """
+ x_dtype = x.dtype
+ x = x.float()
+ rrms = torch.rsqrt(torch.mean(x**2, dim=-1, keepdim=True) + 1e-6)
+ return (x * rrms).to(dtype=x_dtype) * self.scale
+
+
+class QKNorm(torch.nn.Module):
+ """
+ Separate RMSNorm for query and key tensors in attention.
+
+ Normalizing queries and keys independently before attention computation improves
+ training stability and can lead to better performance.
+ """
+
+ def __init__(self, dim: int):
+ """
+ Args:
+ dim: Head dimension for queries and keys.
+ """
+ super().__init__()
+ self.query_norm = RMSNorm(dim)
+ self.key_norm = RMSNorm(dim)
+
+ def forward(self, q: Tensor, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]:
+ """
+ Args:
+ q: Query tensor of shape `(..., head_dim)`.
+ k: Key tensor of shape `(..., head_dim)`.
+ v: Value tensor (used only for dtype matching).
+
+ Returns:
+ Tuple of normalized `(query, key)` tensors with dtype matching `v`.
+ """
+ q = self.query_norm(q)
+ k = self.key_norm(k)
+ return q.to(v), k.to(v)
+
+
+def attention(q: Tensor, k: Tensor, v: Tensor, pe: Tensor) -> Tensor:
+ q, k = apply_rope(q, k, pe)
+
+ x = torch.nn.functional.scaled_dot_product_attention(q, k, v)
+ x = einops.rearrange(x, "B H L D -> B L (H D)")
+
+ return x
+
+
+def rope(pos: Tensor, dim: int, theta: int) -> Tensor:
+ assert dim % 2 == 0
+ scale = torch.arange(0, dim, 2, dtype=pos.dtype, device=pos.device) / dim
+ omega = 1.0 / (theta**scale)
+ out = torch.einsum("...n,d->...nd", pos, omega)
+ out = torch.stack(
+ [torch.cos(out), -torch.sin(out), torch.sin(out), torch.cos(out)], dim=-1
+ )
+ out = einops.rearrange(out, "b n d (i j) -> b n d i j", i=2, j=2)
+ return out.float()
+
+
+def apply_rope(xq: Tensor, xk: Tensor, freqs_cis: Tensor) -> tuple[Tensor, Tensor]:
+ xq_ = xq.float().reshape(*xq.shape[:-1], -1, 1, 2)
+ xk_ = xk.float().reshape(*xk.shape[:-1], -1, 1, 2)
+ xq_out = freqs_cis[..., 0] * xq_[..., 0] + freqs_cis[..., 1] * xq_[..., 1]
+ xk_out = freqs_cis[..., 0] * xk_[..., 0] + freqs_cis[..., 1] * xk_[..., 1]
+ return xq_out.reshape(*xq.shape).type_as(xq), xk_out.reshape(*xk.shape).type_as(xk)
diff --git a/flux_rgbd/depth/__init__.py b/flux_rgbd/depth/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..668d27b8139e3711e9cd7e01739b23257e3eb0df
--- /dev/null
+++ b/flux_rgbd/depth/__init__.py
@@ -0,0 +1,8 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) 2026 World Labs.
+"""Depth-side preprocessing and schedule helpers for flux_rgbd."""
+
+from flux_rgbd.depth.preprocess import DepthConfig, decode_depth
+from flux_rgbd.depth.schedule import ScheduleConfig, rollout_timesteps
+
+__all__ = ["DepthConfig", "decode_depth", "ScheduleConfig", "rollout_timesteps"]
diff --git a/flux_rgbd/depth/preprocess.py b/flux_rgbd/depth/preprocess.py
new file mode 100644
index 0000000000000000000000000000000000000000..fcf56e6d2358f9ede2e077f3fe7d8dcd1fafc7c3
--- /dev/null
+++ b/flux_rgbd/depth/preprocess.py
@@ -0,0 +1,123 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) 2026 World Labs.
+"""Depth-side decoding: model tokens → depth pixel map.
+
+Decoding steps:
+ 1. Unpatchify the (tok_h, tok_w, patch_h * patch_w) token grid.
+ 2. Divide out the post-normalization scalar `depth_value_scale`.
+ 3. Apply the inverse of every recorded normalization stage, in reverse.
+
+Only `"contract"` (the mip-NeRF 360 radial squash, arXiv 2111.12077
+Eq. 10) is genuinely invertible — the other stages discard per-sample
+statistics during encoding and pass through unchanged on decode.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+
+import einops
+import torch
+from torch import Tensor
+
+_KNOWN_STAGES = frozenset({"contract", "normal", "mean", "unit_mean", "none"})
+
+
+@dataclasses.dataclass(frozen=True)
+class DepthConfig:
+ """How depth was normalized at training time. Decoder reads these."""
+
+ depth_normalize_mode: tuple[str, ...] | str = ("unit_mean", "contract")
+ patch_size: int = 16
+ depth_value_scale: float = 1.0
+
+ def __post_init__(self) -> None:
+ stages = self.depth_normalize_mode
+ if isinstance(stages, str):
+ object.__setattr__(self, "depth_normalize_mode", (stages,))
+ unknown = set(self.depth_normalize_mode) - _KNOWN_STAGES
+ if unknown:
+ raise ValueError(f"unknown stages {sorted(unknown)}; expected {sorted(_KNOWN_STAGES)}")
+ if self.depth_value_scale <= 0:
+ raise ValueError(f"depth_value_scale must be > 0; got {self.depth_value_scale}")
+ if self.patch_size <= 0:
+ raise ValueError(f"patch_size must be > 0; got {self.patch_size}")
+
+
+def _inv_contract(z: Tensor) -> Tensor:
+ """Inverse of the mip-NeRF 360 radial contract bijection (operates on last dim).
+
+ Contract maps R^n into the ball of radius 2; this undoes it. For |z| <= 1
+ the contract was identity, so this is also identity in that region.
+ """
+ mag_sq = torch.clamp(z.square().sum(dim=-1, keepdim=True), min=1.0)
+ return z / (2.0 * mag_sq.sqrt() - mag_sq)
+
+
+def _undo_stage(depth: Tensor, stage: str) -> Tensor:
+ if stage != "contract": # "normal"/"mean"/"unit_mean"/"none" are pass-through.
+ return depth
+ if depth.shape[-1] == 1:
+ return _inv_contract(depth)
+ return _inv_contract(depth.unsqueeze(-1)).squeeze(-1)
+
+
+def decode_depth(tokens: Tensor, config: DepthConfig) -> Tensor:
+ """Token grid -> depth pixel map.
+
+ Input shape: (..., tok_h, tok_w, patch_h * patch_w)
+ Output shape: (..., tok_h * patch_size, tok_w * patch_size, 1)
+ """
+ p = config.patch_size
+ depth = einops.rearrange(tokens, "... h w (p1 p2 c) -> ... (h p1) (w p2) c", p1=p, p2=p)
+ if config.depth_value_scale != 1.0:
+ depth = depth / config.depth_value_scale
+ for stage in reversed(config.depth_normalize_mode):
+ depth = _undo_stage(depth, stage)
+ return depth
+
+
+def _contract(z: Tensor) -> Tensor:
+ """Forward mip-NeRF 360 radial contract (inverse of `_inv_contract`).
+
+ Identity for |z| <= 1; otherwise squashes radius r into (2 - 1/r).
+ """
+ mag = z.norm(dim=-1, keepdim=True)
+ safe = mag.clamp(min=1e-12)
+ scale = torch.where(mag <= 1.0, torch.ones_like(mag), (2.0 - 1.0 / safe) / safe)
+ return z * scale
+
+
+def _apply_stage(depth: Tensor, stage: str) -> Tensor:
+ """Forward of `_undo_stage`. Only `contract` and `unit_mean` are active.
+
+ `unit_mean` divides by the per-map mean so the depth is scale-normalised
+ the way the model was trained; it is pass-through on decode (the absolute
+ scale is not recoverable), so encode/decode round-trips up to that scale.
+ """
+ if stage == "unit_mean":
+ return depth / depth.mean().clamp(min=1e-12)
+ if stage != "contract": # "normal"/"mean"/"none" are pass-through.
+ return depth
+ if depth.shape[-1] == 1:
+ return _contract(depth)
+ return _contract(depth.unsqueeze(-1)).squeeze(-1)
+
+
+def encode_depth(depth_map: Tensor, config: DepthConfig) -> Tensor:
+ """Depth pixel map -> token grid (inverse of `decode_depth`).
+
+ Input shape: (..., H, W, 1)
+ Output shape: (..., H/patch_size, W/patch_size, patch_size**2)
+
+ Used for ``mode="d2i"`` to turn a depth map into the depth-stream tokens
+ the model conditions on. Because `unit_mean` is scale-normalising, only the
+ relative depth structure matters — the input need not be metric.
+ """
+ p = config.patch_size
+ depth = depth_map
+ for stage in config.depth_normalize_mode:
+ depth = _apply_stage(depth, stage)
+ if config.depth_value_scale != 1.0:
+ depth = depth * config.depth_value_scale
+ return einops.rearrange(depth, "... (h p1) (w p2) c -> ... h w (p1 p2 c)", p1=p, p2=p)
diff --git a/flux_rgbd/depth/schedule.py b/flux_rgbd/depth/schedule.py
new file mode 100644
index 0000000000000000000000000000000000000000..0a7494a14d16249042abd66c8489ae24e111f26f
--- /dev/null
+++ b/flux_rgbd/depth/schedule.py
@@ -0,0 +1,87 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) 2026 World Labs.
+"""Per-modality flow-matching timestep schedules for RGB + depth inference.
+
+Each modality walks from t = t_max down to t = t_min, warped by the
+SD3 / FLUX shift transform
+
+ t' = exp(mu) / (exp(mu) + (1 / t - 1) ** sigma)
+
+so larger mu pushes the schedule toward noisier (later) timesteps.
+
+In asymmetric modes one stream is held at the boundary of its noise
+range:
+ "joint" — both schedules are warped linear walks.
+ "i2d" — RGB held at t = 0 (clean RGB conditioning).
+ "d2i" — depth held at t = 0; RGB optionally held at t = 1.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+import math
+from typing import Literal
+
+import torch
+from torch import Tensor
+
+Mode = Literal["joint", "i2d", "d2i"]
+
+
+@dataclasses.dataclass
+class ScheduleConfig:
+ rgb_shift_mu: float = 0.0
+ rgb_shift_sigma: float = 1.0
+ depth_shift_mu: float = 0.0
+ depth_shift_sigma: float = 1.0
+ # Some d2i checkpoints were trained with RGB pinned at full noise; this
+ # flag mirrors that conditioning at sample time.
+ d2i_full_noise_rgb: bool = False
+
+
+def _shift(mu: float, sigma: float, t: Tensor) -> Tensor:
+ return math.exp(mu) / (math.exp(mu) + (1.0 / t - 1.0) ** sigma)
+
+
+def _f_alpha(t: Tensor, alpha: float) -> Tensor:
+ """Latent-forcing Möbius warp f_α(t) = αt / (1 + (α-1)t). Fixes 0 and 1."""
+ return (alpha * t) / (1.0 + (alpha - 1.0) * t)
+
+
+def rollout_timesteps(config: ScheduleConfig, num_steps: int, *,
+ mode: Mode = "joint",
+ log2_alpha: float | None = None,
+ t_max: float = 1.0, t_min: float = 0.0,
+ device: torch.device | str | None = None,
+ dtype: torch.dtype = torch.float32) -> tuple[Tensor, Tensor]:
+ """Return per-modality timestep tensors of shape `(num_steps + 1,)`.
+
+ ``log2_alpha`` (joint mode only) tilts the RGB/depth denoising trajectory:
+ the depth schedule becomes ``f_α(t_rgb)`` with ``α = 2 ** log2_alpha`` on top
+ of the training time-shift density. ``α > 1`` keeps depth noisier for longer
+ so RGB resolves first (rgb-first → cleaner depth); ``α < 1`` is depth-first;
+ ``α = 1`` (or ``None``) is the diagonal joint schedule.
+ """
+ if num_steps < 1:
+ raise ValueError(f"num_steps must be >= 1; got {num_steps}")
+
+ linear = torch.linspace(t_max, t_min, num_steps + 1, device=device, dtype=dtype)
+ t_rgb = _shift(config.rgb_shift_mu, config.rgb_shift_sigma, linear)
+ t_depth = _shift(config.depth_shift_mu, config.depth_shift_sigma, linear)
+
+ if mode == "i2d":
+ t_rgb = torch.zeros_like(t_rgb)
+ elif mode == "d2i":
+ if config.d2i_full_noise_rgb:
+ t_rgb = torch.ones_like(t_rgb)
+ t_depth = torch.zeros_like(t_depth)
+ elif mode == "joint":
+ if log2_alpha is not None:
+ # Warp the depth schedule off the (time-shifted) RGB grid, matching
+ # the final-paper time-shift trajectory: nodes follow the training
+ # sigmoid density, then the Möbius warp tilts depth vs RGB.
+ t_depth = _f_alpha(t_rgb, 2.0 ** log2_alpha)
+ else:
+ raise ValueError(f"unknown mode {mode!r}; expected joint / i2d / d2i")
+
+ return t_rgb, t_depth
diff --git a/flux_rgbd/dit.py b/flux_rgbd/dit.py
new file mode 100644
index 0000000000000000000000000000000000000000..420760e005ef4be05f8d02b1178ce5e318a69264
--- /dev/null
+++ b/flux_rgbd/dit.py
@@ -0,0 +1,407 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2025 Black Forest Labs.
+# Copyright (c) 2026 World Labs. Modifications: depth stream, joint RGBD
+# attention, depth decoder blocks, cross-stream timestep mixing.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""FluxRGBDDiT — FLUX.2 dual+single-stream transformer extended for joint RGB+depth.
+
+Derived from the FLUX.2 reference implementation:
+ https://github.com/black-forest-labs/flux2
+
+Architecture, inference-only:
+ * Depth is a peer stream alongside image and text — each gets its own
+ pre-norm, Q/K/V, MLP inside every dual-stream block. Joint attention
+ runs over the concatenated `[txt, img, depth]` sequence.
+ * 8 dual-stream blocks + 24 single-stream blocks (FLUX.2 backbone).
+ * 4-block depth-only decoder before the depth output head.
+ * 4D RoPE position ids (axes_dim=(32,32,32,32), theta=2000) shared via
+ EmbedND. RGB tokens use time_id=0, depth uses time_id=1 so RoPE can
+ distinguish them.
+
+Reuses the FLUX.2 reference building blocks unchanged so img/txt-side
+weights lifted from an RGB-only FLUX.2 [klein-9B] base load 1:1.
+"""
+
+from __future__ import annotations
+
+import einops
+import torch
+from torch import Tensor, nn
+
+from flux_rgbd._flux2 import model as flux2_model
+from flux_rgbd._flux2.model import (
+ EmbedND,
+ LastLayer,
+ MLPEmbedder,
+ Modulation,
+ SelfAttention,
+ SiLUActivation,
+ SingleStreamBlock,
+ timestep_embedding,
+)
+
+
+class _TripleStreamBlock(nn.Module):
+ """Joint-attention block over (img, text, depth) with per-stream AdaLN.
+
+ Submodule names on the img/txt halves match FLUX.2's DoubleStreamBlock
+ so RGB-only base weights load 1:1; the depth half adds new submodules
+ with the same shapes as img.
+ """
+
+ def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float):
+ super().__init__()
+ if hidden_size % num_heads != 0:
+ raise ValueError(f"hidden_size {hidden_size} not divisible by num_heads {num_heads}")
+ mlp_hidden = int(hidden_size * mlp_ratio)
+ self.num_heads = num_heads
+
+ # Same shapes per stream: pre-norm + (Q,K,V,proj) + post-norm + 2-up SiLU MLP.
+ # The `* 2` in the first Linear is the gated-SiLU activation packing FLUX.2 uses.
+ for prefix in ("img", "txt", "depth"):
+ setattr(self, f"{prefix}_norm1", nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6))
+ setattr(self, f"{prefix}_attn", SelfAttention(dim=hidden_size, num_heads=num_heads))
+ setattr(self, f"{prefix}_norm2", nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6))
+ setattr(self, f"{prefix}_mlp", nn.Sequential(
+ nn.Linear(hidden_size, mlp_hidden * 2, bias=False),
+ SiLUActivation(),
+ nn.Linear(mlp_hidden, hidden_size, bias=False),
+ ))
+
+ def _qkv(self, x: Tensor, norm: nn.LayerNorm, attn: SelfAttention,
+ mod1: tuple[Tensor, Tensor, Tensor]):
+ """Pre-norm + AdaLN + Q/K/V projection. Returns (q, k, v, gate)."""
+ shift, scale, gate = mod1
+ qkv = attn.qkv((1 + scale) * norm(x) + shift)
+ q, k, v = einops.rearrange(qkv, "B L (K H D) -> K B H L D",
+ K=3, H=self.num_heads)
+ q, k = attn.norm(q, k, v)
+ return q, k, v, gate
+
+ @staticmethod
+ def _residual(x, attn_out, proj, gate1, mod2, norm2, mlp):
+ """attn residual then MLP residual, both gated. Standard DiT pattern."""
+ x = x + gate1 * proj(attn_out)
+ shift, scale, gate2 = mod2
+ return x + gate2 * mlp((1 + scale) * norm2(x) + shift)
+
+ def forward(self, img, txt, depth, pe_img, pe_txt, pe_depth,
+ mod_img, mod_txt, mod_depth):
+ q_img, k_img, v_img, g_img = self._qkv(img, self.img_norm1, self.img_attn, mod_img[0])
+ q_txt, k_txt, v_txt, g_txt = self._qkv(txt, self.txt_norm1, self.txt_attn, mod_txt[0])
+ q_d, k_d, v_d, g_d = self._qkv(depth, self.depth_norm1, self.depth_attn, mod_depth[0])
+
+ # Joint attention over [txt, img, depth]; BFL's `attention` does
+ # RoPE + scaled_dot_product_attention + rearrange.
+ q = torch.cat([q_txt, q_img, q_d], dim=2)
+ k = torch.cat([k_txt, k_img, k_d], dim=2)
+ v = torch.cat([v_txt, v_img, v_d], dim=2)
+ pe = torch.cat([pe_txt, pe_img, pe_depth], dim=2)
+ out = flux2_model.attention(q, k, v, pe)
+
+ n_txt, n_img = q_txt.shape[2], q_img.shape[2]
+ txt_out = out[:, :n_txt]
+ img_out = out[:, n_txt : n_txt + n_img]
+ depth_out = out[:, n_txt + n_img :]
+
+ img = self._residual(img, img_out, self.img_attn.proj, g_img,
+ mod_img[1], self.img_norm2, self.img_mlp)
+ txt = self._residual(txt, txt_out, self.txt_attn.proj, g_txt,
+ mod_txt[1], self.txt_norm2, self.txt_mlp)
+ depth = self._residual(depth, depth_out, self.depth_attn.proj, g_d,
+ mod_depth[1], self.depth_norm2, self.depth_mlp)
+ return img, txt, depth
+
+
+def _stack_per_token_mod(
+ mod_img: tuple[Tensor, Tensor, Tensor],
+ mod_depth: tuple[Tensor, Tensor, Tensor],
+ n_txt: int, n_img: int, n_depth: int,
+) -> tuple[Tensor, Tensor, Tensor]:
+ """Build the per-token modulation triple consumed by SingleStreamBlock.
+
+ Each ``Modulation`` output is broadcast over the sequence axis. For the
+ triple-stream single stack we need different (shift, scale, gate) for
+ the depth tokens vs the txt+img tokens — so we expand each entry along
+ seq and ``cat``.
+ """
+ n_txt_img = n_txt + n_img
+
+ def _build(slot: int) -> Tensor:
+ head = mod_img[slot].expand(-1, n_txt_img, -1)
+ tail = mod_depth[slot].expand(-1, n_depth, -1)
+ return torch.cat([head, tail], dim=1)
+
+ return _build(0), _build(1), _build(2)
+
+
+class FluxRGBDDiT(nn.Module):
+ """FLUX.2 Klein-9B DiT with peer depth stream.
+
+ Forward: (img, depth, text, RGB timestep, depth timestep, *_ids)
+ → (rgb_latent_out, depth_latent_out).
+
+ Architecture (v1 defaults match the published checkpoint):
+ depth_double=8 triple-stream blocks → full-token concat →
+ depth_single=24 single-stream blocks → split →
+ depth_decoder_num_layers=4 single blocks over depth only →
+ final_layer / depth_final_layer.
+ """
+
+ def __init__(
+ self,
+ *,
+ in_channels: int = 128,
+ depth_channels: int = 256,
+ context_in_dim: int = 12288,
+ hidden_size: int = 4096,
+ num_heads: int = 32,
+ depth_double: int = 8,
+ depth_single: int = 24,
+ depth_decoder_num_layers: int = 4,
+ axes_dim: tuple[int, ...] = (32, 32, 32, 32),
+ theta: int = 2000,
+ mlp_ratio: float = 3.0,
+ use_guidance_embed: bool = False,
+ cross_stream_timestep_mixing: bool = False,
+ ):
+ super().__init__()
+
+ pe_dim = hidden_size // num_heads
+ if sum(axes_dim) != pe_dim:
+ raise ValueError(
+ f"axes_dim sum {sum(axes_dim)} != hidden_size/num_heads {pe_dim}"
+ )
+
+ self.in_channels = in_channels
+ self.depth_channels = depth_channels
+ self.hidden_size = hidden_size
+ self.num_heads = num_heads
+ self.depth_double = depth_double
+ self.depth_single = depth_single
+ self.depth_decoder_num_layers = depth_decoder_num_layers
+
+ # Stream-in projections.
+ self.img_in = nn.Linear(in_channels, hidden_size, bias=False)
+ self.txt_in = nn.Linear(context_in_dim, hidden_size, bias=False)
+ self.depth_in = nn.Linear(depth_channels, hidden_size, bias=False)
+
+ # Timestep embedders. `time_in` carries the RGB timestep (plus optional
+ # guidance scale); `time_in_depth` carries the depth timestep.
+ self.time_in = MLPEmbedder(
+ in_dim=256, hidden_dim=hidden_size, disable_bias=True
+ )
+ self.time_in_depth = MLPEmbedder(
+ in_dim=256, hidden_dim=hidden_size, disable_bias=True
+ )
+ self.use_guidance_embed = use_guidance_embed
+ if use_guidance_embed:
+ self.guidance_in = MLPEmbedder(
+ in_dim=256, hidden_dim=hidden_size, disable_bias=True
+ )
+
+ # Cross-stream timestep mixing: each stream's modulation vector also
+ # sees the *other* stream's noise level, projected through a small MLP
+ # and gated by a learnable scalar (trained from a zero init, so the
+ # path is identity at step 0 and a learned delta thereafter):
+ # vec_img += cross_alpha_img * time_in_depth_to_img(emb(t_depth))
+ # vec_depth += cross_alpha_depth * time_in_rgb_to_depth(emb(t_rgb))
+ # This lets the var-AR checkpoint condition each modality on how noisy
+ # the other one is (e.g. clean RGB in i2d, clean depth in d2i).
+ self.cross_stream_timestep_mixing = cross_stream_timestep_mixing
+ if cross_stream_timestep_mixing:
+ self.time_in_depth_to_img = MLPEmbedder(
+ in_dim=256, hidden_dim=hidden_size, disable_bias=True
+ )
+ self.time_in_rgb_to_depth = MLPEmbedder(
+ in_dim=256, hidden_dim=hidden_size, disable_bias=True
+ )
+ # Shape (1,) not () to mirror the training checkpoint's parameter
+ # layout (kept 1-D for FSDP2's no-scalar-parameter rule).
+ self.cross_alpha_img = nn.Parameter(torch.zeros(1))
+ self.cross_alpha_depth = nn.Parameter(torch.zeros(1))
+
+ # Position embedder shared across streams.
+ self.pe_embedder = EmbedND(dim=pe_dim, theta=theta, axes_dim=axes_dim)
+
+ # Dual-stream stack.
+ self.double_blocks = nn.ModuleList(
+ [_TripleStreamBlock(hidden_size, num_heads, mlp_ratio)
+ for _ in range(depth_double)]
+ )
+
+ # Single-stream stack — reuses the FLUX.2 SingleStreamBlock with
+ # per-token modulation built on the fly.
+ self.single_blocks = nn.ModuleList(
+ [SingleStreamBlock(hidden_size, num_heads, mlp_ratio=mlp_ratio)
+ for _ in range(depth_single)]
+ )
+
+ # Modulation heads.
+ self.double_stream_modulation_img = Modulation(
+ hidden_size, double=True, disable_bias=True
+ )
+ self.double_stream_modulation_txt = Modulation(
+ hidden_size, double=True, disable_bias=True
+ )
+ self.double_stream_modulation_depth = Modulation(
+ hidden_size, double=True, disable_bias=True
+ )
+ self.single_stream_modulation = Modulation(
+ hidden_size, double=False, disable_bias=True
+ )
+ self.single_stream_modulation_depth = Modulation(
+ hidden_size, double=False, disable_bias=True
+ )
+
+ # Optional depth-only decoder stack (4 SingleStreamBlocks for v1).
+ if depth_decoder_num_layers > 0:
+ self.depth_decoder_blocks = nn.ModuleList(
+ [SingleStreamBlock(hidden_size, num_heads, mlp_ratio=mlp_ratio)
+ for _ in range(depth_decoder_num_layers)]
+ )
+ self.depth_decoder_modulation = Modulation(
+ hidden_size, double=False, disable_bias=True
+ )
+ else:
+ self.depth_decoder_blocks = nn.ModuleList()
+ self.depth_decoder_modulation = None
+
+ # Final unpatchifiers.
+ self.final_layer = LastLayer(hidden_size, in_channels)
+ self.depth_final_layer = LastLayer(hidden_size, depth_channels)
+
+ def forward(
+ self,
+ img: Tensor,
+ img_ids: Tensor,
+ timesteps: Tensor,
+ ctx: Tensor,
+ ctx_ids: Tensor,
+ depth: Tensor,
+ depth_ids: Tensor,
+ depth_timesteps: Tensor,
+ guidance: Tensor | None = None,
+ ) -> tuple[Tensor, Tensor]:
+ """Triple-stream forward.
+
+ Args:
+ img: `(B, n_img, in_channels)` RGB latent tokens
+ img_ids: `(B, n_img, 4)` 4D position ids
+ timesteps: `(B,)` RGB diffusion time
+ ctx: `(B, n_txt, context_in_dim)` text embeddings
+ ctx_ids: `(B, n_txt, 4)` text position ids
+ depth: `(B, n_depth, depth_channels)` depth latent tokens
+ depth_ids: `(B, n_depth, 4)` depth position ids
+ depth_timesteps: `(B,)` depth diffusion time
+ guidance: `(B,)` optional guidance scale
+
+ Returns:
+ `(rgb_out, depth_out)` patches.
+ """
+ # Body dtype (set by the parameters of the input projections). Inputs
+ # are cast to this so mixed-precision callers (BF16 body + FP32
+ # sampling state) work transparently.
+ body_dtype = self.img_in.weight.dtype
+ img = img.to(body_dtype)
+ ctx = ctx.to(body_dtype)
+ depth = depth.to(body_dtype)
+ timesteps = timesteps.to(body_dtype)
+ depth_timesteps = depth_timesteps.to(body_dtype)
+ if guidance is not None:
+ guidance = guidance.to(body_dtype)
+
+ # Timestep embeddings (separate per modality). `timestep_embedding`
+ # always returns FP32; cast before the BF16 MLPEmbedder weights.
+ rgb_t_emb = timestep_embedding(timesteps, 256).to(body_dtype)
+ depth_t_emb = timestep_embedding(depth_timesteps, 256).to(body_dtype)
+ vec_img = self.time_in(rgb_t_emb)
+ if self.use_guidance_embed and guidance is not None:
+ vec_img = vec_img + self.guidance_in(
+ timestep_embedding(guidance, 256).to(body_dtype)
+ )
+ vec_depth = self.time_in_depth(depth_t_emb)
+
+ # Cross-stream timestep mixing (see __init__): fold the other stream's
+ # noise level into each modulation vector. The scalar gate is part of
+ # the body dtype after `.to(dtype=…)`, so no extra cast is needed.
+ if self.cross_stream_timestep_mixing:
+ vec_img = vec_img + self.cross_alpha_img * self.time_in_depth_to_img(
+ depth_t_emb
+ )
+ vec_depth = vec_depth + self.cross_alpha_depth * self.time_in_rgb_to_depth(
+ rgb_t_emb
+ )
+
+ # Per-stream modulation coefficients.
+ mod_img_double = self.double_stream_modulation_img(vec_img)
+ mod_txt_double = self.double_stream_modulation_txt(vec_img)
+ mod_depth_double = self.double_stream_modulation_depth(vec_depth)
+ mod_img_single, _ = self.single_stream_modulation(vec_img)
+ mod_depth_single, _ = self.single_stream_modulation_depth(vec_depth)
+
+ # Project each stream into the hidden dim.
+ img = self.img_in(img)
+ txt = self.txt_in(ctx)
+ depth = self.depth_in(depth)
+
+ # Position embeddings.
+ pe_img = self.pe_embedder(img_ids)
+ pe_txt = self.pe_embedder(ctx_ids)
+ pe_depth = self.pe_embedder(depth_ids)
+
+ # Dual-stream stack.
+ for block in self.double_blocks:
+ img, txt, depth = block(
+ img, txt, depth,
+ pe_img, pe_txt, pe_depth,
+ mod_img_double, mod_txt_double, mod_depth_double,
+ )
+
+ # Single-stream stack over the concatenated [txt, img, depth] sequence
+ # with per-token modulation.
+ n_txt = txt.shape[1]
+ n_img = img.shape[1]
+ n_depth = depth.shape[1]
+ joint = torch.cat([txt, img, depth], dim=1)
+ pe_joint = torch.cat([pe_txt, pe_img, pe_depth], dim=2)
+ per_token_mod = _stack_per_token_mod(
+ mod_img_single, mod_depth_single, n_txt, n_img, n_depth
+ )
+ for block in self.single_blocks:
+ joint = block(joint, pe_joint, per_token_mod)
+
+ # Split back per stream.
+ rgb_tokens = joint[:, n_txt : n_txt + n_img]
+ depth_tokens = joint[:, n_txt + n_img :]
+
+ # Optional depth-only decoder.
+ if self.depth_decoder_num_layers > 0:
+ depth_decoder_mod, _ = self.depth_decoder_modulation(vec_depth)
+ for block in self.depth_decoder_blocks:
+ depth_tokens = block(depth_tokens, pe_depth, depth_decoder_mod)
+
+ # Each output head may be in a different dtype than the body
+ # (e.g. FP32 depth head over a BF16 body); cast inputs to the
+ # head's own dtype at the boundary.
+ rgb_head_dtype = self.final_layer.linear.weight.dtype
+ depth_head_dtype = self.depth_final_layer.linear.weight.dtype
+ rgb_out = self.final_layer(
+ rgb_tokens.to(rgb_head_dtype), vec_img.to(rgb_head_dtype)
+ )
+ depth_out = self.depth_final_layer(
+ depth_tokens.to(depth_head_dtype), vec_depth.to(depth_head_dtype)
+ )
+ return rgb_out, depth_out
diff --git a/flux_rgbd/factories.py b/flux_rgbd/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..86111ce12d7dd9fa96bcb27a7c3672796b4d9fc1
--- /dev/null
+++ b/flux_rgbd/factories.py
@@ -0,0 +1,56 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) 2026 World Labs.
+"""Model variant builders. One function per published checkpoint."""
+
+from __future__ import annotations
+
+from flux_rgbd.depth.preprocess import DepthConfig
+from flux_rgbd.depth.schedule import ScheduleConfig
+from flux_rgbd.dit import FluxRGBDDiT
+from flux_rgbd.model import FluxRGBD
+
+
+def flux_rgbd_9b_v1() -> tuple[FluxRGBD, DepthConfig, ScheduleConfig]:
+ """v1 9B checkpoint: 8 dual + 24 single + 4 depth-decoder blocks."""
+ dit = FluxRGBDDiT(
+ in_channels=128, depth_channels=256, context_in_dim=12_288,
+ hidden_size=4096, num_heads=32,
+ depth_double=8, depth_single=24, depth_decoder_num_layers=4,
+ axes_dim=(32, 32, 32, 32), theta=2000, mlp_ratio=3.0,
+ )
+ return (
+ FluxRGBD(dit),
+ DepthConfig(depth_normalize_mode=("unit_mean", "contract"), patch_size=16),
+ ScheduleConfig(rgb_shift_mu=1.1, depth_shift_mu=1.1),
+ )
+
+
+def flux_rgbd_9b_v2() -> tuple[FluxRGBD, DepthConfig, ScheduleConfig]:
+ """v2 9B checkpoint: v1 architecture + cross-stream timestep mixing.
+
+ Same backbone as v1 (8 dual + 24 single + 4 depth-decoder blocks; ~12B
+ params total — "9B" names the FLUX.2 backbone class), plus a cross-stream
+ timestep-mixing path (``cross_alpha_*`` +
+ ``time_in_{rgb_to_depth,depth_to_img}``): each stream's modulation vector
+ sees a gated embedding of the *other* stream's timestep. Training-time
+ depth-visibility gating was configured as a no-op in the released
+ checkpoint and is not modelled at inference.
+ """
+ dit = FluxRGBDDiT(
+ in_channels=128, depth_channels=256, context_in_dim=12_288,
+ hidden_size=4096, num_heads=32,
+ depth_double=8, depth_single=24, depth_decoder_num_layers=4,
+ axes_dim=(32, 32, 32, 32), theta=2000, mlp_ratio=3.0,
+ cross_stream_timestep_mixing=True,
+ )
+ return (
+ FluxRGBD(dit),
+ DepthConfig(depth_normalize_mode=("unit_mean", "contract"), patch_size=16),
+ ScheduleConfig(rgb_shift_mu=1.1, depth_shift_mu=1.1),
+ )
+
+
+VARIANTS = {
+ "flux_rgbd_9b_v1": flux_rgbd_9b_v1,
+ "flux_rgbd_9b_v2": flux_rgbd_9b_v2,
+}
diff --git a/flux_rgbd/model.py b/flux_rgbd/model.py
new file mode 100644
index 0000000000000000000000000000000000000000..6d764f19f8114e5c525efa15397b85c9a055144c
--- /dev/null
+++ b/flux_rgbd/model.py
@@ -0,0 +1,154 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) 2026 World Labs.
+"""FluxRGBD — pairs the depth-extended DiT with a joint flow-matching sampler.
+
+Public surface:
+ * `model.forward(...)` — one denoiser call.
+ * `model.sample(...)` — N-step Euler rollout over RGB + depth. Three
+ modes: `"joint"` (text → RGBD), `"i2d"` (text+RGB → depth),
+ `"d2i"` (text+depth → RGB).
+"""
+
+from __future__ import annotations
+
+import einops
+import torch
+from torch import Tensor, nn
+
+from flux_rgbd.depth.schedule import Mode, ScheduleConfig, rollout_timesteps
+from flux_rgbd.dit import FluxRGBDDiT
+
+
+class FluxRGBD(nn.Module):
+ def __init__(self, dit: FluxRGBDDiT) -> None:
+ super().__init__()
+ self.dit = dit
+ self.in_channels = dit.in_channels
+ self.depth_channels = dit.depth_channels
+
+ # ------------------------------------------------------------------ ids
+ # FLUX.2's 4D position ids: (time_axis, row, col, seq_idx).
+ # Image tokens use (0, row, col, 0); depth tokens use (1, row, col, 0)
+ # so RoPE can distinguish them. Text tokens use (0, 0, 0, seq_idx).
+
+ @staticmethod
+ def _img_ids(batch: int, h: int, w: int, *, device, time_id: float = 0.0) -> Tensor:
+ ids = torch.zeros(h, w, 4, device=device)
+ ids[..., 0] = time_id
+ ids[..., 1] = torch.arange(h, device=device)[:, None]
+ ids[..., 2] = torch.arange(w, device=device)[None, :]
+ return einops.repeat(ids, "h w d -> b (h w) d", b=batch)
+
+ @staticmethod
+ def _txt_ids(batch: int, seq_len: int, *, device) -> Tensor:
+ ids = torch.zeros(batch, seq_len, 4, device=device)
+ ids[..., 3] = torch.arange(seq_len, device=device)
+ return ids
+
+ # ----------------------------------------------------------------- step
+
+ def forward(self, img, timesteps, ctx, img_height, img_width,
+ depth, depth_timesteps, guidance=None):
+ b, device = img.shape[0], img.device
+ return self.dit(
+ img=img,
+ img_ids=self._img_ids(b, img_height, img_width, device=device),
+ timesteps=timesteps,
+ ctx=ctx,
+ ctx_ids=self._txt_ids(b, ctx.shape[1], device=device),
+ depth=depth,
+ depth_ids=self._img_ids(b, img_height, img_width, device=device, time_id=1.0),
+ depth_timesteps=depth_timesteps,
+ guidance=guidance,
+ )
+
+ # --------------------------------------------------------------- sample
+
+ @torch.no_grad()
+ def sample(self, *, ctx: Tensor, img_height: int, img_width: int,
+ num_steps: int = 50, mode: Mode = "joint",
+ schedule_config: ScheduleConfig | None = None,
+ cfg_scale: float = 1.0, guidance: float = 1.0,
+ seed: int | None = None,
+ rgb_use_x_prediction: bool = False,
+ depth_use_x_prediction: bool = True,
+ x_prediction_t_min: float = 0.05,
+ clean_rgb: Tensor | None = None,
+ clean_depth: Tensor | None = None,
+ null_text_embed: Tensor | None = None,
+ log2_alpha: float | None = None) -> tuple[Tensor, Tensor]:
+ if mode == "i2d" and clean_rgb is None:
+ raise ValueError("clean_rgb is required for mode='i2d'")
+ if mode == "d2i" and clean_depth is None:
+ raise ValueError("clean_depth is required for mode='d2i'")
+
+ device, dtype, batch = ctx.device, ctx.dtype, ctx.shape[0]
+ num_tokens = img_height * img_width
+
+ gen = torch.Generator(device=device).manual_seed(seed) if seed is not None else None
+
+ def randn(*shape):
+ return torch.randn(*shape, device=device, dtype=dtype, generator=gen)
+
+ if mode == "i2d":
+ rgb = clean_rgb.to(dtype=dtype, device=device)
+ depth = randn(batch, num_tokens, self.depth_channels)
+ elif mode == "d2i":
+ rgb = randn(batch, num_tokens, self.in_channels)
+ depth = clean_depth.to(dtype=dtype, device=device)
+ else: # joint
+ rgb = randn(batch, num_tokens, self.in_channels)
+ depth = randn(batch, num_tokens, self.depth_channels)
+
+ schedule_config = schedule_config or ScheduleConfig()
+ t_rgb_sched, t_depth_sched = rollout_timesteps(
+ schedule_config, num_steps, mode=mode, log2_alpha=log2_alpha,
+ device=device, dtype=dtype,
+ )
+
+ guidance_tensor = (torch.full((batch,), guidance, device=device, dtype=dtype)
+ if self.dit.use_guidance_embed else None)
+
+ use_cfg = cfg_scale > 1.0
+ if use_cfg:
+ uncond = null_text_embed if null_text_embed is not None else torch.zeros_like(ctx)
+ if uncond.ndim == 2:
+ uncond = uncond.unsqueeze(0)
+ if uncond.shape[0] == 1 and batch > 1:
+ uncond = uncond.expand(batch, *uncond.shape[1:])
+ uncond = uncond.to(device=device, dtype=dtype)
+
+ for step in range(num_steps):
+ t_rgb_b = t_rgb_sched[step].expand(batch)
+ t_depth_b = t_depth_sched[step].expand(batch)
+ dt_rgb = t_rgb_sched[step + 1] - t_rgb_sched[step]
+ dt_depth = t_depth_sched[step + 1] - t_depth_sched[step]
+
+ pred_rgb, pred_depth = self.forward(
+ img=rgb, timesteps=t_rgb_b, ctx=ctx,
+ img_height=img_height, img_width=img_width,
+ depth=depth, depth_timesteps=t_depth_b, guidance=guidance_tensor,
+ )
+ if use_cfg:
+ pred_rgb_u, pred_depth_u = self.forward(
+ img=rgb, timesteps=t_rgb_b, ctx=uncond,
+ img_height=img_height, img_width=img_width,
+ depth=depth, depth_timesteps=t_depth_b, guidance=guidance_tensor,
+ )
+ pred_rgb = pred_rgb_u + cfg_scale * (pred_rgb - pred_rgb_u)
+ pred_depth = pred_depth_u + cfg_scale * (pred_depth - pred_depth_u)
+
+ # x-prediction → velocity: when the head was trained to predict
+ # the clean x rather than the velocity, convert it before Euler.
+ if rgb_use_x_prediction:
+ clamp = t_rgb_b.view(batch, 1, 1).clamp(min=x_prediction_t_min)
+ pred_rgb = (rgb - pred_rgb) / clamp
+ if depth_use_x_prediction:
+ clamp = t_depth_b.view(batch, 1, 1).clamp(min=x_prediction_t_min)
+ pred_depth = (depth - pred_depth) / clamp
+
+ # Euler step. dt=0 for the frozen modality in i2d/d2i is a no-op.
+ rgb = rgb + dt_rgb * pred_rgb
+ depth = depth + dt_depth * pred_depth
+
+ return rgb, depth
diff --git a/flux_rgbd/pointcloud.py b/flux_rgbd/pointcloud.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce1f3b9a5e45d631c55bd59ce72fe387f287540e
--- /dev/null
+++ b/flux_rgbd/pointcloud.py
@@ -0,0 +1,55 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) 2026 World Labs.
+"""Point-cloud post-processing utilities."""
+
+from __future__ import annotations
+
+import numpy as np
+
+
+def statistical_outlier_mask(points: np.ndarray, *, num_neighbors: int = 5,
+ std_ratio: float = 1.0) -> np.ndarray:
+ """Boolean inlier mask via statistical outlier rejection.
+
+ For each point, the mean distance to its ``num_neighbors`` nearest
+ neighbours is computed; a point is an outlier when that mean exceeds
+ ``global_mean + std_ratio * global_std``. This removes the flying-pixel
+ floaters that back-projected depth produces at depth discontinuities.
+ Mirrors Open3D's ``remove_statistical_outlier``.
+
+ Args:
+ points: ``(N, 3)`` float array.
+ num_neighbors: neighbours used to estimate each point's local spacing.
+ std_ratio: smaller is more aggressive (keeps fewer points).
+
+ Returns:
+ ``(N,)`` boolean mask, ``True`` for inliers.
+ """
+ n = len(points)
+ if n <= num_neighbors:
+ return np.ones(n, dtype=bool)
+ # Lazy import: only point-cloud export needs scipy.
+ from scipy.spatial import cKDTree
+ tree = cKDTree(points)
+ # k + 1: the nearest neighbour is the point itself (distance 0); drop it.
+ dists, _ = tree.query(points, k=num_neighbors + 1, workers=-1)
+ mean_dist = dists[:, 1:].mean(axis=1)
+ threshold = mean_dist.mean() + std_ratio * float(mean_dist.std())
+ return mean_dist <= threshold
+
+
+def depth_edge_mask(depth: np.ndarray, *, rtol: float = 0.04,
+ kernel_size: int = 3) -> np.ndarray:
+ """Boolean mask, ``True`` on depth-discontinuity pixels.
+
+ A pixel is an edge when the local depth range (max − min over a
+ ``kernel_size`` window) exceeds ``rtol`` of its depth. Removing these
+ pixels *before* back-projection deletes the occlusion-boundary "veils"
+ that bridge foreground→background — the connected floaters that
+ statistical outlier rejection cannot catch (they have close neighbours).
+ Mirrors MoGe / utils3d ``depth_edge`` (``rtol`` path).
+ """
+ from scipy.ndimage import maximum_filter, minimum_filter
+ local_range = (maximum_filter(depth, size=kernel_size)
+ - minimum_filter(depth, size=kernel_size))
+ return local_range > rtol * np.maximum(depth, 1e-6)
diff --git a/flux_rgbd/runner.py b/flux_rgbd/runner.py
new file mode 100644
index 0000000000000000000000000000000000000000..a35482caf438f63cbca545be0a23c418181214cc
--- /dev/null
+++ b/flux_rgbd/runner.py
@@ -0,0 +1,307 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) 2026 World Labs.
+"""Public inference API: FluxRGBDRunner.
+
+Bundles the diffusion model, the FLUX.2 VAE decoder, and the Qwen3
+text encoder so callers can go from a text prompt to a numpy RGB image
+plus a depth map in one call.
+"""
+
+from __future__ import annotations
+
+import datetime as _dt
+import json
+import re
+from pathlib import Path
+
+import cv2
+import einops
+import numpy as np
+import torch
+from matplotlib import cm
+from safetensors.torch import load_file as load_safetensors
+from torch import Tensor
+
+from flux_rgbd._flux2.autoencoder import Flux2Decoder, Flux2Encoder
+from flux_rgbd.depth.preprocess import decode_depth, encode_depth
+from flux_rgbd.depth.schedule import Mode
+from flux_rgbd.factories import VARIANTS
+from flux_rgbd.text_encoder import Qwen3Embedder
+
+
+def _load_local_or_hub(repo_id_or_path: str) -> tuple[Path, Path]:
+ """Resolve `config.json` and `model.safetensors` to local paths.
+
+ Accepts a local directory or a HuggingFace repo id.
+ """
+ local = Path(repo_id_or_path)
+ if local.is_dir():
+ return local / "config.json", local / "model.safetensors"
+ from huggingface_hub import hf_hub_download
+ return (
+ Path(hf_hub_download(repo_id=repo_id_or_path, filename="config.json")),
+ Path(hf_hub_download(repo_id=repo_id_or_path, filename="model.safetensors")),
+ )
+
+
+def _materialize_meta_tensors(model: torch.nn.Module, device) -> list[str]:
+ """Give a real (uninitialized) tensor to any parameter/buffer still on the
+ meta device after an ``assign=True`` load.
+
+ A meta-device build defers allocation until weights are assigned, so any
+ tensor the checkpoint does *not* supply remains on `meta` and would crash
+ at first use. A complete checkpoint leaves none — this is a safety net for
+ drift between the model definition and the checkpoint (returns the names it
+ had to materialize so callers can surface the gap).
+ """
+ materialized: list[str] = []
+ for name, tensor in [*model.named_parameters(), *model.named_buffers()]:
+ if not tensor.is_meta:
+ continue
+ *parents, leaf = name.split(".")
+ owner = model
+ for part in parents:
+ owner = getattr(owner, part)
+ real = torch.empty(tensor.shape, dtype=tensor.dtype, device=device)
+ if isinstance(tensor, torch.nn.Parameter):
+ owner._parameters[leaf] = torch.nn.Parameter(real, requires_grad=False)
+ else:
+ owner._buffers[leaf] = real
+ materialized.append(name)
+ return materialized
+
+
+class FluxRGBDRunner:
+ """High-level inference wrapper."""
+
+ def __init__(self, model, decoder, embedder, depth_config, schedule_config,
+ *, device="cuda", img_hw=(512, 512), latent_compression=16):
+ self.model = model
+ self.decoder = decoder
+ self.embedder = embedder
+ self.depth_config = depth_config
+ self.schedule_config = schedule_config
+ self.device = torch.device(device)
+ self.img_hw = img_hw
+ self.latent_compression = latent_compression
+ self.latent_hw = (img_hw[0] // latent_compression, img_hw[1] // latent_compression)
+ self._encoder: Flux2Encoder | None = None # lazy: only needed for i2d
+ self._null_text_embed: Tensor | None = None # lazy CFG uncond, see _null_embed
+
+ @classmethod
+ def from_pretrained(cls, repo_id_or_path, *, device="cuda",
+ dtype=torch.float32, head_dtype: torch.dtype | None = None,
+ text_encoder="Qwen/Qwen3-8B",
+ img_hw=(512, 512), latent_compression=16):
+ """Load model + decoder + embedder. Path or HuggingFace Hub repo id.
+
+ ``head_dtype`` (optional) overrides the dtype of the depth output head.
+ Setting ``dtype=torch.bfloat16, head_dtype=torch.float32`` runs the
+ DiT body in BF16 (fast) but keeps the depth final layer in FP32,
+ which avoids BF16 quantization artifacts under x-prediction.
+ """
+ config_path, weights_path = _load_local_or_hub(repo_id_or_path)
+ config = json.loads(config_path.read_text())
+ builder = VARIANTS[config["variant"]]
+ # Build on the meta device, then assign the checkpoint tensors straight
+ # onto `device`. This skips ~45 s of random initialization for the 9B
+ # model (every parameter is overwritten by the checkpoint anyway) and
+ # the H2D copy of a throwaway randinit model — cutting DiT load from
+ # ~60 s to ~4 s. Bit-identical to the eager construct+load path.
+ with torch.device("meta"):
+ model, depth_cfg, schedule_cfg = builder()
+ state_dict = load_safetensors(str(weights_path), device=str(device))
+ if dtype is not None:
+ state_dict = {k: v.to(dtype) for k, v in state_dict.items()}
+ model.load_state_dict(state_dict, strict=False, assign=True)
+ leftover = _materialize_meta_tensors(model, device)
+ if leftover:
+ raise RuntimeError(
+ f"checkpoint is missing {len(leftover)} tensors (e.g. "
+ f"{leftover[:4]}); refusing to run with uninitialized weights. "
+ f"Re-download the checkpoint or check --model.")
+ model.eval()
+ if head_dtype is not None and head_dtype != dtype:
+ model.dit.depth_final_layer.to(head_dtype)
+
+ decoder = Flux2Decoder().to(device).eval()
+ decoder.load_weights()
+ embedder = Qwen3Embedder(model_spec=text_encoder, device=device).eval()
+ return cls(model, decoder, embedder, depth_cfg, schedule_cfg,
+ device=device, img_hw=img_hw,
+ latent_compression=latent_compression)
+
+ def _ensure_encoder(self) -> Flux2Encoder:
+ if self._encoder is None:
+ enc = Flux2Encoder(filename="ae_encoder.safetensors").to(self.device).eval()
+ enc.load_weights()
+ self._encoder = enc
+ return self._encoder
+
+ @torch.no_grad()
+ def encode_image(self, image_chw_uint8: np.ndarray) -> Tensor:
+ """uint8 (H, W, 3) image → latent tokens (1, num_tokens, in_channels).
+
+ Resizes to ``self.img_hw`` and normalises to [-1, 1] before encoding.
+ Output dtype matches the diffusion model's parameter dtype.
+ """
+ h, w = self.img_hw
+ rgb = cv2.resize(image_chw_uint8, (w, h), interpolation=cv2.INTER_AREA)
+ x = torch.from_numpy(rgb).to(self.device).float() / 255.0 # (H, W, 3)
+ x = x * 2 - 1 # [-1, 1]
+ x = x.unsqueeze(0) # (1, H, W, 3)
+ encoder = self._ensure_encoder()
+ latents_nhwc = encoder(x) # (1, lat_h, lat_w, in_channels)
+ b, lh, lw, c = latents_nhwc.shape
+ # Match the model body dtype so the DiT can consume directly.
+ body_dtype = next(self.model.dit.img_in.parameters()).dtype
+ return latents_nhwc.reshape(b, lh * lw, c).to(body_dtype)
+
+ @torch.no_grad()
+ def encode_depth_map(self, depth_hw: np.ndarray) -> Tensor:
+ """(H, W) depth map → depth-stream tokens (1, num_tokens, depth_channels).
+
+ For ``mode="d2i"``. Resizes to ``self.img_hw`` then patchifies via the
+ same normalisation the model was trained with (see ``encode_depth``).
+ Because that normalisation is scale-invariant (``unit_mean``), the input
+ can be metric or relative depth. Output dtype matches the depth stream.
+ """
+ h, w = self.img_hw
+ d = cv2.resize(depth_hw.astype(np.float32), (w, h), interpolation=cv2.INTER_NEAREST)
+ x = torch.from_numpy(d).to(self.device)[None, ..., None] # (1, H, W, 1)
+ tokens = encode_depth(x, self.depth_config) # (1, lat_h, lat_w, depth_channels)
+ b, lh, lw, c = tokens.shape
+ body_dtype = next(self.model.dit.depth_in.parameters()).dtype
+ return tokens.reshape(b, lh * lw, c).to(body_dtype)
+
+ @torch.no_grad()
+ def _null_embed(self) -> Tensor:
+ """Cached Qwen3 embedding of the empty string, used as the CFG uncond.
+
+ This checkpoint family was trained with the encoder's ``""`` embedding
+ as the unconditional. Letting ``model.sample`` fall back to
+ ``zeros_like(ctx)`` instead gives an out-of-distribution uncond that
+ CFG amplifies by ``(cfg_scale - 1)``, softening the RGB and corrupting
+ depth — so we encode ``""`` with the same embedder used for the prompt
+ (keeping cond/uncond in the same space) and reuse it across calls.
+ """
+ if self._null_text_embed is None:
+ null = self.embedder.forward([""]).to(self.device, torch.float32)
+ if null.ndim == 2:
+ null = null.unsqueeze(0)
+ self._null_text_embed = null
+ return self._null_text_embed
+
+ @torch.no_grad()
+ def generate(self, prompt: str, *, mode: Mode = "joint", num_steps: int = 50,
+ cfg_scale: float = 2.5, seed: int = 0,
+ clean_rgb_image: np.ndarray | None = None,
+ clean_rgb: Tensor | None = None,
+ clean_depth: Tensor | None = None,
+ refine_depth_i2d: bool = False,
+ i2d_cfg_scale: float = 1.0,
+ log2_alpha: float | None = None) -> dict:
+ """Sample one (RGB, depth) pair from `prompt`. Returns a dict with:
+
+ rgb: (H, W, 3) uint8
+ depth: (H, W) float32 depth, relative scale (positive = farther)
+ rgb_latent / depth_latent: raw model outputs (for debugging)
+ metadata: dict of the call parameters
+
+ ``refine_depth_i2d`` (joint mode only) turns the call into a two-stage
+ pipeline: stage 1 joint-samples RGB+depth at ``cfg_scale``, then stage 2
+ re-derives depth via image→depth on the stage-1 RGB *latent* at
+ ``i2d_cfg_scale``. The final RGB is stage 1's; the final depth is stage
+ 2's. Conditioning depth on a fully-formed RGB (instead of a co-evolving
+ noisy one) gives sharper, more RGB-consistent geometry.
+
+ ``log2_alpha`` (joint mode only) tilts the RGB/depth denoising trajectory
+ toward rgb-first (``> 0``) for cleaner depth; see ``rollout_timesteps``.
+ """
+ text_embed = self.embedder.forward([prompt]).to(self.device, torch.float32)
+ if text_embed.ndim == 2:
+ text_embed = text_embed.unsqueeze(0)
+
+ # Convenience: if the caller supplies a clean image instead of token
+ # latents, encode it here.
+ if clean_rgb is None and clean_rgb_image is not None:
+ clean_rgb = self.encode_image(clean_rgb_image)
+
+ torch.manual_seed(seed)
+ torch.cuda.manual_seed_all(seed)
+
+ lh, lw = self.latent_hw
+ rgb_lat, depth_lat = self.model.sample(
+ ctx=text_embed, img_height=lh, img_width=lw,
+ num_steps=num_steps, mode=mode,
+ schedule_config=self.schedule_config,
+ cfg_scale=cfg_scale, seed=seed,
+ rgb_use_x_prediction=False, depth_use_x_prediction=True,
+ clean_rgb=clean_rgb, clean_depth=clean_depth,
+ null_text_embed=self._null_embed() if cfg_scale > 1.0 else None,
+ log2_alpha=log2_alpha,
+ )
+
+ # Stage 2 (optional): re-derive depth from the stage-1 RGB latent via
+ # i2d. cfg_scale=1.0 here means no guidance, so no null embed is needed.
+ if refine_depth_i2d and mode == "joint":
+ _, depth_lat = self.model.sample(
+ ctx=text_embed, img_height=lh, img_width=lw,
+ num_steps=num_steps, mode="i2d",
+ schedule_config=self.schedule_config,
+ cfg_scale=i2d_cfg_scale, seed=seed,
+ rgb_use_x_prediction=False, depth_use_x_prediction=True,
+ clean_rgb=rgb_lat,
+ null_text_embed=self._null_embed() if i2d_cfg_scale > 1.0 else None,
+ )
+
+ # VAE decode → uint8 RGB.
+ rgb_spatial = einops.rearrange(rgb_lat, "b (h w) c -> b h w c", h=lh, w=lw)
+ rgb_hw3 = (self.decoder(rgb_spatial.float())[0] * 0.5 + 0.5).clamp(0, 1)
+ rgb = (rgb_hw3.float().cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
+
+ # Contract-bijection decode → depth.
+ depth_spatial = einops.rearrange(depth_lat, "b (h w) c -> b 1 h w c", h=lh, w=lw)
+ depth = decode_depth(depth_spatial, self.depth_config) \
+ .squeeze(0).squeeze(0).squeeze(-1).float().cpu().numpy()
+
+ return {
+ "rgb": rgb, "depth": depth,
+ "rgb_latent": rgb_lat.detach(), "depth_latent": depth_lat.detach(),
+ "metadata": {"prompt": prompt, "mode": mode, "seed": seed,
+ "num_steps": num_steps, "cfg_scale": cfg_scale,
+ "img_hw": self.img_hw},
+ }
+
+ def save(self, result: dict, output_root: str | Path) -> dict[str, str]:
+ """Save a generation into a timestamped subdir of `output_root`.
+
+ Writes: rgb.png, depth_raw.npy (raw depth, relative scale), depth_magma.png
+ (disparity visualization, near = bright), metadata.json.
+ """
+ slug = re.sub(r"[^a-z0-9]+", "_", result["metadata"]["prompt"].lower()).strip("_")[:48]
+ ts = _dt.datetime.now().strftime("%Y%m%d_%H%M%S")
+ d = Path(output_root) / f"{ts}_{slug}"
+ d.mkdir(parents=True, exist_ok=True)
+
+ rgb, depth = result["rgb"], result["depth"]
+ cv2.imwrite(str(d / "rgb.png"), cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR))
+ np.save(d / "depth_raw.npy", depth)
+
+ # Disparity (1/depth) magma, robustly normalized to the 5–95th
+ # percentile so near surfaces read bright and far ones dark.
+ valid = (depth > 0) & np.isfinite(depth)
+ if valid.any():
+ disparity = np.zeros_like(depth, dtype=np.float32)
+ disparity[valid] = 1.0 / depth[valid]
+ lo, hi = np.percentile(disparity[valid], [5, 95])
+ disparity = np.clip((disparity - lo) / max(hi - lo, 1e-8), 0, 1)
+ disparity[~valid] = 0.0
+ magma = (cm.magma(disparity)[..., :3] * 255).astype(np.uint8)
+ cv2.imwrite(str(d / "depth_magma.png"), cv2.cvtColor(magma, cv2.COLOR_RGB2BGR))
+
+ (d / "metadata.json").write_text(json.dumps(result["metadata"], indent=2))
+ return {"run_dir": str(d), "rgb": str(d / "rgb.png"),
+ "depth_raw": str(d / "depth_raw.npy"),
+ "depth_magma": str(d / "depth_magma.png"),
+ "metadata": str(d / "metadata.json")}
diff --git a/flux_rgbd/text_encoder.py b/flux_rgbd/text_encoder.py
new file mode 100644
index 0000000000000000000000000000000000000000..15cdd0859ff01845ffa86e1b7be382d4fbc3cf68
--- /dev/null
+++ b/flux_rgbd/text_encoder.py
@@ -0,0 +1,111 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) 2026 World Labs.
+"""Qwen3-based text encoder for the FLUX-RGBD model family.
+
+The encoder turns text prompts into a stack of hidden states drawn from
+three intermediate layers of Qwen3-8B-FP8 (layers 9, 18, 27, mirroring
+the FLUX.2 [klein] recipe). Output shape is
+
+ (batch, MAX_LENGTH=512, 3 * hidden_size = 12288)
+
+Pads to the full ``MAX_LENGTH`` (no stripping) because the FLUX.2 DiT was
+trained against the padded text stream produced by Black Forest Labs.
+
+Loads everything from HuggingFace Hub — no internal mirrors, no auth.
+"""
+
+from __future__ import annotations
+
+import einops
+import torch
+from torch import Tensor, nn
+from transformers import AutoModelForCausalLM, AutoTokenizer
+
+from flux_rgbd._flux2.constants import MAX_LENGTH, OUTPUT_LAYERS_QWEN3
+
+DEFAULT_MODEL_SPEC = "Qwen/Qwen3-8B-FP8"
+
+
+class Qwen3Embedder(nn.Module):
+ """Wrap a Qwen3 causal LM, surface multi-layer hidden states.
+
+ Inference-only. Chat-template policy matches the upstream FLUX.2
+ [klein] recipe: user role, generation-prompt appended, thinking
+ disabled.
+ """
+
+ def __init__(
+ self,
+ model_spec: str = DEFAULT_MODEL_SPEC,
+ *,
+ device: str | torch.device = "cuda",
+ max_length: int = MAX_LENGTH,
+ output_layers: tuple[int, ...] = OUTPUT_LAYERS_QWEN3,
+ ) -> None:
+ super().__init__()
+ self.model_spec = model_spec
+ self.device_ = torch.device(device)
+ self.max_length = max_length
+ self.output_layers = tuple(output_layers)
+
+ self._tokenizer = AutoTokenizer.from_pretrained(model_spec)
+ # Load straight onto the target device. `dtype=None` lets
+ # transformers honor whatever the checkpoint advertises (FP8 for
+ # the Qwen3-8B-FP8 we use by default).
+ self._model = AutoModelForCausalLM.from_pretrained(
+ model_spec, dtype=None, device_map=str(self.device_)
+ )
+ self._model.eval()
+ self._pad_id = self._tokenizer.pad_token_id or 0
+
+ @staticmethod
+ def _apply_chat_template(tok, text: str) -> str:
+ return tok.apply_chat_template(
+ [{"role": "user", "content": text}],
+ tokenize=False,
+ add_generation_prompt=True,
+ enable_thinking=False,
+ )
+
+ def _tokenize(self, prompts: list[str]) -> tuple[Tensor, Tensor]:
+ formatted = [self._apply_chat_template(self._tokenizer, p) for p in prompts]
+ # First tokenize without padding to control truncation; we'll pad
+ # ourselves to `max_length` so the output sequence length is fixed
+ # regardless of the prompt batch.
+ encoded = self._tokenizer(
+ formatted,
+ padding=False,
+ truncation=True,
+ max_length=self.max_length,
+ )
+ rows = encoded["input_ids"]
+ b = len(rows)
+ input_ids = torch.full(
+ (b, self.max_length), fill_value=self._pad_id, dtype=torch.long
+ )
+ attn = torch.zeros((b, self.max_length), dtype=torch.long)
+ for i, row in enumerate(rows):
+ n = min(len(row), self.max_length)
+ input_ids[i, :n] = torch.tensor(row[:n], dtype=torch.long)
+ attn[i, :n] = 1
+ return input_ids.to(self.device_), attn.to(self.device_)
+
+ @torch.inference_mode()
+ def forward(self, prompts: list[str]) -> Tensor:
+ """Encode a batch of prompts.
+
+ Returns:
+ Tensor of shape ``(batch, max_length, len(output_layers) * hidden_size)``.
+ """
+ input_ids, attention_mask = self._tokenize(list(prompts))
+ out = self._model(
+ input_ids=input_ids,
+ attention_mask=attention_mask,
+ output_hidden_states=True,
+ use_cache=False,
+ )
+ # `hidden_states` is a tuple of length num_hidden_layers + 1.
+ layers = torch.stack(
+ [out.hidden_states[k] for k in self.output_layers], dim=1
+ ) # (b, len(output_layers), seq, hidden)
+ return einops.rearrange(layers, "b c l d -> b l (c d)")
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..d1b691a89a81eb7302e2261a0cc658129c0bdaf0
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,16 @@
+torch>=2.5
+einops>=0.8
+jaxtyping>=0.2
+transformers==5.10.2
+safetensors>=0.4
+huggingface_hub>=0.24
+accelerate>=1.0
+kernels>=0.4,<0.15
+numpy>=1.26,<3
+opencv-python-headless>=4.8
+pillow>=10
+matplotlib>=3.8
+scipy>=1.11
+trimesh>=4.4
+gradio>=6,<7
+spaces