Part 2 of the Hawk Eye Spatial Qualimetry series. Part 1 covered SVD plane fitting and trimmed OLS for 3D tread profiles. Here we answer the question Part 1 left open: how far away was the camera?
Hawk Eye is the internal codename. These components are coming soon to *Tyre Cam AI** (free scans are limited, and a free account unlocks more). This post covers only the rangefinding layer.*
1. The problem: relative depth has no units
In Part 1 we extracted a 3D tread relief from a single photo. The output is relative: a monocular U-Net returns values in [0, 1], and the Poisson-integrated relief gives a delta-Z in arbitrary units. Turning that into millimetres needs a scale, and the scale depends on something we never measured: the distance between the lens and the rubber.
The same 8 mm groove spans very different pixel counts at 30 cm and at 90 cm, and its shading gradients change too. If the pipeline treats both shots identically, the fusion layer ends up trusting a 3D estimate that is physically less reliable at one end of the range.
The obvious fix is a depth sensor, and it does not scale:
- LiDAR / ToF is a small minority of devices: roughly 5% in our target fleet (an internal estimate, not a survey).
- Stereo needs two calibrated cameras on a rigid baseline. A mechanic with a phone has neither.
- Hardcoded tire geometry breaks the moment someone photographs a 175 mm hatchback tire instead of a 245 mm SUV tire.
So the Silver Kernel Rangefinder is a passive monocular estimator that fuses two independent cues:
- Geometry: the angular scale of the detected tread ROI in the frame (a pinhole relation).
- Optical physics: how defocused and how contrast-attenuated the texture inside the ROI is (Depth-from-Defocus, DfD).
It works from 25 to 100 cm and needs only NumPy plus one PIL resize. Project constraints: no third-party CV packages in the core, no LiDAR, no stereo, no per-tire dimension tables.
![Figure 1. Panel 3 of the Hawk quad-panel HUD: live AI Overlay showing monocular depth estimation (5.7 mm), rangefinder distance readout (Dist.: 59 cm, Z_geom: 58 cm, OptF: 0.990, C_michel: 0.35, Conf: 93%), and 100% model consensus.]

Figure 1. Panel 3 of the Hawk quad-panel HUD: live AI Overlay showing monocular depth estimation (5.7 mm), rangefinder distance readout (Dist.: 59 cm, Z_geom: 58 cm, OptF: 0.990, C_michel: 0.35, Conf: 93%), and 100% model consensus.
2. The math
2.1 Geometry cue: pinhole angular scale
Let r = w_roi / w_frame be the fraction of the frame width occupied by the tread ROI. For a pinhole camera, apparent size is inversely proportional to distance:
K is one scalar per camera system (focal length, sensor width and the framing behaviour of the ROI detector folded together), and b is a small lens-to-reference offset. Section 5 is explicit about what K does and does not assume.
Because z is proportional to 1/r, relative errors pass through 1:1:
A 3 px jitter on a 600 px ROI is about 0.5% of distance, far below the other error sources.
2.2 Optical cue: thin-lens defocus
For a thin lens with focal length f and f-number N, focused at z_f, a point at distance z is imaged as a blur circle of diameter
Blur grows with |z - z_f|, so sharpness is a magnitude cue. Nearer and farther than the focal plane look alike. Geometry resolves that sign.
To measure blur without a reference image we use the variance of the discrete Laplacian of the luma channel:
Why it works. Natural textures have a power-law power spectrum P(ω)∝ω−α with α≈2 . A Gaussian blur of width σb multiplies power by e−σb2ω2 , and the Laplacian multiplies it by ω4 . Integrating over the 2D frequency plane:
So σ∇2∝σb−2 , and the reciprocal 0.04/σ∇2 grows like the square of the blur radius. The model holds while blur is resolvable ( σb≳1 px). Below that, the pixel grid and sensor noise set a floor. This is why every ROI is resampled to a fixed 224x224 grid before measuring anything.
2.3 Michelson contrast, robust version
Raw min/max are single-pixel statistics, dominated by specular highlights on wet rubber and by hot pixels. Percentiles make the metric robust.
Contrast carries distance information because a fixed physical texture period maps to a pixel period proportional to 1/z. As the camera backs off, micro-texture approaches the sensor Nyquist limit where lens and pixel MTF attenuate it. Contrast therefore decays with distance even when the lens is in focus. That is a second cue with a failure profile different from Laplacian variance.
2.4 Combining the cues: the optic factor
The project uses "optical factor" in two roles, and it helps to keep them apart:
-
optic_factoris a bounded multiplier on distance, defined below. -
k_opt = sigma_luma / mu_lumais a quality gate used by the fusion layer (Section 4).
with wopt=0.15 . The constants here (0.04, the 50/50 split, w_opt, the clamp) are empirically tuned on our own captures. Treat them as calibrated heuristics, not laws of nature. 0.04 is the Laplacian sigma of in-focus reference captures on the 224 grid, so the first term is about 0 for a reference-sharp ROI. The 50/50 split reflects that we had no evidence to prefer one cue, and with a small w_opt the result is insensitive to it.
The design principle matters more than the constants:
-
w_optis small on purpose. Geometry is the backbone. Optics nudges it and, more importantly, tells us how much to trust it. - The +/-40% clamp guarantees that a motion-blurred or out-of-focus frame cannot send the estimate to infinity.
One more use of spatial frequency: we split the Laplacian into 16x16 tiles and compute a local-variance matrix. The fraction of tiles carrying texture is a coverage score, which rejects ROIs half-filled with asphalt or fender. The per-tile heatmaps we use for diagnostics are not in the hot path, and we can share that code on request.
3. The code
Self-contained: pip install numpy pillow, paste, run.
3.1 Primitives
from __future__ import annotations from typing import NamedTuple import numpy as np from PIL import Image CANON = 224 # same grid as the ONNX input tensors _LUMA = np.array([0.299, 0.587, 0.114], dtype=np.float32) def canonicalize(roi_rgb: np.ndarray, size: int = CANON) -> np.ndarray: """Resize ROI to size x size uint8 RGB so threshol...


