92 lines
3.4 KiB
Python
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)
|