"""HSV asphalt/road mask over the bottom crop of the frame. Backyards border roads and driveways. We don't have a map, so we cheaply flag "road ahead" by thresholding low-saturation grey (asphalt/concrete) in the bottom strip of the camera frame and reporting how much of the left/centre/right thirds is covered. The control layer uses :class:`RoadInfo.clearer_side` to steer away from the road. ``cv2`` is imported lazily so the package imports without OpenCV; when ``road_enabled`` is False we skip OpenCV entirely and return an empty result. """ from __future__ import annotations import numpy as np from config import PerceptionConfig from gowelcome.types import RoadInfo class RoadDetector: """Threshold the bottom crop of a frame for road-coloured pixels.""" def __init__(self, cfg: PerceptionConfig) -> None: """Prepare the road detector. When ``cfg.road_enabled`` is False this is a no-op shell: ``analyze`` returns an empty :class:`RoadInfo` and OpenCV is never imported. Args: cfg: Perception configuration (crop fraction, HSV bounds, kernel). Raises: ImportError: if road detection is enabled but ``cv2`` is missing, with a hint to ``pip install opencv-python``. """ self.cfg = cfg self._cv2 = None if cfg.road_enabled: try: import cv2 # lazy: heavy/optional dep except ImportError as exc: # pragma: no cover - exercised off-robot raise ImportError( "opencv-python (cv2) is required for RoadDetector when " "road_enabled is True. Install it with: " "pip install opencv-python" ) from exc self._cv2 = cv2 def analyze(self, frame) -> RoadInfo: """Estimate road coverage in the bottom crop of ``frame``. Args: frame: ``HxWx3`` BGR ``uint8`` numpy array. Returns: A :class:`RoadInfo` with overall coverage and per-third coverage (each in ``[0, 1]``) plus the binary crop mask. If road detection is disabled, returns ``RoadInfo(0, 0, 0, 0, None)``. """ cfg = self.cfg if not cfg.road_enabled or self._cv2 is None: return RoadInfo(0.0, 0.0, 0.0, 0.0, None) cv2 = self._cv2 h, w = frame.shape[:2] # Bottom crop: keep the lowest ``road_crop_frac`` of the frame. crop = frame[int(h * (1.0 - cfg.road_crop_frac)):, :] hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV) mask = cv2.inRange( hsv, np.array(cfg.road_hsv_lower), np.array(cfg.road_hsv_upper), ) # Morphological opening to de-speckle the binary mask. kernel = np.ones((cfg.road_morph_kernel, cfg.road_morph_kernel), np.uint8) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) # Mean over a {0,255} mask -> fraction of road pixels once /255. coverage = float(mask.mean()) / 255.0 # Split the crop width into equal thirds; per-third coverage. cw = mask.shape[1] third = max(1, cw // 3) left = float(mask[:, :third].mean()) / 255.0 center = float(mask[:, third:2 * third].mean()) / 255.0 right = float(mask[:, 2 * third:].mean()) / 255.0 return RoadInfo(coverage, left, center, right, mask=mask)