diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5b050f278139b6ef62ec43f038ad79c8c3882d9f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - repo: https://github.com/psf/black + rev: 22.6.0 + hooks: + - id: black + - repo: https://github.com/PyCQA/isort + rev: 5.10.1 + hooks: + - id: isort + - repo: https://github.com/PyCQA/flake8 + rev: 4.0.1 + hooks: + - id: flake8 diff --git a/pyproject.toml b/pyproject.toml index a94d26e536f661a1ff4823056be02756669bb68f..d07d56603168c00dcc9366956da18a095af90267 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,13 +7,9 @@ name = "farmbot_yolo" description = "farmbot object identification using YOLO/Darknet" version = "0.1.0" readme = "README.md" -license = {text = "TODO"} -authors = [ - { name = "Ziliang Xiong", email = "13718722639leo@gmail.com" } -] -classifiers = [ - "Programming Language :: Python :: 3", -] +license = { text = "TODO" } +authors = [{ name = "Ziliang Xiong", email = "13718722639leo@gmail.com" }] +classifiers = ["Programming Language :: Python :: 3"] requires-python = ">=3.7" dependencies = [ "requests", diff --git a/src/farmbot_yolo/client.py b/src/farmbot_yolo/client.py index bac752a19d03878719beb79d852d23bfb24a2748..a3f2df7ddb0c618cc17cc0c6c53cc3f88c410f56 100644 --- a/src/farmbot_yolo/client.py +++ b/src/farmbot_yolo/client.py @@ -2,17 +2,20 @@ Not useful for the local drive script """ -import paho.mqtt.client as mqtt import json import time -from uuid import uuid4 # 通用唯一标识符 ( Universally Unique Identifier ) -import logging # 日志模块 +from logging import getLogger +from uuid import uuid4 + +import paho.mqtt.client as mqtt # values over max (and under min) will be clipped MAX_X = 2400 MAX_Y = 1200 MAX_Z = 469 # TODO test this one! +log = getLogger(__name__) + def coord(x, y, z): return {"kind": "coordinate", "args": {"x": x, "y": y, "z": z}} # 返回json 嵌套对象 @@ -119,17 +122,6 @@ class FarmbotClient(object): self.client.on_connect = self._on_connect # ??? self.client.on_message = self._on_message - logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s", - filename="farmbot_client.log", - filemode="a", - ) - console = logging.StreamHandler() - console.setLevel(logging.INFO) - console.setFormatter(logging.Formatter("%(asctime)s\t%(message)s")) - logging.getLogger("").addHandler(console) - self.connected = False self.client.connect( "clever-octopus.rmq.cloudamqp.com", 1883, 60 @@ -146,36 +138,35 @@ class FarmbotClient(object): y = clip(y, 0, MAX_Y) z = clip(z, 0, MAX_Z) status_ok = self._blocking_request(move_request(x, y, z)) # 发请求 - logging.info( + log.info( "MOVE (%s,%s,%s) [%s]", x, y, z, status_ok ) # 存日志,包括执行了什么“move x y z +返回值 ” def take_photo(self): - # TODO: is this enough? it's issue a request for the photo, but is the actual capture async? + # TODO: is this enough? it's issue a request for the photo, but is the + # actual capture async? status_ok = self._blocking_request(take_photo_request()) - logging.info("TAKE_PHOTO [%s]", status_ok) + log.info("TAKE_PHOTO [%s]", status_ok) def read_pin(self, pin_number, pin_mode="digital"): status_ok = self._blocking_request( read_pin_request(pin_number, pin_mode=pin_mode) ) - logging.info(f"READ PIN (pin_number: {pin_number}) [{status_ok}]") + log.info(f"READ PIN (pin: {pin_number}) [{status_ok}]") def write_pin(self, pin_number, pin_value, pin_mode="digital"): status_ok = self._blocking_request( write_pin_request(pin_number, pin_value, pin_mode=pin_mode) ) - logging.info( - f"WRITE PIN (pin_number: {pin_number}, pin_value: {pin_value}) [{status_ok}]" - ) + log.info(f"WRITE PIN (pin: {pin_number}, value: {pin_value}) [{status_ok}]") def toggle_pin(self, pin_number): status_ok = self._blocking_request(toggle_pin_request(pin_number)) - logging.info(f"TOGGLE PIN (pin_number: {pin_number}) [{status_ok}]") + log.info(f"TOGGLE PIN (pin: {pin_number}) [{status_ok}]") def _blocking_request(self, request, retries_remaining=3): if retries_remaining == 0: - logging.error( + log.error( "< blocking request [%s] OUT OF RETRIES", request ) # 尝试3次,然后在日志中记录错误 return False @@ -185,7 +176,7 @@ class FarmbotClient(object): # assign a new uuid for this attempt self.pending_uuid = str(uuid4()) request["args"]["label"] = self.pending_uuid # 接收move_request函数的json对象 - logging.debug("> blocking request [%s] retries=%d", request, retries_remaining) + log.debug("> blocking request [%s] retries=%d", request, retries_remaining) # send request off 发送请求 self.rpc_status = None @@ -199,24 +190,24 @@ class FarmbotClient(object): time.sleep(0.1) timeout_counter -= 1 if timeout_counter == 0: - logging.warn("< blocking request TIMEOUT [%s]", request) # 时间到了,无应答 + log.warn("< blocking request TIMEOUT [%s]", request) # 时间到了,无应答 return self._blocking_request(request, retries_remaining - 1) self.pending_uuid = None # if it's ok, we're done! if self.rpc_status == "rpc_ok": - logging.debug("< blocking request OK [%s]", request) + log.debug("< blocking request OK [%s]", request) return True # if it's not ok, wait a bit and retry if self.rpc_status == "rpc_error": - logging.warn("< blocking request ERROR [%s]", request) + log.warn("< blocking request ERROR [%s]", request) time.sleep(1) return self._blocking_request(request, retries_remaining - 1) # unexpected state (???) msg = "unexpected rpc_status [%s]" % self.rpc_status - logging.error(msg) + log.error(msg) raise Exception(msg) def _wait_for_connection(self): @@ -229,15 +220,15 @@ class FarmbotClient(object): raise Exception("unable to connect") def _on_connect(self, client, userdata, flags, rc): - logging.debug("> _on_connect") + log.debug("> _on_connect") self.client.subscribe("bot/" + self.device_id + "/from_device") self.connected = True - logging.debug("< _on_connect") + log.debug("< _on_connect") def _on_message(self, client, userdata, msg): resp = json.loads(msg.payload.decode()) if resp["args"]["label"] != "ping": - logging.debug("> _on_message [%s] [%s]", msg.topic, resp) + log.debug("> _on_message [%s] [%s]", msg.topic, resp) if ( msg.topic.endswith("/from_device") and resp["args"]["label"] == self.pending_uuid diff --git a/src/farmbot_yolo/detect.py b/src/farmbot_yolo/detect.py index 252b251970e1002589318ef58ebe96bc362de10e..a9dfe6415df152bd9a280d88f60661d222c2e78f 100644 --- a/src/farmbot_yolo/detect.py +++ b/src/farmbot_yolo/detect.py @@ -1,20 +1,18 @@ -""" -load images taken by the camera, return bounding boxes +"""Detect objects using trained model. -customized based on Alexy/darknet_images.py +Based on `darknet/darknet_images.py +https://github.com/AlexeyAB/darknet/blob/master/darknet_images.py`. """ import json import random import time -from argparse import ArgumentParser -from argparse import Namespace +from argparse import ArgumentParser, Namespace from pathlib import Path import cv2 -from darknet import darknet -from farmbot_yolo import REPO -from farmbot_yolo import TMPDIR +from darknet import darknet +from farmbot_yolo import REPO, TMPDIR def check_arguments_errors(args): @@ -107,7 +105,7 @@ def convert2relative(image, bbox): def save_annotations(original_size, name, image, detections, class_names): """ Files saved with image_name.txt and relative coordinates - oringinal_size is Ziliang's improvement + original_size is Ziliang's improvement """ height, width, _ = original_size diff --git a/src/farmbot_yolo/download.py b/src/farmbot_yolo/download.py index 28cec20cfae9831abade8d55784120044c6be41c..9b69b6f17e9e74b5fd05172e646a17306524cb29 100644 --- a/src/farmbot_yolo/download.py +++ b/src/farmbot_yolo/download.py @@ -2,23 +2,39 @@ import argparse import json import os +from logging import getLogger from pathlib import Path -from typing import List +from typing import Dict, List, Tuple import requests from farmbot_yolo import TMPDIR -from farmbot_yolo import creds +from farmbot_yolo.read_credentials import read_credentials -IMG_FILE_SUFFIX = ".jpg" +log = getLogger(__name__) -REQUEST_HEADERS = { - "Authorization": "Bearer " + creds.token, - "content-type": "application/json", -} +IMG_FILE_SUFFIX = "jpg" -def download_images(directory: os.PathLike, delete_after=False) -> List[str]: +ENDPOINT_URL = "https://my.farmbot.io/api/images" + + +def get_auth_header() -> Dict[str, str]: + creds = read_credentials() + return {"Authorization": f"Bearer {creds['token']}"} + + +def get_img_metadata() -> Dict: + res = requests.get(ENDPOINT_URL, headers=get_auth_header()) + img_metadata_list = res.json() + + log.debug(f"Response: {img_metadata_list}") + log.debug(f"Response length: {len(img_metadata_list)}") + + return img_metadata_list + + +def download_images(directory: os.PathLike) -> List[Tuple[Path, Path]]: """Download all images on server, optionally deleting after download. Parameters @@ -26,49 +42,31 @@ def download_images(directory: os.PathLike, delete_after=False) -> List[str]: directory directory to store images in - Raises - ------ - RuntimeError - If the server gives a bad response (not 200). - Returns ------- - list of str - List of filepaths with downloaded images + List of filepaths with downloaded images and metadata files """ - response = requests.get("https://my.farmbot.io/api/images", headers=REQUEST_HEADERS) - json_response = response.json() - - if response.status_code != 200: - raise RuntimeError(f"Got status code {response.status_code}.") + img_metadata_list = get_img_metadata() - print(json_response) - print(f"Got a response containing {len(json_response)}") - - if len(json_response) < 1: - return [] - - created_files = [] - - for img_dict in json_response: - if "placehold.it" in img_dict["attachment_url"]: - print("IGNORE! placeholder", img_dict["id"]) - continue + downloaded_files = [] + for img_dict in img_metadata_list: server_path: str = img_dict["meta"]["name"] if not server_path.startswith("/tmp/images"): - print("meta name does not start with /tmp/images") + # TODO: Why? + log.info("meta name does not start with /tmp/images, skipping") continue - filename = Path(server_path).stem + IMG_FILE_SUFFIX + filename = f"{Path(server_path).stem}_{img_dict['id']}.{IMG_FILE_SUFFIX}" + filepath = Path(directory) / filename - print(">", filepath) + log.debug(f"Will download to: {filepath}") # download image from google storage and save locally if filepath.exists(): - print("File exists, skipping") + log.warning(f"File {filepath.absolute()} exists, skipping") continue img_req = requests.get(img_dict["attachment_url"], allow_redirects=True) @@ -76,7 +74,7 @@ def download_images(directory: os.PathLike, delete_after=False) -> List[str]: with filepath.open(mode="wb") as fp: fp.write(img_req.content) - img_metadata_dict = { + saved_metadata = { "farmbot_metadata": { "id": img_dict["id"], "location": { @@ -87,20 +85,25 @@ def download_images(directory: os.PathLike, delete_after=False) -> List[str]: } } - metadata_filepath = filepath.with_suffix(".json") - with metadata_filepath.open(mode="w") as fp: - json.dump(img_metadata_dict, fp) + saved_metadata_filepath = filepath.with_suffix(".json") + with saved_metadata_filepath.open(mode="w") as fp: + json.dump(saved_metadata, fp) - # post delete from cloud storage - if delete_after: - requests.delete( - f"https://my.farmbot.io/api/images/{img_dict['id']}", - headers=REQUEST_HEADERS, - ) + downloaded_files.append((filepath, saved_metadata_filepath)) - created_files.append((filepath, metadata_filepath)) + return downloaded_files - return created_files + +def delete_image(id: str): + requests.delete(f"{ENDPOINT_URL}/{id}", headers=get_auth_header()) + + +def delete_all_on_server() -> List[str]: + img_metadata_list = get_img_metadata() + ids = [data["id"] for data in img_metadata_list] + + for id in ids: + delete_image(id) if __name__ == "__main__": @@ -111,8 +114,11 @@ if __name__ == "__main__": help="Directory to download images too.", default=TMPDIR, ) - parser.add_argument("-d", "--delete", action="store_true") + parser.add_argument("-d", "--delete-all", action="store_true") args = parser.parse_args() - download_images(args.download_dir, delete_after=args.delete) + download_images(args.download_dir) + + if args.delete_all: + delete_all_on_server() diff --git a/src/farmbot_yolo/gripper.py b/src/farmbot_yolo/gripper.py index b5f8fef872ed3e44b9aa95a7364980ea00de8c0e..3e0011f5e3de5d864f9e416f84f0a63667579e15 100644 --- a/src/farmbot_yolo/gripper.py +++ b/src/farmbot_yolo/gripper.py @@ -2,27 +2,44 @@ TODO: Integrate with FarmbotClient or FarmbotYoloClient """ +from argparse import ArgumentParser +from typing import Dict + from farmbot_yolo.client import FarmbotClient -from farmbot_yolo.creds import device_id -from farmbot_yolo.creds import token +from farmbot_yolo.read_credentials import read_credentials GRIPPER_PIN = 12 GRIPPER_OPEN_STATE = 0 GRIPPER_CLOSED_STATE = 1 -def gripper_open(): +def gripper_open(creds: Dict[str, str]): """Manipulate gripper by setting pin to 1.""" - client = FarmbotClient(device_id, token) + client = FarmbotClient(creds["device_id"], creds["token"]) client.write_pin(GRIPPER_PIN, GRIPPER_OPEN_STATE, pin_mode="digital") client.shutdown() -def gripper_close(): +def gripper_close(creds: Dict[str, str]): """Manipulate gripper by setting pin to 0.""" - client = FarmbotClient(device_id, token) + client = FarmbotClient(creds["device_id"], creds["token"]) client.write_pin(GRIPPER_PIN, GRIPPER_CLOSED_STATE, pin_mode="digital") client.shutdown() -# TODO: Add argumentparser and ifnamemain function +if __name__ == "__main__": + parser = ArgumentParser("Manipulate gripper.") + parser.add_argument("--open", action="store_true") + parser.add_argument("--close", action="store_true") + + args = parser.parse_args() + + creds = read_credentials + + if args.open: + gripper_open(creds) + elif args.close: + gripper_close(creds) + else: + parser.print_help() + exit(1) diff --git a/src/farmbot_yolo/location.py b/src/farmbot_yolo/location.py index 2f9e1ef6345f096847879b85f74a92dfed217749..41cbef233f178441c7c54e425ab4d38815acd3f1 100644 --- a/src/farmbot_yolo/location.py +++ b/src/farmbot_yolo/location.py @@ -1,7 +1,8 @@ """Convert coordinates local to photos to global coordinates. -This script loads intrinsci camera matrix, which has been determined by + +This script loads intrinsic camera matrix, which has been determined by calibration using MATLAB. It reads YOLO's bounding boxes and calculate their -global 2D coordinate on the planting bed accodring to coordinate transform. +global 2D coordinate on the planting bed according to coordinate transform. """ import json @@ -17,21 +18,15 @@ from scipy.io import loadmat from farmbot_yolo import LOGDIR, REPO, TMPDIR """Logger for log file""" -_LOG = getLogger(__name__) +log = getLogger(__name__) -"""Type alias""" +"""Type aliases""" CameraPosition = Tuple[float, float, float] BoundingBox = Tuple[float, float, float, float] KMatrix = ndarray(shape=(3, 3)) -"""Allowed input and output file formats.""" -_CAM_EXTENSIONS = "mat" -_ANNOTATION_EXTENSIONS = "txt" -_LOCATIONS_EXTENSIONS = "txt" -_OFFSET_EXTENSIONS = "txt" - - -"""Constant sweeping height""" +# Constant sweeping height +# TODO: Expose to users SWEEP_Z = 575 # change according to the setting, z=-100, height is 57.5cm @@ -50,32 +45,14 @@ def read_offsets(offset_path: Path) -> Tuple[Tuple[float, float]]: distance of the camera centroid to z axis of Farmbot (dx, dy) & distance of the gripper centroid to z axis of Farmbot (dx, dy) """ - if not offset_path.is_file(): - _LOG.error("{} is not a file or does not exist".format(offset_path)) - return None - - if not offset_path.suffix.lstrip(".") in _OFFSET_EXTENSIONS: - _LOG.error( - "{} must have an legal\ - extension: {}".format( - offset_path, _OFFSET_EXTENSIONS - ) - ) - return None - - try: - with open(offset_path, "r") as f: - offsets = f.readlines() - except IOError: - _LOG.error("Unable to open input file {}.".format(offset_path)) - return None - - cam_offset = (int(offsets[1]), int(offsets[2])) - gripper_offset = (int(offsets[4]), int(offsets[5])) - _LOG.info( - "Load the gripper offset\n{}\n and the camera offset \n{}".format( - gripper_offset, cam_offset - ) + with offset_path.open(mode="r") as fp: + offsets = fp.readlines() + + cam_offset = (float(offsets[1]), float(offsets[2])) + gripper_offset = (float(offsets[4]), float(offsets[5])) + + log.info( + "Load the gripper offset: {gripper_offset} and the camera offset {cam_offset}" ) return cam_offset, gripper_offset @@ -93,28 +70,26 @@ def load_cam_matrix(cam_path: Path) -> Optional[ndarray]: intrinsic_matrix K matrix of the camera """ - if not cam_path.suffix.lstrip(".") == _CAM_EXTENSIONS: - _LOG.error("{} has an illegal extension".format(cam_path)) - return None - - try: - data = loadmat(cam_path) - except FileNotFoundError: - _LOG.error(" No such file") - return None + with cam_path.open(mode="r") as fp: + data = loadmat(fp) intrinsic_matrix = data["camera_no_distortion"][0, 0][11] - _LOG.info("Load intrinsic_matrix of the camera \n{}".format(intrinsic_matrix)) + log.info(f"Loaded intrinsic_matrix of the camera \n{intrinsic_matrix}") return intrinsic_matrix def cam_coordinate(pixel_x: int, pixel_y: int, cam_matrix) -> Tuple[float, float]: - """ - Project one object's pixel coordinate into the camera coordinate system + """Project one object's pixel coordinate into the camera coordinate system. + Parameters + ---------- Input: detection: a bounding box <x, y, w, h> - inner_matrix: matrix K that contains focal length and other inner parameters - Output: object's centroid location in camera coordinate + cam_matrix + Matrix K that contains focal length and other inner parameters. + + Returns + ------- + object's bounding box centroid coordinate in the camera coordinate system. """ normalized_coordinate = dot( inv(cam_matrix.transpose()), @@ -123,11 +98,8 @@ def cam_coordinate(pixel_x: int, pixel_y: int, cam_matrix) -> Tuple[float, float camera_coordinate = squeeze(normalized_coordinate) ratio = float(SWEEP_Z / camera_coordinate[2]) local_position = (ratio * camera_coordinate[0], ratio * camera_coordinate[1]) - _LOG.debug( - "Transfer from pixel coordinate to Camera coordinate. \n {}".format( - local_position - ) - ) + log.debug(f"Transfer from pixel coordinate to Camera coordinate. {local_position}") + return local_position @@ -159,25 +131,23 @@ def global_coordinate( x_camera, y_camera = coords_camera x_encoders, y_encoders = coords_encoders + # TODO: Expose to user x_delta_camera_gripper = 150 y_delta_camera_gripper = 15 - # TODO: Double check + # TODO: Test more x_global = x_camera + x_encoders + x_delta_camera_gripper y_global = y_camera + y_encoders + y_delta_camera_gripper - # 411 300 45 0 return x_global, y_global def cal_location(args: Namespace) -> ndarray: - """ - main function for this script - """ + """Main function for this script.""" cam_offset, gripper_offset = read_offsets(args.offset) K_matrix = load_cam_matrix(args.camera_matrix) - _LOG.info("Global coordinate calculation begins.") + log.info("Global coordinate calculation begins.") img_metadata_files = args.input_dir.glob("*.json") @@ -192,7 +162,7 @@ def cal_location(args: Namespace) -> ndarray: x_encoders = farmbot_metadata["location"]["x"] y_encoders = farmbot_metadata["location"]["y"] - _LOG.debug(f"Loaded detection annotations: {annotations}") + log.debug(f"Loaded detection annotations: {annotations}") updated_annotations = [] for annotation in annotations: diff --git a/src/farmbot_yolo/main.py b/src/farmbot_yolo/main.py index e1871366e771e3105c2c619fff147866cdb20678..7bd0bdc1a640ddecc9227461425ca5427f2c2c8b 100644 --- a/src/farmbot_yolo/main.py +++ b/src/farmbot_yolo/main.py @@ -1,23 +1,23 @@ -""" -Author: Ziliang -The main script of the project, it calls scripts for movement, detection, and coordinate calculation. +"""Demo script. +Calls scripts for movement, detection, and coordinate calculation. """ from argparse import ArgumentParser, Namespace -from logging import StringTemplateStyle -from os import listdir, remove -from os.path import join +from logging import getLogger from pathlib import Path -from typing import Tuple + from numpy import sqrt from pandas import DataFrame -from gripper import gripper_close, gripper_open -from farmbot_yolo.move import * -from farmbot_yolo.detect import * -from farmbot_yolo.location import * +from farmbot_yolo import TMPDIR +from farmbot_yolo.detect import detect +from farmbot_yolo.download import download_images +from farmbot_yolo.gripper import gripper_close, gripper_open +from farmbot_yolo.location import cal_location +from farmbot_yolo.move import simple_move +from farmbot_yolo.scan_bed import scan_bed -_LOG = getLogger(__name__) +log = getLogger(__name__) GRIP_Z = 468 # measure! SCAN_Z = 0 @@ -32,7 +32,8 @@ def remove_overlap(table_coordinate: DataFrame, tolerance=50.00) -> DataFrame: , delete the one with lower probability Choose a reasonable tolerance!! - :param table_coordinate: pandas dataframe that each row corresponds to a target [class, x, y, confidence] + :param table_coordinate: pandas data frame that each row corresponds to a + target [class, x, y, confidence] :param tolerance: a distance threshold """ num_coordinates, num_col = table_coordinate.shape @@ -52,34 +53,21 @@ def remove_overlap(table_coordinate: DataFrame, tolerance=50.00) -> DataFrame: return table_coordinate -def remove_temp(path: Path) -> None: - """ - Clean temporary files, i.e., photos, location.txt, annotations - """ - for filename in listdir(path): - file = Path(join(path, filename)) - if file.is_file(): - remove(file) - return - - def main(args: Namespace): - # clean temporary files - remove_temp(args.input) - remove_temp(args.locations) - remove_temp(args.annotations) # start from the origin simple_move(ORIGIN_X, ORIGIN_Y, ORIGIN_Z) - _LOG.info("Go back to the origin") + log.info("Go back to the origin") # scan - scan(args.photo, args.locations, flag=False) - _LOG.info("Scan the planting bed") + log.info("Scan the planting bed") + scan_bed() + log.info("Downloading images") + download_images(TMPDIR) # detect detect(args) - _LOG.info("Detection is done") + log.info("Detection is done") # calculate locations list_global_coordinate = cal_location(args) - _LOG.info("Global coordinate calculation is done.") + log.info("Global coordinate calculation is done.") # choose class table_global_coordinate = DataFrame( list_global_coordinate, columns=["class", "x", "y", "confidence"] @@ -90,10 +78,10 @@ def main(args: Namespace): goal_class = table_global_coordinate[ table_global_coordinate["class"] == args.category ] - _LOG.info("Choose {}".format(args.category)) - # if there is no desiered class of plants + log.info("Choose {}".format(args.category)) + # if there is no desired class of plants if goal_class.empty: - _LOG.info("There is no {}".format(args.category)) + log.info("There is no {}".format(args.category)) # move and grip num_goals, num_col = goal_class.shape for i in range(num_goals): @@ -102,7 +90,7 @@ def main(args: Namespace): open() gripper_open() # to make sure the gripper is open before gripping gripper_close() - # go back to the orgin + # go back to the origin simple_move(x, y, GRIP_Z, False) gripper_open() return @@ -111,46 +99,11 @@ def main(args: Namespace): if __name__ == "__main__": parser = ArgumentParser(description="YOLOv3 detection on Farmbot") # parsers for move - parser.add_argument( - "-p", - "--photo", - type=Path, - default="../img", - help="Mode for FarmBot, 1 for simple move with an assigned detination, 2 for scaning", - ) - # parsers for detect - parser.add_argument( - "--input", - type=str, - default="../img", - help="image source. It can be a single image, a" - "txt with paths to them, or a folder. Image valid" - " formats are jpg, jpeg or png." - "If no input is given, ", - ) - parser.add_argument( - "--batch_size", - default=1, - type=int, - help="number of images to be processed at the same time", - ) parser.add_argument( "--weights", default="../weights/yolov3-vattenhallen_best.weights", help="yolo weights path", ) - parser.add_argument( - "--ext_output", - action="store_true", - default=True, - help="display bbox coordinates of detected objects", - ) - parser.add_argument( - "--save_labels", - action="store_true", - default=True, - help="save detections bbox for each image in yolo format", - ) parser.add_argument( "--config_file", default="../cfg/yolov3-vattenhallen-test.cfg", @@ -165,7 +118,7 @@ if __name__ == "__main__": default=0.25, help="remove detections with lower confidence", ) - # arguemtns for grip + # arguments for grip parser.add_argument( "-ca", "--category", @@ -181,35 +134,6 @@ if __name__ == "__main__": default="../static/camera_no_distortion.mat", help="Path to mat file that contains intrinsic camera matrix K", ) - parser.add_argument( - "-loc", - "--locations", - type=Path, - default="../img/locations/", - help="the path to txt files contains locations from encoders corresponds to each photo", - ) - parser.add_argument( - "-a", - "--annotations", - type=Path, - default="../img/annotations", - help="the path to txt files contains annotations for each photo", - ) - parser.add_argument( - "-o", - "--offset", - type=Path, - default="../static/distance.txt", - help="the txt contains distance offset for camera and gripper", - ) - parser.add_argument( - "-l", "--log", type=Path, default="../log/main.log", help="Path to the log file" - ) - parser.add_argument("-v", "--verbose", action="store_true", help="Verbose mode.") args = parser.parse_args() - if args.verbose: - basicConfig(filename=args.log, level=DEBUG) - else: - basicConfig(filename=args.log, level=INFO) main(args) diff --git a/src/farmbot_yolo/move.py b/src/farmbot_yolo/move.py index f44058db6d4397250cdee05d5409eb4894f25dbb..2f5103d047da1ffb8513073b5d75367c4096ae55 100644 --- a/src/farmbot_yolo/move.py +++ b/src/farmbot_yolo/move.py @@ -1,129 +1,31 @@ -""" -Author: Ziliang Xiong -This script is for all the functions that drive Farmbot to Move, including: -1. Taking Photos 2. Move to an assigned point (x, y, z) -3. Sweep the planting bed 4. Grip a target -Note: it is for remote server, can ben replaced by a local script -""" -from argparse import ArgumentParser -from logging import getLogger -from os import path, makedirs, system -import sys -from time import sleep, strftime, time - -# from serial import Serial, PARITY_NONE, STOPBITS_ONE, EIGHTBITS -from requests.api import delete -from typing import List -from pathlib import Path -from logging import basicConfig, DEBUG, INFO, error, getLogger -from urllib import request +"""Move tool to coordinate.""" -from datetime import timezone, datetime -from dateutil.parser import parse -from requests import get, delete -from farmbot_yolo import LOGDIR, TMPDIR +from argparse import ArgumentParser -from farmbot_yolo import creds -import farmbot_yolo from farmbot_yolo.client import FarmbotClient -from farmbot_yolo.download import download_images - -_SWEEEP_HEIGHT = 0 +from farmbot_yolo.read_credentials import read_credentials -log = getLogger(__name__) +def simple_move(x: float, y: float, z: float) -> None: + """Move to coordinate -def scan( - img_dir: Path, - min_x=0, - max_x=1175, - min_y=0, - max_y=974, - delta=300, - offset=0, -) -> List: - """ - scan the bed at a certain height, first move along x axis, then y, like a zig zag; - Taking pictures and record the location of the camera that corresponds to the picture - The default value of x, y should be from the measurement of Farmbot - Input: min_x: left most point on x axis - max_x: right most point on x axis - min_y: front most point on y axis - max_y: back most point on y axis - delta: the interval for scaning - offset: - """ - pts = [] - sweep_y_negative = False - for x in range(min_x, max_x, delta): - y_range = range(min_y, max_y, delta) - if sweep_y_negative: - y_range = reversed(y_range) - sweep_y_negative = not sweep_y_negative - for y in y_range: - pts.append((x + offset, y + offset)) - - log.info("Moving pattern generated") - - client = FarmbotClient(creds.device_id, creds.token) - client.move(0, 0, _SWEEEP_HEIGHT) # ensure moving from original - for x, y in pts: - client.move(x, y, _SWEEEP_HEIGHT) # move camera - # take_photo(img_path) - client.take_photo() - client.shutdown() - - return pts - - -def simple_move(x: int, y: int, z: int) -> None: - """ - Move to a place, if flag is true, take a picture - Input: x, y,z: destination point - photo: take a pic or not + Parameters + ---------- + x + y + z """ creds = read_credentials() client = FarmbotClient(creds["device_id"], creds["token"]) client.move(x, y, z) client.shutdown() - return None if __name__ == "__main__": parser = ArgumentParser() - parser.add_argument( - "-m", - "--mode", - type=int, - help="Mode for FarmBot, 1 for simple move with an assigned detination, 2 for scanning", - ) - parser.add_argument( - "-l", - "--log", - type=Path, - default=LOGDIR / "move.log", - help="Path to the log file", - ) - parser.add_argument( - "-p", - "--photo_dir", - type=Path, - default=TMPDIR, - help="Directory to store photos.", - ) - parser.add_argument("-v", "--verbose", action="store_true", help="Verbose mode") + parser.add_argument("X", type=float, help="X coordinate of the target") + parser.add_argument("Y", type=float, help="Y coordinate of the target") + parser.add_argument("Z", type=float, help="Z coordinate of the target") args = parser.parse_args() - if args.mode == 1: - log.info("Input the destination:") - destination_x = int(input("X:")) - destination_y = int(input("Y:")) - destination_z = int(input("Z:")) - simple_move_start = time() - simple_move(destination_x, destination_y, destination_z) - log.info(f"time cost {time()-simple_move_start}") - elif args.mode == 2: - scan(args.photo_dir) - download_images(args.photo_dir) - else: - log.error("Wrong mode number {arguments.mode}") + simple_move(args.X, args.Y, args.Z) diff --git a/src/farmbot_yolo/read_credentials.py b/src/farmbot_yolo/read_credentials.py index 6a310c218fd6b02fc87482c77f9d2a750d0979d8..26f077ab21d4b85381d92e3081ed60659ba35681 100644 --- a/src/farmbot_yolo/read_credentials.py +++ b/src/farmbot_yolo/read_credentials.py @@ -3,9 +3,13 @@ from typing import Dict from farmbot_yolo import CREDS_PATH -def read_credentials() -> Dict[str]: + +def read_credentials() -> Dict[str, str]: if not CREDS_PATH.exists(): - raise RuntimeError("No creds.json found in project root. Run python -m farmbot_yolo.get_credentials.") + raise RuntimeError( + "No creds.json found in project root. " + + "Run python -m farmbot_yolo.request_token" + ) with CREDS_PATH.open(mode="r") as fp: creds_dict = json.load(fp) diff --git a/src/farmbot_yolo/request_token.py b/src/farmbot_yolo/request_token.py index 4c9e726a043afe02f6c2bfbdcd0b0a2373af3d63..5f71926ef21616ae943b117f05653ecf7332547b 100644 --- a/src/farmbot_yolo/request_token.py +++ b/src/farmbot_yolo/request_token.py @@ -1,33 +1,53 @@ -#!/usr/bin/env python3 +"""Request a token for my.farmbot.io""" -# request a token (for creds.py) - -import argparse import json -from urllib import request +from argparse import ArgumentParser +from getpass import getpass +from logging import getLogger + +import requests + +from farmbot_yolo import CREDS_PATH + +log = getLogger(__name__) + +ENDPOINT = "https://my.farmbot.io/api/tokens" + + +def request_token(email: str, password: str) -> None: + + auth_info = {"user": {"email": email, "password": password}} + + res = requests.post(ENDPOINT, json=auth_info) + + res_dict = res.json() + + log.debug(f"MQTT host: {res_dict['token']['unencoded']['mqtt']}") + + creds_dict = { + "user_id": res_dict["token"]["unencoded"]["bot"], + "token": res_dict["token"]["encoded"], + } -from farmbot_yolo import HERE + log.debug(f"Credentials from server: {creds_dict}") -parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) -parser.add_argument("--email", type=str, help="user email for token request") -parser.add_argument("--password", type=str, help="user password for token request") -opts = parser.parse_args() -print("opts %s" % opts) + with CREDS_PATH.open(mode="w") as fp: + json.dump(creds_dict, fp) -auth_info = {"user": {"email": opts.email, "password": opts.password}} -req = request.Request("https://my.farmbot.io/api/tokens") -req.add_header("Content-Type", "application/json") -response = request.urlopen(req, data=json.dumps(auth_info).encode("utf-8")) +if __name__ == "__main__": -token_info = json.loads(response.read().decode()) + parser = ArgumentParser() + parser.add_argument("--email", type=str, help="User email for token request") + args = parser.parse_args() -print("mqtt host [%s]" % token_info["token"]["unencoded"]["mqtt"]) + log.debug(f"Args: {args}") -print("rewriting creds.py") + if not args.email: + email = input("Username: ") + else: + email = args.email -creds_py_path = HERE / "creds.py" + password = getpass() -with creds_py_path.open(mode="w") as f: - f.write('device_id="%s"\n' % token_info["token"]["unencoded"]["bot"]) - f.write('token="%s"\n' % token_info["token"]["encoded"]) + request_token(email, password) diff --git a/src/farmbot_yolo/scan_bed.py b/src/farmbot_yolo/scan_bed.py new file mode 100644 index 0000000000000000000000000000000000000000..32fc245d4d3d05a84a2b32a85aea1fff5c2f35ee --- /dev/null +++ b/src/farmbot_yolo/scan_bed.py @@ -0,0 +1,80 @@ +from argparse import ArgumentParser +from typing import List + +from farmbot_yolo.client import FarmbotClient +from farmbot_yolo.read_credentials import read_credentials + +SCAN_Z_POS = 0 + + +def scan_bed( + min_x: float = 0, + max_x: float = 1175, + min_y: float = 0, + max_y: float = 974, + delta: float = 300, +) -> List: + """Scan the whole bed in a zig zag pattern. + The default value of x, y should be based on the bed measurements. + + Parameters + ---------- + min_x + The minimum x coordinate. + max_x + The maximum x coordinate. + min_y + The minimum y coordinate. + max_y + The maximum y coordinate. + delta + The distance between points. + + Returns + ------- + List + A list of points + """ + pts = [] + sweep_y_negative = False + + for x in range(min_x, max_x, delta): + y_range = range(min_y, max_y, delta) + if sweep_y_negative: + y_range = reversed(y_range) + sweep_y_negative = not sweep_y_negative + for y in y_range: + pts.append((x, y)) + + pts.append((x, y)) + + creds = read_credentials() + client = FarmbotClient(creds["device_id"], creds["token"]) + client.move(0, 0, SCAN_Z_POS) # move to home + for x, y in pts: + client.move(x, y, SCAN_Z_POS) # move camera + client.take_photo() + + return pts + + +if __name__ == "__main__": + parser = ArgumentParser() + parser.add_argument("start_x", type=float, help="Start X coordinate") + parser.add_argument("start_y", type=float, help="Start Y coordinate") + parser.add_argument("end_x", type=float, help="End X coordinate") + parser.add_argument("end_y", type=float, help="End Y coordinate") + parser.add_argument( + "delta", + type=float, + help="Distance between points", + ) + args = parser.parse_args() + + scan_bed( + min_x=args.start_x, + max_x=args.end_x, + min_y=args.start_y, + max_y=args.end_y, + delta=args.delta, + ) diff --git a/static/distance.txt b/static/distance.txt deleted file mode 100644 index ec6f0de0cdd5248d546b69b59205536ac78f9929..0000000000000000000000000000000000000000 --- a/static/distance.txt +++ /dev/null @@ -1,6 +0,0 @@ -camera's distance to the encoder --30 --130 -gripper's distance to the encoder -45 -0