diff --git a/.gitignore b/.gitignore index e53ff510047602c1f1616ceec5d2fc1397c8c77d..577d1422637de8a6406aedda5f7f1af3eec2f9a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ -*.pyc # Created by https://www.toptal.com/developers/gitignore/api/python # Edit at https://www.toptal.com/developers/gitignore?templates=python -weights/ - ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ @@ -143,6 +140,7 @@ dmypy.json # Cython debug symbols cython_debug/ -weights/yolov3-veges_best.weights -weights/yolov3-vattenhallen_best.weights -src/creds.py + +# custom +weights/ +creds.py diff --git a/Makefile b/Makefile index 22b2866bb53321933dc8b11f2a3d48c3517ce46f..8c613cd2285167d70ff8c28fedfa4d22848d3129 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,12 @@ .DEFAULT_GOAL := default LIBSO=1 -export LIBSO darknet: git submodule init git submodule update default: darknet - $(MAKE) -C darknet - -.PHONY: default + $(MAKE) LIBSO=$(LIBSO) -C darknet +.PHONY: default darknet diff --git a/img/WIN_20211103_21_16_18_Pro.jpg b/img/WIN_20211103_21_16_18_Pro.jpg deleted file mode 100644 index 27b585013a55f2853a29424ccfeb124cc719edb1..0000000000000000000000000000000000000000 Binary files a/img/WIN_20211103_21_16_18_Pro.jpg and /dev/null differ diff --git a/img/annotations/WIN_20211103_21_16_18_Pro.txt b/img/annotations/WIN_20211103_21_16_18_Pro.txt deleted file mode 100644 index 7fd6b5a6fdeeb230cbf138bde46f802ea1928095..0000000000000000000000000000000000000000 --- a/img/annotations/WIN_20211103_21_16_18_Pro.txt +++ /dev/null @@ -1,3 +0,0 @@ -3 1055.2865 148.5889 443.2753 73.4710 99.5100 -1 188.5453 22.3466 85.2053 49.0357 99.9200 -3 1048.2261 134.2752 271.3041 76.3464 99.9600 diff --git a/img/locations/WIN_20211101_17_19_37_Pro.txt b/img/locations/WIN_20211101_17_19_37_Pro.txt deleted file mode 100644 index fdd0b107edc822f4b6d0a84b83d0b5d910757b21..0000000000000000000000000000000000000000 --- a/img/locations/WIN_20211101_17_19_37_Pro.txt +++ /dev/null @@ -1 +0,0 @@ -350 700 -100 \ No newline at end of file diff --git a/img/readme.md b/img/readme.md deleted file mode 100644 index d6af6012b91c95a8c1a29b38ffad1f453ca7a0e9..0000000000000000000000000000000000000000 --- a/img/readme.md +++ /dev/null @@ -1,2 +0,0 @@ -This folder is to store all the photos of the planting bed, -and is supposed to be cleaned each time after detection. \ No newline at end of file diff --git a/src/client.py b/src/client.py deleted file mode 100755 index fbb44f6b886a2bb818f4b5f611af6797ee9789cc..0000000000000000000000000000000000000000 --- a/src/client.py +++ /dev/null @@ -1,141 +0,0 @@ -''' -Communicate with the server at the manufacturer -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 #日志模块 - -# values over max (and under min) will be clipped -MAX_X = 2400 -MAX_Y = 1200 -MAX_Z = 469 # TODO test this one! - -def coord(x, y, z): - return {"kind": "coordinate", "args": {"x": x, "y": y, "z": z}} # 返回json 嵌套对象 - -def move_request(x, y, z): - return {"kind": "rpc_request", # 返回 json对象,对象内含数组 - "args": {"label": ""}, - "body": [{"kind": "move_absolute", - "args": {"location": coord(x, y, z), - "offset": coord(0, 0, 0), - "speed": 100}}]} - -def take_photo_request(): - return {"kind": "rpc_request", - "args": {"label": ""}, #label空着是为了在blocking_request中填上uuid,唯一识别码 - "body": [{"kind": "take_photo", "args": {}}]} - -def clip(v, min_v, max_v): - if v < min_v: return min_v - if v > max_v: return max_v - return v - -class FarmbotClient(object): - - def __init__(self, device_id, token): - - self.device_id = device_id - self.client = mqtt.Client() # 类元素继承了另一个对象 - self.client.username_pw_set(self.device_id, token) #传入 用户名和密码 - 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) #前面的url要运行按README.md中request_token.py 后面俩是TCP Port, Websocket Port - self.client.loop_start() - # 初始化函数里就会连接到服务器上,所以每次实例化一个新的client时,就已经连上了 - - - def shutdown(self): - self.client.disconnect() - self.client.loop_stop() - - def move(self, x, y, z): - x = clip(x, 0, MAX_X) - y = clip(y, 0, MAX_Y) - z = clip(z, 0, MAX_Z) - status_ok = self._blocking_request(move_request(x, y, z)) # 发请求 - logging.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? - status_ok = self._blocking_request(take_photo_request()) - logging.info("TAKE_PHOTO [%s]", status_ok) - - def _blocking_request(self, request, retries_remaining=3): - if retries_remaining==0: - logging.error("< blocking request [%s] OUT OF RETRIES", request) #尝试3次,然后在日志中记录错误 - return False - - self._wait_for_connection() #在哪定义的? - - # 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) - - # send request off 发送请求 - self.rpc_status = None - self.client.publish("bot/" + self.device_id + "/from_clients", json.dumps(request)) - - # wait for response - timeout_counter = 600 # ~1min 等待1s - while self.rpc_status is None: #这个self.rpc_status 是应答的flag - time.sleep(0.1) - timeout_counter -= 1 - if timeout_counter == 0: - logging.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) - 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) - time.sleep(1) - return self._blocking_request(request, retries_remaining-1) - - # unexpected state (???) - msg = "unexpected rpc_status [%s]" % self.rpc_status - logging.error(msg) - raise Exception(msg) - - - def _wait_for_connection(self): - # TODO: better way to do all this async event driven rather than with polling :/ - timeout_counter = 600 # ~1min - while not self.connected: #用一个self.connected判断连上了没有,若没连上,等待 - time.sleep(0.1) - timeout_counter -= 1 - if timeout_counter == 0: - raise Exception("unable to connect") - - def _on_connect(self, client, userdata, flags, rc): - logging.debug("> _on_connect") - self.client.subscribe("bot/" + self.device_id + "/from_device") - self.connected = True - logging.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) - if msg.topic.endswith("/from_device") and resp['args']['label'] == self.pending_uuid: - self.rpc_status = resp['kind'] diff --git a/src/gripper.py b/src/gripper.py index d53b714fc353c24ca9f0435d3f0dc8262a09aa92..27b72215533b7f2414874aca6fb8068055dd86e7 100755 --- a/src/gripper.py +++ b/src/gripper.py @@ -1,11 +1,24 @@ -#!/usr/bin/env python3 -import serial +"""Functions to manipulate gripper. + +TODO: Integrate with FarmbotClient or FarmbotYoloClient +""" +from utils.client import FarmbotClient +from utils.creds import device_id +from utils.creds import token + +GRIPPER_PIN = 12 +GRIPPER_OPEN_STATE = 0 +GRIPPER_CLOSED_STATE = 1 + def gripper_open(): - ser = serial.Serial('/dev/ttyUSB0') - ser.write(str.encode("o")) - ser.close + """Manipulate gripper by setting pin to 1.""" + client = FarmbotClient(device_id, token) + client.write_pin(GRIPPER_PIN, GRIPPER_OPEN_STATE, pin_mode="digital") + client.shutdown() + def gripper_close(): - ser = serial.Serial('/dev/ttyUSB0') - ser.write(str.encode("c")) - ser.close \ No newline at end of file + """Manipulate gripper by setting pin to 0.""" + client = FarmbotClient(device_id, token) + client.write_pin(GRIPPER_PIN, GRIPPER_CLOSED_STATE, pin_mode="digital") + client.shutdown() diff --git a/src/main.py b/src/main.py index c68b26834577376f28ee6adbdeb8bf66f9ee69db..d719b2a8023d4f8d9747b4c0ec0a6f81bd68707e 100644 --- a/src/main.py +++ b/src/main.py @@ -48,7 +48,6 @@ 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 diff --git a/src/move.py b/src/move.py index 3768b918c06a894d58bd0311faac64012f0eed35..afa038ffd45ee74d472ff4473201a08e631858fc 100644 --- a/src/move.py +++ b/src/move.py @@ -8,6 +8,7 @@ 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 @@ -20,8 +21,8 @@ from datetime import timezone, datetime from dateutil.parser import parse from requests import get, delete -import creds -from client import FarmbotClient +import utils.creds as creds +from utils.client import FarmbotClient _SWEEEP_HEIGHT = 0 @@ -40,7 +41,7 @@ class Opts: def scan(img_path: Path, location_path: Path, # smaller delta - min_x=0, max_x=1300, min_y=0, max_y=1000, delta=1000, offset=0, flag=True) -> List: #里面的数字需要重新测量 + min_x=0, max_x=1175, min_y=0, max_y=974, delta=300, offset=0, flag=True) -> 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 @@ -76,7 +77,8 @@ def scan(img_path: Path, location_path: Path, # smaller delta 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) + #take_photo(img_path) + client.take_photo() client.shutdown() # write to img/location with open(path.join(location_path, "location.txt"), 'w') as f: @@ -85,14 +87,21 @@ def scan(img_path: Path, location_path: Path, # smaller delta return None -def take_photo(img_path: Path): - HERE = path.dirname(__file__) - IMG_DIR = path.join(HERE, img_path) +def take_photo(): + client = FarmbotClient(creds.device_id, creds.token) + client.take_photo() + # download image + system('python ./utils/download.py') + + +# def take_photo(img_path: Path): +# HERE = path.dirname(__file__) +# IMG_DIR = path.join(HERE, img_path) - with request.urlopen('http://localhost:8080/?action=snapshot') as photo: - filename = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + ".jpg" - with open(path.join(IMG_DIR, filename), mode="wb") as save_file: - save_file.write(photo.read()) +# with request.urlopen('http://localhost:8080/?action=snapshot') as photo: +# filename = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + ".jpg" +# with open(path.join(IMG_DIR, filename), mode="wb") as save_file: +# save_file.write(photo.read()) def simple_move(x: int, y: int, z: int) -> None: @@ -145,12 +154,14 @@ if __name__ == '__main__': destination_x = int(input('X:')) destination_y = int(input('Y:')) destination_z = int(input('Z:')) - photo = True if input('Take a photo or not?[Y/N]:') == 'Y' else False simple_move_start = time() - simple_move(destination_x, destination_y, destination_z, photo) + simple_move(destination_x, destination_y, destination_z) Logger.info(f'time cost {time()-simple_move_start}') elif arguments.mode == 2: scan(arguments.photo, arguments.locations, flag=False) + #take_photo(arguments.photo) + elif arguments.mode == 3: + take_photo() else: Logger.error('Wrong mode number {arguments.mode}') diff --git a/src/utils/client.py b/src/utils/client.py new file mode 100755 index 0000000000000000000000000000000000000000..296fcc467546b1e1da63d8072ff47207e96f3698 --- /dev/null +++ b/src/utils/client.py @@ -0,0 +1,245 @@ +"""Communicate with the server at the manufacturer. + +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 # 日志模块 + +# values over max (and under min) will be clipped +MAX_X = 2400 +MAX_Y = 1200 +MAX_Z = 469 # TODO test this one! + + +def coord(x, y, z): + return {"kind": "coordinate", "args": {"x": x, "y": y, "z": z}} # 返回json 嵌套对象 + + +def move_request(x, y, z): + return { + "kind": "rpc_request", # 返回 json对象,对象内含数组 + "args": {"label": ""}, + "body": [ + { + "kind": "move_absolute", + "args": { + "location": coord(x, y, z), + "offset": coord(0, 0, 0), + "speed": 100, + }, + } + ], + } + + +def read_pin_request(pin_number, pin_mode="digital"): + modes = {"digital": 0, "analog": 1} + + if pin_mode != "digital": + raise NotImplementedError() + + return { + "kind": "rpc_request", + "args": {"label": ""}, + "body": [ + { + "kind": "read_pin", + "args": { + "label": "pin" + str(pin_number), + "pin_mode": modes[pin_mode] or (modes["digital"]), + "pin_number": pin_number, + }, + } + ], + } + + +def write_pin_request(pin_number, pin_value, pin_mode="digital"): + modes = {"digital": 0, "analog": 1} + + if pin_mode != "digital": + raise NotImplementedError() + + return { + "kind": "rpc_request", + "args": {"label": ""}, + "body": [ + { + "kind": "write_pin", + "args": { + "pin_mode": modes[pin_mode] or (modes["digital"]), + "pin_number": pin_number, + "pin_value": pin_value, + }, + } + ], + } + + +def toggle_pin_request(pin_number): + return { + "kind": "rpc_request", + "args": {"label": ""}, + "body": [ + { + "kind": "toggle_pin", + "args": { + "pin_number": pin_number, + }, + } + ], + } + + +def take_photo_request(): + return { + "kind": "rpc_request", + "args": {"label": ""}, # label空着是为了在blocking_request中填上uuid,唯一识别码 + "body": [{"kind": "take_photo", "args": {}}], + } + + +def clip(v, min_v, max_v): + if v < min_v: + return min_v + if v > max_v: + return max_v + return v + + +class FarmbotClient(object): + def __init__(self, device_id, token): + + self.device_id = device_id + self.client = mqtt.Client() # 类元素继承了另一个对象 + self.client.username_pw_set(self.device_id, token) # 传入 用户名和密码 + 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 + ) # 前面的url要运行按README.md中request_token.py 后面俩是TCP Port, Websocket Port + self.client.loop_start() + # 初始化函数里就会连接到服务器上,所以每次实例化一个新的client时,就已经连上了 + + def shutdown(self): + self.client.disconnect() + self.client.loop_stop() + + def move(self, x, y, z): + x = clip(x, 0, MAX_X) + y = clip(y, 0, MAX_Y) + z = clip(z, 0, MAX_Z) + status_ok = self._blocking_request(move_request(x, y, z)) # 发请求 + logging.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? + status_ok = self._blocking_request(take_photo_request()) + logging.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}]") + + 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}]" + ) + + 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}]") + + def _blocking_request(self, request, retries_remaining=3): + if retries_remaining == 0: + logging.error( + "< blocking request [%s] OUT OF RETRIES", request + ) # 尝试3次,然后在日志中记录错误 + return False + + self._wait_for_connection() # 在哪定义的? + + # 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) + + # send request off 发送请求 + self.rpc_status = None + self.client.publish( + "bot/" + self.device_id + "/from_clients", json.dumps(request) + ) + + # wait for response + timeout_counter = 600 # ~1min 等待1s + while self.rpc_status is None: # 这个self.rpc_status 是应答的flag + time.sleep(0.1) + timeout_counter -= 1 + if timeout_counter == 0: + logging.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) + 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) + time.sleep(1) + return self._blocking_request(request, retries_remaining - 1) + + # unexpected state (???) + msg = "unexpected rpc_status [%s]" % self.rpc_status + logging.error(msg) + raise Exception(msg) + + def _wait_for_connection(self): + # TODO: better way to do all this async event driven rather than with polling :/ + timeout_counter = 600 # ~1min + while not self.connected: # 用一个self.connected判断连上了没有,若没连上,等待 + time.sleep(0.1) + timeout_counter -= 1 + if timeout_counter == 0: + raise Exception("unable to connect") + + def _on_connect(self, client, userdata, flags, rc): + logging.debug("> _on_connect") + self.client.subscribe("bot/" + self.device_id + "/from_device") + self.connected = True + logging.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) + if ( + msg.topic.endswith("/from_device") + and resp["args"]["label"] == self.pending_uuid + ): + self.rpc_status = resp["kind"] diff --git a/src/utils/download.py b/src/utils/download.py new file mode 100644 index 0000000000000000000000000000000000000000..67caace59ae348c9a5181e23e9d0faebee39516b --- /dev/null +++ b/src/utils/download.py @@ -0,0 +1,102 @@ +"""Download images from Farmbot instance.""" +import argparse +import os +from pathlib import Path +from typing import List + +import requests + +import utils.creds as creds + + +# note: download only returns 100 at a time! +# note: we are currently ignoreing placeholders + +DEBUG_SKIP_DELETE_FILES = True + +IMG_FILE_SUFFIX = ".jpg" + +REQUEST_HEADERS = { + "Authorization": "Bearer " + creds.token, + "content-type": "application/json", +} + + +def download_images(directory: os.PathLike, delete_after=False) -> List[str]: + """Download all images on server, optionally deleting after download. + + Parameters + ---------- + 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 + """ + 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}.") + + print(json_response) + print(f"Got a response containing {len(json_response)}") + + if len(json_response) < 1: + return [] + + img_paths = [] + + for img_dict in json_response: + if "placehold.it" in img_dict["attachment_url"]: + print("IGNORE! placeholder", img_dict["id"]) + continue + + server_path: str = img_dict["meta"]["name"] + + if not server_path.startswith("/tmp/images"): + print("meta name does not start with /tmp/images") + continue + + filename = Path(server_path).stem + IMG_FILE_SUFFIX + filepath = Path(directory) / filename + + print(">", filepath) + + # download image from google storage and save locally + if filepath.exists(): + print("File exists, skipping") + continue + + img_req = requests.get(img_dict["attachment_url"], allow_redirects=True) + + with filepath.open(mode="wb") as fp: + fp.write(img_req.content) + + img_paths.append(filepath) + + # post delete from cloud storage + if delete_after: + requests.delete( + f"https://my.farmbot.io/api/images/{img_dict['id']}", + headers=REQUEST_HEADERS, + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Farmbot YOLO image downloader") + parser.add_argument( + "download_dir", type=Path, help="Directory to download images too." + ) + parser.add_argument("-d", "--delete", action="store_true") + + args = parser.parse_args() + + download_images(args.download_dir, delete_after=args.delete) diff --git a/src/request_token.py b/src/utils/request_token.py similarity index 100% rename from src/request_token.py rename to src/utils/request_token.py