{"slug": "beyond-opencv-light-weight-monocular-depth-post-processing-in-pure-numpy", "title": "Beyond OpenCV: Light-Weight Monocular Depth Post-Processing in Pure NumPy", "summary": "A developer behind Hawk Eye, an industrial edge AI application for monocular tire tread depth assessment, built a pure-NumPy post-processing routine that removes camera perspective tilt from monocular depth maps without OpenCV, SciPy, or Scikit-Image. The method fits a least-squares plane to the depth surface using SVD via np.linalg.lstsq rather than the normal equations, avoiding the ill-conditioning that arises when forming A^T*A on a 224x224 grid, and applies percentile masking so deep grooves don't corrupt the surface fit. The detrended output is then scaled into millimeter tread-depth profiles.", "body_md": "When building desktop or edge AI applications, the default instinct for many engineers is to run `pip install opencv-python`.\n\nHowever, as your architecture matures toward lightweight, self-contained edge software (such as a PyQt6 GUI app running ONNX Runtime on CPU), third-party C++ wrappers introduce real operational friction:\n\n`opencv-python-headless` on Linux/Windows x64) that expand desktop installers.\nIn our project — **Hawk Eye**, an industrial AI edge application for monocular tire tread depth assessment — we enforced a strict rule: **Zero CV-specific external dependencies** (no OpenCV, SciPy, or Scikit-Image). The pipeline relies strictly on PyQt6, ONNX Runtime, PIL, and pure NumPy.\n\nIn this article, we look at how we solve camera perspective tilt in monocular depth maps using simple vector operations in NumPy.\n\nMonocular depth estimation models (like U-Net architectures optimized for edge CPU) map 2D pixels to depth predictions `Z_raw` in range `[0.0, 1.0]`.\n\nIn field diagnostics, handheld devices or smartphone cameras are almost never held perfectly parallel to the tire surface. A 5-degree perspective tilt creates an artificial linear gradient across the depth map, causing the model to mistake perspective for actual physical tread wear.\n\nTo fix this, we detrend the baseline reference plane using Ordinary Least Squares (OLS) fitted via SVD.\n\nWe model the background surface using a linear 3D equation:\n\n`Z(x, y) = a * x + b * y + c`\n\nWhere `(x, y)` are spatial pixel coordinates, `a, b` are surface slopes, and `c` is the global baseline offset.\n\nGiven an ROI matrix `Z_raw` of size `H x W`, we set up an overdetermined linear system `A * x = b`.\n\nIn textbook linear algebra, the analytical solution is written via Normal Equations:\n\n`x_opt = inv(A^T * A) * A^T * b`\n\nIn production, forming `A^T * A` squares the dynamic range of pixel coordinates. For a `224x224` depth grid (`N = 50,176` pixels, max coordinate `x_max = 223`), the condition number of `A^T * A` reaches `10^4 - 10^5`. Explicit matrix inversion leads to numerical instability on low-precision CPU runtimes.\n\nInstead, we use **Singular Value Decomposition (SVD)** via `np.linalg.lstsq`. SVD solves the system directly without forming `A^T * A`, avoiding matrix inversion instability.\n\nBelow is the self-contained implementation written purely in NumPy. It fits a least-squares plane, subtracts perspective tilt, and uses percentile masking to prevent deep grooves from corrupting the surface fit.\n\n``` python\n python\nimport numpy as np\n\ndef detrend_and_scale_depth(\n    depth_raw: np.ndarray, \n    scale_3d: float = 1.80, \n    bias_mm: float = 0.0,\n    groove_mask_percentile: float = 40.0\n) -> np.ndarray:\n    \"\"\"\n    Removes camera perspective tilt using SVD Least Squares Plane Fitting\n    and converts raw relative depth predictions into millimeter profile maps.\n\n    Convention: Assumes Z_groove < Z_surface (lower relative height in depth_raw).\n\n    Parameters\n    ----------\n    depth_raw : np.ndarray\n        2D floating-point array (H, W) of relative predictions from ONNX U-Net.\n    scale_3d : float\n        Optics scale factor (mm per relative depth unit).\n    bias_mm : float\n        Systemic bias correction in millimeters.\n    groove_mask_percentile : float\n        Empirical threshold (40%) masking lower depth values (grooves) \n        during plane fitting to prevent fit sags.\n\n    Returns\n    -------\n    np.ndarray\n        2D array (H, W) containing detrended physical tread depth in millimeters.\n    \"\"\"\n    h, w = depth_raw.shape\n\n    # 1. Generate 2D pixel coordinate grids\n    grid_y, grid_x = np.indices((h, w), dtype=np.float32)\n\n    x_flat = grid_x.ravel()\n    y_flat = grid_y.ravel()\n    z_flat = depth_raw.ravel()\n\n    # Design matrix A: Shape (N, 3) where N = H * W\n    A = np.column_stack([x_flat, y_flat, np.ones_like(x_flat)])\n\n    # 2. Trimmed OLS: Mask out deep grooves to fit plane ONLY on top tread blocks\n    threshold_val = np.percentile(z_flat, groove_mask_percentile)\n    mask = z_flat >= threshold_val\n\n    # 3. Solve for plane parameters [a, b, c] via SVD (lstsq)\n    plane_params, _, _, _ = np.linalg.lstsq(A[mask], z_flat[mask], rcond=None)\n    a, b, c = plane_params\n\n    # 4. Reconstruct background trend plane across full ROI\n    z_plane = (a * grid_x + b * grid_y + c)\n\n    # 5. Detrend surface (Subtract baseline slope)\n    z_detrended = depth_raw - z_plane\n\n    # 6. Robust baseline shift (P05 percentile instead of min to reject noise)\n    baseline_floor = np.percentile(z_detrended, 5)\n    z_detrended -= baseline_floor\n\n    # 7. Convert relative depth delta to physical millimeters\n    depth_mm = (z_detrended * scale_3d) + bias_mm\n\n    return depth_mm\n\nif __name__ == \"__main__\":\n    # Simulate 100x100 tile with camera tilt + 2.0 unit groove (20% of area)\n    y, x = np.ogrid[:100, :100]\n    simulated_tilt = 0.05 * x + 0.02 * y\n    simulated_groove = np.where((x > 40) & (x < 60), -2.0, 0.0)\n    raw_input = simulated_tilt + simulated_groove\n\n    # Process depth map\n    depth_mm = detrend_and_scale_depth(raw_input, scale_3d=2.5)\n\n    # Evaluation using P95 - P05 percentiles (Assumes groove area >= 5-10% of ROI)\n    groove_depth = np.percentile(depth_mm, 95) - np.percentile(depth_mm, 5)\n\n    print(f\"Input Range (Tilted): [{raw_input.min():.2f}, {raw_input.max():.2f}]\")\n    print(f\"Corrected Depth Range: [{depth_mm.min():.2f}, {depth_mm.max():.2f}] mm\")\n    print(f\"Measured Groove Depth: {groove_depth:.2f} mm (Expected Synthetic: 5.00 mm)\")\nInput Range (Tilted): [0.00, 6.93]\nCorrected Depth Range: [0.00, 5.00] mm\nMeasured Groove Depth: 5.00 mm (Expected Synthetic: 5.00 mm)\n```\n\nWhile linear plane detrending is fast, edge deployment has specific constraints:\n\n`groove_mask_percentile=40`) to fit strictly to elevated tread blocks (valid for typical 15–35% groove occupancy).`z = a*x^2 + b*y^2 + c*x*y + d*x + e*y + f` should replace global OLS.\nReplacing external CV packages with vectorized NumPy operations yielded clear benefits in our PyQt6 runtime:\n\n`224x224` depth ROI (median of 100 runs after 10 warm-up iterations on Intel Core i5-1135G7 @ 2.40GHz, `OMP_NUM_THREADS=1`, NumPy 1.26.4).\nLinear algebra operations in pure NumPy are often enough for edge post-processing without bundling full C++ vision frameworks.\n\nWhile synthetic tests verify the math, production calibration against real physical depth gauges (e.g., Mitutoyo 571-201-30) remains necessary to establish the optics scale factor (`scale_3d`) for different smartphone camera modules.\n\nBy combining SVD plane fitting with percentile masking, we achieved stable profile extraction for Hawk Eye — completely independent of external computer vision packages.", "url": "https://wpnews.pro/news/beyond-opencv-light-weight-monocular-depth-post-processing-in-pure-numpy", "canonical_source": "https://dev.to/silver_kernel/beyond-opencv-light-weight-monocular-depth-post-processing-in-pure-numpy-35f9", "published_at": "2026-10-30 05:43:25+00:00", "updated_at": "2026-10-30 05:51:59.127399+00:00", "lang": "en", "topics": ["computer-vision", "machine-learning", "ai-tools"], "entities": ["Hawk Eye", "NumPy", "OpenCV", "ONNX Runtime", "PyQt6", "SciPy", "Scikit-Image"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/beyond-opencv-light-weight-monocular-depth-post-processing-in-pure-numpy", "markdown": "https://wpnews.pro/news/beyond-opencv-light-weight-monocular-depth-post-processing-in-pure-numpy.md", "text": "https://wpnews.pro/news/beyond-opencv-light-weight-monocular-depth-post-processing-in-pure-numpy.txt", "jsonld": "https://wpnews.pro/news/beyond-opencv-light-weight-monocular-depth-post-processing-in-pure-numpy.jsonld"}}