| --- |
| license: cc-by-4.0 |
| tags: |
| - onnx |
| - stereo |
| - stereo-matching |
| - computer-vision |
| - viame |
| --- |
| |
| # VIAME Stereo Template Matcher |
|
|
| Epipolar template matching for stereo correspondence, exported from |
| [VIAME](https://github.com/VIAME/VIAME) as a single ONNX graph. Given a set of points in |
| the left image of a calibrated stereo pair, it returns the matching points in the right |
| image, plus match scores. |
|
|
| This is VIAME's stereo measurement **method 1** |
| (`epipolar_template_matching`): for each source point it walks the epipolar curve implied |
| by the calibration, sampling candidate depths, and picks the best NCC (`TM_CCOEFF_NORMED`) |
| template match. Pair it with two-view triangulation to measure real-world lengths — the |
| usual application is measuring fish in stereo camera rigs. |
|
|
| **There are no learned weights.** The graph is pure geometry plus normalized |
| cross-correlation, which is why it is 89 KB. Nothing here was trained, and there is no |
| training data or evaluation set. |
|
|
| ## Files |
|
|
| | File | Size | Notes | |
| | --- | --- | --- | |
| | `stereo_match.onnx` | 89 KB | opset 18, IR 8, float32 throughout | |
|
|
| ## Baked-in constants |
|
|
| Two parameters are **frozen into the graph at export time** and are not runtime inputs: |
|
|
| | Constant | Value | |
| | --- | --- | |
| | `template_size` | 13 (13×13 NCC patch) | |
| | `num_samples` | 5000 (depth samples along the epipolar curve) | |
|
|
| These match `configs/pipelines/interactive_stereo_template.conf` in VIAME, which is what |
| the DIVE desktop interactive stereo service loads. Note they are **not** the export |
| script's own defaults (25 / 5000) — re-exporting without explicit flags produces a model |
| that loads identically but matches differently. To reproduce this exact file: |
|
|
| ```bash |
| python plugins/onnx/export_stereo_mapping.py --model match \ |
| --out stereo_match.onnx --template-size 13 --num-samples 5000 |
| ``` |
|
|
| ## Inputs |
|
|
| All float32. The world frame is the **left camera**, so the left camera is normally |
| `R_left = I`, `t_left = 0`, and the right camera carries the rig's relative pose. |
| Matrices are row-major. |
|
|
| | Name | Shape | Meaning | |
| | --- | --- | --- | |
| | `left_gray` | `[H, W]` | Left image, grayscale, 0–255 | |
| | `right_gray` | `[Hr, Wr]` | Right image, grayscale, 0–255 | |
| | `points_left` | `[P, 2]` | Source points `(x, y)` in left-image pixels | |
| | `K_left` | `[3, 3]` | Left intrinsics | |
| | `dist_left` | `[8]` | Left distortion `[k1, k2, p1, p2, k3, k4, k5, k6]`, zero-padded | |
| | `R_left` | `[3, 3]` | Left rotation (identity in the usual convention) | |
| | `t_left` | `[3]` | Left translation (zero in the usual convention) | |
| | `K_right` | `[3, 3]` | Right intrinsics | |
| | `dist_right` | `[8]` | Right distortion | |
| | `R_right` | `[3, 3]` | Rig rotation, left → right | |
| | `t_right` | `[3]` | Rig translation, in calibration units | |
| | `min_depth` | scalar | Near bound of the depth search | |
| | `max_depth` | scalar | Far bound of the depth search | |
|
|
| Grayscale must use BT.601 luma (`0.299 R + 0.587 G + 0.114 B`) to match the OpenCV |
| `BGR2GRAY` the reference implementation uses. |
|
|
| ## Outputs |
|
|
| | Name | Shape | Meaning | |
| | --- | --- | --- | |
| | `right_points` | `[P, 2]` | Matched points in right-image pixels | |
| | `best_score` | `[P]` | Best NCC score | |
| | `second_score` | `[P]` | Best NCC score outside a `template_size` neighborhood of the winner | |
|
|
| ## Acceptance thresholds |
|
|
| The graph deliberately does **not** apply a score threshold — it returns the best match |
| unconditionally, so the host decides what to accept. VIAME's two reference hosts disagree, |
| and both are defensible: |
|
|
| | Host | Threshold | Uniqueness ratio | |
| | --- | --- | --- | |
| | `interactive_stereo_template.conf` (DIVE desktop) | 0.5 | none | |
| | `plugins/onnx/run_epipolar_onnx.py` | 0.2 | 0.85 | |
|
|
| The uniqueness test, where used, rejects a match when |
| `second_score / best_score > ratio` — i.e. the winner was not clearly better than an |
| unrelated candidate elsewhere on the curve. Useful on repetitive texture. |
|
|
| ## Search range |
|
|
| The graph takes a **depth** range, but disparity is usually the more natural way to think |
| about it. Convert with: |
|
|
| ``` |
| min_depth = fx * baseline / max_disparity |
| max_depth = fx * baseline / min_disparity |
| ``` |
|
|
| where `fx = K_left[0][0]` and `baseline = ||t_right||`. |
|
|
| A disparity range of **2–300 px** matches the DIVE desktop interactive stereo config, but |
| this is scene-dependent: VIAME's batch measurement pipelines ship 7–724 for other rigs. Too |
| wide invites false matches; too narrow misses the target entirely. Calibrate it to how far |
| the same object actually shifts between your two cameras. |
|
|
| ## Usage |
|
|
| ### Python |
|
|
| ```python |
| import numpy as np, onnxruntime as ort |
| |
| sess = ort.InferenceSession("stereo_match.onnx", providers=["CPUExecutionProvider"]) |
| out = sess.run(None, { |
| "left_gray": left.astype(np.float32), # [H, W], 0-255 |
| "right_gray": right.astype(np.float32), |
| "points_left": np.array([[330.4, 234.8]], np.float32), |
| "K_left": K1, "dist_left": d1, "R_left": np.eye(3, dtype=np.float32), |
| "t_left": np.zeros(3, np.float32), |
| "K_right": K2, "dist_right": d2, "R_right": R, "t_right": T, |
| "min_depth": np.float32(fx * baseline / 300), |
| "max_depth": np.float32(fx * baseline / 2), |
| }) |
| right_points, best_score, second_score = out |
| accepted = best_score >= 0.5 |
| ``` |
|
|
| ### Browser / Node (onnxruntime-web) |
|
|
| The graph is small and CPU-only, so it runs comfortably in a browser via the WASM |
| execution provider — this is how [DIVE](https://github.com/Kitware/dive) warps a detection |
| from one camera to the other with no backend. A complete client-side implementation lives |
| in `client/dive-common/use/stereo/` (see |
| [Kitware/dive#1709](https://github.com/Kitware/dive/pull/1709)), covering calibration |
| parsing, the search-range conversion above, and two-view triangulation. |
|
|
| ### Not a transformers.js model |
|
|
| This is a bespoke geometry graph, not a transformer. It has no `config.json`, no tokenizer |
| or image processor, and no architecture in the transformers.js registry, so `pipeline()` / |
| `AutoModel` will not load it. Use onnxruntime (or onnxruntime-web) directly. |
|
|
| ## Swapping left and right |
|
|
| The graph always matches *left → right*. To warp a point annotated on the right camera, |
| invert the rig instead of swapping the inputs: the new world frame is the old right camera, |
| so `R' = Rᵀ` and `T' = -Rᵀ·T`, with the intrinsics and distortion swapped between sides. |
|
|
| ## Accuracy |
|
|
| Validated against the VIAME C++ / Python reference implementation to roughly a quarter |
| pixel. Note the exporter's own verification reports a sub-pixel `right_points` difference |
| between eager PyTorch and onnxruntime (about 0.04 px at `template_size=13`) caused by |
| ties between equally-scoring epipolar candidates; scores match to ~1e-8. |
|
|
| ## License and provenance |
|
|
| CC-BY-4.0. Produced by `plugins/onnx/export_stereo_mapping.py` in |
| [VIAME](https://github.com/VIAME/VIAME), whose core infrastructure is BSD-3-Clause. |
|
|