PerceptionBenchmark / src /epipolar.py
DariusGiannoli
Step 6: Epipolar Geometry — sparse stereo matching with F matrix, ORB keypoints, template matching along epipolar lines
f36ea70
"""
src/epipolar.py — Sparse Epipolar Stereo Matching
==================================================
Given detected objects in the left image:
1. Compute the fundamental matrix **F** from camera calibration.
2. Extract ORB keypoints inside each detection bounding-box.
3. For every keypoint, project the epipolar line onto the right image.
4. Template-match along that line to find the correspondence.
5. Compute disparity d = x_L − x_R
6. Recover depth Z = f·B / (d + d_offs)
"""
import cv2
import numpy as np
import time
# ------------------------------------------------------------------
# Fundamental matrix
# ------------------------------------------------------------------
def fundamental_from_calibration(cam0: np.ndarray, cam1: np.ndarray,
baseline_mm: float) -> np.ndarray:
"""Compute F for a rectified stereo pair.
E = [t]_× with t = [B, 0, 0]
F = K_R^{-T} E K_L^{-1}
For rectified images the result confirms that epipolar
lines are horizontal.
"""
tx = np.array([[0.0, 0.0, 0.0],
[0.0, 0.0, -baseline_mm],
[0.0, baseline_mm, 0.0]])
F = np.linalg.inv(cam1).T @ tx @ np.linalg.inv(cam0)
norm = np.linalg.norm(F)
if norm > 0:
F /= norm
return F
def fundamental_from_scalars(focal: float, cx0: float, cy: float,
cx1: float) -> np.ndarray:
"""Build F when only scalar calibration values are available."""
K_L = np.array([[focal, 0, cx0], [0, focal, cy], [0, 0, 1]], dtype=np.float64)
K_R = np.array([[focal, 0, cx1], [0, focal, cy], [0, 0, 1]], dtype=np.float64)
tx = np.array([[0, 0, 0], [0, 0, -1], [0, 1, 0]], dtype=np.float64)
F = np.linalg.inv(K_R).T @ tx @ np.linalg.inv(K_L)
norm = np.linalg.norm(F)
if norm > 0:
F /= norm
return F
# ------------------------------------------------------------------
# Epipolar line helpers
# ------------------------------------------------------------------
def compute_epipolar_lines(F: np.ndarray, points: np.ndarray) -> np.ndarray:
"""Return Nx3 lines l = F·p (ax + by + c = 0) for Nx2 *points*."""
pts_h = np.hstack([points, np.ones((len(points), 1))])
lines = (F @ pts_h.T).T
return lines
# ------------------------------------------------------------------
# Template matching along epipolar line
# ------------------------------------------------------------------
def _match_along_epipolar(img_left, img_right, pt_left,
patch_half: int = 25, ndisp: int = 128):
"""NCC template match of a patch around *pt_left* along the same row.
Returns ``((matched_x, matched_y), confidence)`` or ``(None, 0.0)``.
"""
H, W = img_left.shape[:2]
px, py = int(pt_left[0]), int(pt_left[1])
y0 = max(0, py - patch_half)
y1 = min(H, py + patch_half + 1)
x0 = max(0, px - patch_half)
x1 = min(W, px + patch_half + 1)
template = img_left[y0:y1, x0:x1]
if template.size == 0 or template.shape[0] < 5 or template.shape[1] < 5:
return None, 0.0
# Search strip — same rows, up to ndisp pixels to the left
strip_x0 = max(0, px - ndisp - patch_half)
strip_x1 = min(W, px + patch_half + 1)
strip = img_right[y0:y1, strip_x0:strip_x1]
if strip.shape[1] < template.shape[1] or strip.shape[0] < template.shape[0]:
return None, 0.0
tmpl_gray = cv2.cvtColor(template, cv2.COLOR_BGR2GRAY) if len(template.shape) == 3 else template
strip_gray = cv2.cvtColor(strip, cv2.COLOR_BGR2GRAY) if len(strip.shape) == 3 else strip
result = cv2.matchTemplate(strip_gray, tmpl_gray, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(result)
matched_x = strip_x0 + max_loc[0] + template.shape[1] // 2
return (matched_x, py), float(max_val)
# ------------------------------------------------------------------
# Full sparse depth pipeline
# ------------------------------------------------------------------
def sparse_epipolar_depth(img_left, img_right, detections, F,
focal, baseline, doffs, ndisp=128,
n_keypoints=10, patch_half=25,
match_thresh=0.3):
"""Run sparse epipolar matching for every detection.
*detections* : list of ``(source, x1, y1, x2, y2, label, conf)``
Returns ``(results, elapsed_ms)``.
"""
orb = cv2.ORB_create(nfeatures=n_keypoints * 3)
t0 = time.perf_counter()
results = []
H, W = img_left.shape[:2]
for det in detections:
source, dx1, dy1, dx2, dy2, label, conf = det
dx1, dy1, dx2, dy2 = int(dx1), int(dy1), int(dx2), int(dy2)
bx0, by0 = max(0, min(dx1, W - 1)), max(0, min(dy1, H - 1))
bx1, by1 = max(0, min(dx2, W)), max(0, min(dy2, H))
roi = img_left[by0:by1, bx0:bx1]
if roi.size == 0:
continue
# ---- keypoints --------------------------------------------------
gray_roi = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
kps = orb.detect(gray_roi, None)
if not kps:
cx, cy = (bx0 + bx1) // 2, (by0 + by1) // 2
keypoints_global = [(cx, cy)]
else:
kps = sorted(kps, key=lambda k: k.response, reverse=True)[:n_keypoints]
keypoints_global = [(int(k.pt[0]) + bx0, int(k.pt[1]) + by0) for k in kps]
# ---- epipolar lines ---------------------------------------------
pts_arr = np.array(keypoints_global, dtype=np.float64)
epi_lines = compute_epipolar_lines(F, pts_arr)
# ---- match -------------------------------------------------------
matches = []
matched_pts = []
for kp, eline in zip(keypoints_global, epi_lines):
m_pt, m_conf = _match_along_epipolar(
img_left, img_right, kp,
patch_half=patch_half, ndisp=ndisp)
if m_pt is not None and m_conf >= match_thresh:
disp = kp[0] - m_pt[0]
depth = (focal * baseline) / (disp + doffs) if (disp + doffs) > 0 else 0.0
matches.append({
"left_pt": kp,
"right_pt": m_pt,
"epi_line": eline.tolist(),
"disparity": float(disp),
"depth_mm": float(depth),
"match_conf": m_conf,
})
matched_pts.append(m_pt)
else:
matched_pts.append(None)
valid_depths = [m["depth_mm"] for m in matches if m["depth_mm"] > 0]
median_depth = float(np.median(valid_depths)) if valid_depths else 0.0
results.append({
"source": source, "label": label,
"box": (dx1, dy1, dx2, dy2), "det_conf": conf,
"keypoints": keypoints_global,
"matched_pts": matched_pts,
"epi_lines": epi_lines,
"matches": matches,
"n_keypoints": len(keypoints_global),
"n_matched": len(matches),
"median_depth_mm": median_depth,
})
elapsed_ms = (time.perf_counter() - t0) * 1000
return results, elapsed_ms
# ------------------------------------------------------------------
# Visualisation helpers
# ------------------------------------------------------------------
_COLORS = [(0, 255, 0), (0, 0, 255), (255, 0, 0), (255, 255, 0),
(255, 0, 255), (0, 255, 255), (128, 255, 0), (255, 128, 0),
(0, 128, 255), (128, 0, 255)]
def draw_epipolar_canvas(img_left, img_right, result_entry):
"""Side-by-side image: keypoints (L) ↔ epipolar lines + matches (R)."""
H, W = img_left.shape[:2]
canvas = np.zeros((H, W * 2 + 20, 3), dtype=np.uint8)
canvas[:, :W] = img_left
canvas[:, W + 20:] = img_right
# Separator
canvas[:, W:W + 20] = 40
dx1, dy1, dx2, dy2 = result_entry["box"]
cv2.rectangle(canvas, (dx1, dy1), (dx2, dy2), (255, 255, 0), 2)
kps = result_entry["keypoints"]
mpts = result_entry["matched_pts"]
elines = result_entry["epi_lines"]
for i, (kp, m_pt, eline) in enumerate(zip(kps, mpts, elines)):
color = _COLORS[i % len(_COLORS)]
kx, ky = int(kp[0]), int(kp[1])
# keypoint on left
cv2.circle(canvas, (kx, ky), 5, color, -1)
# epipolar line on right half
a, b, c = float(eline[0]), float(eline[1]), float(eline[2])
if abs(b) > 1e-9:
ry0 = int(-c / b)
ry1 = int(-(a * W + c) / b)
cv2.line(canvas, (W + 20, ry0), (W + 20 + W, ry1), color, 1, cv2.LINE_AA)
if m_pt is not None:
mx, my = int(m_pt[0]), int(m_pt[1])
cv2.circle(canvas, (W + 20 + mx, my), 5, color, -1)
cv2.circle(canvas, (W + 20 + mx, my), 7, (255, 255, 255), 1)
# correspondence line
cv2.line(canvas, (kx, ky), (W + 20 + mx, my), color, 1, cv2.LINE_AA)
return canvas