92 lines
3.4 KiB
Python

"""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)