├── data └── motivation.png ├── launch └── vs_navigation.launch ├── scripts ├── vs_navigationNode.py ├── Camera.py ├── movingVariance.py ├── geometric.py ├── controller.py ├── contours.py ├── featureMatching.py ├── vs_nodeHandler.py └── imageProc.py ├── CMakeLists.txt ├── package.xml ├── LICENSE.md ├── .gitignore ├── configs └── params.yaml └── README.md /data/motivation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agricultural-Robotics-Bonn/visual-multi-crop-row-navigation/HEAD/data/motivation.png -------------------------------------------------------------------------------- /launch/vs_navigation.launch: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /scripts/vs_navigationNode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8 -*- 3 | import rospy 4 | from vs_nodeHandler import vs_nodeHandler 5 | 6 | 7 | if __name__ == '__main__': 8 | # initializing vs navigator node 9 | rospy.init_node('vs_navigator', anonymous=False) 10 | rospy.loginfo("#[VS] Visual_servoing navigator node running ...") 11 | 12 | # instantiating navigator class 13 | nodeHandler = vs_nodeHandler() 14 | 15 | try: 16 | rospy.spin() 17 | except KeyboardInterrupt: 18 | rospy.logwarn("#[VS] Shutting down Phenobot Controller") -------------------------------------------------------------------------------- /scripts/Camera.py: -------------------------------------------------------------------------------- 1 | class Camera: 2 | def __init__(self, id,deltax,deltay,deltaz,tilt_angle,height,width,focal_length,sensor_width): 3 | self.id = id 4 | self.deltax = deltax # Translation to the origin of the robot 5 | self.deltay = deltay 6 | self.deltaz = deltaz # Heigth of the camera 7 | self.tilt_angle = tilt_angle # Tilt Angle 8 | self.height = height # Camera Intrinsics 9 | self.width = width 10 | self.focal_length = focal_length 11 | self.sensor_width = sensor_width -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.0.2) 2 | project(visual_multi_crop_row_navigation) 3 | 4 | find_package(catkin REQUIRED COMPONENTS 5 | roscpp 6 | rospy 7 | std_msgs 8 | ) 9 | 10 | ## DEPENDS: system dependencies of this project that dependent projects also need 11 | catkin_package( 12 | # INCLUDE_DIRS include 13 | # LIBRARIES msr_project 14 | # CATKIN_DEPENDS roscpp rospy std_msgs 15 | # DEPENDS system_lib 16 | ) 17 | 18 | ## Specify additional locations of header files 19 | ## Your package locations should be listed before other locations 20 | include_directories( 21 | # include 22 | ${catkin_INCLUDE_DIRS} 23 | ) 24 | 25 | ## Add folders to be run by python nosetests 26 | # catkin_add_nosetests(test) 27 | # catkin_install_python(PROGRAMS scripts/vs_nodeHandler.py 28 | # DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION} 29 | # ) 30 | -------------------------------------------------------------------------------- /scripts/movingVariance.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import numpy as np 4 | import matplotlib.pyplot as plt 5 | from scipy.signal import find_peaks 6 | import copy 7 | 8 | # funciton to compute the moving standard deviation for a given window size 9 | 10 | def movingStd(data, winSize=5): 11 | _data = data.copy() 12 | # compute a moving standard deviation 13 | stdVec = np.zeros((len(_data)-winSize, 1)) 14 | for i in range(0, len(stdVec)): 15 | sqsum = 0 16 | meanVal = np.mean(_data[i:i+winSize]) 17 | for j in range(i, i+winSize): 18 | sqsum += (_data[j]-meanVal)**2 19 | stdVec[i] = np.sqrt(sqsum/(winSize)) 20 | 21 | # normalize 22 | stdVec = stdVec/max(stdVec) 23 | stdVec = stdVec.reshape(len(stdVec),) 24 | # print(repr(stdVec)) 25 | # plt.plot(stdVec) 26 | # plt.show() 27 | return stdVec 28 | 29 | def findPicksTroughths(movVarM, prominence=0.5, height=None): 30 | _movVarM = movVarM.copy() 31 | # find negative peaks (=least std, most stable line) 32 | peaksNeg, _ = find_peaks(-_movVarM, height=height) 33 | # find positive peaks (=least stable lines = crop row transition) 34 | peaksPos, _ = find_peaks(_movVarM, prominence=0.5, height=height) 35 | return peaksPos, peaksNeg 36 | -------------------------------------------------------------------------------- /package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | visual_multi_crop_row_navigation 4 | 0.0.1 5 | phenobot multicrop row visual servoing navigation 6 | 7 | 8 | alireza 9 | 10 | 11 | 12 | 13 | MIT 14 | 15 | catkin 16 | roscpp 17 | rospy 18 | std_msgs 19 | roscpp 20 | rospy 21 | std_msgs 22 | roscpp 23 | rospy 24 | std_msgs 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2022, Agricultural-Robotics-Bonn 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | *.pyc 10 | *.pkl 11 | # C extensions 12 | *.so 13 | *.pth 14 | *.csv 15 | *.pickle 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | logs/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | .pytest_cache/ 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | .hypothesis/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule.* 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # Environments 88 | .env 89 | .venv 90 | env/ 91 | venv/ 92 | ENV/ 93 | env.bak/ 94 | venv.bak/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | 109 | 110 | # End of https://www.gitignore.io/api/python 111 | 112 | # PyCharm 113 | .idea/ 114 | 115 | # logs 116 | phenobot_rl/dqnWeeding/logs/ 117 | -------------------------------------------------------------------------------- /configs/params.yaml: -------------------------------------------------------------------------------- 1 | # ROS node 2 | 3 | # sub 4 | # simulation 5 | frontColor_topic: '/rs_nav_front/camera/color/image_raw' 6 | frontDepth_topic: '/rs_nav_front/camera/depth/image_raw' 7 | frontCameraInfo_topic: '/rs_nav_front/camera/color/camera_info' 8 | backColor_topic: '/rs_nav_front/camera/color/image_raw' 9 | backDepth_topic: '/rs_nav_front/camera/depth/image_raw' 10 | backCameraInfo_topic: '/rs_nav_front/camera/color/camera_info' 11 | 12 | # thorvald 13 | # frontColor_topic: '/rs_nav_front/color/image_raw' 14 | # frontDepth_topic: '/rs_nav_front/aligned_depth_to_color/image_raw' 15 | # frontCameraInfo_topic: '/rs_nav_front/color/camera_info' 16 | # backColor_topic: '/rs_nav_back/color/image_raw' 17 | # backDepth_topic: '/rs_nav_back/aligned_depth_to_color/image_raw' 18 | # backCameraInfo_topic: '/rs_nav_back/color/camera_info' 19 | # in experiments of the paper we use neither odom nor IMU ! 20 | # using Odomtery 21 | useOdom: False 22 | odomTopic: /odometry/base_raw 23 | # use IMU for maintaining orinetation 24 | useImu: False 25 | imuTopic: /imu/orientation 26 | #pub 27 | # simulation 28 | cmd_vel_topic: /phenobot/twist_mux/cmd_vel 29 | # thorvlad 30 | # cmd_vel_topic: /twist_mux/cmd_vel 31 | # node params 32 | update rate: 30 33 | queue_size: 5 34 | # processed image size scale 35 | imgResizeRatio: 100 36 | # for filtering contours 37 | minContourArea: 10 38 | # stationary debug mode, (without publishing velocity) 39 | stationaryDebug: False 40 | # run time params 41 | # Mode 1: Driving forward with front camera (starting mode) 42 | # Mode 2: Driving forward with back camera 43 | # Mode 3: Driving backwards with back camera 44 | # Mode 4: Driving backwards with front camera 45 | navigationMode: 1 46 | # angular velocity scaler 47 | maxOmega: 0.05 48 | minOmega: 0.01 49 | omegaScaler: 0.1 50 | maxLinearVel: 0.5 51 | minLinearVel: 0.01 52 | # Parameter for setting the number of rows to pass in one switch 53 | linesToPass: 1 54 | # Max Offset of the Window for extracting the turn features 55 | maxMatchingDifference: 100 56 | # Min Offset of the Window for extracting the turn features 57 | minMatchingDifference: 0 58 | # Threshold for keypoints 59 | minKeypointNum: 10 60 | # scanner window params 61 | scanSteps: 5 62 | scanStartPoint: 0 63 | scanEndPoint: 1280 64 | scanWindowWidth: 128 65 | # tracker params 66 | trackingBoxWidth: 200 67 | topOffset: 0 68 | bottomOffset: 0 69 | sacleRatio: 0.4 70 | # for dividing bushy rows 71 | maxCoutourHeight: 120 72 | # in case of using bigger size image size, we suggest to set ROI 73 | enable_roi: True 74 | p1: 75 | - 0 76 | - 0 77 | p2: 78 | - 350 79 | - 0 80 | p3: 81 | - 10 82 | - 720 83 | p4: 84 | - 0 85 | - 720 86 | p5: 87 | - 830 88 | - 0 89 | p6: 90 | - 1280 91 | - 0 92 | p7: 93 | - 1280 94 | - 720 95 | p8: 96 | - 1270 97 | - 720 98 | -------------------------------------------------------------------------------- /scripts/geometric.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def computeTheta(lineStart, lineEnd): 5 | """function to compute theta 6 | Args: 7 | lineStart (_type_): start point of line 8 | lineEnd (_type_): end point of line 9 | Returns: 10 | _type_: angle of line 11 | """ 12 | return -(np.arctan2(abs(lineStart[1]-lineEnd[1]), lineStart[0]-lineEnd[0])) 13 | 14 | def lineIntersectY(m, b, y): 15 | """ function to evaluate the estimated line 16 | Args: 17 | m (_type_): slope 18 | b (_type_): bias 19 | y (_type_): Y loc 20 | Returns: 21 | _type_: X loc 22 | """ 23 | # line calculations 24 | x = m * y + b 25 | return x 26 | 27 | def isInBox(box, p): 28 | """checks if point is inside the box 29 | Args: 30 | box (_type_): box 31 | p (_type_): point 32 | Returns: 33 | _type_: True or False 34 | """ 35 | bl = box[0] 36 | tr = box[2] 37 | if (p[0] > bl[0] and p[0] < tr[0] and p[1] > bl[1] and p[1] < tr[1]): 38 | return True 39 | else: 40 | return False 41 | 42 | 43 | def getImgLineUpDown(line, imageHeight): 44 | up = [line[1], 0] 45 | down = [line[0], imageHeight] 46 | return up, down 47 | 48 | def lineIntersectImgSides(m, b, imageWidth): 49 | """_summary_ 50 | Args: 51 | m (_type_): slope 52 | b (_type_): bias 53 | Returns: 54 | _type_: left and right interceptions 55 | """ 56 | l_i = -b / m 57 | r_i = (imageWidth - b) / m 58 | return l_i, r_i 59 | 60 | def lineIntersectImgUpDown(m, b, imageHeight): 61 | """function to compute the bottom and top intersect between the line and the image 62 | Args: 63 | m (_type_): slope 64 | b (_type_): bias 65 | Returns: 66 | _type_: top and bottom intersection points on image boarders 67 | """ 68 | # line calculations 69 | b_i = b 70 | t_i = m * imageHeight + b 71 | return t_i, b_i 72 | 73 | def lineIntersectWin(m, b, imageHeight, topOffset, bottomOffset): 74 | """function to compute the bottom and top intersect between the line and the window 75 | Args: 76 | m (_type_): slope 77 | b (_type_): bias 78 | Returns: 79 | _type_: to and bottom intersection with a box 80 | """ 81 | # line calculations 82 | b_i = m * bottomOffset + b 83 | t_i = m * (imageHeight - topOffset) + b 84 | return t_i, b_i 85 | 86 | def getLineRphi(xyCords): 87 | """sets r , phi line 88 | Args: 89 | xyCords (_type_): x, y coordinates of point 90 | Returns: 91 | _type_: r, phi of line 92 | """ 93 | x_coords, y_coords = zip(*xyCords) 94 | coefficients = np.polyfit(x_coords, y_coords, 1) 95 | return coefficients[0], coefficients[1] 96 | -------------------------------------------------------------------------------- /scripts/controller.py: -------------------------------------------------------------------------------- 1 | # Class to compute the control parameters 2 | import numpy as np 3 | 4 | # Function to wrap an angle to the interval [-pi,pi] 5 | def wrapToPi(theta): 6 | while theta < -np.pi: 7 | theta = theta + 2*np.pi 8 | while theta > np.pi: 9 | theta = theta - 2*np.pi 10 | return theta 11 | 12 | 13 | # Function to compute the controls given the current state and the desired state and velocity 14 | def visualServoingCtl (camera, desiredState, actualState, v_des): 15 | # specifiy the acutal state for better readability 16 | x = actualState[0] 17 | y = actualState[1] 18 | theta = actualState[2] 19 | 20 | # some crazy parameters 21 | lambda_x_1 = 10 22 | lambda_w_1= 3000 23 | lambdavec = np.array([lambda_x_1,lambda_w_1]) 24 | 25 | # state if it is a row or a column controller 26 | controller_type = 0 27 | 28 | # s_c = J * u = L_sc * T_r->c * u_r 29 | 30 | # Computation of the Interaction Matrix as presented by Cherubini & Chaumette 31 | # relates the control variables [v,w] in the camera frame to the change of the features [x,y,theta] 32 | angle = camera.tilt_angle 33 | delta_z = camera.deltaz 34 | IntMat = np.array([[(-np.sin(angle)-y*np.cos(angle))/delta_z, 0, x*(np.sin(angle)+y*np.cos(angle))/delta_z, x*y, -1-x**2, y], 35 | [0, -(np.sin(angle)+y*np.cos(angle))/delta_z, y*(np.sin(angle)+y*np.cos(angle))/delta_z, 1+y**2, -x*y, -x], 36 | [np.cos(angle)*np.power(np.cos(theta),2)/delta_z, np.cos(angle)*np.cos(theta)*np.sin(theta)/delta_z, 37 | -(np.cos(angle)*np.cos(theta)*(y*np.sin(theta) + x*np.cos(theta)))/delta_z, 38 | -(y*np.sin(theta) + x*np.cos(theta))*np.cos(theta), -(y*np.sin(theta) + x*np.cos(theta))*np.sin(theta), -1]]) 39 | 40 | # Computation of the transformation from the robot to the camera frame 41 | delta_y = camera.deltay 42 | TransfMat = np.array([[0,-delta_y], 43 | [-np.sin(angle),0], 44 | [np.cos(angle),0], 45 | [0,0], 46 | [0,-np.cos(angle)], 47 | [0,-np.sin(angle)]]) 48 | Trans_vel = TransfMat[:,0] 49 | Trans_ang = TransfMat[:,1] 50 | 51 | # Computation of the Jacobi-Matrix for velocity and angular velocity and their pseudo inverse 52 | # The Jacobi-Matrix relates the control variables in the robot frame to the change of the features 53 | Jac = np.array([IntMat[controller_type,:],IntMat[2,:]]) 54 | Jac_vel = np.matmul(Jac,Trans_vel) 55 | # Jac_vel_pi = np.linalg.pinv([Jac_vel]) 56 | Jac_ang = np.matmul(Jac,Trans_ang) 57 | Jac_ang_pi = np.linalg.pinv([Jac_ang]) 58 | 59 | # Compute the delta, in this case the difference between the actual state and the desired state 60 | trans_delta = actualState[controller_type] - desiredState[controller_type] 61 | ang_delta = actualState[2] - desiredState[2] 62 | delta = np.array([trans_delta,wrapToPi(ang_delta)]) 63 | 64 | # Compute the feedback control for the angular velocity 65 | temp = lambdavec * delta 66 | ang_fb = np.matmul(-Jac_ang_pi.T,(temp + Jac_vel * v_des)) 67 | return ang_fb 68 | 69 | -------------------------------------------------------------------------------- /scripts/contours.py: -------------------------------------------------------------------------------- 1 | 2 | import cv2 as cv 3 | import numpy as np 4 | from geometric import * 5 | 6 | def getContourCenter(contours): 7 | """function to compute the center points of the contours 8 | 9 | Args: 10 | contours (_type_): contours from image 11 | 12 | Returns: 13 | _type_: contours centers 14 | """ 15 | # get center of contours 16 | contCenterPTS = np.zeros((len(contours),2)) 17 | for i in range(0, len(contours)): 18 | # get contour 19 | c_curr = contours[i]; 20 | 21 | # get moments 22 | M = cv.moments(c_curr) 23 | 24 | # compute center of mass 25 | if(M['m00'] != 0): 26 | cx = int(M['m10']/M['m00']) 27 | cy = int(M['m01']/M['m00']) 28 | contCenterPTS[i,:] = [cy,cx] 29 | 30 | contCenterPTS = contCenterPTS[~np.all(contCenterPTS == 0, axis=1)] 31 | return contCenterPTS 32 | 33 | def getPlantMasks(binrayMask, min_contour_area, bushy=False): 34 | # find contours 35 | contours = cv.findContours(binrayMask, cv.RETR_TREE, cv.CHAIN_APPROX_NONE)[1] 36 | 37 | # cv.imshow("crop rows mask",self.mask) 38 | # self.handleKey() 39 | 40 | # filter contours based on size 41 | # self.filtered_contours = contours 42 | filtered_contours = list() 43 | for i in range(len(contours)): 44 | if cv.contourArea(contours[i]) > min_contour_area: 45 | 46 | if bushy : 47 | cn_x, cn_y, cnt_w, cn_h = cv.boundingRect(contours[i]) 48 | 49 | # split to N contours h/max_coutour_height 50 | sub_contours = splitContours(contours[i], cn_x, cn_y, cnt_w, cn_h) 51 | for j in sub_contours: 52 | if j != []: 53 | filtered_contours.append(j) 54 | else: 55 | if contours[i] != []: 56 | filtered_contours.append(contours[i]) 57 | # else: 58 | # filtered_contours.append(contours[i]) 59 | return filtered_contours 60 | 61 | def splitContours(contour, x, y, w, h, max_coutour_height): 62 | """splits larg contours in smaller regions 63 | 64 | Args: 65 | contour (_type_): _description_ 66 | x (_type_): _description_ 67 | y (_type_): _description_ 68 | w (_type_): _description_ 69 | h (_type_): _description_ 70 | 71 | Returns: 72 | _type_: sub polygons (seperated countours) 73 | """ 74 | sub_polygon_num = h // max_coutour_height 75 | sub_polys = list() 76 | subContour = list() 77 | vtx_idx = list() 78 | contour = [contour.squeeze().tolist()] 79 | for subPoly in range(1, sub_polygon_num + 1): 80 | for vtx in range(len(contour[0])): 81 | if (subPoly - 1 * max_coutour_height) -1 <= contour[0][vtx][1] and \ 82 | (subPoly * max_coutour_height) -1 >= contour[0][vtx][1] and \ 83 | vtx not in vtx_idx: 84 | subContour.append([contour[0][vtx]]) 85 | vtx_idx.append(vtx) 86 | 87 | sub_polys.append(np.array(subContour)) 88 | subContour = list() 89 | 90 | return sub_polys 91 | 92 | def sortContours(contours, method="left-to-right"): 93 | """initialize the reverse flag and sort index 94 | 95 | Args: 96 | cnts (_type_): _description_ 97 | method (str, optional): _description_. Defaults to "left-to-right". 98 | 99 | Returns: 100 | _type_: sorted countours, bboxes 101 | """ 102 | reverse = False 103 | i = 0 104 | # handle if we need to sort in reverse 105 | if method == "right-to-left" or method == "bottom-to-top": 106 | reverse = True 107 | # handle if we are sorting against the y-coordinate rather than 108 | # the x-coordinate of the bounding box 109 | if method == "top-to-bottom" or method == "bottom-to-top": 110 | i = 1 111 | # construct the list of bounding boxes and sort them from top to 112 | # bottom 113 | boundingBoxes = [cv.boundingRect(c) for c in contours] 114 | (contours, boundingBoxes) = zip(*sorted(zip(contours, boundingBoxes), 115 | key=lambda b:b[1][i], reverse=reverse)) 116 | # return the list of sorted contours and bounding boxes 117 | return contours, boundingBoxes 118 | 119 | def getContoursInWindow(contourCenters, box): 120 | """iflters out countours inside a box 121 | 122 | Args: 123 | contourCenters (_type_): _description_ 124 | box (_type_): _description_ 125 | 126 | Returns: 127 | _type_: contour centers 128 | """ 129 | points = [] 130 | for cnt in range(len(contourCenters[1])): 131 | x, y = contourCenters[0][cnt], contourCenters[1][cnt] 132 | if isInBox(list(box), [x, y]): 133 | points.append([x, y]) 134 | return points 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Visual Multi Crop Row Navigation in Arable Farming Fields 3 | 4 | A work presented in IROS 2022 - Kyoto, Japan 5 | 6 |
7 | BonnBot 8 |
9 | 10 | Check out the [video1](https://youtu.be/z2Cb2FFZ2aU?t=43), of our robot following this approach to navigate on a real multi lace row-crop field (beans field). 11 |
12 | 13 | [![IMAGE ALT TEXT HERE](https://img.youtube.com/vi/z2Cb2FFZ2aU/0.jpg)](https://www.youtube.com/watch?v=z2Cb2FFZ2aU) 14 | 15 |
16 | 17 | ### pyCUDA installation (optional) 18 | 19 | wget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu1804/x86_64$ 20 | sudo mv cuda-ubuntu1804.pin /etc/apt/preferences.d/cuda-repository-pin-600 21 | wget https://developer.download.nvidia.com/compute/cuda/10.2/Prod/local_install$ 22 | sudo dpkg -i cuda-repo-ubuntu1804-10-2-local-10.2.89-440.33.01_1.0-1_amd64.deb 23 | sudo apt-key add /var/cuda-repo-10-2-local-10.2.89-440.33.01/7fa2af80.pub 24 | sudo apt-get update 25 | sudo apt-get -y install cuda 26 | 27 | ### Dependencies 28 | - OpenCV == 3.3.0.10 29 | - pip install opencv-python==3.3.0.10 30 | - pip install opencv-contrib-python==3.3.0.10 31 | - ROS Melodic 32 | - itertools 33 | - scipy >= 1.5.2 34 | - numpy >= 1.19.9 35 | 36 | ### Build and run 37 | 38 | navigate to your catkin workspace folder, e.g.: 39 | 40 | cd catkin_ws/ 41 | 42 | compile: 43 | 44 | rm -r build/ 45 | rm -r devel/ 46 | catkin_make 47 | 48 | source setup file: 49 | 50 | source ./devel/setup.bash 51 | 52 | 53 | launch main script: 54 | 55 | roslaunch visual_multi_crop_row_navigation vs_navigation.launch 56 | 57 | 58 | ### Dependencies: 59 | - Thorvald Platfrom package [Thorvald Saga](https://www.nmbu.no/en/faculty/realtek/research/groups/roboticsandcontrol/thorvaldinstall/node/34632) 60 | - ROS Melodic 61 | - python packages: 62 | 63 | sudo apt-get install python-catkin-tools python3-dev python3-catkin-pkg-modules python3-numpy python3-yaml ros-melodic-cv-bridge python3-opencv 64 | python3 -m pip install scikit-build scikit-learn laspy pandas 65 | - build CV_Bridge for python3: 66 | 67 | cd catkin_ws 68 | git clone https://github.com/ros-perception/vision_opencv.git src/vision_opencv 69 | 70 | Find version: 71 | 72 | sudo apt-cache show ros-melodic-cv-bridge | grep Version 73 | Version: 1.12.8-0xenial-20180416-143935-0800 74 | 75 | Checkout right version in git repo. In our case it is 1.12.8 76 | 77 | cd src/vision_opencv/ 78 | git checkout 1.12.8 79 | cd ../../ 80 | 81 | build 82 | 83 | catkin_make --cmake-args \ 84 | -DCMAKE_BUILD_TYPE=Release \ 85 | -DPYTHON_EXECUTABLE=/usr/bin/python3 \ 86 | -DPYTHON_INCLUDE_DIR=/usr/include/python3.6m \ 87 | -DPYTHON_LIBRARY=/usr/lib/x86_64-linux-gnu/libpython3.6m.so 88 | 89 | ## Multi-Crop Row Navigation Dataset 90 | 91 | used for analyze the robustness of crop-row detection 92 | technique as a quantitative evaluate. For each of the 93 | five crops (sugar_beet, potato, beans, lemon-balm and coriander) and three 94 | simulated fields, 100 images were annotated using data from 95 | BonnBot-I where the camera tilt angle ρ was varied from 55◦ 96 | to 75◦. The annotations contain ground-truth of all the lines 97 | located underneath the robot. 98 | To measure the accuracy we compare the predicted lines of each image to the ground-truth 99 | using two parameters: position and orientation. The position 100 | of a line is defined based on its intersection with bottom edge 101 | of the image, where the distance between the prediction and 102 | the ground truth is normalized based on the width of the 103 | image. 104 | 105 |
106 | 107 | [![IMAGE ALT TEXT HERE](https://)](https://) 108 | 109 |
110 | 111 | for accessing dataset please sned an email to: Alireza.ahmadi@uni-bonn.de 112 | 113 | ## Citation 114 | if you use this project in your recent works please refernce to it by: 115 | 116 | ```bash 117 | 118 | @article{ahmadi2021towards, 119 | title={Towards Autonomous Crop-Agnostic Visual Navigation in Arable Fields}, 120 | author={Ahmadi, Alireza and Halstead, Michael and McCool, Chris}, 121 | journal={arXiv preprint arXiv:2109.11936}, 122 | year={2021} 123 | } 124 | 125 | @inproceedings{ahmadi2020visual, 126 | title={Visual servoing-based navigation for monitoring row-crop fields}, 127 | author={Ahmadi, Alireza and Nardi, Lorenzo and Chebrolu, Nived and Stachniss, Cyrill}, 128 | booktitle={2020 IEEE International Conference on Robotics and Automation (ICRA)}, 129 | pages={4920--4926}, 130 | year={2020}, 131 | organization={IEEE} 132 | } 133 | ``` 134 | 135 | ## Acknowledgments 136 | This work has been supported by the German Research Foundation under Germany’s Excellence Strategy, EXC-2070 - 390732324 ([PhenoRob](http://www.phenorob.de/)) and [Bonn AgRobotics Group](http://agrobotics.uni-bonn.de/) 137 | -------------------------------------------------------------------------------- /scripts/featureMatching.py: -------------------------------------------------------------------------------- 1 | 2 | import cv2 as cv 3 | import numpy as np 4 | import itertools 5 | import matplotlib.pyplot as plt 6 | from movingVariance import * 7 | 8 | from shapely.geometry import Point 9 | from shapely.geometry.polygon import Polygon 10 | 11 | class featureMatching: 12 | def __init__(self, featureParams): 13 | self.featureParams = featureParams 14 | self.simDistVec= [] 15 | self.meanVec = [] 16 | self.count = 0 17 | 18 | def sampleCropRowFeatures(self, mode, rgbImg, greenIDx, binaryMask, wLocs): 19 | _rgbImg = rgbImg.copy() 20 | # get the correct window depending on the current mode 21 | rowId = None 22 | if mode == 3: 23 | rowId = 0 24 | else: 25 | rowId = -1 26 | self.refWindowLoc = wLocs[rowId] 27 | # int_coords = lambda x: np.array(x).round().astype(np.int32) 28 | # exterior = np.array([int_coords(wLocs[rowId].exterior.coords)]) 29 | # bboxImg = self.cropBboxFromImage(_greenIDx, exterior) 30 | # get masked rgb Image (ExG masked) 31 | rgbMasked = self.maskRgb(rgbImg, binaryMask) 32 | # detect keypoints in image 33 | self.refKeypoints, self.refDescriptors = self.detectTrackingFeatures(rgbMasked) 34 | # filter out features in desired window location 35 | self.refKeypoints, self.refDescriptors = self.filterKeypoints(self.refKeypoints, 36 | self.refDescriptors, 37 | self.refWindowLoc) 38 | # draw keypoints on rgb Image 39 | self.drawKeyPoints(_rgbImg, self.refKeypoints, [255,0,0]) 40 | 41 | def reset(self): 42 | pass 43 | 44 | def detectNewCropLane(self, mode, rgbImg, greenIDx, binaryMask, wLocs, numofCropRows): 45 | _rgbImg = rgbImg.copy() 46 | # get masked rgb Image (ExG masked) 47 | rgbMasked = self.maskRgb(rgbImg, binaryMask) 48 | # extract keypoints and descriptors from new detected line 49 | srcKeypoints, srcDescriptors = self.detectTrackingFeatures(rgbMasked) 50 | # filter in desired window 51 | windowLoc = self.refWindowLoc 52 | # filter out features in desired window location 53 | srcKeypoints, srcDescriptors = self.filterKeypoints(srcKeypoints, srcDescriptors, windowLoc) 54 | # find matches between ref and src keypoints 55 | matches = self.matchTrackingFeatures(srcKeypoints, srcDescriptors) 56 | # if not that indicates that the window is between two lines 57 | # draw keypoints on rgb Image 58 | self.drawKeyPoints(_rgbImg, srcKeypoints, [0,255,0]) 59 | # collect matches 60 | self.simDistVec = list(self.simDistVec) 61 | self.simDistVec.append(len(matches)) 62 | # create recognition signal 63 | peaks, troughs= [], [] 64 | if self.count > 90: 65 | self.simDistVec = np.where(np.array(self.simDistVec) >= 10, 10, np.min(self.simDistVec)) 66 | # compute moving standard deviation 67 | mvSignal = movingStd(self.simDistVec) 68 | # find positive an negative peaks of the signal 69 | peaks, troughs = findPicksTroughths(self.simDistVec, 0.5) 70 | print("peaks", len(peaks), "troughs", len(troughs)) 71 | 72 | self.count+=1 73 | if ((len(peaks) >= numofCropRows and len(troughs) >= numofCropRows)): 74 | plt.plot(self.simDistVec) 75 | pickPoses = [self.simDistVec[p] for p in peaks] 76 | troughPoses = [self.simDistVec[p] for p in troughs] 77 | plt.plot(peaks, pickPoses , "o") 78 | plt.plot(troughs, troughPoses , "x") 79 | plt.plot(np.zeros_like(self.simDistVec), "--", color="gray") 80 | plt.show() 81 | 82 | return (len(peaks) >= numofCropRows and len(troughs) >= numofCropRows) 83 | 84 | def maskRgb(self, rgbImg, mask): 85 | maskedRgb = np.zeros(np.shape(rgbImg), np.uint8) 86 | idx = (mask!=0) 87 | maskedRgb[idx] = rgbImg[idx] 88 | return maskedRgb 89 | 90 | def cropBboxFromImage(self, image, bbox): 91 | imgWHeight, imgWidth = np.shape(image) 92 | oneRowMask = np.zeros((imgWHeight, imgWidth), dtype=np.uint8) 93 | cv.fillPoly(oneRowMask, bbox, (255)) 94 | res = cv.bitwise_and(image, image ,mask=oneRowMask) 95 | bbox = cv.boundingRect(bbox) # returns (x,y,w,h) of the rect 96 | bboxImg = res[bbox[1]: bbox[1] + bbox[3], bbox[0]: bbox[0] + bbox[2]] 97 | # cv.imshow("test", bboxImg) 98 | # cv.waitKey(0) 99 | # cv.destroyAllWindows() 100 | return bboxImg 101 | 102 | def filterKeypoints(self, keyPoints, descriptors, polygon): 103 | filteredKeypoints = [] 104 | filteredDescriptors = [] 105 | for kp, des in itertools.izip(keyPoints, descriptors): 106 | if polygon.contains(Point(kp.pt[0], kp.pt[1])): 107 | filteredKeypoints.append(kp) 108 | filteredDescriptors.append(des) 109 | return filteredKeypoints, filteredDescriptors 110 | 111 | def drawKeyPoints(self, rgbImg, keyPoints, color=[0, 0, 255], imShow=False): 112 | for kp in keyPoints: 113 | ptX = kp.pt[0] 114 | ptY = kp.pt[1] 115 | rgbImg[int(ptY)-3:int(ptY)+3, int(ptX) - 116 | 3:int(ptX)+3] = color 117 | if imShow: 118 | cv.imshow("test", rgbImg) 119 | cv.waitKey(0) 120 | cv.destroyAllWindows() 121 | return rgbImg 122 | 123 | def detectTrackingFeatures(self, greenIDX): 124 | """ Function to compute the features of the line which has to be recognized 125 | 126 | Args: 127 | rgbImg (_type_): _description_ 128 | greenIDX (_type_): _description_ 129 | mode (_type_): _description_ 130 | wLocs (_type_): _description_ 131 | turnWindowWidth (_type_): _description_ 132 | min_matching_dif_features (_type_): _description_ 133 | max_matching_dif_features (_type_): _description_ 134 | 135 | Returns: 136 | _type_: _description_ 137 | """ 138 | print("#[INF] detect Tracking Features") 139 | # Initiate SIFT detector 140 | sift = cv.xfeatures2d.SIFT_create() 141 | # get sift key points 142 | keyPointsRaw, descriptorsRaw = sift.detectAndCompute(greenIDX, None) 143 | matchingKeypoints = [] 144 | featureDescriptors = [] 145 | # maintain the keypoints lying in the first window 146 | for kp, desc in itertools.izip(keyPointsRaw, descriptorsRaw): 147 | matchingKeypoints.append(kp) 148 | featureDescriptors.append(desc) 149 | return matchingKeypoints, featureDescriptors 150 | 151 | def matchTrackingFeatures(self, srcKeypoints, srcDescriptors): 152 | """Function to compute the features in the second window 153 | """ 154 | # Check if there's a suitable number of key points 155 | qualifiedMatches = [] 156 | if len(srcKeypoints) > self.featureParams["minKeypointNum"]: 157 | # compute the matches between the key points 158 | FLANN_INDEX_KDTREE = 1 159 | index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5) 160 | search_params = dict(checks=50) 161 | flann = cv.FlannBasedMatcher(index_params, search_params) 162 | matches = flann.knnMatch(np.asarray( 163 | self.refDescriptors, np.float32), np.asarray(srcDescriptors, np.float32), k=2) 164 | # search for good matches (Lowes Ratio Test) 165 | for m, n in matches: 166 | if m.distance < 0.8*n.distance: 167 | qualifiedMatches.append(m) 168 | else: 169 | print('Not enough key points for matching for matching') 170 | 171 | return qualifiedMatches -------------------------------------------------------------------------------- /scripts/vs_nodeHandler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8 -*- 3 | from __future__ import division 4 | from __future__ import print_function 5 | from future.builtins import input 6 | 7 | import rospy 8 | import math 9 | import cv2 as cv 10 | import Camera as cam 11 | import imageProc as imc 12 | import controller as visualServoingCtl 13 | import numpy as np 14 | import time 15 | 16 | from geometry_msgs.msg import Twist 17 | import itertools 18 | 19 | import message_filters 20 | 21 | import tf2_ros 22 | import tf_conversions 23 | import tf2_geometry_msgs 24 | import image_geometry 25 | import geometry_msgs.msg 26 | from std_msgs.msg import Float64 27 | from cv_bridge import CvBridge, CvBridgeError 28 | from sensor_msgs.msg import Image, CameraInfo 29 | 30 | from featureMatching import * 31 | 32 | class vs_nodeHandler: 33 | 34 | def __init__(self): 35 | # cv bridge 36 | self.bridge = CvBridge() 37 | self.queue_size = 1 38 | 39 | # subscribed Topics (Images of front and back camera) 40 | self.frontColor_topic = rospy.get_param('frontColor_topic') 41 | self.frontDepth_topic = rospy.get_param('frontDepth_topic') 42 | self.frontCameraInfo_topic = rospy.get_param('frontCameraInfo_topic') 43 | 44 | self.frontColor_sub = message_filters.Subscriber( 45 | self.frontColor_topic, Image, queue_size=self.queue_size) 46 | self.frontDepth_sub = message_filters.Subscriber( 47 | self.frontDepth_topic, Image, queue_size=self.queue_size) 48 | self.frontCameraInfo_sub = message_filters.Subscriber( 49 | self.frontCameraInfo_topic, CameraInfo, queue_size=self.queue_size) 50 | 51 | self.ts = message_filters.ApproximateTimeSynchronizer( 52 | [self.frontColor_sub, self.frontDepth_sub, self.frontCameraInfo_sub], queue_size=self.queue_size, slop=0.5) 53 | self.ts.registerCallback(self.frontSyncCallback) 54 | 55 | self.backColor_topic = rospy.get_param('backColor_topic') 56 | self.backDepth_topic = rospy.get_param('backDepth_topic') 57 | self.backCameraInfo_topic = rospy.get_param('backCameraInfo_topic') 58 | 59 | self.backColor_sub = message_filters.Subscriber( 60 | self.backColor_topic, Image, queue_size=self.queue_size) 61 | self.backDepth_sub = message_filters.Subscriber( 62 | self.backDepth_topic, Image, queue_size=self.queue_size) 63 | self.backCameraInfo_sub = message_filters.Subscriber( 64 | self.backCameraInfo_topic, CameraInfo, queue_size=self.queue_size) 65 | 66 | self.ts = message_filters.ApproximateTimeSynchronizer( 67 | [self.backColor_sub, self.backDepth_sub, self.backCameraInfo_sub], queue_size=self.queue_size, slop=0.5) 68 | self.ts.registerCallback(self.backSyncCallback) 69 | 70 | # Initialize ros publisher, ros subscriber, topics we publish 71 | self.graphic_pub = rospy.Publisher('vs_nav/graphic',Image, queue_size=1) 72 | self.mask_pub = rospy.Publisher('vs_nav/mask',Image, queue_size=1) 73 | self.exg_pub = rospy.Publisher('vs_nav/ExG',Image, queue_size=1) 74 | 75 | cmd_vel_topic = rospy.get_param('cmd_vel_topic') 76 | self.velocity_pub = rospy.Publisher(cmd_vel_topic, Twist, queue_size=1) 77 | 78 | # settings 79 | # Mode 1: Driving forward with front camera (starting mode) 80 | # Mode 2: Driving forward with back camera 81 | # Mode 3: Driving backwards with back camera 82 | # Mode 4: Driving backwards with front camera 83 | self.navigationMode = rospy.get_param('navigationMode') 84 | # debug mode without publishing velocities 85 | self.stationaryDebug = rospy.get_param('stationaryDebug') 86 | # rotation direction FLAG 87 | self.rotationDir = 1 88 | # direction of motion 1: forward, -1:backward 89 | if self.navigationMode == 1 or self.navigationMode == 2: 90 | self.linearMotionDir = 1 91 | else: 92 | self.linearMotionDir = -1 93 | # true: front camera, False: back camera 94 | if self.isUsingFrontCamera(): 95 | self.primaryCamera = True 96 | else: 97 | self.primaryCamera = False 98 | # used to recoder direction of motion 99 | self.omegaBuffer = list() 100 | 101 | self.scannerParams = { 102 | "scanSteps": rospy.get_param('scanSteps'), 103 | "scanEndPoint": rospy.get_param('scanEndPoint'), 104 | "scanStartPoint": rospy.get_param('scanStartPoint'), 105 | "scanWindowWidth": rospy.get_param('scanWindowWidth') 106 | } 107 | # in case of using bigger size image size, we suggest to set ROI 108 | self.rioParams = { 109 | "enable_roi": rospy.get_param('enable_roi'), 110 | "p1": rospy.get_param('p1'), 111 | "p2": rospy.get_param('p2'), 112 | "p3": rospy.get_param('p3'), 113 | "p4": rospy.get_param('p4'), 114 | "p5": rospy.get_param('p5'), 115 | "p6": rospy.get_param('p6'), 116 | "p7": rospy.get_param('p7'), 117 | "p8": rospy.get_param('p8') 118 | } 119 | 120 | self.contourParams = { 121 | "imgResizeRatio": rospy.get_param('imgResizeRatio'), 122 | "minContourArea": rospy.get_param('minContourArea'), 123 | } 124 | 125 | self.featureParams = { 126 | "linesToPass": rospy.get_param('linesToPass'), 127 | "minKeypointNum": rospy.get_param('minKeypointNum'), 128 | "maxMatchingDifference": rospy.get_param('maxMatchingDifference'), 129 | "minMatchingDifference": rospy.get_param('minMatchingDifference'), 130 | } 131 | 132 | self.trackerParams = { 133 | "sacleRatio": rospy.get_param('sacleRatio'), 134 | "topOffset": rospy.get_param('topOffset'), 135 | "bottomOffset": rospy.get_param('bottomOffset'), 136 | "trackingBoxWidth": rospy.get_param('trackingBoxWidth'), 137 | } 138 | 139 | # speed limits 140 | self.omegaScaler = rospy.get_param('omegaScaler') 141 | self.maxOmega = rospy.get_param('maxOmega') 142 | self.minOmega = rospy.get_param('minOmega') 143 | self.maxLinearVel = rospy.get_param('maxLinearVel') 144 | self.minLinearVel = rospy.get_param('minLinearVel') 145 | 146 | # images 147 | self.frontImg = None 148 | self.frontDepth = None 149 | self.backImg = None 150 | self.backDepth = None 151 | 152 | self.velocityMsg = Twist() 153 | self.enoughPoints = True 154 | self.samplingDone = False 155 | 156 | # camera 157 | self.camera = cam.Camera(1,1.2,0,1,np.deg2rad(-80),0.96,0,0,1) 158 | self.imageProcessor = imc.imageProc(self.scannerParams, 159 | self.contourParams, 160 | self.rioParams, 161 | self.trackerParams) 162 | 163 | self.cameraModel = image_geometry.PinholeCameraModel() 164 | rospy.loginfo('Detection Camera initialised..') 165 | print('') 166 | 167 | self.featureMatcher = featureMatching(self.featureParams) 168 | 169 | rospy.loginfo("#[VS] navigator initialied ... ") 170 | 171 | # main function to guide the robot through crop rows 172 | def navigate(self): 173 | # get the currently used image 174 | primaryRGB, primaryDepth = self.getProcessingImage(self.frontImg, 175 | self.frontDepth, 176 | self.backImg, 177 | self.backDepth) 178 | # If the feature extractor is not initialized yet, this has to be done 179 | if not self.imageProcessor.findCropLane(primaryRGB, primaryDepth, mode='RGB-D'): 180 | # this is only False if the initialization in 'setImage' was unsuccessful 181 | rospy.logwarn("The initialization was unsuccessful!! ") 182 | else: 183 | print("cropLaneFound", self.imageProcessor.cropLaneFound, "cropRowEnd", self.imageProcessor.cropRowEnd) 184 | # if the robot is currently following a line and is not turning just compute the controls 185 | if self.isFollowingLane() : 186 | self.imageProcessor.trackCropLane(self.navigationMode) 187 | ctlCommands = self.computeControls(self.imageProcessor.cropLaneFound, 188 | self.imageProcessor.P, 189 | self.imageProcessor.ang) 190 | 191 | if not self.imageProcessor.cropRowEnd: 192 | print("#[INF] Following detected Lane ...") 193 | self.setRobotVelocities(ctlCommands[0], 0.0, ctlCommands[1]) 194 | 195 | elif self.imageProcessor.cropRowEnd: 196 | print("#[INF] End of Lane detected ...") 197 | 198 | if self.isExitingLane(): 199 | self.updateNavigationStage() 200 | else: 201 | self.updateNavigationStage() 202 | self.imageProcessor.reset() 203 | self.imageProcessor.isInitialized = False 204 | self.imageProcessor.cropRowEnd = False 205 | self.switchCamera() 206 | 207 | elif self.isSwitchingLane() and not self.samplingDone: 208 | print("#[INF] Sampling the Lane!!") 209 | self.stopRobot(2.0) 210 | # Compute the features for the turning and stop the movement 211 | self.featureMatcher.sampleCropRowFeatures(self.navigationMode, 212 | self.imageProcessor.primaryRGBImg, 213 | self.imageProcessor.greenIDX, 214 | self.imageProcessor.mask, 215 | self.imageProcessor.rowTrackingBoxes) 216 | self.samplingDone = True 217 | self.stopRobot(2.0) 218 | else: 219 | # test if the condition for the row switching is fulfilled 220 | foundNewCropLane = self.featureMatcher.detectNewCropLane(self.navigationMode, 221 | self.imageProcessor.primaryRGBImg, 222 | self.imageProcessor.greenIDX, 223 | self.imageProcessor.mask, 224 | self.imageProcessor.rowTrackingBoxes, 225 | self.imageProcessor.numOfCropRows) 226 | if foundNewCropLane: 227 | self.stopRobot(2.0) 228 | print("following new Lane !!") 229 | # the turn is completed and the new lines to follow are computed 230 | self.switchDirection() 231 | print("#[INF] Turning Mode disabled, Entering next lane") 232 | self.updateNavigationStage() 233 | self.imageProcessor.reset() 234 | self.imageProcessor.isInitialized = False 235 | self.imageProcessor.cropRowEnd = False 236 | else: 237 | # if the condition is not fulfilled the robot moves continouisly sidewards 238 | self.setRobotVelocities(0.0, -0.05, 0.0) 239 | # check Odometry for safty (not to drive so much!) 240 | print("#[INF] Side motion to find New Lane ...") 241 | 242 | self.publishImageTopics() 243 | 244 | print("#[INF] m:", 245 | self.navigationMode, 246 | "p-cam:", "front" if self.primaryCamera else "back", 247 | "vel-x,y,z", 248 | self.velocityMsg.linear.x, 249 | self.velocityMsg.linear.y, 250 | round(self.velocityMsg.angular.z, 3), 251 | self.imageProcessor.numOfCropRows) 252 | 253 | def publishImageTopics(self): 254 | # Publish the Graphics image 255 | self.imageProcessor.drawGraphics() 256 | graphic_img = self.bridge.cv2_to_imgmsg(self.imageProcessor.graphicsImg, encoding='bgr8') 257 | self.graphic_pub.publish(graphic_img) 258 | # publish predicted Mask 259 | mask_msg = CvBridge().cv2_to_imgmsg(self.imageProcessor.mask) 260 | mask_msg.header.stamp = rospy.Time.now() 261 | self.mask_pub.publish(mask_msg) 262 | # publish Exg image 263 | exg_msg = CvBridge().cv2_to_imgmsg(self.imageProcessor.greenIDX) 264 | exg_msg.header.stamp = rospy.Time.now() 265 | self.exg_pub.publish(exg_msg) 266 | 267 | def setRobotVelocities(self, x, y, omega): 268 | self.velocityMsg = Twist() 269 | self.velocityMsg.linear.x = x 270 | self.velocityMsg.linear.y = y 271 | self.velocityMsg.angular.z = omega 272 | if not self.stationaryDebug: 273 | # publish the commands to the robot 274 | if self.velocityMsg is not None: 275 | self.velocity_pub.publish(self.velocityMsg) 276 | 277 | def stopRobot(self, delay): 278 | self.velocityMsg = Twist() 279 | self.velocityMsg.linear.x = 0.0 280 | self.velocityMsg.linear.y = 0.0 281 | self.velocityMsg.angular.z = 0.0 282 | time.sleep(delay) 283 | self.velocity_pub.publish(self.velocityMsg) 284 | 285 | def frontSyncCallback(self, rgbImage, depthImage, camera_info_msg): 286 | self.cameraModel.fromCameraInfo(camera_info_msg) 287 | try: 288 | # Convert your ROS Image message to OpenCV2 289 | self.frontImg = self.bridge.imgmsg_to_cv2(rgbImage, "bgr8") 290 | except CvBridgeError as e: 291 | print(e) 292 | try: 293 | # Convert your ROS Image message to OpenCV2 294 | # The depth image is a single-channel float32 image 295 | # the values is the distance in mm in z axis 296 | cv_depth = self.bridge.imgmsg_to_cv2(depthImage, "passthrough") 297 | # Convert the depth image to a Numpy array since most cv functions 298 | # require Numpy arrays. 299 | self.frontDepth = np.array(cv_depth, dtype=np.float32) 300 | # Normalize the depth image to fall between 0 (black) and 1 (white) 301 | cv.normalize(self.frontDepth, self.frontDepth, 302 | 0.0, 1.0, cv.NORM_MINMAX) 303 | except CvBridgeError as e: 304 | print(e) 305 | 306 | # get image size 307 | self.imgHeight, self.imgWidth, self.imgCh = self.frontImg.shape 308 | # if the image is not empty 309 | if self.frontImg is not None and self.backImg is not None: 310 | # compute and publish robot controls if the image is currently used 311 | if self.primaryCamera: 312 | self.navigate() 313 | 314 | def backSyncCallback(self, rgbImage, depthImage, camera_info_msg): 315 | self.cameraModel.fromCameraInfo(camera_info_msg) 316 | # print("here") 317 | try: 318 | # Convert your ROS Image message to OpenCV2 319 | self.backImg = self.bridge.imgmsg_to_cv2(rgbImage, "bgr8") 320 | except CvBridgeError as e: 321 | print(e) 322 | try: 323 | # Convert your ROS Image message to OpenCV2 324 | # The depth image is a single-channel float32 image 325 | # the values is the distance in mm in z axis 326 | cv_depth = self.bridge.imgmsg_to_cv2(depthImage, "passthrough") 327 | # Convert the depth image to a Numpy array since most cv functions 328 | # require Numpy arrays. 329 | self.backDepth = np.array(cv_depth, dtype=np.float32) 330 | # Normalize the depth image to fall between 0 (black) and 1 (white) 331 | cv.normalize(self.backDepth, self.backDepth, 332 | 0.0, 1.0, cv.NORM_MINMAX) 333 | except CvBridgeError as e: 334 | print(e) 335 | 336 | # get image size 337 | self.imgHeight, self.imgWidth, self.imgCh = self.backImg.shape 338 | # if the image is not empty 339 | if self.frontImg is not None and self.backImg is not None: 340 | # compute and publish robot controls if the image is currently used 341 | if not self.primaryCamera: 342 | self.navigate() 343 | 344 | def front_camera_callback(self, data): 345 | """Function to deal with the front image, called by the subscriber 346 | 347 | Args: 348 | data (_type_): _description_ 349 | """ 350 | # get and set new image from the ROS topic 351 | self.frontImg = self.bridge.imgmsg_to_cv2(data, desired_encoding='rgb8') 352 | # get image size 353 | self.imgHeight, self.imgWidth, self.imgCh = self.frontImg.shape 354 | # if the image is not empty 355 | if self.frontImg is not None and self.backImg is not None: 356 | # compute and publish robot controls if the image is currently used 357 | if self.primaryCamera: 358 | self.navigate() 359 | 360 | def back_camera_callback(self, data): 361 | """Function to deal with the back image 362 | 363 | Args: 364 | data (_type_): _description_ 365 | """ 366 | # get and set new image from the ROS topic 367 | self.backImg = self.bridge.imgmsg_to_cv2(data, desired_encoding='rgb8') 368 | # get image size 369 | self.imgHeight, self.imgWidth, self.imgCh = self.backImg.shape 370 | # if the image is not empty 371 | if self.frontImg is not None and self.backImg is not None: 372 | # compute and publish robot controls if the image is currently used 373 | if not self.primaryCamera: 374 | self.navigate() 375 | 376 | def updateNavigationStage(self): 377 | """updates navigation stagte (one higher or reset to 1 from > 4) 378 | """ 379 | self.navigationMode += 1 380 | if self.navigationMode > 6: 381 | self.navigationMode = 1 382 | inputKey = input("#[INF] Press Enter to continue with mode:") 383 | print("#[INF] Switched to mode ", self.navigationMode) 384 | 385 | def isSwitchingLane(self): 386 | """condition of line existing action, modes 3, 6 387 | 388 | Returns: 389 | _type_: _description_ 390 | """ 391 | if self.navigationMode == 3 or self.navigationMode == 6: 392 | return True 393 | else: 394 | return False 395 | 396 | def isExitingLane(self): 397 | """condition of line existing action, modes 2, 5 398 | 399 | Returns: 400 | _type_: _description_ 401 | """ 402 | if self.navigationMode in [2 ,5]: 403 | return True 404 | else: 405 | return False 406 | 407 | def isFollowingLane(self): 408 | """if following a lane, modes 1, 4 409 | 410 | Returns: 411 | _type_: _description_ 412 | """ 413 | if self.navigationMode in [1,2,4,5]: 414 | return True 415 | else: 416 | return False 417 | 418 | def isUsingFrontCamera(self): 419 | if self.navigationMode == 1 or self.navigationMode == 5 or self.navigationMode == 6: 420 | return True 421 | else: 422 | return False 423 | 424 | def isUsingBackCamera(self): 425 | if self.navigationMode == 2 or self.navigationMode == 3 or self.navigationMode == 4: 426 | return True 427 | else: 428 | return False 429 | 430 | def switchDirection(self): 431 | """Function to manage the control variable for the driving direction 432 | """ 433 | self.linearMotionDir = -self.linearMotionDir 434 | print("#####################switched Direction of Motion ...", self.linearMotionDir) 435 | 436 | def switchRotationDir(self): 437 | """Function to manage the control variable for the driving rotation 438 | """ 439 | self.rotationDir = -self.rotationDir 440 | print("&&&&&&&&&&&&&&&&&&&&&switched Direction of Rotation ...", self.rotationDir) 441 | 442 | def switchCamera(self): 443 | print("switch camera to the other ...") 444 | # Function to manage the switching between the two cameras 445 | self.primaryCamera = not self.primaryCamera 446 | 447 | def getProcessingImage(self, frontRgb, frontDepth, backRgb, backDepth): 448 | """Function to set the currently used image 449 | 450 | Args: 451 | frontImg (_type_): _description_ 452 | backImg (_type_): _description_ 453 | 454 | Returns: 455 | _type_: _description_ 456 | """ 457 | # The front image is used 458 | if self.primaryCamera: 459 | primaryRgb = frontRgb 460 | primaryDepth = frontDepth 461 | # The back image is used 462 | else: 463 | primaryRgb = backRgb 464 | primaryDepth = backDepth 465 | return primaryRgb, primaryDepth 466 | 467 | def computeControls(self, LaneFound, P, Angle): 468 | """Function to compute the controls when following a crop row 469 | 470 | Returns: 471 | _type_: _description_ 472 | """ 473 | if LaneFound: 474 | # define desired and actual feature vector 475 | desiredFeature = np.array([0.0, self.imgWidth/2, 0.0]) 476 | actualFeature = np.array([P[0], P[1], Angle]) 477 | # compute controls 478 | controls = visualServoingCtl.visualServoingCtl(self.camera, 479 | desiredFeature, 480 | actualFeature, 481 | self.maxLinearVel) 482 | if self.isExitingLane(): 483 | self.rotationDir = -1 484 | else: 485 | self.rotationDir = 1 486 | # scale rotational velocity 487 | omega = self.omegaScaler * controls 488 | # set linear speed and direction 489 | rho = 0.2 * self.linearMotionDir 490 | # store the command in a cache 491 | self.omegaBuffer.append(omega) 492 | 493 | return [rho, omega] 494 | else: 495 | # using the last known control 496 | if len(self.omegaBuffer) == 0: 497 | self.omegaBuffer.append(0.0) 498 | # straight exit 499 | omega = 0.0 500 | rho = 0.05 * self.linearMotionDir 501 | return [rho, omega] 502 | 503 | def transformTargets(self, targets, frameName): 504 | cameraToOdomTF = None 505 | targetList = [] 506 | try: 507 | cameraToOdomTF = self.tfBuffer.lookup_transform( 508 | frameName, self.cameraModel.tfFrame(), rospy.Time(0)) 509 | except: 510 | rospy.logerr("lookup_transform " + frameName + " to " + self.cameraModel.tfFrame() + " failed !!!") 511 | 512 | if not cameraToOdomTF == None: 513 | for i in range(len(targets)): 514 | stem = tf2_geometry_msgs.do_transform_pose(targets[i], cameraToOdomTF) 515 | targetList.append(stem) 516 | 517 | return targetList -------------------------------------------------------------------------------- /scripts/imageProc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | from __future__ import division 3 | from __future__ import print_function 4 | 5 | import scipy 6 | import cv2 as cv 7 | import numpy as np 8 | from matplotlib import pyplot as plt 9 | import itertools 10 | 11 | from torch import zero_ 12 | from cv_bridge import CvBridge 13 | 14 | from shapely.geometry import Point 15 | from shapely.geometry.polygon import Polygon 16 | 17 | from contours import * 18 | from geometric import * 19 | from featureMatching import * 20 | from movingVariance import * 21 | 22 | class imageProc: 23 | def __init__(self, scannerParams, contourParams, roiParams, trackerParams): 24 | """# class FeatureExtractor to extract the the line Features: bottom point, angle 25 | 26 | Args: 27 | windowProp (_type_): _description_ 28 | roiParams (_type_): _description_ 29 | featureParams (_type_): _description_ 30 | """ 31 | self.roiParams = roiParams 32 | self.contourParams = contourParams 33 | self.scannerParams = scannerParams 34 | self.trackerParams = trackerParams 35 | 36 | self.reset() 37 | 38 | def reset(self): 39 | print("**************Reset*************") 40 | self.count = 0 41 | self.pointsInTop = 0 42 | self.pointsInBottom = 0 43 | 44 | # parameters 45 | self.bushy = False 46 | self.CropRows = [] 47 | self.primaryRGBImg = [] 48 | self.cropLaneFound = False 49 | self.isInitialized = False 50 | 51 | self.cropRowEnd = False 52 | 53 | self.trackingBoxLoc = [] 54 | 55 | # features 56 | self.allLineEnd = [] 57 | self.allLineStart = [] 58 | self.mainLine_up = np.array([]) 59 | self.mainLine_down = np.array([]) 60 | 61 | # window parameters 62 | self.trackingWindowOffsTop = 0 63 | self.trackingWindowOffsBottom = 0 64 | self.trackingWindowTopScaleRatio = 0.4 # scales the top of the window 65 | 66 | self.imgHeight, self.imgWidth = 720, 1280 67 | 68 | # steps create linspace 69 | self.scanFootSteps = np.linspace(self.scannerParams["scanStartPoint"], 70 | self.scannerParams["scanEndPoint"], 71 | self.scannerParams["scanWindowWidth"]) 72 | self.rowTrackingBoxes = [] 73 | self.updateTrackingBoxes() 74 | 75 | self.numOfCropRows = len(self.rowTrackingBoxes) 76 | 77 | def findCropLane(self, rgbImg, depthImg, mode='RGB-D', bushy=False): 78 | """finds Crops Rows in the image based on RGB and depth data 79 | Args: 80 | bushy (bool, optional): _description_. Defaults to False. 81 | """ 82 | self.primaryRGBImg = rgbImg.copy() 83 | self.primaryDepthImg = depthImg.copy() 84 | self.imgHeight, self.imgWidth, self.imgChannels = rgbImg.shape 85 | # if crop canopy type is busshy like Coriander 86 | self.bushy = bushy 87 | 88 | # check if current image is not Empty or None 89 | if self.primaryRGBImg is None or len(self.primaryRGBImg) == 0: 90 | print("#[ERR] CR-Scanner - Image is None, Check Topics or the Sensor !") 91 | else: 92 | # initial RGB image process to get mask GreenIDx and plant centers 93 | self.mask, self.greenIDX, self.plantObjects2D, self.plantCenters2D = self.processRGBImage(self.primaryRGBImg) 94 | self.numPlantsInScene = len(self.plantCenters2D[0]) 95 | if not self.isInitialized: 96 | print("#[INF] Find Crop Lane") 97 | self.lines2D, self.linesROIs2D = self.findCropRows2D(self.primaryRGBImg) 98 | # self.lines3D, self.linesROIs3D = self.findCropRows3D(self.mask, self.plantCenters2D, self.primaryDepthImg) 99 | # merge both predictions to get more robust results! 100 | 101 | return self.cropLaneFound 102 | 103 | def findCropRows3D(self, maskImg, plantCenters2D, depthImg): 104 | lines = [] 105 | linesROIs = [] 106 | # project the plant centers to 3D 107 | 108 | # cluster points 109 | 110 | # fit line on each cluster 111 | 112 | # project back 3D points to image 113 | 114 | # return the resutls 115 | return lines, linesROIs 116 | 117 | def findCropRows2D(self, rgbImg): 118 | # search for lines at given window locations using LQ-Adjustment 119 | lines, linesROIs, _ = self.findLinesInImage() 120 | # if any feature is observed during the scan 121 | if len(lines) != 0: 122 | np.set_printoptions(suppress=True) 123 | # compute moving standard deviation 124 | mvSignal = movingStd(lines[:, 0]) 125 | # find positive an negative peaks of the signal 126 | peaksPos, peaksNeg = findPicksTroughths(mvSignal, 0.5) 127 | # locate best lines 128 | self.CropRows, self.trackingBoxLoc = self.findCropRowsInMVSignal(peaksPos, peaksNeg, 129 | mvSignal, lines, linesROIs) 130 | self.numOfCropRows = len(self.trackingBoxLoc) 131 | self.lostCropRows = list(np.zeros(self.numOfCropRows)) 132 | # if there is any proper line to follow 133 | if self.numOfCropRows != 0: 134 | # set parameters indicating a successfull initialization 135 | self.isInitialized = True 136 | self.cropLaneFound = True 137 | print('#[INF] Controller Initialized - Crop Rows:', 138 | len(self.CropRows), 139 | ', Window positions:', 140 | self.CropRows[:,0].tolist()) 141 | else: 142 | print( 143 | '--- Initialisation failed - No lines detected due to peak detection ---') 144 | self.cropLaneFound = False 145 | else: 146 | print( 147 | '--- Initialisation failed - No lines detected by sweeping window ---') 148 | self.cropLaneFound = False 149 | return lines, linesROIs 150 | 151 | def findCropRowsInMVSignal(self, peaksPos, peaksNeg, mvSignal, lines, linesROIs): 152 | # if multiple lines 153 | if len(peaksPos) != 0 and len(peaksNeg) != 0 and len(mvSignal) != 0: 154 | # best line one each side of the crop row transition 155 | qualifiedLines = np.zeros((len(peaksPos)+1, 2)) 156 | windowLocations = np.zeros((len(peaksPos)+1, 1)) 157 | # get lines at each side of a positive peak (= crop row 158 | # transition and select the best) 159 | try: 160 | lidx = 0 161 | # every peak stands for 2 croprows 162 | for k in range(0, len(peaksPos) + 1): 163 | # first peak 164 | if k == 0: 165 | peaksNegLine = peaksNeg[peaksNeg < peaksPos[k]] 166 | else: 167 | # second to last-1 168 | if k < len(peaksPos): 169 | linestmp = peaksNeg[peaksNeg < peaksPos[k]] 170 | peaksNegLine = linestmp[linestmp > peaksPos[k-1]] 171 | # last peak 172 | else: 173 | peaksNegLine = peaksNeg[peaksNeg > peaksPos[k-1]] 174 | 175 | if len(peaksNegLine) != 0: 176 | # best line (measured by variance) 177 | bestLine = np.where( 178 | mvSignal[peaksNegLine] == np.min(mvSignal[peaksNegLine]))[0][0] 179 | # parameters of that line 180 | qualifiedLines[lidx, :] = lines[peaksNegLine[bestLine]] 181 | # location of the window which led to this line 182 | if linesROIs[peaksNegLine[bestLine]] != [0]: 183 | windowLocations[lidx, 184 | :] = linesROIs[peaksNegLine[bestLine]] 185 | lidx += 1 186 | except: 187 | 188 | # fallback: just take all negative peaks 189 | qualifiedLines = lines[peaksNeg] 190 | windowLocations = np.mean(linesROIs[peaksNeg]) 191 | 192 | # if there are no positive peaks but negative ones: no crop 193 | # row transition -> there might be just one line 194 | elif len(peaksPos) == 0 and len(peaksNeg) != 0 and len(mvSignal) != 0: 195 | peaksNegLine = peaksNeg 196 | bestLine = np.where(mvSignal[peaksNegLine] == np.min(mvSignal[peaksNegLine]))[0][0] 197 | 198 | qualifiedLines = np.zeros((1, 2)) 199 | windowLocations = np.zeros((1, 1)) 200 | 201 | qualifiedLines[0, :] = lines[peaksNegLine[bestLine]] 202 | windowLocations[0] = linesROIs[peaksNegLine[bestLine]] 203 | else: 204 | qualifiedLines = [] 205 | windowLocations = [] 206 | 207 | qualifiedLines = qualifiedLines[~np.all(qualifiedLines == 0, axis=1)] 208 | windowLocations = windowLocations[~np.all(windowLocations == 0, axis=1)] 209 | 210 | return qualifiedLines, windowLocations 211 | 212 | def findLinesInImage(self): 213 | """function to get lines at given window locations 214 | Args: 215 | self.scanFootSteps (_type_): _description_ 216 | Returns: 217 | _type_: lines in image 218 | """ 219 | # initialization 220 | lines = np.zeros((len(self.scanFootSteps), 2)) 221 | trackingWindows = np.zeros((len(self.scanFootSteps), 1)) 222 | meanLinesInWindows = np.zeros((len(self.scanFootSteps), 1)) 223 | angleVariance = None 224 | # counting the points in the top and bottom half 225 | self.pointsInBottom = 0 226 | self.pointsInTop = 0 227 | # for all windows 228 | for boxIdx in range(0, self.numOfCropRows, 1): 229 | # define window 230 | if self.isInitialized: 231 | # get x values, where the top and bottom of the window intersect 232 | # with the previous found line 233 | lineIntersection = lineIntersectWin(self.CropRows[boxIdx, 1], 234 | self.CropRows[boxIdx, 0], 235 | self.imgHeight, 236 | self.trackerParams["topOffset"], 237 | self.trackerParams["bottomOffset"]) 238 | self.updateTrackingBoxes(boxIdx, lineIntersection) 239 | 240 | plantsInCropRow = [] 241 | for ptIdx in range(self.numPlantsInScene): 242 | # checking #points in upper/lower half of the image 243 | self.checkPlantsLocTB(self.plantCenters2D[:, ptIdx]) 244 | 245 | # if plant center is inside tracking box 246 | if self.rowTrackingBoxes[boxIdx].contains(Point(self.plantCenters2D[0, ptIdx], self.plantCenters2D[1, ptIdx])): 247 | plantsInCropRow.append(self.plantCenters2D[:, ptIdx]) 248 | 249 | if len(plantsInCropRow) >= 2: 250 | # flipped points 251 | ptsFlip = np.flip(plantsInCropRow, axis=1) 252 | # get line at scanning window 253 | xM, xB = getLineRphi(ptsFlip) 254 | t_i, b_i = lineIntersectImgUpDown(xM, xB, self.imgHeight) 255 | l_i, r_i = lineIntersectImgSides(xM, xB, self.imgHeight) 256 | # print("row ID:", boxIdx, t_i, b_i, l_i, r_i ) 257 | # if the linefit does not return None and the line-image intersections 258 | # are within the image bounds 259 | if xM is not None and b_i >= 0 and b_i <= self.imgWidth: 260 | lines[boxIdx, :] = [xB, xM] 261 | # store the window location, which generated these line 262 | # parameters 263 | if self.isInitialized == False: 264 | trackingWindows[boxIdx] = self.scanFootSteps[boxIdx] 265 | # mean x value of all points used for fitting the line 266 | meanLinesInWindows[boxIdx] = np.median(plantsInCropRow, axis=0)[0] 267 | 268 | # if the feature extractor is initalized, adjust the window 269 | # size with the variance of the line fit 270 | if self.isInitialized and angleVariance is not None: 271 | self.trackerParams["trackingBoxWidth"] = max(3*angleVariance, self.trackerParams["trackingBoxWidth"]) 272 | 273 | # delete zero rows from line parameters 274 | lines = lines[~np.all(lines == 0, axis=1)] 275 | trackingWindows = trackingWindows[~np.all(trackingWindows == 0, axis=1)] 276 | meanLinesInWindows = meanLinesInWindows[~np.all(meanLinesInWindows == 0, axis=1)] 277 | 278 | return lines, trackingWindows, meanLinesInWindows 279 | 280 | def updateTrackingBoxes(self, boxID=0, lineIntersection=None): 281 | 282 | if not self.isInitialized and lineIntersection==None: 283 | # initial tracking Boxes 284 | for i in range(len(self.scanFootSteps)): 285 | # window is centered around self.scanFootSteps[i] 286 | boxBL_x = self.scanFootSteps[i] 287 | boxBR_x = self.scanFootSteps[i] + self.trackerParams["trackingBoxWidth"] 288 | boxTL_x = int(boxBL_x - self.trackerParams["trackingBoxWidth"]/2 * self.trackerParams["sacleRatio"]) 289 | boxTR_x = int(boxTL_x + self.trackerParams["trackingBoxWidth"] * self.trackerParams["sacleRatio"]) 290 | boxT_y = self.trackerParams["bottomOffset"] 291 | boxB_y = self.imgHeight - self.trackerParams["topOffset"] 292 | # store the corner points 293 | self.rowTrackingBoxes.append(Polygon([(boxBR_x, boxB_y), 294 | (boxBL_x, boxB_y), 295 | (boxTL_x, boxT_y), 296 | (boxTR_x, boxT_y)])) 297 | else: 298 | # window corner points are left and right of these 299 | boxBL_x = int(lineIntersection[0] - self.trackerParams["trackingBoxWidth"]/2) 300 | boxBR_x = int(boxBL_x + self.trackerParams["trackingBoxWidth"]) 301 | boxTL_x = int(lineIntersection[1] - self.trackerParams["trackingBoxWidth"]/2 * self.trackerParams["sacleRatio"]) 302 | boxTR_x = int(boxTL_x + self.trackerParams["trackingBoxWidth"] * self.trackerParams["sacleRatio"]) 303 | boxT_y = self.trackerParams["bottomOffset"] 304 | boxB_y = self.imgHeight - self.trackerParams["topOffset"] 305 | # store the corner points 306 | self.rowTrackingBoxes[boxID] = Polygon([(boxBR_x, boxB_y), 307 | (boxBL_x, boxB_y), 308 | (boxTL_x, boxT_y), 309 | (boxTR_x, boxT_y)]) 310 | 311 | def checkPlantsInRows(self, cropRowID, plantsInCroRow): 312 | 313 | if len(plantsInCroRow) >= 2: 314 | return True 315 | else: 316 | self.lostCropRows[cropRowID] = 1 317 | return False 318 | 319 | def checkPlantsLocTB(self, point): 320 | if point[1] < self.imgHeight/2: 321 | self.pointsInTop += 1 322 | else: 323 | self.pointsInBottom += 1 324 | 325 | def trackCropLane(self, mode=None): 326 | """function to get lines at given window locations 327 | Returns: 328 | _type_: _description_ 329 | """ 330 | P, ang = None, None 331 | # if the feature extractor is initalized 332 | if self.cropLaneFound: 333 | # get the lines at windows defined through previous found lines 334 | lines, linesROIs, meanLinesInWindows = self.findLinesInImage() 335 | 336 | # if 'all' lines are found by 'self.getLinesInImage' 337 | if len(lines) >= len(self.trackingBoxLoc): 338 | # the line parameters are the new tracked lines (for the next step) 339 | self.CropRows = lines 340 | # location is always the left side of the window 341 | self.trackingBoxLoc = meanLinesInWindows - self.trackerParams["trackingBoxWidth"]/2 342 | # average image intersections of all found lines 343 | avgOfLines = np.mean(np.c_[lineIntersectImgUpDown( 344 | self.CropRows[:, 1], self.CropRows[:, 0], self.imgHeight)], axis=0) 345 | # get AvgLine in image cords 346 | self.mainLine_up, self.mainLine_down = getImgLineUpDown( 347 | avgOfLines, self.imgHeight) 348 | # compute all intersections between the image and each line 349 | allLineIntersect = np.c_[lineIntersectWin(self.CropRows[:, 1], 350 | self.CropRows[:, 0], 351 | self.imgHeight, 352 | self.trackerParams["topOffset"], 353 | self.trackerParams["bottomOffset"])] 354 | # store start and end points of lines - mainly for plotting 355 | self.allLineStart = np.c_[ 356 | allLineIntersect[:, 0], self.imgHeight - self.trackerParams["bottomOffset"] * np.ones((len(self.CropRows), 1))] 357 | self.allLineEnd = np.c_[ 358 | allLineIntersect[:, 1], self.trackerParams["topOffset"] * np.ones((len(self.CropRows), 1))] 359 | # main features 360 | self.P = self.cameraToImage([avgOfLines[1], self.imgHeight]) 361 | self.ang = computeTheta(self.mainLine_up, self.mainLine_down) 362 | self.cropLaneFound = True 363 | 364 | else: 365 | print("#[ERR] Lost at least one line") 366 | self.cropLaneFound = False 367 | else: 368 | print('Running rest()..') 369 | 370 | if self.pointsInBottom == 0 and self.pointsInTop == 0: 371 | self.cropLaneFound = False 372 | 373 | if self.pointsInBottom == 0 and mode in [2, 5]: 374 | self.cropRowEnd = True 375 | elif self.pointsInTop == 0 and mode in [1, 4]: 376 | self.cropRowEnd = True 377 | else: 378 | self.cropRowEnd = False 379 | 380 | return self.cropLaneFound, self.cropRowEnd, P, ang 381 | 382 | def processRGBImage(self, rgbImg): 383 | """function to extract the greenidx and the contour center points 384 | Returns: 385 | _type_: _description_ 386 | """ 387 | # change datatype to enable negative values for self.greenIDX calculation 388 | rgbImg = rgbImg.astype('int32') 389 | # apply ROI 390 | rgbImg = self.applyROI(rgbImg) 391 | # get green index and binary mask (Otsu is used for extracting the green) 392 | mask, greenIDX = self.getExgMask(rgbImg) 393 | # find contours 394 | plantObjects = getPlantMasks( 395 | mask, self.contourParams["minContourArea"], bushy=False) 396 | # get center of contours 397 | contCenterPTS = getContourCenter(plantObjects) 398 | x = contCenterPTS[:, 1] 399 | y = contCenterPTS[:, 0] 400 | # cv.imshow("RGB image",mask.astype('uint8')) 401 | # self.handleKey(0) 402 | return mask, greenIDX, plantObjects, np.array([x, y]) 403 | 404 | def getExgMask(self, img): 405 | """Function to compute the green index of the image 406 | """ 407 | _img = img.copy() 408 | 409 | _img = _img.astype('int32') 410 | # Vegetation Mask 411 | r = _img[:, :, 0] 412 | g = _img[:, :, 1] 413 | b = _img[:, :, 2] 414 | 415 | # calculate Excess Green Index and filter out negative values 416 | greenIDX = 2*g - r - b 417 | greenIDX[greenIDX < 0] = 0 418 | greenIDX = greenIDX.astype('uint8') 419 | 420 | # Otsu's thresholding after gaussian smoothing 421 | blur = cv.GaussianBlur(greenIDX, (5, 5), 0) 422 | threshold, threshIMG = cv.threshold( 423 | blur, 0, 255, cv.THRESH_BINARY+cv.THRESH_OTSU) 424 | 425 | # dilation 426 | kernel = np.ones((10, 10), np.uint8) 427 | binaryMask = cv.dilate(threshIMG, kernel, iterations=1) 428 | 429 | # Erotion 430 | # er_kernel = np.ones((10,10),dtype=np.uint8) # this must be tuned 431 | # binaryMask= cv.erode(binaryMask, er_kernel) 432 | 433 | return binaryMask, greenIDX 434 | 435 | def applyROI(self, img): 436 | _img = img.copy() 437 | # defining ROI windown on the image 438 | if self.roiParams["enable_roi"]: 439 | r_pts = [self.roiParams["p1"], self.roiParams["p2"], 440 | self.roiParams["p3"], self.roiParams["p4"]] 441 | l_pts = [self.roiParams["p5"], self.roiParams["p6"], 442 | self.roiParams["p7"], self.roiParams["p8"]] 443 | 444 | cv.fillPoly(_img, np.array([r_pts]), (0, 0, 0)) 445 | cv.fillPoly(_img, np.array([l_pts]), (0, 0, 0)) 446 | return _img 447 | 448 | def cameraToImage(self, P): 449 | """function to transform the feature point from camera to image frame 450 | Args: 451 | P (_type_): point in camera Fr 452 | Returns: 453 | _type_: point in image Fr 454 | """ 455 | P[0] = P[0] - self.imgWidth/2 456 | P[1] = P[1] - self.imgHeight/2 457 | return P 458 | 459 | def drawGraphics(self): 460 | """function to draw the lines and the windows onto the image (self.primaryRGBImg) 461 | """ 462 | if self.primaryRGBImg != []: 463 | self.graphicsImg = self.primaryRGBImg.copy() 464 | # main line 465 | cv.line(self.graphicsImg, (int(self.mainLine_up[0]), int(self.mainLine_up[1])), (int( 466 | self.mainLine_down[0]), int(self.mainLine_down[1])), (255, 0, 0), thickness=3) 467 | # contoures 468 | cv.drawContours(self.graphicsImg, 469 | self.plantObjects2D, -1, (10, 50, 150), 3) 470 | for i in range(0, len(self.allLineStart)): 471 | # helper lines 472 | cv.line(self.graphicsImg, (int(self.allLineStart[i, 0]), int(self.allLineStart[i, 1])), (int( 473 | self.allLineEnd[i, 0]), int(self.allLineEnd[i, 1])), (0, 255, 0), thickness=1) 474 | 475 | for i in range(self.numOfCropRows): 476 | int_coords = lambda x: np.array(x).round().astype(np.int32) 477 | exterior = [int_coords(self.rowTrackingBoxes[i].exterior.coords)] 478 | cv.polylines(self.graphicsImg, exterior, True, (0, 255, 255)) 479 | 480 | for i in range(len(self.plantCenters2D[0])): 481 | # draw point on countur centers 482 | x = int(self.plantCenters2D[0, i]) 483 | y = int(self.plantCenters2D[1, i]) 484 | self.graphicsImg = cv.circle( 485 | self.graphicsImg, (x, y), 3, (255, 0, 255), 5) 486 | 487 | def handleKey(self, sleepTime=0): 488 | key = cv.waitKey(sleepTime) 489 | # Close program with keyboard 'q' 490 | if key == ord('q'): 491 | cv.destroyAllWindows() 492 | exit(1) 493 | --------------------------------------------------------------------------------