Files
ruvnet--RuView/rust-port/wifi-densepose-rs/crates/wifi-densepose-pointcloud/src/depth.rs
T
rUv 0943a32248 feat: Real-time dense point cloud from camera + WiFi CSI (#405)
* Add wifi-densepose-pointcloud: real-time dense point cloud from camera + WiFi CSI

New crate with 5 modules:
- depth: monocular depth estimation + 3D backprojection (ONNX-ready, synthetic fallback)
- pointcloud: Point3D/ColorPoint types, PLY export, Gaussian splat conversion
- fusion: WiFi occupancy volume → point cloud + multi-modal voxel fusion
- stream: HTTP + Three.js viewer server (Axum, port 9880)
- main: CLI with serve/capture/demo subcommands

Demo output: 271 WiFi points + 19,200 depth points → 4,886 fused → 1,718 Gaussian splats.
Serves interactive 3D viewer at http://localhost:9880 with Three.js orbit controls.

ADR-SYS-0021 documents the architecture for camera + WiFi CSI dense point cloud pipeline.

Co-Authored-By: claude-flow <ruv@ruv.net>

* Optimize pointcloud: larger splat voxels, smaller responses, faster fusion

- Gaussian splat voxel size: 0.10 → 0.15 (42% fewer splats: 1718 → 994)
- Splat response: 399 KB → 225 KB (44% smaller)
- Pipeline: 22.2ms mean (100 runs, σ=0.3ms)
- Cloud API: 1.11ms avg, 905 req/s
- Splats API: 1.39ms avg, 719 req/s
- Binary: 1.0 MB arm64 (Mac Mini), tested

Co-Authored-By: claude-flow <ruv@ruv.net>

* Complete implementation: camera capture, WiFi CSI receiver, training pipeline

Three new modules added to wifi-densepose-pointcloud:

1. camera.rs — Cross-platform camera capture
   - macOS: AVFoundation via Swift, ffmpeg avfoundation
   - Linux: V4L2, ffmpeg v4l2
   - Camera detection, listing, frame capture to RGB
   - Graceful fallback to synthetic data when no camera

2. csi.rs — WiFi CSI receiver for ESP32 nodes
   - UDP listener for CSI JSON frames from ESP32
   - Per-link attenuation tracking with EMA smoothing
   - Simplified RF tomography (backprojection to occupancy grid)
   - Test frame sender for development without hardware
   - Ready for real ESP32 CSI data from ruvzen

3. training.rs — Calibration and training pipeline
   - Depth calibration: grid search over scale/offset/gamma
   - Occupancy training: threshold optimization for presence detection
   - Ground truth reference points for depth RMSE measurement
   - Preference pair export (JSONL) for DPO training on ruOS brain
   - Brain integration: submit observations as memories
   - Persistent calibration files (JSON)

New CLI commands:
   ruview-pointcloud cameras         # list available cameras
   ruview-pointcloud train           # run calibration + training
   ruview-pointcloud csi-test        # send test CSI frames
   ruview-pointcloud serve --csi     # serve with live CSI input

All tested: demo, training (10 samples, 4 reference points, 3 pairs),
CSI receiver (50 test frames), server API.

Co-Authored-By: claude-flow <ruv@ruv.net>

* Fix viewer: replace WebSocket with fetch polling

Co-Authored-By: claude-flow <ruv@ruv.net>

* Wire live camera into server — real-time updating point cloud

- Server captures from /dev/video0 at 2fps via ffmpeg
- Background tokio task refreshes cloud + splats every 500ms
- Viewer polls /api/splats every 500ms, only updates on new frame
- Shows 🟢 LIVE / 🔴 DEMO indicator
- Camera position set for first-person view (looking forward into scene)
- Downsample 4x for performance (19,200 points per frame)
- Graceful fallback to demo data if camera capture fails

Co-Authored-By: claude-flow <ruv@ruv.net>

* Add MiDaS GPU depth, serial CSI reader, full sensor fusion

- MiDaS depth server: PyTorch on CUDA, real monocular depth estimation
- Rust server calls MiDaS via HTTP for neural depth (falls back to luminance)
- Serial CSI reader for ESP32 with motion detection + presence estimation
- CSI disabled by default (RUVIEW_CSI=1 to enable) — serial reader needs baud config
- Edge-enhanced depth for better object boundaries
- All sensors wired: camera, ESP32 CSI, mmWave (CSI gated until serial fixed)

Co-Authored-By: claude-flow <ruv@ruv.net>

* Complete 7-component sensor fusion pipeline (all working)

1. ADR-018 binary parser — decodes ESP32 CSI UDP frames, extracts I/Q subcarriers
2. WiFlow pose — 17 COCO keypoints from CSI (186K param model loaded)
3. Camera depth — MiDaS on CUDA + luminance fallback
4. Sensor fusion — camera depth + CSI occupancy grid + skeleton overlay
5. RF tomography — ISTA-inspired backprojection from per-node RSSI
6. Vital signs — breathing rate from CSI phase analysis
7. Motion-adaptive — skip expensive depth when CSI shows no motion

Live results: 510 CSI frames/session, 17 keypoints, 26% motion, 40 BPM breathing.
Both ESP32 nodes provisioned to send CSI to 192.168.1.123:3333.
Magic number fix: supports both 0xC5110001 (v1) and 0xC5110006 (v6) frames.

Co-Authored-By: claude-flow <ruv@ruv.net>

* Add brain bridge — sparse spatial observation sync every 60s

Stores room scan summaries, motion events, and vital signs
in the ruOS brain as memories. Only syncs every 120 frames
(~60 seconds) to keep the brain sparse and optimized.

Categories: spatial-observation, spatial-motion, spatial-vitals.

Co-Authored-By: claude-flow <ruv@ruv.net>

* Update README + user guide with dense point cloud features

Added pointcloud section to README (quick start, CLI, performance).
Added comprehensive user guide section: setup, sensors, commands,
pipeline components, API endpoints, training, output formats,
deep room scan, ESP32 provisioning.

Co-Authored-By: claude-flow <ruv@ruv.net>

* Add ruview-geo: geospatial satellite integration (11 modules, 8/8 tests)

New crate with free satellite imagery, terrain, OSM, weather, and brain integration.

Modules: types, coord, locate, cache, tiles, terrain, osm, register, fuse, brain, temporal
Tests: 8 passed (haversine, ENU roundtrip, tiles, HGT parse, registration)
Validation: real data — 43.49N 79.71W, 4 Sentinel-2 tiles, 2°C weather, brain stored

Data sources (all free, no API keys):
- EOX Sentinel-2 cloudless (10m satellite tiles)
- SRTM GL1 (30m elevation)
- Overpass API (OSM buildings/roads)
- ip-api.com (geolocation)
- Open Meteo (weather)

ADR-044 documents architecture decisions.
README.md in crate subdirectory.

Co-Authored-By: claude-flow <ruv@ruv.net>

* Update ADR-044: add Common Crawl WET, NASA FIRMS, OpenAQ, Overture Maps sources

Extended geospatial data sources leveraging ruvector's existing web_ingest
and Common Crawl support for hyperlocal context.

Co-Authored-By: claude-flow <ruv@ruv.net>

* Fix OSM/SRTM queries, add change detection + night mode

- OSM: use inclusive building filter with relation query and 25s timeout
- SRTM: switch to NASA public mirror with viewfinderpanoramas fallback
- Add detect_tile_changes() for pixel-diff satellite change detection
- Add is_night() solar-declination model for CSI-only night mode
- 6 new unit tests (night mode + tile change detection)

Co-Authored-By: claude-flow <ruv@ruv.net>

* Enhance viewer: skeleton overlay, weather, buildings, better camera

Add COCO skeleton rendering with yellow keypoint spheres and white bone
lines, info panel sections for weather/buildings/CSI rate/confidence,
overhead camera at (0,2,-4), and denser point size with sizeAttenuation.

Co-Authored-By: claude-flow <ruv@ruv.net>

* Add CSI fingerprint DB + night mode detection

Co-Authored-By: claude-flow <ruv@ruv.net>

* Fix ADR-044 numbering conflict, update geo README

Renumbered provisioning tool ADR from 044 to 050 to avoid conflict
with geospatial satellite integration ADR-044.

Co-Authored-By: claude-flow <ruv@ruv.net>

* Clean up warnings: suppress dead_code for conditional pipeline modules

Removes unused imports/variables via cargo fix and adds #[allow(dead_code)]
for modules used conditionally at runtime (CSI, depth, fusion, serial).
Pointcloud: 28 → 0 warnings. Geo: 2 → 0 warnings. 8/8 tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>

* Fix PR #405 blockers: async runtime panic, crate rename, path traversal, brain URL config

- brain_bridge.rs: replace `Handle::current().block_on(...)` inside async fn
  with `.await` (was a guaranteed "runtime within runtime" panic). Brain URL
  now read from RUVIEW_BRAIN_URL env var (default http://127.0.0.1:9876),
  logged once via OnceLock.
- wifi-densepose-geo: rename Cargo package from `ruview-geo` to
  `wifi-densepose-geo` to match directory and workspace conventions. Update
  all use sites (tests/examples/README). Same env-var pattern for brain URL
  in brain.rs + temporal.rs.
- training.rs: add sanitize_data_path() rejecting `..` components and
  safe_join() that canonicalises + enforces base-dir containment on every
  write (calibration.json, samples.json, preference_pairs.jsonl,
  occupancy_calibration.json). Defence-in-depth check also in main.rs
  before TrainingSession::new.
- osm.rs: clamp Overpass radius to MAX_RADIUS_M=5000m; return Err beyond
  that. Add parse_overpass_json() that rejects malformed payloads
  (missing top-level `elements` array).

Co-Authored-By: claude-flow <ruv@ruv.net>

* csi_pipeline: rename WiFlow stub to heuristic_pose_from_amplitude, decouple UDP

Blocker 3 (PR #405 review): The "WiFlow inference" path was a stub that
built a model from empty weight vectors and synthesised keypoints from
amplitude energy. Presenting this as "WiFlow inference" was misleading.

- Rename WiFlowModel to PoseModelMetadata (empty tag struct; we only care
  if the on-disk file exists)
- Rename load_wiflow_model() -> detect_pose_model_metadata() and log
  "amplitude-energy heuristic enabled/disabled" (no "WiFlow" claim)
- Rename estimate_pose() -> heuristic_pose_from_amplitude() with
  prominent `STUB:` doc comment saying this is NOT a trained model

Blocker 4 (PR #405 review): The UDP receiver held the shared Arc<Mutex>
across a synchronous process_frame() call, starving HTTP handlers.

- Introduce a std::sync::mpsc channel between the UDP thread (which only
  parses + pushes) and a dedicated processor thread (which locks only
  briefly around a single process_frame). HTTP snapshots via
  get_pipeline_output no longer contend with the socket read loop.

Also:
- Move ADR-018 parser to parser.rs (see next commit); csi_pipeline re-exports
- send_test_frames now uses parser::build_test_frame for synthetic frames
- Log a one-line node stats summary every 500 frames (reads every public
  CsiFrame field on the runtime path)

Co-Authored-By: claude-flow <ruv@ruv.net>

* Extract ADR-018 parser into parser.rs + wire Fingerprint CLI

File-split (strong concern #9 in PR #405 review): csi_pipeline.rs was 602
LOC; extract the pure-function ADR-018 parser + synthetic frame builder
into src/parser.rs. Inline unit tests in parser.rs cover:

- 0xC5110001 (raw CSI, v1) roundtrip
- 0xC5110006 (feature state, v6) roundtrip
- wrong magic is rejected
- truncated header is rejected
- truncated payload is rejected

main.rs: expose `fingerprint NAME [--seconds N]` subcommand wiring
record_fingerprint() (this was the only caller needed to make the public
API non-dead on the runtime path). Also:

- Replace `--host/--port` + external `--csi` with a single `--bind`
  defaulting to loopback (`127.0.0.1:9880`) — addresses strong concern
  #7 about exposing camera/CSI/vitals by default.
- Update synthetic `csi-test` to target UDP 3333 (matching the ADR-018
  listener) and use the shared parser::build_test_frame.
- Defence-in-depth: call training::sanitize_data_path on the expanded
  --data-dir before TrainingSession::new does the same.

Co-Authored-By: claude-flow <ruv@ruv.net>

* stream: extract viewer HTML to viewer.html, default bind to loopback

Strong concern #7 (PR #405): default HTTP bind leaked camera/CSI/vitals
to the LAN. The `serve` fn now takes a single `bind` arg and prints a
loud WARNING when bound outside loopback.

Strong concern #10 (PR #405): embedded HTML+JS was ~220 LOC of the 418
LOC stream.rs. Moved the markup verbatim into viewer.html and inlined
via `include_str!("viewer.html")`. Also:

- Drop the #![allow(dead_code)] crate-level silencing (reviewer point
  #11). Remove the now-unused AppState.csi_pipeline field.
- capture_camera_cloud_with_luminance returns the mean luminance of the
  captured frame; the background loop feeds that to
  CsiPipelineState::set_light_level so the night-mode flag actually
  toggles at runtime (previously it could only be set from tests).

Net effect on file size: stream.rs 418 → 232 LOC.

Co-Authored-By: claude-flow <ruv@ruv.net>

* Dead-code cleanup + tests for fusion/depth/OSM/training/fingerprinting

Reviewer point #11 (PR #405): remove the `#![allow(dead_code)]`
silencing added in 8eb808d and fix the underlying issues.

- Delete csi.rs: duplicate of csi_pipeline.rs with incompatible wire
  format (JSON vs ADR-018 binary). csi_pipeline is the real path.
- Delete serial_csi.rs: never referenced by any module.
- Drop Frame.timestamp_ms (unread), AppState.csi_pipeline (unread),
  brain_bridge::brain_available (caller-less), fusion::fetch_wifi_occupancy
  (caller-less) — these had no runtime users.
- Drop crate-level #![allow(dead_code)] from camera.rs, depth.rs,
  fusion.rs, pointcloud.rs.

Tests (target: 8-12, actual: 15 unit + 9 geo unit + 8 geo integration
= 32 total, all pass):

- parser.rs: 5 tests (v1/v6 magic roundtrip, wrong magic, truncated
  header, truncated payload).
- fusion.rs: 2 tests (non-overlapping merge, voxel dedup).
- depth.rs: 2 tests (2x2 backproject → 4 points at z=1, NaN rejected).
- training.rs: 4 tests (rejects `..`, accepts relative child, refuses
  TrainingSession::new("../etc/passwd"), accepts a clean tmpdir).
- csi_pipeline.rs: 2 tests (set_light_level toggles is_dark,
  record_fingerprint stores and self-identifies).
- osm.rs: 3 tests (parse_overpass_json minimal fixture, rejects
  malformed payload, fetch_buildings rejects > MAX_RADIUS_M).

Co-Authored-By: claude-flow <ruv@ruv.net>

* Update README + user-guide for PR #405 review-fix additions

- serve now uses --bind 127.0.0.1:9880 (loopback default) instead of --port
- Add fingerprint subcommand to CLI tables
- Document RUVIEW_BRAIN_URL env var + --brain flag
- Flag pose path as amplitude-energy heuristic stub (not trained WiFlow)
- Security note on exposing server outside loopback
- Add wifi-densepose-pointcloud + wifi-densepose-geo rows to crate table

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-20 12:48:54 -04:00

264 lines
9.1 KiB
Rust

//! Monocular depth estimation via MiDaS ONNX + backprojection to 3D points.
#![allow(dead_code)]
use crate::pointcloud::{PointCloud, ColorPoint};
use anyhow::Result;
/// Default camera intrinsics (approximate for HD webcam)
pub struct CameraIntrinsics {
pub fx: f32, // focal length x (pixels)
pub fy: f32, // focal length y (pixels)
pub cx: f32, // principal point x
pub cy: f32, // principal point y
pub width: u32,
pub height: u32,
}
impl Default for CameraIntrinsics {
fn default() -> Self {
Self {
fx: 525.0, fy: 525.0, // typical webcam focal length
cx: 320.0, cy: 240.0, // center of 640x480
width: 640, height: 480,
}
}
}
/// Backproject a depth map to 3D points using camera intrinsics.
///
/// depth_map: row-major [height x width] in meters
/// rgb: optional row-major [height x width x 3] color
pub fn backproject_depth(
depth_map: &[f32],
intrinsics: &CameraIntrinsics,
rgb: Option<&[u8]>,
downsample: u32,
) -> PointCloud {
let mut cloud = PointCloud::new("camera_depth");
let w = intrinsics.width;
let h = intrinsics.height;
let step = downsample.max(1);
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;
let z = depth_map[idx];
// Skip invalid depths
if z <= 0.01 || z > 10.0 || z.is_nan() { continue; }
// Backproject: (u, v, z) → (X, Y, Z)
let px = (x as f32 - intrinsics.cx) * z / intrinsics.fx;
let py = (y as f32 - intrinsics.cy) * z / intrinsics.fy;
let (r, g, b) = if let Some(rgb_data) = rgb {
let ri = idx * 3;
if ri + 2 < rgb_data.len() {
(rgb_data[ri], rgb_data[ri + 1], rgb_data[ri + 2])
} else {
(128, 128, 128)
}
} else {
// Color by depth (blue=near, red=far)
let t = ((z - 0.5) / 4.0).clamp(0.0, 1.0);
((t * 255.0) as u8, ((1.0 - t) * 128.0) as u8, ((1.0 - t) * 255.0) as u8)
};
cloud.points.push(ColorPoint { x: px, y: py, z, r, g, b, intensity: 1.0 });
}
}
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);
}
// Fallback: luminance + edge-based pseudo-depth
let w = width as usize;
let h = height as usize;
let mut lum = vec![0.0f32; w * h];
for i in 0..w * h {
let ri = i * 3;
if ri + 2 < image_data.len() {
lum[i] = (0.299 * image_data[ri] as f32
+ 0.587 * image_data[ri + 1] as f32
+ 0.114 * image_data[ri + 2] as f32) / 255.0;
}
}
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]
+ 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);
}
}
let mut depth_map = vec![3.0f32; w * h];
for i in 0..w * h {
let base = 1.0 + (1.0 - lum[i]) * 3.5;
let edge_boost = edges[i] * 1.5;
depth_map[i] = (base - edge_boost).max(0.3);
}
Ok(depth_map)
}
/// 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;
if rgb.len() < expected { anyhow::bail!("rgb too small"); }
// Send RGB as JSON array to depth server
let rgb_list: Vec<u8> = rgb[..expected].to_vec();
let body = serde_json::json!({
"width": width,
"height": height,
"rgb": rgb_list,
});
let body_bytes = serde_json::to_vec(&body)?;
let client = std::net::TcpStream::connect_timeout(
&"127.0.0.1:9885".parse()?, std::time::Duration::from_millis(500)
)?;
client.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
client.set_write_timeout(Some(std::time::Duration::from_secs(2)))?;
use std::io::{Read, Write};
let mut stream = client;
let req = format!(
"POST /depth HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
body_bytes.len()
);
stream.write_all(req.as_bytes())?;
stream.write_all(&body_bytes)?;
// Read response
let mut resp = Vec::new();
stream.read_to_end(&mut resp)?;
// Skip HTTP headers
let body_start = resp.windows(4).position(|w| w == b"\r\n\r\n")
.map(|p| p + 4).unwrap_or(0);
let depth_bytes = &resp[body_start..];
let n = (width * height) as usize;
if depth_bytes.len() < n * 4 { anyhow::bail!("depth response too small"); }
let depth: Vec<f32> = depth_bytes[..n * 4].chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect();
Ok(depth)
}
/// Capture depth cloud from camera (placeholder — real impl uses nokhwa or v4l2).
pub async fn capture_depth_cloud(_frames: usize) -> Result<PointCloud> {
eprintln!("Camera capture not available (no camera on this machine).");
eprintln!("Use --demo for synthetic data, or run on a machine with a camera.");
Ok(demo_depth_cloud())
}
/// Generate a demo depth point cloud (synthetic room scene).
pub fn demo_depth_cloud() -> PointCloud {
let _cloud = PointCloud::new("demo_camera_depth");
let intrinsics = CameraIntrinsics::default();
// Simulate a depth map: room with walls at 3m, floor, and a person at 2m
let w = 160; // downsampled
let h = 120;
let mut depth = vec![3.0f32; w * h];
// Floor plane (bottom third)
for y in (h * 2 / 3)..h {
for x in 0..w {
depth[y * w + x] = 1.0 + (y - h * 2 / 3) as f32 * 0.05;
}
}
// Person silhouette (center, depth=2m)
for y in (h / 4)..(h * 3 / 4) {
for x in (w * 2 / 5)..(w * 3 / 5) {
let dy = (y as f32 - h as f32 / 2.0).abs() / (h as f32 / 4.0);
let dx = (x as f32 - w as f32 / 2.0).abs() / (w as f32 / 5.0);
if dx * dx + dy * dy < 1.0 {
depth[y * w + x] = 2.0 + (dx * dx + dy * dy) * 0.3;
}
}
}
let scaled_intrinsics = CameraIntrinsics {
fx: intrinsics.fx * w as f32 / intrinsics.width as f32,
fy: intrinsics.fy * h as f32 / intrinsics.height as f32,
cx: w as f32 / 2.0,
cy: h as f32 / 2.0,
width: w as u32,
height: h as u32,
};
backproject_depth(&depth, &scaled_intrinsics, None, 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backproject_2x2_depth_yields_four_points() {
// 2x2 image, depth=1m everywhere; trivial intrinsics.
let intr = CameraIntrinsics {
fx: 1.0, fy: 1.0, cx: 0.5, cy: 0.5,
width: 2, height: 2,
};
let depth = vec![1.0f32; 4];
let cloud = backproject_depth(&depth, &intr, None, 1);
assert_eq!(cloud.points.len(), 4, "2x2 depth → 4 backprojected points");
// Every point should be at z=1.0.
for p in &cloud.points {
assert!((p.z - 1.0).abs() < 1e-6, "z should be 1.0, got {}", p.z);
}
// With cx=0.5, cy=0.5 the four pixel centers backproject symmetrically
// about the optical axis: x in {-0.5, 0.5}, y in {-0.5, 0.5}.
let mut xs: Vec<f32> = cloud.points.iter().map(|p| p.x).collect();
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!((xs[0] + 0.5).abs() < 1e-6);
assert!((xs.last().unwrap() - 0.5).abs() < 1e-6);
}
#[test]
fn backproject_rejects_invalid_depth() {
let intr = CameraIntrinsics {
fx: 1.0, fy: 1.0, cx: 0.5, cy: 0.5,
width: 2, height: 2,
};
// All pixels NaN → no points.
let depth = vec![f32::NAN; 4];
let cloud = backproject_depth(&depth, &intr, None, 1);
assert_eq!(cloud.points.len(), 0);
}
}
#[allow(dead_code)]
fn find_midas_model() -> Result<String> {
let paths = [
dirs::home_dir().unwrap_or_default().join(".local/share/ruview/midas_v21_small_256.onnx"),
dirs::home_dir().unwrap_or_default().join(".cache/ruview/midas_v21_small_256.onnx"),
std::path::PathBuf::from("/usr/local/share/ruview/midas_v21_small_256.onnx"),
];
for p in &paths {
if p.exists() { return Ok(p.to_string_lossy().to_string()); }
}
anyhow::bail!("MiDaS ONNX model not found. Download:\n wget https://github.com/isl-org/MiDaS/releases/download/v3_1/midas_v21_small_256.onnx -O ~/.local/share/ruview/midas_v21_small_256.onnx")
}