feat(pointcloud): add Depth Anything Rust backend

This commit is contained in:
ruv
2026-07-20 10:12:49 -04:00
parent aa7cb449bc
commit 3e3d9437aa
10 changed files with 1344 additions and 30 deletions
@@ -0,0 +1,149 @@
# ADR-271: Depth Anything Rust Camera-Depth Backend
- **Status**: Accepted — phase 1 implemented
- **Date**: 2026-07-20
- **Deciders**: ruv
- **Tags**: rust, depth-anything, ggml, camera, point-cloud, ffi, model-licensing
## Context
The `wifi-densepose-pointcloud` crate fuses camera-derived depth with WiFi CSI
occupancy. Its current camera depth path calls an optional loopback MiDaS service
and otherwise produces a luminance/edge heuristic. The fallback is deterministic
and useful for demonstrations, but it is not a learned or metrically trustworthy
depth estimator. It also provides neither confidence nor camera pose.
`mudler/depth-anything.cpp` is a C++17/ggml inference engine for Depth Anything
2 and 3. It offers quantized GGUF models, CPU/CUDA/Metal/Vulkan execution, and a
flat C ABI that returns dense depth, confidence, intrinsics, extrinsics, and a
metric-output flag. Reimplementing the transformer, DPT heads, quantization, and
ggml backends in Rust would duplicate a large parity-sensitive codebase and make
RuView responsible for maintaining several hardware backends.
The upstream ABI currently accepts image paths rather than in-memory RGB frames.
RuView captures RGB8 buffers directly, so phase 1 needs a bounded file bridge
until an upstream RGB-buffer ABI is available.
Model licensing is not uniform. The upstream engine is MIT, while official DA3
weights include Apache-2.0 and CC-BY-NC-4.0 variants. A permissively licensed
runtime must not make non-commercial weights look generally redistributable.
## Decision
RuView will integrate Depth Anything as an **optional native backend behind a
safe Rust boundary**, not as a pure-Rust model rewrite and not as a required
dependency of camera-free sensing.
1. Load the upstream shared library dynamically at runtime. The RuView build
remains Rust-only and succeeds without CMake, ggml, a model, or a camera.
2. Isolate the ABI and all unsafe operations in `depth_anything.rs`. Keep the
native library alive for the lifetime of its function pointers and context,
serialize calls through Rust mutable ownership, copy native output into Rust
vectors, and release every upstream allocation through its matching function.
3. Require C ABI version 4. Fail closed on any other version.
4. Configure the backend with environment variables:
- `RUVIEW_DEPTH_BACKEND=auto|depth-anything|midas|heuristic`
- `RUVIEW_DA_LIBRARY`, `RUVIEW_DA_MODEL`, `RUVIEW_DA_MODEL_LICENSE`
- optional `RUVIEW_DA_MODEL_SHA256` and `RUVIEW_DA_THREADS`
- `RUVIEW_DA_ALLOW_NONCOMMERCIAL=1` for an explicit local-only override
5. Permit Apache-2.0 weights by default. Reject unknown licenses and reject
CC-BY-NC-4.0 unless the explicit non-commercial override is present. RuView
will not bundle model weights.
6. When a SHA-256 is configured, verify it before native model loading.
7. Bridge RGB8 frames through a unique, create-new PPM file with an RAII cleanup
guard. Never reuse a predictable existing file. Replace this bridge when the
upstream project exposes an in-memory RGB ABI.
8. Accept only metric Depth Anything output in the point-cloud fusion path.
Relative depth must not be labelled or fused as metres.
9. Preserve the existing MiDaS and heuristic paths. `auto` attempts configured
Depth Anything first, then MiDaS, then the heuristic. An explicitly selected
Depth Anything backend reports configuration/load/inference errors instead of
silently claiming that heuristic output came from the model.
10. Carry backend identity, metric status, confidence, and model-provided camera
geometry into the Rust `DepthEstimate`. Confidence becomes point intensity;
low-confidence pixels are excluded before voxel fusion.
11. Camera depth remains an optional point-cloud feature. It does not enter the
RuView/RuField camera-free sensing proof path.
12. Run the native context on a dedicated 16 MiB-stack worker. On Windows, the
validated upstream ABI-v4 build currently access-violates while destroying a
successfully used DA2 context; RuView therefore retains its process-singleton
context and DLL until OS process teardown. Other platforms call `da_capi_free`.
## Consequences
### Positive
- RuView gains local metric monocular depth without Python or PyTorch inference.
- The Rust application owns configuration, policy, provenance, memory safety,
fallback behavior, and multimodal fusion.
- Confidence and calibrated intrinsics improve point-cloud quality over the
current fixed-intrinsics heuristic.
- Dynamic loading avoids a mandatory C++ toolchain and preserves portable
default builds.
- Explicit licensing and digest gates make model provenance auditable.
### Negative
- Production Depth Anything use still requires building or obtaining a compatible
native library and separately obtaining model weights.
- The phase-1 PPM bridge performs bounded temporary-file I/O per inferred frame.
- The upstream Windows build needs `CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS=ON`, and its
current DA2 destructor requires a process-lifetime context workaround.
- Metric models are substantially larger than the current heuristic and are not
suitable for ESP32 firmware.
- GPU backend quality and performance remain an upstream responsibility.
### Neutral
- MiDaS compatibility and the deterministic heuristic remain available.
- Motion gating in the point-cloud server continues to limit expensive inference.
- This decision does not authorize redistribution of third-party model weights.
## Validation and acceptance gates
1. Default builds and tests pass with no native library installed.
2. License policy accepts Apache-2.0, rejects unknown licenses, and gates
CC-BY-NC-4.0 behind an explicit override.
3. Digest mismatch prevents model loading.
4. The PPM bridge validates buffer dimensions, uses create-new semantics, and
removes its file on success and failure.
5. A mock ABI proves version gating, native-buffer copying/freeing, metric
enforcement, confidence propagation, and context destruction.
6. Backprojection consumes model dimensions/intrinsics and confidence without
out-of-bounds access.
7. `cargo test -p wifi-densepose-pointcloud` and Clippy pass.
8. A real upstream Windows/MSVC ABI-v4 build passes `depth-check --abi-only` and
synthetic inference with the Apache-2.0 DA2 metric Hypersim small Q4_K model
(`decd99f5c756b564aae5ca1a1612f896e7f76889060e1d25ba610549bbc39b52`).
Hardware camera validation remains pending receipt of the target hardware.
## Alternatives considered
### Pure Rust DA3 rewrite
Rejected for phase 1. It would duplicate ggml kernels, quantization, model
conversion, and multiple GPU backends while delaying usable integration.
### Statically vendor depth-anything.cpp and ggml
Rejected as the default. It would force the C++ build and backend dependency tree
onto every RuView build, including camera-free and ESP32-oriented workflows.
### Continue with only the MiDaS service
Rejected as the sole production path because it requires an external process and
does not provide the same confidence, pose, quantization, or native deployment
surface.
### Treat relative depth as metres
Rejected because it creates geometrically false point clouds and contaminates
WiFi/mmWave fusion.
## Links
- [depth-anything.cpp](https://github.com/mudler/depth-anything.cpp)
- [Depth Anything 3](https://github.com/ByteDance-Seed/Depth-Anything-3)
- [ADR-058](ADR-058-ruvector-wasm-browser-pose-example.md)
- [ADR-094](ADR-094-pointcloud-github-pages-deployment.md)
- [ADR-260](ADR-260-rufield-mfs.md)
+1
View File
@@ -88,6 +88,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-150](ADR-150-rf-foundation-encoder.md) | RF Foundation Encoder: pose-preserving, subject/room/device-invariant CSI embedding | Proposed |
| [ADR-151](ADR-151-room-calibration-specialist-training.md) | Per-Room Calibration & Specialized Model Training (room-first → bank of small ruVector specialists) | Proposed |
| [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) | WiFi-Pose SOTA 2026 Intake: geometry-conditioned calibration, external benchmarks, foundation-encoder recipe | Proposed |
| [ADR-271](ADR-271-depth-anything-rust-camera-depth-backend.md) | Depth Anything Rust Camera-Depth Backend | Accepted (phase 1) |
### Platform and UI
+43 -1
View File
@@ -954,6 +954,46 @@ Open `http://localhost:9880` for the interactive Three.js 3D viewer.
| Camera (`/dev/video0`) | Yes (Linux UVC) | RGB frames → MiDaS depth → 3D points |
| ESP32 CSI (UDP:3333) | Yes (if provisioned) | ADR-018 binary → occupancy + pose + vitals |
| MiDaS depth server (port 9885) | Optional | GPU-accelerated neural depth estimation |
| depth-anything.cpp ABI v4 | Optional | Local metric depth + confidence + calibrated camera geometry |
### Depth Anything backend (optional)
RuView can load [`depth-anything.cpp`](https://github.com/mudler/depth-anything.cpp)
at runtime without adding a C++ requirement to normal Rust builds. Build its
shared library with `DA_SHARED=ON`, obtain an official **metric** GGUF separately,
and configure:
```bash
export RUVIEW_DEPTH_BACKEND=depth-anything
export RUVIEW_DA_LIBRARY=/opt/depth-anything/libdepthanything.so
export RUVIEW_DA_MODEL=/opt/models/depth-anything-metric.gguf
export RUVIEW_DA_MODEL_LICENSE=Apache-2.0
export RUVIEW_DA_MODEL_SHA256=<64-character-model-digest>
export RUVIEW_DA_THREADS=8
export RUVIEW_DA_MIN_CONFIDENCE=1.0
ruview-pointcloud serve --bind 127.0.0.1:9880
```
On Windows, build with both `DA_SHARED=ON` and
`CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS=ON`, then point `RUVIEW_DA_LIBRARY` at the
ABI-v4 DLL. The upstream default Windows shared build currently exports no C
symbols. RuView also retains the process-singleton native context until Windows
process teardown to avoid an upstream DA2 destructor access violation observed
after successful inference. On macOS, use the corresponding `.dylib`.
`RUVIEW_DEPTH_BACKEND=auto` (the default) activates
Depth Anything only when its library/model variables are configured, then falls
back to the loopback MiDaS service and finally the deterministic heuristic.
Selecting `depth-anything` explicitly is fail-closed: configuration, license,
digest, ABI, non-metric output, or inference errors are returned to the caller.
RuView does not bundle model weights. Apache-2.0 model declarations are accepted
by default. CC-BY-NC-4.0 requires `RUVIEW_DA_ALLOW_NONCOMMERCIAL=1` and remains
local/non-commercial; unknown licenses are rejected. Consult each official model
card rather than inferring a weight license from the engine's MIT license.
The current upstream C ABI takes image paths, so RuView uses create-new temporary
PPM files with automatic cleanup. An in-memory RGB ABI is the planned replacement.
### Commands
@@ -966,6 +1006,8 @@ Open `http://localhost:9880` for the interactive Three.js 3D viewer.
| `ruview-pointcloud train --data-dir ./data [--brain URL]` | Depth calibration + occupancy training (writes under canonicalized `data-dir`; refuses `..` traversal) |
| `ruview-pointcloud csi-test --count 100` | Send test CSI frames (no ESP32 needed) |
| `ruview-pointcloud fingerprint <name> [--seconds 5]` | Record a named CSI room fingerprint for later matching |
| `ruview-pointcloud depth-check --abi-only` | Verify configured native symbols and ABI v4 without loading a model |
| `ruview-pointcloud depth-check --infer` | Verify license/digest/model load and run one synthetic metric-depth inference |
### Pipeline Components
@@ -974,7 +1016,7 @@ Open `http://localhost:9880` for the interactive Three.js 3D viewer.
3. **Vital Signs** — Breathing rate from CSI phase analysis (peak counting on stable subcarrier)
4. **Motion Detection** — CSI amplitude variance over 20 frames, triggers adaptive capture
5. **RF Tomography** — Backprojection from per-node RSSI to 8×8×4 occupancy grid
6. **Camera Depth** — MiDaS monocular depth (GPU) with luminance+edge fallback
6. **Camera Depth** — optional depth-anything.cpp metric depth, then MiDaS, then a deterministic luminance+edge fallback
7. **Sensor Fusion** — Voxel-grid merging of camera depth + CSI occupancy
8. **Brain Bridge** — Stores spatial observations in the ruOS brain every 60 seconds
+11 -1
View File
@@ -153,6 +153,16 @@ MODEL_STORAGE_PATH=./models
TEMP_STORAGE_PATH=./temp
MAX_STORAGE_SIZE_GB=100
# Optional Depth Anything camera-depth backend (ADR-271).
# Model weights are not bundled. Verify the license on the official model card.
# RUVIEW_DEPTH_BACKEND=auto
# RUVIEW_DA_LIBRARY=/opt/depth-anything/libdepthanything.so
# RUVIEW_DA_MODEL=/opt/models/depth-anything-metric.gguf
# RUVIEW_DA_MODEL_LICENSE=Apache-2.0
# RUVIEW_DA_MODEL_SHA256=<64-character-sha256>
# RUVIEW_DA_THREADS=8
# RUVIEW_DA_MIN_CONFIDENCE=1.0
# =============================================================================
# MONITORING SETTINGS
# =============================================================================
@@ -202,4 +212,4 @@ OPENAPI_URL=/openapi.json # Set to null to disable in production
# DOCS_URL=null
# REDOC_URL=null
# OPENAPI_URL=null
# LOG_FILE=/var/log/wifi-densepose/app.log
# LOG_FILE=/var/log/wifi-densepose/app.log
Generated
+2
View File
@@ -11084,9 +11084,11 @@ dependencies = [
"clap",
"criterion",
"dirs 5.0.1",
"libloading 0.8.9",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"tokio",
"tower-http",
]
@@ -19,6 +19,8 @@ clap = { version = "4", features = ["derive"] }
chrono = "0.4"
dirs = "5"
reqwest = { version = "0.12", features = ["json"], default-features = false }
libloading = "0.8"
sha2 = { workspace = true }
[dev-dependencies]
criterion = { workspace = true }
+355 -20
View File
@@ -1,10 +1,13 @@
//! Monocular depth estimation via MiDaS ONNX + backprojection to 3D points.
#![allow(dead_code)]
use crate::depth_anything::{DepthAnythingBackend, DepthAnythingConfig};
use crate::pointcloud::{ColorPoint, PointCloud};
use anyhow::Result;
use std::sync::{Mutex, OnceLock};
/// Default camera intrinsics (approximate for HD webcam)
#[derive(Clone, Debug)]
pub struct CameraIntrinsics {
pub fx: f32, // focal length x (pixels)
pub fy: f32, // focal length y (pixels)
@@ -27,6 +30,70 @@ impl Default for CameraIntrinsics {
}
}
impl CameraIntrinsics {
pub fn for_dimensions(width: u32, height: u32) -> Self {
let default = Self::default();
Self {
fx: default.fx * width as f32 / default.width as f32,
fy: default.fy * height as f32 / default.height as f32,
cx: width as f32 / 2.0,
cy: height as f32 / 2.0,
width,
height,
}
}
pub fn from_matrix_or_default(matrix: &[f32; 9], width: u32, height: u32) -> Self {
let values = (matrix[0], matrix[4], matrix[2], matrix[5]);
if [values.0, values.1, values.2, values.3]
.iter()
.all(|value| value.is_finite())
&& values.0 > 0.0
&& values.1 > 0.0
{
Self {
fx: values.0,
fy: values.1,
cx: values.2,
cy: values.3,
width,
height,
}
} else {
Self::for_dimensions(width, height)
}
}
}
#[derive(Clone, Debug)]
pub struct DepthEstimate {
pub depth: Vec<f32>,
pub confidence: Option<Vec<f32>>,
pub width: u32,
pub height: u32,
pub intrinsics: CameraIntrinsics,
pub extrinsics: Option<[f32; 12]>,
pub is_metric: bool,
pub backend: &'static str,
pub min_confidence: f32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum BackendSelection {
Auto,
DepthAnything,
Midas,
Heuristic,
}
struct DepthEstimator {
selection: BackendSelection,
depth_anything: Option<DepthAnythingBackend>,
}
static DEFAULT_ESTIMATOR: OnceLock<std::result::Result<Mutex<DepthEstimator>, String>> =
OnceLock::new();
/// Backproject a depth map to 3D points using camera intrinsics.
///
/// depth_map: row-major [height x width] in meters
@@ -36,6 +103,17 @@ pub fn backproject_depth(
intrinsics: &CameraIntrinsics,
rgb: Option<&[u8]>,
downsample: u32,
) -> PointCloud {
backproject_depth_with_confidence(depth_map, intrinsics, rgb, None, 0.0, downsample)
}
fn backproject_depth_with_confidence(
depth_map: &[f32],
intrinsics: &CameraIntrinsics,
rgb: Option<&[u8]>,
confidence: Option<&[f32]>,
min_confidence: f32,
downsample: u32,
) -> PointCloud {
let mut cloud = PointCloud::new("camera_depth");
let w = intrinsics.width;
@@ -45,10 +123,22 @@ pub fn backproject_depth(
for y in (0..h).step_by(step as usize) {
for x in (0..w).step_by(step as usize) {
let idx = (y * w + x) as usize;
if idx >= depth_map.len() {
continue;
}
let z = depth_map[idx];
let point_confidence = confidence
.and_then(|values| values.get(idx).copied())
.unwrap_or(1.0);
// Skip invalid depths
if z <= 0.01 || z > 10.0 || z.is_nan() {
if z <= 0.01
|| z > 10.0
|| !z.is_finite()
|| !point_confidence.is_finite()
|| point_confidence < min_confidence
{
continue;
}
@@ -80,23 +170,167 @@ pub fn backproject_depth(
r,
g,
b,
intensity: 1.0,
intensity: point_confidence,
});
}
}
cloud
}
/// Run depth estimation on an image.
///
/// Tries MiDaS GPU server (127.0.0.1:9885) first, falls back to luminance+edges.
pub fn estimate_depth(image_data: &[u8], width: u32, height: u32) -> Result<Vec<f32>> {
// Try MiDaS GPU server
if let Ok(depth) = estimate_depth_midas_server(image_data, width, height) {
return Ok(depth);
/// Backproject a typed estimate, preserving model dimensions, calibrated camera
/// intrinsics, confidence, and RGB alignment.
pub fn backproject_estimate(
estimate: &DepthEstimate,
source_rgb: Option<(&[u8], u32, u32)>,
downsample: u32,
) -> PointCloud {
let resized_rgb = source_rgb.and_then(|(rgb, width, height)| {
resize_rgb_nearest(rgb, width, height, estimate.width, estimate.height).ok()
});
backproject_depth_with_confidence(
&estimate.depth,
&estimate.intrinsics,
resized_rgb.as_deref(),
estimate.confidence.as_deref(),
estimate.min_confidence,
downsample,
)
}
impl DepthEstimator {
fn from_env() -> Result<Self> {
let selection = match std::env::var("RUVIEW_DEPTH_BACKEND")
.unwrap_or_else(|_| "auto".into())
.trim()
.to_ascii_lowercase()
.as_str()
{
"auto" => BackendSelection::Auto,
"depth-anything" | "depth_anything" | "da" => BackendSelection::DepthAnything,
"midas" => BackendSelection::Midas,
"heuristic" | "demo" => BackendSelection::Heuristic,
value => anyhow::bail!(
"unknown RUVIEW_DEPTH_BACKEND '{value}'; expected auto, depth-anything, midas, or heuristic"
),
};
let configured = std::env::var_os("RUVIEW_DA_LIBRARY").is_some()
|| std::env::var_os("RUVIEW_DA_MODEL").is_some();
let depth_anything = if selection == BackendSelection::DepthAnything
|| (selection == BackendSelection::Auto && configured)
{
match DepthAnythingConfig::from_env()
.and_then(|config| DepthAnythingBackend::load(&config))
{
Ok(backend) => Some(backend),
Err(error) if selection == BackendSelection::Auto => {
eprintln!(
"Depth Anything auto-configuration unavailable ({error:#}); falling back to MiDaS/heuristic"
);
None
}
Err(error) => return Err(error),
}
} else {
None
};
Ok(Self {
selection,
depth_anything,
})
}
// Fallback: luminance + edge-based pseudo-depth
fn estimate(&mut self, rgb: &[u8], width: u32, height: u32) -> Result<DepthEstimate> {
if matches!(
self.selection,
BackendSelection::DepthAnything | BackendSelection::Auto
) {
if let Some(backend) = self.depth_anything.as_mut() {
match backend.infer_rgb(rgb, width, height) {
Ok(estimate) => return Ok(estimate),
Err(error) if self.selection == BackendSelection::Auto => {
eprintln!(
"Depth Anything inference unavailable ({error:#}); falling back to MiDaS/heuristic"
);
}
Err(error) => return Err(error),
}
} else if self.selection == BackendSelection::DepthAnything {
anyhow::bail!("Depth Anything backend was selected but is not initialized");
}
}
if matches!(
self.selection,
BackendSelection::Midas | BackendSelection::Auto
) {
match estimate_depth_midas_server(rgb, width, height) {
Ok(depth) => {
return Ok(DepthEstimate {
depth,
confidence: None,
width,
height,
intrinsics: CameraIntrinsics::for_dimensions(width, height),
extrinsics: None,
is_metric: false,
backend: "midas",
min_confidence: 0.0,
})
}
Err(error) if self.selection == BackendSelection::Auto => {
eprintln!("MiDaS service unavailable ({error:#}); using heuristic depth");
}
Err(error) => return Err(error),
}
}
let depth = estimate_depth_heuristic(rgb, width, height)?;
Ok(DepthEstimate {
depth,
confidence: None,
width,
height,
intrinsics: CameraIntrinsics::for_dimensions(width, height),
extrinsics: None,
is_metric: false,
backend: "heuristic",
min_confidence: 0.0,
})
}
}
/// Run the configured depth backend. The estimator is initialized once so a
/// native model context is reused across frames.
pub fn estimate_depth_frame(image_data: &[u8], width: u32, height: u32) -> Result<DepthEstimate> {
let estimator = DEFAULT_ESTIMATOR.get_or_init(|| {
DepthEstimator::from_env()
.map(Mutex::new)
.map_err(|error| format!("{error:#}"))
});
let estimator = estimator
.as_ref()
.map_err(|error| anyhow::anyhow!(error.clone()))?;
estimator
.lock()
.map_err(|_| anyhow::anyhow!("depth estimator lock poisoned"))?
.estimate(image_data, width, height)
}
/// Compatibility helper for training code that only consumes the dense map.
pub fn estimate_depth(image_data: &[u8], width: u32, height: u32) -> Result<Vec<f32>> {
Ok(estimate_depth_frame(image_data, width, height)?.depth)
}
fn estimate_depth_heuristic(image_data: &[u8], width: u32, height: u32) -> Result<Vec<f32>> {
let expected = (width as usize)
.checked_mul(height as usize)
.and_then(|pixels| pixels.checked_mul(3))
.ok_or_else(|| anyhow::anyhow!("RGB dimensions overflow"))?;
if width == 0 || height == 0 || image_data.len() < expected {
anyhow::bail!("RGB buffer is smaller than {width}x{height} RGB8");
}
let w = width as usize;
let h = height as usize;
let mut lum = vec![0.0f32; w * h];
@@ -110,19 +344,22 @@ pub fn estimate_depth(image_data: &[u8], width: u32, height: u32) -> Result<Vec<
}
}
let mut edges = vec![0.0f32; w * h];
for y in 1..h - 1 {
for x in 1..w - 1 {
let gx = -lum[(y - 1) * w + x - 1] + lum[(y - 1) * w + x + 1]
- 2.0 * lum[y * w + x - 1]
+ 2.0 * lum[y * w + x + 1]
- lum[(y + 1) * w + x - 1]
+ lum[(y + 1) * w + x + 1];
let gy =
-lum[(y - 1) * w + x - 1] - 2.0 * lum[(y - 1) * w + x] - lum[(y - 1) * w + x + 1]
if w >= 3 && h >= 3 {
for y in 1..h - 1 {
for x in 1..w - 1 {
let gx = -lum[(y - 1) * w + x - 1] + lum[(y - 1) * w + x + 1]
- 2.0 * lum[y * w + x - 1]
+ 2.0 * lum[y * w + x + 1]
- lum[(y + 1) * w + x - 1]
+ lum[(y + 1) * w + x + 1];
let gy = -lum[(y - 1) * w + x - 1]
- 2.0 * lum[(y - 1) * w + x]
- lum[(y - 1) * w + x + 1]
+ lum[(y + 1) * w + x - 1]
+ 2.0 * lum[(y + 1) * w + x]
+ lum[(y + 1) * w + x + 1];
edges[y * w + x] = (gx * gx + gy * gy).sqrt().min(1.0);
edges[y * w + x] = (gx * gx + gy * gy).sqrt().min(1.0);
}
}
}
let mut depth_map = vec![3.0f32; w * h];
@@ -134,6 +371,42 @@ pub fn estimate_depth(image_data: &[u8], width: u32, height: u32) -> Result<Vec<
Ok(depth_map)
}
fn resize_rgb_nearest(
rgb: &[u8],
source_width: u32,
source_height: u32,
target_width: u32,
target_height: u32,
) -> Result<Vec<u8>> {
let expected = (source_width as usize)
.checked_mul(source_height as usize)
.and_then(|pixels| pixels.checked_mul(3))
.ok_or_else(|| anyhow::anyhow!("RGB dimensions overflow"))?;
if source_width == 0
|| source_height == 0
|| target_width == 0
|| target_height == 0
|| rgb.len() < expected
{
anyhow::bail!("invalid RGB resize input");
}
let target_len = (target_width as usize)
.checked_mul(target_height as usize)
.and_then(|pixels| pixels.checked_mul(3))
.ok_or_else(|| anyhow::anyhow!("target RGB dimensions overflow"))?;
let mut output = vec![0u8; target_len];
for y in 0..target_height {
let source_y = ((y as u64 * source_height as u64) / target_height as u64) as u32;
for x in 0..target_width {
let source_x = ((x as u64 * source_width as u64) / target_width as u64) as u32;
let source = ((source_y * source_width + source_x) * 3) as usize;
let target = ((y * target_width + x) * 3) as usize;
output[target..target + 3].copy_from_slice(&rgb[source..source + 3]);
}
}
Ok(output)
}
/// Call MiDaS depth server running on GPU (127.0.0.1:9885).
fn estimate_depth_midas_server(rgb: &[u8], width: u32, height: u32) -> Result<Vec<f32>> {
let expected = (width * height * 3) as usize;
@@ -283,4 +556,66 @@ mod tests {
let cloud = backproject_depth(&depth, &intr, None, 1);
assert_eq!(cloud.points.len(), 0);
}
#[test]
fn confidence_gate_filters_and_propagates_intensity() {
let estimate = DepthEstimate {
depth: vec![1.0, 1.0, 1.0, 1.0],
confidence: Some(vec![0.2, 0.9, 1.1, 2.0]),
width: 2,
height: 2,
intrinsics: CameraIntrinsics {
fx: 1.0,
fy: 1.0,
cx: 0.5,
cy: 0.5,
width: 2,
height: 2,
},
extrinsics: Some([0.0; 12]),
is_metric: true,
backend: "test",
min_confidence: 1.0,
};
let cloud = backproject_estimate(&estimate, None, 1);
assert_eq!(cloud.points.len(), 2);
assert_eq!(cloud.points[0].intensity, 1.1);
assert_eq!(cloud.points[1].intensity, 2.0);
}
#[test]
fn backprojection_tolerates_short_depth_buffer() {
let intr = CameraIntrinsics {
fx: 1.0,
fy: 1.0,
cx: 0.5,
cy: 0.5,
width: 2,
height: 2,
};
let cloud = backproject_depth(&[1.0], &intr, None, 1);
assert_eq!(cloud.points.len(), 1);
}
#[test]
fn nearest_rgb_resize_preserves_corner_colors() {
let source = [255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255];
let resized = resize_rgb_nearest(&source, 2, 2, 4, 4).unwrap();
assert_eq!(&resized[0..3], &[255, 0, 0]);
assert_eq!(&resized[(15 * 3)..(16 * 3)], &[255, 255, 255]);
}
#[test]
fn heuristic_handles_one_pixel_image() {
let depth = estimate_depth_heuristic(&[127, 127, 127], 1, 1).unwrap();
assert_eq!(depth.len(), 1);
assert!(depth[0].is_finite());
}
#[test]
fn model_intrinsics_fall_back_when_invalid() {
let intr = CameraIntrinsics::from_matrix_or_default(&[0.0; 9], 320, 240);
assert!(intr.fx > 0.0 && intr.fy > 0.0);
assert_eq!((intr.width, intr.height), (320, 240));
}
}
@@ -0,0 +1,717 @@
//! Safe Rust ownership boundary for the optional depth-anything.cpp C ABI.
//!
//! The native engine and model are runtime-provided. RuView neither links the
//! engine at build time nor bundles third-party model weights.
use crate::depth::{CameraIntrinsics, DepthEstimate};
use anyhow::{bail, Context, Result};
use libloading::Library;
use sha2::{Digest, Sha256};
use std::ffi::{c_char, c_void, CStr, CString};
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::ptr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, SyncSender};
use std::thread::JoinHandle;
const REQUIRED_ABI_VERSION: i32 = 4;
const MAX_OUTPUT_PIXELS: usize = 16_777_216;
const NATIVE_THREAD_STACK_BYTES: usize = 16 * 1024 * 1024;
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
type AbiVersionFn = unsafe extern "C" fn() -> i32;
type LoadFn = unsafe extern "C" fn(*const c_char, i32) -> *mut c_void;
type FreeFn = unsafe extern "C" fn(*mut c_void);
type LastErrorFn = unsafe extern "C" fn(*mut c_void) -> *const c_char;
type DepthDenseFn = unsafe extern "C" fn(
*mut c_void,
*const c_char,
*mut i32,
*mut i32,
*mut *mut f32,
*mut *mut f32,
*mut *mut f32,
*mut f32,
*mut f32,
*mut i32,
) -> i32;
type FreeFloatsFn = unsafe extern "C" fn(*mut f32);
#[derive(Clone, Copy)]
struct Api {
abi_version: AbiVersionFn,
load: LoadFn,
free: FreeFn,
last_error: LastErrorFn,
depth_dense: DepthDenseFn,
free_floats: FreeFloatsFn,
}
#[derive(Clone, Debug)]
pub struct DepthAnythingConfig {
pub library_path: PathBuf,
pub model_path: PathBuf,
pub model_license: String,
pub model_sha256: Option<String>,
pub threads: i32,
pub allow_noncommercial: bool,
pub min_confidence: f32,
}
impl DepthAnythingConfig {
pub fn from_env() -> Result<Self> {
let library_path = required_env("RUVIEW_DA_LIBRARY")?.into();
let model_path = required_env("RUVIEW_DA_MODEL")?.into();
let model_license = required_env("RUVIEW_DA_MODEL_LICENSE")?;
let model_sha256 = std::env::var("RUVIEW_DA_MODEL_SHA256")
.ok()
.filter(|value| !value.trim().is_empty());
let threads = std::env::var("RUVIEW_DA_THREADS")
.ok()
.map(|value| {
value
.parse::<i32>()
.context("RUVIEW_DA_THREADS must be an integer")
})
.transpose()?
.unwrap_or_else(default_threads);
if threads <= 0 {
bail!("RUVIEW_DA_THREADS must be greater than zero");
}
let min_confidence = std::env::var("RUVIEW_DA_MIN_CONFIDENCE")
.ok()
.map(|value| {
value
.parse::<f32>()
.context("RUVIEW_DA_MIN_CONFIDENCE must be a finite number")
})
.transpose()?
.unwrap_or(1.0);
if !min_confidence.is_finite() {
bail!("RUVIEW_DA_MIN_CONFIDENCE must be finite");
}
Ok(Self {
library_path,
model_path,
model_license,
model_sha256,
threads,
allow_noncommercial: env_truthy("RUVIEW_DA_ALLOW_NONCOMMERCIAL"),
min_confidence,
})
}
pub fn validate(&self) -> Result<()> {
validate_model_license(&self.model_license, self.allow_noncommercial)?;
if !self.library_path.is_file() {
bail!(
"Depth Anything shared library not found: {}",
self.library_path.display()
);
}
if !self.model_path.is_file() {
bail!(
"Depth Anything model not found: {}",
self.model_path.display()
);
}
if let Some(expected) = &self.model_sha256 {
verify_sha256(&self.model_path, expected)?;
}
Ok(())
}
}
struct NativeDepthAnything {
// Function pointers remain valid only while this library is alive.
_library: Option<Library>,
api: Api,
ctx: *mut c_void,
min_confidence: f32,
}
impl NativeDepthAnything {
fn load(config: &DepthAnythingConfig) -> Result<Self> {
// SAFETY: loading a user-configured native library is the explicit opt-in
// of this backend. Symbols are checked immediately and the Library is kept
// alive in the returned value for longer than every copied function pointer.
let library = unsafe { Library::new(&config.library_path) }.with_context(|| {
format!(
"failed to load Depth Anything library {}",
config.library_path.display()
)
})?;
let api = unsafe { load_api(&library)? };
let actual_abi = unsafe { (api.abi_version)() };
if actual_abi != REQUIRED_ABI_VERSION {
bail!(
"unsupported depth-anything.cpp ABI {actual_abi}; required {REQUIRED_ABI_VERSION}"
);
}
let model = path_to_cstring(&config.model_path)?;
// SAFETY: model is NUL-terminated for the duration of the call; threads
// was validated positive; a null context is handled as a load failure.
let ctx = unsafe { (api.load)(model.as_ptr(), config.threads) };
if ctx.is_null() {
bail!(
"depth-anything.cpp rejected model {}",
config.model_path.display()
);
}
Ok(Self {
_library: Some(library),
api,
ctx,
min_confidence: config.min_confidence,
})
}
pub fn infer_rgb(&mut self, rgb: &[u8], width: u32, height: u32) -> Result<DepthEstimate> {
let image = TemporaryPpm::create(rgb, width, height)?;
let image_path = path_to_cstring(image.path())?;
let mut out_h = 0i32;
let mut out_w = 0i32;
let mut depth = ptr::null_mut();
let mut confidence = ptr::null_mut();
let mut sky = ptr::null_mut();
let mut extrinsics = [0.0f32; 12];
let mut intrinsics = [0.0f32; 9];
let mut is_metric = 0i32;
// SAFETY: every output pointer references live Rust storage. The context
// is non-null and exclusively borrowed. Native float buffers are copied
// before being released with the matching upstream deallocator.
let rc = unsafe {
(self.api.depth_dense)(
self.ctx,
image_path.as_ptr(),
&mut out_h,
&mut out_w,
&mut depth,
&mut confidence,
&mut sky,
extrinsics.as_mut_ptr(),
intrinsics.as_mut_ptr(),
&mut is_metric,
)
};
if rc != 0 {
unsafe { self.free_outputs(depth, confidence, sky) };
bail!("depth-anything.cpp inference failed: {}", self.last_error());
}
let result = (|| {
if out_h <= 0 || out_w <= 0 {
bail!("depth-anything.cpp returned invalid dimensions {out_w}x{out_h}");
}
let len = (out_h as usize)
.checked_mul(out_w as usize)
.filter(|len| *len <= MAX_OUTPUT_PIXELS)
.context("depth-anything.cpp output dimensions exceed safety limit")?;
if depth.is_null() {
bail!("depth-anything.cpp returned a null depth buffer");
}
// SAFETY: successful ABI v4 calls promise H*W floats for each non-null
// dense output. The safety limit above also bounds the copy allocation.
let depth_vec = unsafe { std::slice::from_raw_parts(depth, len).to_vec() };
let confidence_vec = if confidence.is_null() {
None
} else {
Some(unsafe { std::slice::from_raw_parts(confidence, len).to_vec() })
};
if is_metric != 1 {
bail!(
"Depth Anything model returned relative depth; RuView point-cloud fusion requires metric depth"
);
}
if depth_vec.iter().any(|value| !value.is_finite()) {
bail!("Depth Anything returned non-finite depth values");
}
let output_width = out_w as u32;
let output_height = out_h as u32;
let camera =
CameraIntrinsics::from_matrix_or_default(&intrinsics, output_width, output_height);
let camera_pose = if extrinsics.iter().any(|value| *value != 0.0) {
Some(extrinsics)
} else {
None
};
Ok(DepthEstimate {
depth: depth_vec,
confidence: confidence_vec,
width: output_width,
height: output_height,
intrinsics: camera,
extrinsics: camera_pose,
is_metric: true,
backend: "depth-anything",
min_confidence: self.min_confidence,
})
})();
unsafe { self.free_outputs(depth, confidence, sky) };
result
}
fn last_error(&self) -> String {
// SAFETY: the ABI documents this pointer as context-owned and valid until
// the next context operation. Copy it immediately and tolerate null/UTF-8.
let ptr = unsafe { (self.api.last_error)(self.ctx) };
if ptr.is_null() {
return "unknown native error".into();
}
unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned()
}
unsafe fn free_outputs(&self, depth: *mut f32, confidence: *mut f32, sky: *mut f32) {
for output in [depth, confidence, sky] {
if !output.is_null() {
// SAFETY: each pointer came from this ABI call and is released at
// most once through the exact deallocator exported by that ABI.
unsafe { (self.api.free_floats)(output) };
}
}
}
#[cfg(test)]
fn from_test_api(api: Api, min_confidence: f32) -> Result<Self> {
let abi = unsafe { (api.abi_version)() };
if abi != REQUIRED_ABI_VERSION {
bail!("unsupported depth-anything.cpp ABI {abi}; required {REQUIRED_ABI_VERSION}");
}
let model = CString::new("mock.gguf").unwrap();
let ctx = unsafe { (api.load)(model.as_ptr(), 1) };
if ctx.is_null() {
bail!("mock model load failed");
}
Ok(Self {
_library: None,
api,
ctx,
min_confidence,
})
}
}
/// Verify that a runtime library exposes the expected C symbols and return its
/// ABI version without loading model weights.
pub fn probe_library(path: &Path) -> Result<i32> {
// SAFETY: the library stays alive until after all symbol calls complete.
let library = unsafe { Library::new(path) }
.with_context(|| format!("failed to load Depth Anything library {}", path.display()))?;
let api = unsafe { load_api(&library)? };
let version = unsafe { (api.abi_version)() };
if version != REQUIRED_ABI_VERSION {
bail!("unsupported depth-anything.cpp ABI {version}; required {REQUIRED_ABI_VERSION}");
}
Ok(version)
}
impl Drop for NativeDepthAnything {
fn drop(&mut self) {
if !self.ctx.is_null() {
// SAFETY: ctx was returned by api.load, remains owned by this wrapper,
// and Drop is its single destruction point while the library is alive.
unsafe { (self.api.free)(self.ctx) };
self.ctx = ptr::null_mut();
}
}
}
enum WorkerRequest {
Infer {
rgb: Vec<u8>,
width: u32,
height: u32,
response: SyncSender<std::result::Result<DepthEstimate, String>>,
},
Shutdown,
}
/// Serialized, thread-affine handle to the native Depth Anything context.
///
/// The dedicated stack is required by the upstream Windows model loader and
/// also prevents a native context from moving between Tokio worker threads.
pub struct DepthAnythingBackend {
requests: SyncSender<WorkerRequest>,
worker: Option<JoinHandle<()>>,
}
impl DepthAnythingBackend {
pub fn load(config: &DepthAnythingConfig) -> Result<Self> {
config.validate()?;
let config = config.clone();
let (requests, incoming): (SyncSender<WorkerRequest>, Receiver<WorkerRequest>) =
mpsc::sync_channel(1);
let (initialized, initialization) = mpsc::sync_channel(0);
let worker = std::thread::Builder::new()
.name("ruview-depth-anything".into())
.stack_size(NATIVE_THREAD_STACK_BYTES)
.spawn(move || {
let mut native = match NativeDepthAnything::load(&config) {
Ok(native) => {
let _ = initialized.send(Ok(()));
native
}
Err(error) => {
let _ = initialized.send(Err(format!("{error:#}")));
return;
}
};
while let Ok(request) = incoming.recv() {
match request {
WorkerRequest::Infer {
rgb,
width,
height,
response,
} => {
let result = native
.infer_rgb(&rgb, width, height)
.map_err(|error| format!("{error:#}"));
let _ = response.send(result);
}
WorkerRequest::Shutdown => {
#[cfg(windows)]
{
// The upstream ABI-v4 CPU DLL built with exported
// symbols currently access-violates in Engine's
// destructor after successful DA2 inference. Keep
// the process-singleton context and DLL loaded until
// Windows reclaims them at process termination.
std::mem::forget(native);
break;
}
#[cfg(not(windows))]
break;
}
}
}
})
.context("failed to spawn Depth Anything native worker")?;
match initialization.recv() {
Ok(Ok(())) => Ok(Self {
requests,
worker: Some(worker),
}),
Ok(Err(error)) => {
let _ = worker.join();
bail!("Depth Anything worker initialization failed: {error}")
}
Err(_) => {
let _ = worker.join();
bail!("Depth Anything worker terminated during initialization")
}
}
}
pub fn infer_rgb(&mut self, rgb: &[u8], width: u32, height: u32) -> Result<DepthEstimate> {
let (response, result) = mpsc::sync_channel(0);
self.requests
.send(WorkerRequest::Infer {
rgb: rgb.to_vec(),
width,
height,
response,
})
.context("Depth Anything worker is unavailable")?;
result
.recv()
.context("Depth Anything worker stopped before returning inference")?
.map_err(anyhow::Error::msg)
}
}
impl Drop for DepthAnythingBackend {
fn drop(&mut self) {
let _ = self.requests.send(WorkerRequest::Shutdown);
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
unsafe fn load_api(library: &Library) -> Result<Api> {
// SAFETY: each symbol name and signature mirrors include/da_capi.h ABI v4.
unsafe {
Ok(Api {
abi_version: *library.get(b"da_capi_abi_version\0")?,
load: *library.get(b"da_capi_load\0")?,
free: *library.get(b"da_capi_free\0")?,
last_error: *library.get(b"da_capi_last_error\0")?,
depth_dense: *library.get(b"da_capi_depth_dense\0")?,
free_floats: *library.get(b"da_capi_free_floats\0")?,
})
}
}
struct TemporaryPpm {
path: PathBuf,
}
impl TemporaryPpm {
fn create(rgb: &[u8], width: u32, height: u32) -> Result<Self> {
let expected = (width as usize)
.checked_mul(height as usize)
.and_then(|pixels| pixels.checked_mul(3))
.context("RGB dimensions overflow")?;
if width == 0 || height == 0 || rgb.len() != expected {
bail!(
"RGB buffer length {} does not match {width}x{height} RGB8 ({expected})",
rgb.len()
);
}
for _ in 0..32 {
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ruview-depth-{}-{sequence}.ppm",
std::process::id()
));
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut file) => {
if let Err(error) = write_ppm(&mut file, rgb, width, height) {
drop(file);
let _ = std::fs::remove_file(&path);
return Err(error);
}
return Ok(Self { path });
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error).context("failed to create temporary PPM"),
}
}
bail!("failed to allocate a unique temporary PPM path")
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TemporaryPpm {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn write_ppm(mut writer: impl Write, rgb: &[u8], width: u32, height: u32) -> Result<()> {
write!(writer, "P6\n{width} {height}\n255\n")?;
writer.write_all(rgb)?;
writer.flush()?;
Ok(())
}
fn validate_model_license(value: &str, allow_noncommercial: bool) -> Result<()> {
let normalized = value.trim().to_ascii_lowercase().replace('_', "-");
match normalized.as_str() {
"apache-2.0" | "apache2" | "apache-2" => Ok(()),
"cc-by-nc-4.0" | "cc-by-nc-4" if allow_noncommercial => Ok(()),
"cc-by-nc-4.0" | "cc-by-nc-4" => bail!(
"CC-BY-NC-4.0 model requires RUVIEW_DA_ALLOW_NONCOMMERCIAL=1; do not redistribute it as a general RuView asset"
),
_ => bail!(
"unsupported or unknown Depth Anything model license '{value}'; expected Apache-2.0"
),
}
}
fn verify_sha256(path: &Path, expected: &str) -> Result<()> {
let expected = expected.trim().to_ascii_lowercase();
if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) {
bail!("RUVIEW_DA_MODEL_SHA256 must contain exactly 64 hexadecimal characters");
}
let mut file = File::open(path)
.with_context(|| format!("failed to open model for hashing: {}", path.display()))?;
let mut hasher = Sha256::new();
// Heap-allocate: the default Windows main-thread stack is 1 MiB, so a
// 1 MiB local array can overflow before the native worker is even started.
let mut buffer = vec![0u8; 1024 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
let actual = format!("{:x}", hasher.finalize());
if actual != expected {
bail!("Depth Anything model SHA-256 mismatch: expected {expected}, got {actual}");
}
Ok(())
}
fn path_to_cstring(path: &Path) -> Result<CString> {
CString::new(path.to_string_lossy().as_bytes())
.with_context(|| format!("path contains an interior NUL: {}", path.display()))
}
fn required_env(name: &str) -> Result<String> {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
.with_context(|| format!("{name} is required for the Depth Anything backend"))
}
fn env_truthy(name: &str) -> bool {
std::env::var(name)
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
fn default_threads() -> i32 {
std::thread::available_parallelism()
.map(|value| value.get().min(i32::MAX as usize) as i32)
.unwrap_or(1)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
static FREE_COUNT: AtomicUsize = AtomicUsize::new(0);
static CONTEXT_FREE_COUNT: AtomicUsize = AtomicUsize::new(0);
static ERROR: &[u8] = b"mock failure\0";
unsafe extern "C" fn abi_v4() -> i32 {
4
}
unsafe extern "C" fn abi_v3() -> i32 {
3
}
unsafe extern "C" fn mock_load(_: *const c_char, _: i32) -> *mut c_void {
Box::into_raw(Box::new(7u8)).cast()
}
unsafe extern "C" fn mock_free(ctx: *mut c_void) {
if !ctx.is_null() {
drop(unsafe { Box::from_raw(ctx.cast::<u8>()) });
CONTEXT_FREE_COUNT.fetch_add(1, Ordering::SeqCst);
}
}
unsafe extern "C" fn mock_error(_: *mut c_void) -> *const c_char {
ERROR.as_ptr().cast()
}
unsafe extern "C" fn mock_free_floats(ptr: *mut f32) {
if !ptr.is_null() {
drop(unsafe { Vec::from_raw_parts(ptr, 4, 4) });
FREE_COUNT.fetch_add(1, Ordering::SeqCst);
}
}
fn leak_four(values: [f32; 4]) -> *mut f32 {
let mut values = Vec::from(values);
let pointer = values.as_mut_ptr();
std::mem::forget(values);
pointer
}
unsafe extern "C" fn mock_dense(
_: *mut c_void,
_: *const c_char,
out_h: *mut i32,
out_w: *mut i32,
depth: *mut *mut f32,
confidence: *mut *mut f32,
sky: *mut *mut f32,
ext: *mut f32,
intr: *mut f32,
metric: *mut i32,
) -> i32 {
unsafe {
*out_h = 2;
*out_w = 2;
*depth = leak_four([1.0, 2.0, 3.0, 4.0]);
*confidence = leak_four([0.5, 1.0, 1.5, 2.0]);
*sky = ptr::null_mut();
std::slice::from_raw_parts_mut(ext, 12).copy_from_slice(&[1.0; 12]);
std::slice::from_raw_parts_mut(intr, 9)
.copy_from_slice(&[100.0, 0.0, 1.0, 0.0, 110.0, 1.0, 0.0, 0.0, 1.0]);
*metric = 1;
}
0
}
fn mock_api(abi_version: AbiVersionFn) -> Api {
Api {
abi_version,
load: mock_load,
free: mock_free,
last_error: mock_error,
depth_dense: mock_dense,
free_floats: mock_free_floats,
}
}
#[test]
fn license_policy_is_fail_closed() {
assert!(validate_model_license("Apache-2.0", false).is_ok());
assert!(validate_model_license("CC-BY-NC-4.0", false).is_err());
assert!(validate_model_license("CC-BY-NC-4.0", true).is_ok());
assert!(validate_model_license("unknown", true).is_err());
}
#[test]
fn ppm_is_binary_rgb_and_self_cleaning() {
let path = {
let image = TemporaryPpm::create(&[255, 0, 0, 0, 255, 0], 2, 1).unwrap();
let path = image.path().to_path_buf();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(&bytes[..11], b"P6\n2 1\n255\n");
assert_eq!(&bytes[11..], &[255, 0, 0, 0, 255, 0]);
path
};
assert!(!path.exists());
}
#[test]
fn ppm_rejects_mismatched_buffer() {
assert!(TemporaryPpm::create(&[0; 5], 2, 1).is_err());
}
#[test]
fn sha256_verification_accepts_exact_digest() {
let image = TemporaryPpm::create(&[1, 2, 3], 1, 1).unwrap();
let bytes = std::fs::read(image.path()).unwrap();
let digest = format!("{:x}", Sha256::digest(bytes));
assert!(verify_sha256(image.path(), &digest).is_ok());
assert!(verify_sha256(image.path(), &"0".repeat(64)).is_err());
}
#[test]
fn abi_version_is_gated() {
assert!(NativeDepthAnything::from_test_api(mock_api(abi_v3), 1.0).is_err());
}
#[test]
fn mock_abi_copies_and_frees_native_outputs() {
FREE_COUNT.store(0, Ordering::SeqCst);
CONTEXT_FREE_COUNT.store(0, Ordering::SeqCst);
{
let mut backend = NativeDepthAnything::from_test_api(mock_api(abi_v4), 1.0).unwrap();
let result = backend.infer_rgb(&[10, 20, 30], 1, 1).unwrap();
assert_eq!(result.depth, vec![1.0, 2.0, 3.0, 4.0]);
assert_eq!(result.confidence.unwrap(), vec![0.5, 1.0, 1.5, 2.0]);
assert_eq!((result.width, result.height), (2, 2));
assert!(result.is_metric);
assert_eq!(result.intrinsics.fx, 100.0);
assert_eq!(FREE_COUNT.load(Ordering::SeqCst), 2);
}
assert_eq!(CONTEXT_FREE_COUNT.load(Ordering::SeqCst), 1);
}
}
@@ -13,6 +13,7 @@ mod brain_bridge;
mod camera;
mod csi_pipeline;
mod depth;
mod depth_anything;
mod fusion;
mod parser;
mod pointcloud;
@@ -85,6 +86,15 @@ enum Commands {
#[arg(long, default_value = "5")]
seconds: u64,
},
/// Validate the optional depth-anything.cpp runtime and model configuration.
DepthCheck {
/// Check only shared-library symbols and ABI; do not load a model.
#[arg(long)]
abi_only: bool,
/// After model validation, run one synthetic RGB inference.
#[arg(long)]
infer: bool,
},
}
#[tokio::main]
@@ -99,9 +109,12 @@ async fn main() -> Result<()> {
if camera::camera_available() {
let config = camera::CameraConfig::default();
let frame = camera::capture_frame(&config)?;
let depth = depth::estimate_depth(&frame.rgb, frame.width, frame.height)?;
let intrinsics = depth::CameraIntrinsics::default();
let cloud = depth::backproject_depth(&depth, &intrinsics, Some(&frame.rgb), 2);
let estimate = depth::estimate_depth_frame(&frame.rgb, frame.width, frame.height)?;
let cloud = depth::backproject_estimate(
&estimate,
Some((&frame.rgb, frame.width, frame.height)),
2,
);
pointcloud::write_ply(&cloud, &output)?;
println!("Captured {} points to {output}", cloud.points.len());
} else {
@@ -151,6 +164,48 @@ async fn main() -> Result<()> {
);
}
}
Commands::DepthCheck { abi_only, infer } => {
let library = std::env::var("RUVIEW_DA_LIBRARY")
.map(std::path::PathBuf::from)
.map_err(|_| anyhow::anyhow!("RUVIEW_DA_LIBRARY is required"))?;
let abi = depth_anything::probe_library(&library)?;
println!("Depth Anything library: {}", library.display());
println!("C ABI: {abi} (supported)");
if !abi_only {
let config = depth_anything::DepthAnythingConfig::from_env()?;
let mut backend = depth_anything::DepthAnythingBackend::load(&config)?;
println!("Model: {}", config.model_path.display());
println!("License declaration: {}", config.model_license);
println!("Model load: OK");
if infer {
let width = 64u32;
let height = 64u32;
let mut rgb = vec![0u8; (width * height * 3) as usize];
for y in 0..height {
for x in 0..width {
let index = ((y * width + x) * 3) as usize;
rgb[index] = (x * 255 / (width - 1)) as u8;
rgb[index + 1] = (y * 255 / (height - 1)) as u8;
rgb[index + 2] = 127;
}
}
let estimate = backend.infer_rgb(&rgb, width, height)?;
let (min_depth, max_depth) = estimate
.depth
.iter()
.fold((f32::INFINITY, f32::NEG_INFINITY), |(min, max), value| {
(min.min(*value), max.max(*value))
});
println!(
"Inference: {}x{}, metric={}, depth={min_depth:.4}..{max_depth:.4}m, confidence={}",
estimate.width,
estimate.height,
estimate.is_metric,
estimate.confidence.is_some()
);
}
}
}
}
Ok(())
@@ -186,11 +186,12 @@ fn capture_camera_cloud_with_luminance() -> (pointcloud::PointCloud, Option<f32>
None
};
let cloud = match depth::estimate_depth(&frame.rgb, frame.width, frame.height) {
Ok(dm) => {
let intr = depth::CameraIntrinsics::default();
depth::backproject_depth(&dm, &intr, Some(&frame.rgb), 2)
}
let cloud = match depth::estimate_depth_frame(&frame.rgb, frame.width, frame.height) {
Ok(estimate) => depth::backproject_estimate(
&estimate,
Some((&frame.rgb, frame.width, frame.height)),
2,
),
Err(_) => depth::demo_depth_cloud(),
};
(cloud, lum)