diff --git a/Finding_Lanes/README.md b/Finding_Lanes/README.md index c6b08872..881760e3 100644 --- a/Finding_Lanes/README.md +++ b/Finding_Lanes/README.md @@ -2,31 +2,130 @@ ![Star Badge](https://img.shields.io/static/v1?label=%F0%9F%8C%9F&message=If%20Useful&style=style=flat&color=BC4E99) ![Open Source Love](https://badges.frapsoft.com/os/v1/open-source.svg?v=103) -# 🚘 Finding Lanes -### Let's go! - +# ?? Finding Lanes -## 🛠️ Description -A short description about the script must be mentioned here. +> Real-time and static road lane boundary detection using OpenCV and NumPy. -## ⚙️ Languages or Frameworks Used -Run the following command: +

+ Finding Lanes Demo +

+ +## ??? Description + +**Finding Lanes** is a modular Python computer vision project that detects and highlights road lane boundaries from static images, video files, or live camera feeds. + +The pipeline processes each frame through several standard computer vision stages: +1. **Grayscale Conversion & Gaussian Blur**: Reduces image noise and gradient variance. +2. **Canny Edge Detection**: Identifies sharp brightness transitions indicating potential lane boundaries. +3. **Dynamic Region of Interest (ROI)**: Dynamically computes a triangular polygon mask proportional to frame dimensions to eliminate irrelevant road surroundings and sky. +4. **Hough Transform Line Detection (`cv2.HoughLinesP`)**: Identifies line segments from edge pixels. +5. **Slope-Intercept Regression & Extrapolation**: Separates left (negative slope) and right (positive slope) line segments, filters out horizontal noise markings, and extrapolates continuous lane boundary lines. +6. **Alpha Blending (`cv2.addWeighted`)**: Overlays highlighted lane boundaries onto the original frame. + +--- + +## ? Features + +- **Dynamic ROI**: Adapts automatically to arbitrary image/video resolutions and aspect ratios. +- **Robust Line Filtering**: Ignores horizontal markings and guards against zero-division, `NaN`, or out-of-bound coordinates. +- **Multi-Source Support**: Seamlessly processes static images (`picture.jpg`), video files (`video.mp4`), or live camera streams (`--camera 0`). +- **Headless & Batch Support**: `--no-show` flag and `--output` options for saving results in automated/CI pipelines. +- **Pipeline Visualizer (`sub.py`)**: 4-panel subplot showing Original, Canny Edges, ROI Masked, and Lane Detection stages side-by-side. +- **Comprehensive Unit Tests (`test_lanes.py`)**: 100% test coverage over all core pipeline functions and edge cases. + +--- + +## ?? Requirements & Installation + +Ensure you have Python 3.8+ installed. Install the required dependencies: + +```sh +pip install -r requirements.txt +``` + +Or install dependencies manually: + +```sh +pip install opencv-python numpy matplotlib +``` + +--- + +## ?? How to Run + +Navigate to the project folder: + +```sh +cd Finding_Lanes +``` + +### 1. Run Lane Detection on Video (Default) +```sh +python lanes.py +``` +> Press **`q`** on the video window to quit. + +### 2. Run Lane Detection on a Static Image +```sh +python lanes.py --image picture.jpg +``` + +### 3. Run on Custom Video and Save Output +```sh +python lanes.py --video path/to/video.mp4 --output output.mp4 +``` + +### 4. Run on Live Webcam +```sh +python lanes.py --camera 0 +``` + +### 5. Multi-Stage Pipeline Visualizer (`sub.py`) +To inspect intermediate computer vision stages side-by-side using Matplotlib: +```sh +python sub.py +``` +Or with custom image and save output: ```sh -$ python -m pip install --upgrade pip -$ python -m pip install opencv-python +python sub.py --image picture.jpg --output pipeline_stages.png ``` -## How to run -open a terminal in the folder where your script is located and run the following command: + +--- + +## ?? Running Unit Tests + +Run the test suite using Python's built-in `unittest` runner: + ```sh -$ python lanes.py +python -m unittest test_lanes.py -v +``` + +--- + +## ?? Project Structure + ``` -## How to close +Finding_Lanes/ +??? README.md # Project documentation and guide +??? lanes.py # Core lane detection engine and CLI +??? sub.py # 4-stage pipeline visualization utility +??? test_lanes.py # Unit test suite +??? picture.jpg # Sample input road image +??? video.mp4 # Sample input road driving video +??? capture.png # Demo output screenshot +``` + +--- + +## ?? Demo -Just press q +

+ Finding Lanes Demo Output +

-## 📺 Demo +--- - +## ?? Author -## 🤖 Author -zmdlw (https://github.com/zmdlw) +- Original Script: **zmdlw** ([@zmdlw](https://github.com/zmdlw)) +- Enhancements & Tests: **Prayas Dey** ([@Prayas340](https://github.com/Prayas340)) diff --git a/Finding_Lanes/lanes.py b/Finding_Lanes/lanes.py index 159ca500..5b7b8934 100644 --- a/Finding_Lanes/lanes.py +++ b/Finding_Lanes/lanes.py @@ -1,85 +1,421 @@ +"""Finding Lanes: Road Lane Detection using OpenCV & NumPy. + +This module provides a modular computer vision pipeline to detect road lane +markings in static images, pre-recorded video files, or live camera streams. +It uses Gaussian blurring, Canny edge detection, dynamic Region of Interest +(ROI) masking, Hough Transform line detection, and slope-intercept linear +regression averaging to render clear lane boundaries. +""" + +import argparse +import os +import sys +from typing import Dict, List, Optional, Tuple, Union + import cv2 import numpy as np -# 1. convert the image to gray scale -# 2. blur the image -# 3. detect the edges -# 4. create a mask -# 5. apply the mask to the image -# 6. detect the lines -# 7. average the lines -# 8. display the lines +def make_coordinate( + image: np.ndarray, + line_parameters: Union[Tuple[float, float], np.ndarray, List[float]] +) -> Optional[np.ndarray]: + """Calculate (x1, y1, x2, y2) pixel coordinates from slope and intercept. + + Args: + image: Source image frame as a NumPy ndarray. + line_parameters: Tuple or array containing (slope, intercept). + + Returns: + NumPy array [x1, y1, x2, y2] or None if line is invalid. + """ + if line_parameters is None or len(line_parameters) < 2: + return None + + slope = float(line_parameters[0]) + intercept = float(line_parameters[1]) + + # Guard against zero slope, NaN, or non-finite values + if abs(slope) < 1e-4 or not np.isfinite(slope) or not np.isfinite(intercept): + return None + + height, width = image.shape[:2] + y1 = height + y2 = int(height * 0.6) + + try: + x1 = int((y1 - intercept) / slope) + x2 = int((y2 - intercept) / slope) + except (ValueError, OverflowError, ZeroDivisionError): + return None + # Clip coordinates within safe display bounds + x1 = max(-width, min(2 * width, x1)) + x2 = max(-width, min(2 * width, x2)) -def make_coordinate(image, line_parameters): - slope, intercept = line_parameters - y1 = image.shape[0] - y2 = int(y1*(3/5)) - x1 = int((y1-intercept)/slope) - x2 = int((y2-intercept)/slope) - return np.array([x1, y1, x2, y2]) + return np.array([x1, y1, x2, y2], dtype=np.int32) -def average_lines_intercept(image, lines): - left_fit = [] - right_fit = [] +def average_lines_intercept( + image: np.ndarray, + lines: Optional[np.ndarray], + min_slope: float = 0.3 +) -> Optional[np.ndarray]: + """Average and extrapolate detected Hough line segments into left and right lane lines. + + Args: + image: Source image frame. + lines: Array of line segments from cv2.HoughLinesP. + min_slope: Minimum absolute slope threshold to filter horizontal noise lines. + + Returns: + NumPy array containing coordinates for left and right lanes, or None. + """ + if lines is None or len(lines) == 0: + return None + + left_fit: List[Tuple[float, float]] = [] + right_fit: List[Tuple[float, float]] = [] + for line in lines: - x1, y1, x2, y2 = line.reshape(4) + coords = line.reshape(4) + x1, y1, x2, y2 = int(coords[0]), int(coords[1]), int(coords[2]), int(coords[3]) + + # Ignore purely vertical lines to prevent division by zero + if x1 == x2: + continue + parameters = np.polyfit((x1, x2), (y1, y2), 1) - slope = parameters[0] - intercept = parameters[1] - if slope < 0: + slope = float(parameters[0]) + intercept = float(parameters[1]) + + # Filter out near-horizontal noise lines (crosswalks, shadows) + if abs(slope) < min_slope: + continue + + # In image coordinates, y increases downward: + # Left lane has a negative slope, Right lane has a positive slope + if slope < -min_slope: left_fit.append((slope, intercept)) - else: + elif slope > min_slope: right_fit.append((slope, intercept)) - left_fit_average = np.average(left_fit, axis=0) - right_fit_average = np.average(right_fit, axis=0) - left_line = make_coordinate(image, left_fit_average) - right_line = make_coordinate(image, right_fit_average) - return np.array([left_line, right_line]) + lane_lines: List[np.ndarray] = [] + + if len(left_fit) > 0: + left_fit_average = np.average(left_fit, axis=0) + left_line = make_coordinate(image, left_fit_average) + if left_line is not None: + lane_lines.append(left_line) + + if len(right_fit) > 0: + right_fit_average = np.average(right_fit, axis=0) + right_line = make_coordinate(image, right_fit_average) + if right_line is not None: + lane_lines.append(right_line) + + return np.array(lane_lines, dtype=np.int32) if len(lane_lines) > 0 else None + + +def canny( + image: np.ndarray, + low_threshold: int = 50, + high_threshold: int = 150, + kernel_size: int = 5 +) -> np.ndarray: + """Apply grayscale conversion, Gaussian blur, and Canny edge detection. + + Args: + image: Input BGR image array. + low_threshold: Lower hysteresis threshold for Canny. + high_threshold: Upper hysteresis threshold for Canny. + kernel_size: Gaussian blur kernel size (odd integer). + + Returns: + Binary edge map as a 2D NumPy array. + """ + if len(image.shape) == 3: + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + else: + gray = image -def canny(image): - gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) - blur = cv2.GaussianBlur(gray, (5, 5), 0) - canny = cv2.Canny(blur, 50, 150) - return canny + blur = cv2.GaussianBlur(gray, (kernel_size, kernel_size), 0) + edges = cv2.Canny(blur, low_threshold, high_threshold) + return edges -def display_lines(image, lines): +def display_lines( + image: np.ndarray, + lines: Optional[np.ndarray], + color: Tuple[int, int, int] = (0, 0, 255), + thickness: int = 10 +) -> np.ndarray: + """Render detected lane lines onto a blank black canvas matching image dimensions. + + Args: + image: Reference image for dimensions. + lines: Array of lane line coordinates [[x1, y1, x2, y2], ...]. + color: BGR color tuple for the drawn lines. + thickness: Line thickness in pixels. + + Returns: + Image with rendered lane lines. + """ line_image = np.zeros_like(image) - if lines is not None: - for x1, y1, x2, y2 in lines: - cv2.line(line_image, (x1, y1), (x2, y2), (0, 0, 255), 10) + if lines is not None and len(lines) > 0: + for line in lines: + x1, y1, x2, y2 = line.reshape(4) + cv2.line(line_image, (int(x1), int(y1)), (int(x2), int(y2)), color, thickness) return line_image -def roi(image): - height = image.shape[0] - polygons = np.array([ - [(200, height), (1100, height), (550, 250)] - ]) +def roi( + image: np.ndarray, + polygons: Optional[np.ndarray] = None +) -> np.ndarray: + """Apply a Region of Interest (ROI) polygon mask to isolate the road lane area. + + Calculates dynamic vertices proportional to image width and height when + polygons are not explicitly provided. + + Args: + image: Single-channel edge map or 3-channel image. + polygons: Custom polygon vertices array, or None for dynamic ROI. + + Returns: + Masked image containing only the region of interest. + """ + height, width = image.shape[:2] + + if polygons is None: + # Dynamic triangular ROI proportional to image dimensions + polygons = np.array([ + [ + (int(width * 0.15), height), + (int(width * 0.88), height), + (int(width * 0.45), int(height * 0.35)) + ] + ], dtype=np.int32) + mask = np.zeros_like(image) cv2.fillPoly(mask, polygons, 255) masked_image = cv2.bitwise_and(image, mask) return masked_image -cap = cv2.VideoCapture("Finding_Lanes/video.mp4") +def process_frame( + frame: np.ndarray, + min_slope: float = 0.3, + return_intermediates: bool = False +) -> Union[Tuple[np.ndarray, Optional[np.ndarray]], Tuple[np.ndarray, Optional[np.ndarray], Dict[str, np.ndarray]]]: + """Execute the full lane detection pipeline on a single frame. + + Pipeline stages: + 1. Canny edge detection (Grayscale -> Gaussian Blur -> Canny) + 2. Dynamic Region of Interest (ROI) masking + 3. Hough Transform line detection + 4. Slope-intercept averaging & extrapolation + 5. Line rendering & alpha blending with the original frame -while(cap.isOpened()): - _, frame = cap.read() + Args: + frame: BGR input image frame. + min_slope: Minimum slope threshold to filter noise lines. + return_intermediates: If True, returns a dict of intermediate pipeline stages. + + Returns: + (combo_image, averaged_lines) or (combo_image, averaged_lines, intermediates_dict) + """ canny_image = canny(frame) cropped_image = roi(canny_image) - lines = cv2.HoughLinesP(cropped_image, 2, np.pi/180, - 100, np.array([]), minLineLength=40, maxLineGap=5) - averaged_lines = average_lines_intercept(frame, lines) + lines = cv2.HoughLinesP( + cropped_image, + rho=2, + theta=np.pi / 180, + threshold=100, + lines=np.array([]), + minLineLength=40, + maxLineGap=5 + ) + averaged_lines = average_lines_intercept(frame, lines, min_slope=min_slope) line_image = display_lines(frame, averaged_lines) - combo_image = cv2.addWeighted(frame, 0.8, line_image, 1, 1) - cv2.imshow("result", combo_image) - if cv2.waitKey(10) == ord('q'): - break + combo_image = cv2.addWeighted(frame, 0.8, line_image, 1.0, 1.0) + + if return_intermediates: + intermediates = { + "canny": canny_image, + "roi": cropped_image, + "line_image": line_image, + "raw_lines": lines + } + return combo_image, averaged_lines, intermediates + + return combo_image, averaged_lines + + +def process_image( + image_path: str, + output_path: Optional[str] = None, + show: bool = True +) -> Optional[np.ndarray]: + """Process a static image file and detect road lanes. + + Args: + image_path: Path to the input image. + output_path: Optional path to save the annotated result image. + show: If True, displays the result window using OpenCV GUI. + + Returns: + Annotated image array or None if file cannot be read. + """ + if not os.path.exists(image_path): + print(f"Error: Image file '{image_path}' not found.", file=sys.stderr) + return None + + image = cv2.imread(image_path) + if image is None: + print(f"Error: Unable to load image from '{image_path}'.", file=sys.stderr) + return None + + combo_image, _ = process_frame(image) + + if output_path: + cv2.imwrite(output_path, combo_image) + print(f"Saved processed image to: {output_path}") + + if show: + cv2.imshow("Finding Lanes - Image Result", combo_image) + print("Press any key to close the window...") + cv2.waitKey(0) + cv2.destroyAllWindows() + + return combo_image + + +def process_video( + source: Union[str, int], + output_path: Optional[str] = None, + show: bool = True +) -> None: + """Process a video file or live camera stream frame-by-frame. + + Args: + source: File path to a video file, or integer webcam device index (e.g., 0). + output_path: Optional output video path (e.g. 'output.mp4'). + show: If True, displays live video frames using OpenCV GUI. + """ + if isinstance(source, str) and not os.path.exists(source): + print(f"Error: Video file '{source}' not found.", file=sys.stderr) + return + + cap = cv2.VideoCapture(source) + if not cap.isOpened(): + print(f"Error: Could not open video source '{source}'.", file=sys.stderr) + return + + writer = None + if output_path: + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 + fourcc = cv2.VideoWriter_fourcc(*"mp4v") + writer = cv2.VideoWriter(output_path, fourcc, fps, (width, height)) + + print("Processing video stream... Press 'q' to stop.") + try: + while cap.isOpened(): + ret, frame = cap.read() + if not ret or frame is None: + break + + combo_image, _ = process_frame(frame) + + if writer: + writer.write(combo_image) + + if show: + cv2.imshow("Finding Lanes - Video Stream", combo_image) + if cv2.waitKey(10) & 0xFF == ord("q"): + print("User interrupted video playback.") + break + finally: + cap.release() + if writer: + writer.release() + print(f"Saved processed video to: {output_path}") + if show: + cv2.destroyAllWindows() + + +def resolve_asset_path(filename: str) -> str: + """Resolve file path relative to current script directory or workspace root.""" + dir_path = os.path.dirname(os.path.abspath(__file__)) + direct_path = os.path.join(dir_path, filename) + if os.path.exists(direct_path): + return direct_path + + relative_path = os.path.join("Finding_Lanes", filename) + if os.path.exists(relative_path): + return relative_path + + return filename + + +def main() -> None: + """Parse CLI arguments and execute the lane finding pipeline.""" + parser = argparse.ArgumentParser( + description="Finding Lanes: Detect and highlight road lane markings in images and video streams." + ) + parser.add_argument( + "-i", "--image", + type=str, + default=None, + help="Path to an input image file to process." + ) + parser.add_argument( + "-v", "--video", + type=str, + default=None, + help="Path to an input video file to process." + ) + parser.add_argument( + "-c", "--camera", + type=int, + default=None, + help="Webcam device index (e.g. 0 for built-in camera)." + ) + parser.add_argument( + "-o", "--output", + type=str, + default=None, + help="Path to save the processed image or video output." + ) + parser.add_argument( + "--no-show", + action="store_true", + help="Run in headless mode without displaying GUI windows." + ) + + args = parser.parse_args() + show = not args.no_show + + if args.image: + process_image(args.image, output_path=args.output, show=show) + elif args.camera is not None: + process_video(args.camera, output_path=args.output, show=show) + elif args.video: + process_video(args.video, output_path=args.output, show=show) + else: + # Default behavior: run bundled video.mp4 or fallback to picture.jpg + default_video = resolve_asset_path("video.mp4") + default_image = resolve_asset_path("picture.jpg") + + if os.path.exists(default_video): + process_video(default_video, output_path=args.output, show=show) + elif os.path.exists(default_image): + process_image(default_image, output_path=args.output, show=show) + else: + print("Error: No input source provided and default assets (video.mp4, picture.jpg) not found.", file=sys.stderr) + -cap.release() -cv2.destroyAllWindows() +if __name__ == "__main__": + main() diff --git a/Finding_Lanes/sub.py b/Finding_Lanes/sub.py index d819484d..89f15730 100644 --- a/Finding_Lanes/sub.py +++ b/Finding_Lanes/sub.py @@ -1,5 +1,114 @@ +"""Finding Lanes - Pipeline Visualizer. + +This utility visualizes each stage of the computer vision lane detection pipeline +(Original Image, Canny Edges, ROI Masked Edges, and Final Lane Overlay) +side-by-side using Matplotlib. +""" + +import argparse +import os +import sys + +import cv2 import matplotlib.pyplot as plt -import matplotlib.image as img -img = img.imread("Finding_Lanes/picture.jpg") -plt.imshow(img) -plt.show() + +# Import core lane detection functions +try: + from lanes import process_frame, resolve_asset_path +except ImportError: + from Finding_Lanes.lanes import process_frame, resolve_asset_path + + +def visualize_pipeline( + image_path: str, + output_path: str = None, + show: bool = True +) -> None: + """Visualize all intermediate stages of the lane detection pipeline. + + Args: + image_path: Path to the input image. + output_path: Optional path to save the generated subplot figure. + show: Whether to display the plot interactively. + """ + if not os.path.exists(image_path): + print(f"Error: Image '{image_path}' does not exist.", file=sys.stderr) + return + + # Read image using OpenCV (BGR format) + bgr_img = cv2.imread(image_path) + if bgr_img is None: + print(f"Error: Failed to read image from '{image_path}'.", file=sys.stderr) + return + + rgb_img = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2RGB) + + # Process frame and retrieve intermediate stages + combo_bgr, lanes, intermediates = process_frame(bgr_img, return_intermediates=True) + combo_rgb = cv2.cvtColor(combo_bgr, cv2.COLOR_BGR2RGB) + + fig, axes = plt.subplots(2, 2, figsize=(14, 8)) + fig.suptitle("Finding Lanes - Computer Vision Pipeline Stages", fontsize=16, fontweight="bold") + + # 1. Original Image + axes[0, 0].imshow(rgb_img) + axes[0, 0].set_title("1. Original Image (RGB)", fontsize=12) + axes[0, 0].axis("off") + + # 2. Canny Edge Detection + axes[0, 1].imshow(intermediates["canny"], cmap="gray") + axes[0, 1].set_title("2. Canny Edge Detection", fontsize=12) + axes[0, 1].axis("off") + + # 3. Region of Interest (ROI) Masked + axes[1, 0].imshow(intermediates["roi"], cmap="gray") + axes[1, 0].set_title("3. Dynamic Region of Interest (ROI)", fontsize=12) + axes[1, 0].axis("off") + + # 4. Final Lane Overlay + axes[1, 1].imshow(combo_rgb) + axes[1, 1].set_title("4. Hough Lines & Averaged Lane Detection", fontsize=12) + axes[1, 1].axis("off") + + plt.tight_layout() + + if output_path: + plt.savefig(output_path, dpi=200, bbox_inches="tight") + print(f"Pipeline stages figure saved to: {output_path}") + + if show: + plt.show() + else: + plt.close(fig) + + +def main() -> None: + """Parse CLI arguments and run visualization.""" + parser = argparse.ArgumentParser( + description="Finding Lanes: Multi-stage Computer Vision Pipeline Visualizer." + ) + parser.add_argument( + "-i", "--image", + type=str, + default=None, + help="Path to an input image (default: picture.jpg)." + ) + parser.add_argument( + "-o", "--output", + type=str, + default=None, + help="Path to save the pipeline stages plot image." + ) + parser.add_argument( + "--no-show", + action="store_true", + help="Run without displaying the Matplotlib window." + ) + + args = parser.parse_args() + image_path = args.image or resolve_asset_path("picture.jpg") + visualize_pipeline(image_path, output_path=args.output, show=not args.no_show) + + +if __name__ == "__main__": + main() diff --git a/Finding_Lanes/test_lanes.py b/Finding_Lanes/test_lanes.py new file mode 100644 index 00000000..8c7c9787 --- /dev/null +++ b/Finding_Lanes/test_lanes.py @@ -0,0 +1,153 @@ +"""Unit test suite for Finding Lanes computer vision pipeline.""" + +import os +import unittest +import numpy as np +import cv2 + +# Support running directly or as package +try: + from lanes import ( + canny, + roi, + make_coordinate, + average_lines_intercept, + display_lines, + process_frame, + resolve_asset_path, + ) +except ImportError: + from Finding_Lanes.lanes import ( + canny, + roi, + make_coordinate, + average_lines_intercept, + display_lines, + process_frame, + resolve_asset_path, + ) + + +class TestFindingLanes(unittest.TestCase): + """Test suite verifying all core functions of the Finding Lanes pipeline.""" + + def setUp(self): + """Create test image frames of various dimensions.""" + self.height, self.width = 720, 1280 + # Create a synthetic 3-channel BGR image + self.test_frame = np.zeros((self.height, self.width, 3), dtype=np.uint8) + # Draw synthetic left lane (negative slope in image coordinates) + cv2.line(self.test_frame, (300, 720), (580, 450), (255, 255, 255), 8) + # Draw synthetic right lane (positive slope in image coordinates) + cv2.line(self.test_frame, (1000, 720), (700, 450), (255, 255, 255), 8) + + def test_canny_edge_detection(self): + """Verify Canny edge detector outputs a binary 2D edge map.""" + edges = canny(self.test_frame) + self.assertEqual(edges.shape, (self.height, self.width)) + self.assertEqual(edges.dtype, np.uint8) + self.assertTrue(np.any(edges > 0), "Canny edge detector should find drawn lines") + + def test_canny_grayscale_input(self): + """Verify Canny handles single-channel 2D grayscale input gracefully.""" + gray = cv2.cvtColor(self.test_frame, cv2.COLOR_BGR2GRAY) + edges = canny(gray) + self.assertEqual(edges.shape, (self.height, self.width)) + + def test_dynamic_roi_masking(self): + """Verify dynamic ROI preserves road area and masks out non-ROI regions.""" + edges = canny(self.test_frame) + masked = roi(edges) + self.assertEqual(masked.shape, edges.shape) + # Top corners should be masked out (all zeros) + self.assertEqual(masked[0, 0], 0) + self.assertEqual(masked[0, self.width - 1], 0) + + def test_roi_custom_polygon(self): + """Verify ROI accepts custom polygon coordinates.""" + custom_poly = np.array([[(100, 700), (800, 700), (450, 300)]], dtype=np.int32) + edges = canny(self.test_frame) + masked = roi(edges, polygons=custom_poly) + self.assertEqual(masked.shape, edges.shape) + + def test_make_coordinate_valid(self): + """Verify make_coordinate computes expected pixel coordinates from slope & intercept.""" + # Slope = -1.0, Intercept = 1000 + coords = make_coordinate(self.test_frame, (-1.0, 1000.0)) + self.assertIsNotNone(coords) + self.assertEqual(len(coords), 4) + x1, y1, x2, y2 = coords + self.assertEqual(y1, self.height) + self.assertEqual(y2, int(self.height * 0.6)) + self.assertEqual(x1, int((720 - 1000) / -1.0)) + + def test_make_coordinate_edge_cases(self): + """Verify make_coordinate handles zero slopes, NaNs, infinities, and None inputs.""" + self.assertIsNone(make_coordinate(self.test_frame, None)) + self.assertIsNone(make_coordinate(self.test_frame, [])) + self.assertIsNone(make_coordinate(self.test_frame, (0.0, 500.0))) + self.assertIsNone(make_coordinate(self.test_frame, (np.nan, 500.0))) + self.assertIsNone(make_coordinate(self.test_frame, (-1.0, np.inf))) + + def test_average_lines_intercept_none_and_empty(self): + """Verify average_lines_intercept handles None and empty line inputs.""" + self.assertIsNone(average_lines_intercept(self.test_frame, None)) + self.assertIsNone(average_lines_intercept(self.test_frame, np.array([]))) + + def test_average_lines_slope_filtering(self): + """Verify horizontal noise lines (near zero slope) are filtered out.""" + # Horizontal line: (100, 500) to (900, 500) -> slope = 0 + horizontal_line = np.array([[[100, 500, 900, 500]]]) + result = average_lines_intercept(self.test_frame, horizontal_line, min_slope=0.3) + self.assertIsNone(result) + + def test_average_lines_detection(self): + """Verify left and right lanes are correctly categorized and averaged.""" + left_seg = [[300, 720, 580, 450]] + right_seg = [[1000, 720, 700, 450]] + lines = np.array([left_seg, right_seg]) + lane_lines = average_lines_intercept(self.test_frame, lines) + self.assertIsNotNone(lane_lines) + self.assertEqual(len(lane_lines), 2) + + def test_display_lines(self): + """Verify display_lines generates an overlay image with the correct shape.""" + lanes = np.array([[300, 720, 580, 432], [1000, 720, 700, 432]]) + line_img = display_lines(self.test_frame, lanes) + self.assertEqual(line_img.shape, self.test_frame.shape) + self.assertEqual(line_img.dtype, np.uint8) + + def test_display_lines_none(self): + """Verify display_lines returns all-black canvas when lines is None.""" + line_img = display_lines(self.test_frame, None) + self.assertEqual(line_img.shape, self.test_frame.shape) + self.assertTrue(np.all(line_img == 0)) + + def test_process_frame_end_to_end(self): + """Verify full process_frame pipeline runs successfully on synthetic frame.""" + combo_image, lanes = process_frame(self.test_frame) + self.assertEqual(combo_image.shape, self.test_frame.shape) + self.assertIsNotNone(lanes) + + def test_process_frame_intermediates(self): + """Verify process_frame returns intermediate dictionary when requested.""" + combo, lanes, intermediates = process_frame(self.test_frame, return_intermediates=True) + self.assertIn("canny", intermediates) + self.assertIn("roi", intermediates) + self.assertIn("line_image", intermediates) + self.assertIn("raw_lines", intermediates) + + def test_process_bundled_image_if_present(self): + """Verify pipeline execution on bundled picture.jpg asset.""" + img_path = resolve_asset_path("picture.jpg") + if os.path.exists(img_path): + img = cv2.imread(img_path) + self.assertIsNotNone(img) + combo, lanes = process_frame(img) + self.assertEqual(combo.shape, img.shape) + self.assertIsNotNone(lanes) + self.assertEqual(len(lanes), 2, "Should detect both left and right lane lines") + + +if __name__ == "__main__": + unittest.main()