depth2normal 1.0.0__tar.gz → 2.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {depth2normal-1.0.0 → depth2normal-2.0.1}/.github/workflows/ci.yml +4 -1
  2. {depth2normal-1.0.0 → depth2normal-2.0.1}/.gitignore +3 -0
  3. depth2normal-2.0.1/PKG-INFO +273 -0
  4. depth2normal-2.0.1/README.md +234 -0
  5. depth2normal-2.0.1/assets/CREDITS.md +10 -0
  6. depth2normal-2.0.1/assets/hero.webp +0 -0
  7. depth2normal-2.0.1/assets/pipeline.webp +0 -0
  8. {depth2normal-1.0.0 → depth2normal-2.0.1}/pyproject.toml +24 -5
  9. depth2normal-2.0.1/src/depth2normal/__init__.py +26 -0
  10. depth2normal-2.0.1/src/depth2normal/cli.py +207 -0
  11. depth2normal-2.0.1/src/depth2normal/converter.py +209 -0
  12. depth2normal-2.0.1/src/depth2normal/estimate.py +180 -0
  13. depth2normal-2.0.1/src/depth2normal/filters.py +118 -0
  14. depth2normal-2.0.1/src/depth2normal/onnx_export.py +151 -0
  15. depth2normal-2.0.1/tests/test_cli.py +67 -0
  16. depth2normal-2.0.1/tests/test_converter.py +297 -0
  17. depth2normal-2.0.1/tests/test_estimate.py +65 -0
  18. depth2normal-2.0.1/tests/test_filters.py +91 -0
  19. depth2normal-2.0.1/tests/test_onnx_export.py +86 -0
  20. depth2normal-2.0.1/tools/benchmark.py +108 -0
  21. depth2normal-2.0.1/tools/build_web_models.py +38 -0
  22. {depth2normal-1.0.0 → depth2normal-2.0.1}/uv.lock +178 -140
  23. depth2normal-2.0.1/web/_headers +18 -0
  24. depth2normal-2.0.1/web/app.js +348 -0
  25. depth2normal-2.0.1/web/assets/og.png +0 -0
  26. depth2normal-2.0.1/web/index.html +215 -0
  27. depth2normal-2.0.1/web/models/gaussian-0.5.onnx +0 -0
  28. depth2normal-2.0.1/web/models/gaussian-1.onnx +0 -0
  29. depth2normal-2.0.1/web/models/gaussian-2.onnx +0 -0
  30. depth2normal-2.0.1/web/models/gaussian-3.onnx +0 -0
  31. depth2normal-2.0.1/web/models/scharr.onnx +0 -0
  32. depth2normal-2.0.1/web/models/sobel.onnx +0 -0
  33. depth2normal-2.0.1/web/sample-depth.png +0 -0
  34. depth2normal-2.0.1/web/style.css +601 -0
  35. depth2normal-1.0.0/PKG-INFO +0 -157
  36. depth2normal-1.0.0/README.md +0 -126
  37. depth2normal-1.0.0/run.py +0 -25
  38. depth2normal-1.0.0/src/depth2normal/__init__.py +0 -6
  39. depth2normal-1.0.0/src/depth2normal/cli.py +0 -52
  40. depth2normal-1.0.0/src/depth2normal/converter.py +0 -124
  41. depth2normal-1.0.0/tests/test_converter.py +0 -147
  42. {depth2normal-1.0.0 → depth2normal-2.0.1}/.github/workflows/release.yml +0 -0
  43. {depth2normal-1.0.0 → depth2normal-2.0.1}/LICENSE +0 -0
  44. {depth2normal-1.0.0 → depth2normal-2.0.1}/assets/depth.png +0 -0
  45. {depth2normal-1.0.0 → depth2normal-2.0.1}/assets/normal.png +0 -0
  46. {depth2normal-1.0.0 → depth2normal-2.0.1}/src/depth2normal/__main__.py +0 -0
  47. {depth2normal-1.0.0 → depth2normal-2.0.1}/src/depth2normal/py.typed +0 -0
  48. {depth2normal-1.0.0 → depth2normal-2.0.1}/tests/__init__.py +0 -0
@@ -15,13 +15,16 @@ jobs:
15
15
  - run: uv sync --frozen
16
16
  - run: uv run ruff check .
17
17
  - run: uv run ruff format --check .
18
+ # The browser demo ships generated models; they must match the code.
19
+ - run: uv run python tools/build_web_models.py
20
+ - run: git diff --exit-code web/models
18
21
 
19
22
  test:
20
23
  runs-on: ${{ matrix.os }}
21
24
  strategy:
22
25
  matrix:
23
26
  os: [ubuntu-latest, macos-latest]
24
- python-version: ["3.10", "3.11", "3.12", "3.13"]
27
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
25
28
  steps:
26
29
  - uses: actions/checkout@v4
27
30
  - uses: astral-sh/setup-uv@v5
@@ -39,3 +39,6 @@ htmlcov/
39
39
  # OS
40
40
  .DS_Store
41
41
  Thumbs.db
42
+
43
+ # wrangler
44
+ .wrangler/
@@ -0,0 +1,273 @@
1
+ Metadata-Version: 2.5
2
+ Name: depth2normal
3
+ Version: 2.0.1
4
+ Summary: Convert depth maps to normal maps, from a photo if you have no depth map
5
+ Project-URL: Homepage, https://depth2normal.cobanov.dev
6
+ Project-URL: Repository, https://github.com/cobanov/depth2normal
7
+ Project-URL: Issues, https://github.com/cobanov/depth2normal/issues
8
+ Project-URL: Changelog, https://github.com/cobanov/depth2normal/releases
9
+ Author: Mert Cobanov
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: 3d,computer-vision,depth-estimation,depth-map,image-processing,normal-map,onnx
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Multimedia :: Graphics
25
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: click
29
+ Requires-Dist: numpy
30
+ Requires-Dist: pillow
31
+ Provides-Extra: all
32
+ Requires-Dist: onnx; extra == 'all'
33
+ Requires-Dist: onnxruntime>=1.20; (python_version >= '3.11') and extra == 'all'
34
+ Provides-Extra: estimate
35
+ Requires-Dist: onnxruntime>=1.20; (python_version >= '3.11') and extra == 'estimate'
36
+ Provides-Extra: onnx
37
+ Requires-Dist: onnx; extra == 'onnx'
38
+ Description-Content-Type: text/markdown
39
+
40
+ <p align="center">
41
+ <img src="https://raw.githubusercontent.com/cobanov/depth2normal/main/assets/hero.webp" alt="A Mars photograph next to the normal map depth2normal produced from it" width="700">
42
+ </p>
43
+
44
+ <p align="center">
45
+ A depth map goes in, a normal map comes out.<br>
46
+ If you do not have a depth map, it estimates one.
47
+ </p>
48
+
49
+ <p align="center">
50
+ <a href="https://pypi.org/project/depth2normal/"><img alt="pypi" src="https://img.shields.io/pypi/v/depth2normal?color=8c8cff&labelColor=1a1a1a"></a>
51
+ <a href="https://github.com/cobanov/depth2normal/actions/workflows/ci.yml"><img alt="ci" src="https://img.shields.io/github/actions/workflow/status/cobanov/depth2normal/ci.yml?branch=main&color=8c8cff&labelColor=1a1a1a"></a>
52
+ <img alt="tests" src="https://img.shields.io/badge/tests-87-8c8cff?labelColor=1a1a1a">
53
+ <img alt="python" src="https://img.shields.io/badge/python-3.10%2B-8c8cff?labelColor=1a1a1a">
54
+ <a href="https://github.com/cobanov/depth2normal/blob/main/LICENSE"><img alt="licence" src="https://img.shields.io/badge/licence-MIT-8c8cff?labelColor=1a1a1a"></a>
55
+ </p>
56
+
57
+ ---
58
+
59
+ Turning depth into normals is a small piece of arithmetic: two gradients, a
60
+ vector, one normalisation. The parts that are usually missing sit on either
61
+ side of it. You need a depth map before you can start, and you need Python at
62
+ the other end to run the result. This package covers both.
63
+
64
+ ```sh
65
+ pip install depth2normal
66
+ depth2normal depth.png -o normal.png
67
+ ```
68
+
69
+ - **No depth map, no problem.** `--estimate` runs Depth Anything V2 Small on
70
+ the photo first, so a photograph is a valid input.
71
+ - **The conversion exports to ONNX**, in about 1.7 KB, so it runs in a browser,
72
+ in C#, in Unity, in a ComfyUI node, anywhere ONNX Runtime goes.
73
+ - **Scale invariant.** An 8-bit and a 16-bit copy of one depth map produce
74
+ identical normals, so `strength` means the same thing in every file.
75
+ - **Three runtime dependencies**: NumPy, Pillow and Click. No SciPy, no OpenCV.
76
+ - **A browser demo in `web/`**, no build step, nothing uploaded.
77
+
78
+ ## Install
79
+
80
+ ```sh
81
+ pip install depth2normal # the converter
82
+ pip install 'depth2normal[all]' # plus ONNX export and depth estimation
83
+ ```
84
+
85
+ `uv add depth2normal` works the same way. `[onnx]` is the export, `[estimate]`
86
+ is depth estimation, which needs Python 3.11 or newer because ONNX Runtime
87
+ publishes no 3.10 wheels.
88
+
89
+ ## Use
90
+
91
+ ```sh
92
+ depth2normal depth.png -o normal.png # the common case
93
+ depth2normal depth.png -s 3 -m scharr # stronger relief, sharper filter
94
+ depth2normal depth.png --invert # for maps where bright means far
95
+ depth2normal depth.tif --focal 1200 --range raw # metric depth from a real camera
96
+ depth2normal photo.jpg --estimate -o normal.png # no depth map needed
97
+ depth2normal estimate photo.jpg -o depth.png # just the depth map
98
+ depth2normal export -o depth2normal.onnx # just the graph
99
+ ```
100
+
101
+ | Option | Default | What it does |
102
+ | --- | --- | --- |
103
+ | `-o`, `--output` | `normal_map.png` | Where the normal map goes |
104
+ | `-s`, `--strength` | `1.0` | Gradient multiplier, so how pronounced the relief is |
105
+ | `-m`, `--method` | `gaussian` | `gaussian`, `sobel` or `scharr` |
106
+ | `--sigma` | `1.0` | Gaussian kernel width, smoothness against detail |
107
+ | `--range` | `auto` | `auto`, `minmax` or `raw`, see [How it works](#how-it-works) |
108
+ | `--invert` | off | Flip near and far |
109
+ | `--focal` | | Focal length in pixels, for metric depth (see below) |
110
+ | `--estimate` | off | Treat the input as a photo and estimate its depth first |
111
+ | `--save-depth` | | Keep the estimated depth map as well |
112
+
113
+ ```python
114
+ import depth2normal
115
+
116
+ depth2normal.convert("depth.png", "normal.png", strength=2.0, method="scharr")
117
+
118
+ depth = depth2normal.estimate_depth("photo.jpg") # needs [estimate]
119
+ normal = depth2normal.depth_to_normal(depth, strength=3.0)
120
+
121
+ depth2normal.export_onnx("depth2normal.onnx") # needs [onnx]
122
+ ```
123
+
124
+ ## From a photo
125
+
126
+ <p align="center">
127
+ <img src="https://raw.githubusercontent.com/cobanov/depth2normal/main/assets/pipeline.webp" alt="A photograph, the depth map estimated from it, and the resulting normal map" width="700">
128
+ </p>
129
+
130
+ `--estimate` runs [Depth Anything V2 Small](https://huggingface.co/onnx-community/depth-anything-v2-small)
131
+ (Apache-2.0, 99 MB) through ONNX Runtime, on CUDA if `onnxruntime-gpu` is
132
+ installed. The model is downloaded once, checksummed, and cached in
133
+ `~/.cache/depth2normal`, or wherever `DEPTH2NORMAL_CACHE` points.
134
+
135
+ What comes back is relative depth, not metres. That is what a normal map
136
+ needs, and it is not what a measurement needs.
137
+
138
+ ## Anywhere ONNX Runtime goes
139
+
140
+ ```sh
141
+ depth2normal export -o depth2normal.onnx --method gaussian --sigma 1
142
+ ```
143
+
144
+ | | |
145
+ | --- | --- |
146
+ | Input `depth` | float32 `[1, 1, H, W]`, on the 0-255 range, height and width dynamic |
147
+ | Input `strength` | float32 scalar, so relief is tunable without re-exporting |
148
+ | Output `normal_map` | uint8 `[1, H, W, 3]`, RGB, ready for a canvas or an image file |
149
+ | Size | 1.1 KB for `sobel`, 6.1 KB for `gaussian` at sigma 3 |
150
+
151
+ Method and sigma are baked into the convolution weights, because they are the
152
+ kernel. Strength is not, so one file covers every strength.
153
+
154
+ The graph and the NumPy path agree to within **one level out of 255** on at
155
+ most 3% of pixels, which is float32 against float64 at a rounding boundary.
156
+ `tests/test_onnx_export.py` asserts it.
157
+
158
+ Prebuilt models for every method are attached to
159
+ [the latest release](https://github.com/cobanov/depth2normal/releases/latest).
160
+
161
+ ## In the browser
162
+
163
+ **[depth2normal.cobanov.dev](https://depth2normal.cobanov.dev)** is the same
164
+ conversion as a single page: it loads the exported graph and runs everything
165
+ client side. The source is `web/`, with no build step and no bundler:
166
+
167
+ ```sh
168
+ cd web && python3 -m http.server
169
+ ```
170
+
171
+ A depth map converts in a few milliseconds of WebAssembly. Press **Estimate
172
+ depth** on a photograph and the quantised 27 MB model runs in the tab as well,
173
+ about 6.5 seconds for a 640x480 photo. Nothing is uploaded either way.
174
+
175
+ ## How it works
176
+
177
+ 1. Bring the depth values onto a fixed 0-255 range (see the table below).
178
+ 2. Estimate `dx` and `dy` with a separable derivative filter, reflecting at the
179
+ border.
180
+ 3. Build `(-dx * strength, -dy * strength, 1)`, normalise it to unit length,
181
+ and map it to 8-bit RGB.
182
+
183
+ Step 1 is the one that matters: it is what makes `strength` mean the same
184
+ thing whatever the input's bit depth.
185
+
186
+ | `--range` | What it does | When |
187
+ | --- | --- | --- |
188
+ | `auto` | Divides by the range the values came from: 0-1 float, 8-bit, or 16-bit | The default. Steady across a sequence |
189
+ | `minmax` | Stretches this image's min and max to 0-255 | Low contrast maps |
190
+ | `raw` | Leaves the values alone | Metric depth, or your own scaling |
191
+
192
+ The gradient filters:
193
+
194
+ | Method | Quality | Notes |
195
+ | --- | --- | --- |
196
+ | `gaussian` | Best | Gaussian derivative. `--sigma` trades smoothness against detail |
197
+ | `sobel` | Good | Classic 3x3. Sharp, but staircases on quantised depth |
198
+ | `scharr` | Good | Better rotational accuracy than Sobel, same speed |
199
+
200
+ All three are separable, which is what lets the package drop SciPy and still
201
+ match it to 1e-13 at the same speed, with 19.5 MB less to install. Their
202
+ kernels are normalised, so `strength` means the same thing whichever you pick.
203
+
204
+ ### Metric depth
205
+
206
+ The default treats depth as a height field, which is what a shading normal map
207
+ wants. Given `--focal` (in pixels) it treats the depth as metric distance from
208
+ a pinhole camera instead, unprojects each pixel, and returns the normals of
209
+ that 3-D surface: the same plane twice as far away then comes out with the same
210
+ orientation. Pair it with `--range raw` so the metric values survive. The
211
+ exported ONNX graph is the height field form.
212
+
213
+ ### Rings in the output
214
+
215
+ Contour rings are the source depth map's 8-bit steps, amplified by the
216
+ derivative. Raising `--sigma` smooths them away, and a 16-bit depth map does
217
+ not have them at all. Error against the same surface before quantisation:
218
+
219
+ | Source | Mean error |
220
+ | --- | --- |
221
+ | 8-bit, `--sigma 0.5` | 7.1 levels |
222
+ | 8-bit, `--sigma 1` | 2.4 levels |
223
+ | 8-bit, `--sigma 3` | 0.2 levels |
224
+ | 16-bit, `--sigma 1` | 0.01 levels |
225
+
226
+ ## Measured
227
+
228
+ `tools/benchmark.py` prints these: median of five runs, `gaussian` at sigma 1,
229
+ on synthetic depth maps.
230
+
231
+ **Apple M4 Pro**, Python 3.14, ONNX Runtime on the CPU
232
+
233
+ | Size | NumPy | ONNX Runtime |
234
+ | --- | --- | --- |
235
+ | 1920 x 1080 | 53 ms | 12 ms |
236
+ | 1080 x 1920 | 52 ms | 10 ms |
237
+ | 4096 x 2304 | 243 ms | 54 ms |
238
+ | 2304 x 4096 | 243 ms | 55 ms |
239
+ | 8192 x 4608 | 958 ms | 211 ms |
240
+
241
+ **Intel i5-9600K with an RTX 3090**, Python 3.12
242
+
243
+ | Size | NumPy | ONNX Runtime | CUDA |
244
+ | --- | --- | --- | --- |
245
+ | 1920 x 1080 | 188 ms | 40 ms | 3 ms |
246
+ | 1080 x 1920 | 191 ms | 40 ms | 3 ms |
247
+ | 4096 x 2304 | 1003 ms | 216 ms | 13 ms |
248
+ | 2304 x 4096 | 984 ms | 184 ms | 13 ms |
249
+
250
+ Orientation is free: a portrait image and a landscape one of the same pixel
251
+ count land within a few percent of each other. The exported graph is four to
252
+ five times faster than NumPy on a CPU, and on a GPU it stops being the part
253
+ worth timing. The 37 megapixel row is missing from the second table because
254
+ that card was busy serving another model.
255
+
256
+ Depth estimation of a 640x480 photo takes 231 ms on the M4 Pro CPU and 32 ms on
257
+ the 3090.
258
+
259
+ ## Development
260
+
261
+ ```sh
262
+ uv sync --extra all
263
+ uv run pytest
264
+ uv run ruff check .
265
+ uv run ruff format --check .
266
+ uv run python tools/build_web_models.py
267
+ wrangler pages deploy web --project-name depth2normal # the demo
268
+ ```
269
+
270
+ ## Licence
271
+
272
+ MIT. The depth model is Apache-2.0 and is downloaded, not vendored. Image
273
+ credits are in [assets/CREDITS.md](https://github.com/cobanov/depth2normal/blob/main/assets/CREDITS.md).
@@ -0,0 +1,234 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/cobanov/depth2normal/main/assets/hero.webp" alt="A Mars photograph next to the normal map depth2normal produced from it" width="700">
3
+ </p>
4
+
5
+ <p align="center">
6
+ A depth map goes in, a normal map comes out.<br>
7
+ If you do not have a depth map, it estimates one.
8
+ </p>
9
+
10
+ <p align="center">
11
+ <a href="https://pypi.org/project/depth2normal/"><img alt="pypi" src="https://img.shields.io/pypi/v/depth2normal?color=8c8cff&labelColor=1a1a1a"></a>
12
+ <a href="https://github.com/cobanov/depth2normal/actions/workflows/ci.yml"><img alt="ci" src="https://img.shields.io/github/actions/workflow/status/cobanov/depth2normal/ci.yml?branch=main&color=8c8cff&labelColor=1a1a1a"></a>
13
+ <img alt="tests" src="https://img.shields.io/badge/tests-87-8c8cff?labelColor=1a1a1a">
14
+ <img alt="python" src="https://img.shields.io/badge/python-3.10%2B-8c8cff?labelColor=1a1a1a">
15
+ <a href="https://github.com/cobanov/depth2normal/blob/main/LICENSE"><img alt="licence" src="https://img.shields.io/badge/licence-MIT-8c8cff?labelColor=1a1a1a"></a>
16
+ </p>
17
+
18
+ ---
19
+
20
+ Turning depth into normals is a small piece of arithmetic: two gradients, a
21
+ vector, one normalisation. The parts that are usually missing sit on either
22
+ side of it. You need a depth map before you can start, and you need Python at
23
+ the other end to run the result. This package covers both.
24
+
25
+ ```sh
26
+ pip install depth2normal
27
+ depth2normal depth.png -o normal.png
28
+ ```
29
+
30
+ - **No depth map, no problem.** `--estimate` runs Depth Anything V2 Small on
31
+ the photo first, so a photograph is a valid input.
32
+ - **The conversion exports to ONNX**, in about 1.7 KB, so it runs in a browser,
33
+ in C#, in Unity, in a ComfyUI node, anywhere ONNX Runtime goes.
34
+ - **Scale invariant.** An 8-bit and a 16-bit copy of one depth map produce
35
+ identical normals, so `strength` means the same thing in every file.
36
+ - **Three runtime dependencies**: NumPy, Pillow and Click. No SciPy, no OpenCV.
37
+ - **A browser demo in `web/`**, no build step, nothing uploaded.
38
+
39
+ ## Install
40
+
41
+ ```sh
42
+ pip install depth2normal # the converter
43
+ pip install 'depth2normal[all]' # plus ONNX export and depth estimation
44
+ ```
45
+
46
+ `uv add depth2normal` works the same way. `[onnx]` is the export, `[estimate]`
47
+ is depth estimation, which needs Python 3.11 or newer because ONNX Runtime
48
+ publishes no 3.10 wheels.
49
+
50
+ ## Use
51
+
52
+ ```sh
53
+ depth2normal depth.png -o normal.png # the common case
54
+ depth2normal depth.png -s 3 -m scharr # stronger relief, sharper filter
55
+ depth2normal depth.png --invert # for maps where bright means far
56
+ depth2normal depth.tif --focal 1200 --range raw # metric depth from a real camera
57
+ depth2normal photo.jpg --estimate -o normal.png # no depth map needed
58
+ depth2normal estimate photo.jpg -o depth.png # just the depth map
59
+ depth2normal export -o depth2normal.onnx # just the graph
60
+ ```
61
+
62
+ | Option | Default | What it does |
63
+ | --- | --- | --- |
64
+ | `-o`, `--output` | `normal_map.png` | Where the normal map goes |
65
+ | `-s`, `--strength` | `1.0` | Gradient multiplier, so how pronounced the relief is |
66
+ | `-m`, `--method` | `gaussian` | `gaussian`, `sobel` or `scharr` |
67
+ | `--sigma` | `1.0` | Gaussian kernel width, smoothness against detail |
68
+ | `--range` | `auto` | `auto`, `minmax` or `raw`, see [How it works](#how-it-works) |
69
+ | `--invert` | off | Flip near and far |
70
+ | `--focal` | | Focal length in pixels, for metric depth (see below) |
71
+ | `--estimate` | off | Treat the input as a photo and estimate its depth first |
72
+ | `--save-depth` | | Keep the estimated depth map as well |
73
+
74
+ ```python
75
+ import depth2normal
76
+
77
+ depth2normal.convert("depth.png", "normal.png", strength=2.0, method="scharr")
78
+
79
+ depth = depth2normal.estimate_depth("photo.jpg") # needs [estimate]
80
+ normal = depth2normal.depth_to_normal(depth, strength=3.0)
81
+
82
+ depth2normal.export_onnx("depth2normal.onnx") # needs [onnx]
83
+ ```
84
+
85
+ ## From a photo
86
+
87
+ <p align="center">
88
+ <img src="https://raw.githubusercontent.com/cobanov/depth2normal/main/assets/pipeline.webp" alt="A photograph, the depth map estimated from it, and the resulting normal map" width="700">
89
+ </p>
90
+
91
+ `--estimate` runs [Depth Anything V2 Small](https://huggingface.co/onnx-community/depth-anything-v2-small)
92
+ (Apache-2.0, 99 MB) through ONNX Runtime, on CUDA if `onnxruntime-gpu` is
93
+ installed. The model is downloaded once, checksummed, and cached in
94
+ `~/.cache/depth2normal`, or wherever `DEPTH2NORMAL_CACHE` points.
95
+
96
+ What comes back is relative depth, not metres. That is what a normal map
97
+ needs, and it is not what a measurement needs.
98
+
99
+ ## Anywhere ONNX Runtime goes
100
+
101
+ ```sh
102
+ depth2normal export -o depth2normal.onnx --method gaussian --sigma 1
103
+ ```
104
+
105
+ | | |
106
+ | --- | --- |
107
+ | Input `depth` | float32 `[1, 1, H, W]`, on the 0-255 range, height and width dynamic |
108
+ | Input `strength` | float32 scalar, so relief is tunable without re-exporting |
109
+ | Output `normal_map` | uint8 `[1, H, W, 3]`, RGB, ready for a canvas or an image file |
110
+ | Size | 1.1 KB for `sobel`, 6.1 KB for `gaussian` at sigma 3 |
111
+
112
+ Method and sigma are baked into the convolution weights, because they are the
113
+ kernel. Strength is not, so one file covers every strength.
114
+
115
+ The graph and the NumPy path agree to within **one level out of 255** on at
116
+ most 3% of pixels, which is float32 against float64 at a rounding boundary.
117
+ `tests/test_onnx_export.py` asserts it.
118
+
119
+ Prebuilt models for every method are attached to
120
+ [the latest release](https://github.com/cobanov/depth2normal/releases/latest).
121
+
122
+ ## In the browser
123
+
124
+ **[depth2normal.cobanov.dev](https://depth2normal.cobanov.dev)** is the same
125
+ conversion as a single page: it loads the exported graph and runs everything
126
+ client side. The source is `web/`, with no build step and no bundler:
127
+
128
+ ```sh
129
+ cd web && python3 -m http.server
130
+ ```
131
+
132
+ A depth map converts in a few milliseconds of WebAssembly. Press **Estimate
133
+ depth** on a photograph and the quantised 27 MB model runs in the tab as well,
134
+ about 6.5 seconds for a 640x480 photo. Nothing is uploaded either way.
135
+
136
+ ## How it works
137
+
138
+ 1. Bring the depth values onto a fixed 0-255 range (see the table below).
139
+ 2. Estimate `dx` and `dy` with a separable derivative filter, reflecting at the
140
+ border.
141
+ 3. Build `(-dx * strength, -dy * strength, 1)`, normalise it to unit length,
142
+ and map it to 8-bit RGB.
143
+
144
+ Step 1 is the one that matters: it is what makes `strength` mean the same
145
+ thing whatever the input's bit depth.
146
+
147
+ | `--range` | What it does | When |
148
+ | --- | --- | --- |
149
+ | `auto` | Divides by the range the values came from: 0-1 float, 8-bit, or 16-bit | The default. Steady across a sequence |
150
+ | `minmax` | Stretches this image's min and max to 0-255 | Low contrast maps |
151
+ | `raw` | Leaves the values alone | Metric depth, or your own scaling |
152
+
153
+ The gradient filters:
154
+
155
+ | Method | Quality | Notes |
156
+ | --- | --- | --- |
157
+ | `gaussian` | Best | Gaussian derivative. `--sigma` trades smoothness against detail |
158
+ | `sobel` | Good | Classic 3x3. Sharp, but staircases on quantised depth |
159
+ | `scharr` | Good | Better rotational accuracy than Sobel, same speed |
160
+
161
+ All three are separable, which is what lets the package drop SciPy and still
162
+ match it to 1e-13 at the same speed, with 19.5 MB less to install. Their
163
+ kernels are normalised, so `strength` means the same thing whichever you pick.
164
+
165
+ ### Metric depth
166
+
167
+ The default treats depth as a height field, which is what a shading normal map
168
+ wants. Given `--focal` (in pixels) it treats the depth as metric distance from
169
+ a pinhole camera instead, unprojects each pixel, and returns the normals of
170
+ that 3-D surface: the same plane twice as far away then comes out with the same
171
+ orientation. Pair it with `--range raw` so the metric values survive. The
172
+ exported ONNX graph is the height field form.
173
+
174
+ ### Rings in the output
175
+
176
+ Contour rings are the source depth map's 8-bit steps, amplified by the
177
+ derivative. Raising `--sigma` smooths them away, and a 16-bit depth map does
178
+ not have them at all. Error against the same surface before quantisation:
179
+
180
+ | Source | Mean error |
181
+ | --- | --- |
182
+ | 8-bit, `--sigma 0.5` | 7.1 levels |
183
+ | 8-bit, `--sigma 1` | 2.4 levels |
184
+ | 8-bit, `--sigma 3` | 0.2 levels |
185
+ | 16-bit, `--sigma 1` | 0.01 levels |
186
+
187
+ ## Measured
188
+
189
+ `tools/benchmark.py` prints these: median of five runs, `gaussian` at sigma 1,
190
+ on synthetic depth maps.
191
+
192
+ **Apple M4 Pro**, Python 3.14, ONNX Runtime on the CPU
193
+
194
+ | Size | NumPy | ONNX Runtime |
195
+ | --- | --- | --- |
196
+ | 1920 x 1080 | 53 ms | 12 ms |
197
+ | 1080 x 1920 | 52 ms | 10 ms |
198
+ | 4096 x 2304 | 243 ms | 54 ms |
199
+ | 2304 x 4096 | 243 ms | 55 ms |
200
+ | 8192 x 4608 | 958 ms | 211 ms |
201
+
202
+ **Intel i5-9600K with an RTX 3090**, Python 3.12
203
+
204
+ | Size | NumPy | ONNX Runtime | CUDA |
205
+ | --- | --- | --- | --- |
206
+ | 1920 x 1080 | 188 ms | 40 ms | 3 ms |
207
+ | 1080 x 1920 | 191 ms | 40 ms | 3 ms |
208
+ | 4096 x 2304 | 1003 ms | 216 ms | 13 ms |
209
+ | 2304 x 4096 | 984 ms | 184 ms | 13 ms |
210
+
211
+ Orientation is free: a portrait image and a landscape one of the same pixel
212
+ count land within a few percent of each other. The exported graph is four to
213
+ five times faster than NumPy on a CPU, and on a GPU it stops being the part
214
+ worth timing. The 37 megapixel row is missing from the second table because
215
+ that card was busy serving another model.
216
+
217
+ Depth estimation of a 640x480 photo takes 231 ms on the M4 Pro CPU and 32 ms on
218
+ the 3090.
219
+
220
+ ## Development
221
+
222
+ ```sh
223
+ uv sync --extra all
224
+ uv run pytest
225
+ uv run ruff check .
226
+ uv run ruff format --check .
227
+ uv run python tools/build_web_models.py
228
+ wrangler pages deploy web --project-name depth2normal # the demo
229
+ ```
230
+
231
+ ## Licence
232
+
233
+ MIT. The depth model is Apache-2.0 and is downloaded, not vendored. Image
234
+ credits are in [assets/CREDITS.md](https://github.com/cobanov/depth2normal/blob/main/assets/CREDITS.md).
@@ -0,0 +1,10 @@
1
+ # Image credits
2
+
3
+ `hero.webp` and `pipeline.webp` are built from *Curiosity Self-Portrait at
4
+ Mojave Site on Mount Sharp* (NASA/JPL-Caltech/MSSS, PIA19142), a NASA image in
5
+ the public domain: https://images.nasa.gov/details/PIA19142
6
+
7
+ The depth map beside it was estimated by this package, and the normal map was
8
+ converted from that depth map.
9
+
10
+ `depth.png` and `normal.png` are the project's own sample pair.
Binary file
Binary file
@@ -4,13 +4,21 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "depth2normal"
7
- version = "1.0.0"
8
- description = "Convert depth map images to normal map images"
7
+ version = "2.0.1"
8
+ description = "Convert depth maps to normal maps, from a photo if you have no depth map"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.10"
12
12
  authors = [{ name = "Mert Cobanov" }]
13
- keywords = ["depth-map", "normal-map", "3d", "computer-vision", "image-processing"]
13
+ keywords = [
14
+ "depth-map",
15
+ "normal-map",
16
+ "3d",
17
+ "computer-vision",
18
+ "image-processing",
19
+ "onnx",
20
+ "depth-estimation",
21
+ ]
14
22
  classifiers = [
15
23
  "Development Status :: 5 - Production/Stable",
16
24
  "Intended Audience :: Developers",
@@ -22,6 +30,7 @@ classifiers = [
22
30
  "Programming Language :: Python :: 3.11",
23
31
  "Programming Language :: Python :: 3.12",
24
32
  "Programming Language :: Python :: 3.13",
33
+ "Programming Language :: Python :: 3.14",
25
34
  "Topic :: Multimedia :: Graphics",
26
35
  "Topic :: Scientific/Engineering :: Image Processing",
27
36
  "Typing :: Typed",
@@ -29,17 +38,25 @@ classifiers = [
29
38
  dependencies = [
30
39
  "numpy",
31
40
  "Pillow",
32
- "scipy",
33
41
  "click",
34
42
  ]
35
43
 
44
+ [project.optional-dependencies]
45
+ # Export the conversion as a standalone ONNX graph.
46
+ onnx = ["onnx"]
47
+ # Estimate a depth map from a photo with Depth Anything V2 Small.
48
+ # ONNX Runtime stopped publishing wheels for Python 3.10, so estimation needs 3.11.
49
+ estimate = ["onnxruntime>=1.20; python_version >= '3.11'"]
50
+ all = ["onnx", "onnxruntime>=1.20; python_version >= '3.11'"]
51
+
36
52
  [project.scripts]
37
53
  depth2normal = "depth2normal.cli:cli"
38
54
 
39
55
  [project.urls]
40
- Homepage = "https://github.com/cobanov/depth2normal"
56
+ Homepage = "https://depth2normal.cobanov.dev"
41
57
  Repository = "https://github.com/cobanov/depth2normal"
42
58
  Issues = "https://github.com/cobanov/depth2normal/issues"
59
+ Changelog = "https://github.com/cobanov/depth2normal/releases"
43
60
 
44
61
  [tool.ruff]
45
62
  target-version = "py310"
@@ -53,6 +70,8 @@ testpaths = ["tests"]
53
70
 
54
71
  [dependency-groups]
55
72
  dev = [
73
+ "onnx",
74
+ "onnxruntime>=1.20; python_version >= '3.11'",
56
75
  "pytest",
57
76
  "ruff",
58
77
  ]
@@ -0,0 +1,26 @@
1
+ """depth2normal -- convert depth maps to normal maps."""
2
+
3
+ from depth2normal.converter import (
4
+ METHODS,
5
+ RANGES,
6
+ convert,
7
+ depth_to_normal,
8
+ load_depth,
9
+ rescale_depth,
10
+ )
11
+ from depth2normal.estimate import estimate_depth
12
+ from depth2normal.onnx_export import build_model, export_onnx
13
+
14
+ __version__ = "2.0.1"
15
+ __all__ = [
16
+ "METHODS",
17
+ "RANGES",
18
+ "__version__",
19
+ "build_model",
20
+ "convert",
21
+ "depth_to_normal",
22
+ "estimate_depth",
23
+ "export_onnx",
24
+ "load_depth",
25
+ "rescale_depth",
26
+ ]