ayzeksalimli's picture
Push project (code, README, Docker/compose, models) — no .github/workflows
9f85448 verified
Raw
History Blame Contribute Delete
6.42 kB
"""Monocular depth estimation."""
from __future__ import annotations
import threading
from pathlib import Path
import cv2
import numpy as np
def _pick_device() -> str:
"""Best available inference device."""
try:
import torch
except ImportError:
return "cpu"
try:
if torch.cuda.is_available():
return "cuda"
if torch.backends.mps.is_available():
return "mps"
except (AttributeError, RuntimeError):
return "cpu"
return "cpu"
class DepthEstimator:
"""Monocular depth from a YOLO depth model."""
def __init__(self, model_path: str | Path, imgsz: int = 384) -> None:
from ultralytics import YOLO
model_path = Path(model_path)
if not model_path.exists():
raise FileNotFoundError(f"Depth model not found: {model_path}")
self.device = _pick_device()
self.model = YOLO(str(model_path))
self.imgsz = imgsz
self._lo: float | None = None
self._hi: float | None = None
self._bounds_alpha = 0.08
def __call__(self, frame: np.ndarray) -> np.ndarray:
"""Raw depth map, same size as frame."""
result = self.model.predict(
frame, imgsz=self.imgsz, device=self.device, verbose=False
)[0]
depth = result.depth.data
if hasattr(depth, "cpu"):
depth = depth.cpu().numpy()
depth = np.asarray(depth, dtype=np.float32)
if depth.ndim == 3:
depth = depth[0]
if depth.shape[:2] != frame.shape[:2]:
depth = cv2.resize(depth, (frame.shape[1], frame.shape[0]),
interpolation=cv2.INTER_LINEAR)
return depth
def normalize(self, depth: np.ndarray) -> np.ndarray:
"""Stable 0..1 map, 0 near, 1 far."""
finite = depth[np.isfinite(depth)]
if finite.size == 0:
return np.full(depth.shape, 0.5, dtype=np.float32)
lo, hi = float(np.percentile(finite, 2.0)), float(np.percentile(finite, 98.0))
if self._lo is None or self._hi is None:
self._lo, self._hi = lo, hi
else:
self._lo += self._bounds_alpha * (lo - self._lo)
self._hi += self._bounds_alpha * (hi - self._hi)
span = max(self._hi - self._lo, 1e-6)
norm = np.clip((depth - self._lo) / span, 0.0, 1.0)
return np.nan_to_num(norm, nan=0.5).astype(np.float32)
@staticmethod
def colorize(depth_norm: np.ndarray) -> np.ndarray:
"""Turbo colormap of a normalized depth map."""
u8 = (np.clip(depth_norm, 0.0, 1.0) * 255.0).astype(np.uint8)
return cv2.applyColorMap(255 - u8, cv2.COLORMAP_TURBO)
@staticmethod
def sample(depth_map: np.ndarray, x: float, y: float, radius: int = 9,
percentile: float = 20.0, default: float = 0.0) -> float:
"""Nearest surface around a point."""
h, w = depth_map.shape[:2]
xi = int(np.clip(round(float(x)), 0, w - 1))
yi = int(np.clip(round(float(y)), 0, h - 1))
x0, x1 = max(0, xi - radius), min(w, xi + radius + 1)
y0, y1 = max(0, yi - radius), min(h, yi + radius + 1)
patch = depth_map[y0:y1, x0:x1]
patch = patch[np.isfinite(patch)]
if patch.size == 0:
return default
return float(np.percentile(patch, percentile))
class DepthWorker:
"""Background depth estimation thread."""
def __init__(self, model_path: str | Path, imgsz: int = 384,
input_width: int = 640) -> None:
self.estimator = DepthEstimator(model_path, imgsz)
self.device = self.estimator.device
self.input_width = input_width
self._pending: np.ndarray | None = None
self._target: tuple[int, int] | None = None
self._metric: np.ndarray | None = None
self._norm: np.ndarray | None = None
self._seq = 0
self._lock = threading.Lock()
self._wake = threading.Event()
self._stop = threading.Event()
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
def submit(self, frame: np.ndarray) -> None:
"""Queue the newest frame."""
h, w = frame.shape[:2]
if w > self.input_width:
k = self.input_width / float(w)
small = cv2.resize(frame, (self.input_width, max(1, int(round(h * k)))),
interpolation=cv2.INTER_AREA)
else:
small = frame.copy()
with self._lock:
self._pending = small
self._target = (w, h)
self._wake.set()
def _loop(self) -> None:
"""Consume frames until stopped."""
while not self._stop.is_set():
self._wake.wait(0.1)
self._wake.clear()
with self._lock:
frame, target = self._pending, self._target
self._pending = None
if frame is None or target is None:
continue
try:
metric = self.estimator(frame)
norm = self.estimator.normalize(metric)
except Exception as exc: # keep the app alive on backend errors
print(f"[!] depth failed: {exc}", flush=True)
self._stop.set()
return
if (metric.shape[1], metric.shape[0]) != target:
metric = cv2.resize(metric, target, interpolation=cv2.INTER_LINEAR)
norm = cv2.resize(norm, target, interpolation=cv2.INTER_LINEAR)
with self._lock:
self._metric = metric
self._norm = norm
self._seq += 1
@property
def latest(self) -> np.ndarray | None:
"""Newest metric depth map."""
with self._lock:
return self._metric
@property
def latest_norm(self) -> np.ndarray | None:
"""Newest normalized depth map."""
with self._lock:
return self._norm
@property
def ready(self) -> bool:
"""A depth map is available."""
return self.latest is not None
@property
def frames(self) -> int:
"""Number of finished estimations."""
with self._lock:
return self._seq
def close(self) -> None:
"""Stop the worker thread."""
self._stop.set()
self._wake.set()
self._thread.join(timeout=1.0)