diff --git a/README.md b/README.md index 8fb60f0..1b75c75 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,3 @@ These scripts rely on the [CameraTraps Repo](https://github.com/microsoft/Camera # Processing Batch Images Run_batch_images.py can take in an input folder and apply the detector/classifier pipeline to render annotated images along with an output.json file in directory classifications. The output.json contains the final classifications on the cropped images and can be used to compare against a ground truth set. There is also an intermediate output.json which holds the crop detections and is used by the script to crop images, this one can be moved by configuration in the yml file. These scripts rely on the [CameraTraps Repo](https://github.com/microsoft/CameraTraps). Download this repo and place it anywhere. Add that path to config. The CameraTraps repo will then be added to the python sys environment variables upon loading an individual script. - -# Improvement Efforts -If you encounter any issues upon install or would like a feature added, create an issue in the issue tracker on this github and we will do our best to accomodate! Specifically we are looking to add new integrations for other types of cellular camera traps. diff --git a/config/fetch_and_alert.yml b/config/fetch_and_alert.yml index a414ff8..ae50894 100644 --- a/config/fetch_and_alert.yml +++ b/config/fetch_and_alert.yml @@ -1,9 +1,13 @@ #home_dir - path to local cougarvision repo home_dir: /home/johnsmith/cougarvision +#True to send detections to consumer emails email_alerts: True -er_alerts: True +#True to send detections to earthranger +# 1 = email alerts and 2 = earthranger alerts +email_alerts: True +er_alerts: True #detector_model - path to detector model -detector_model: /home/johnsmith/cougarvision/detector_models/megadetector.pt +detector_model: /home/johnsmith/cougarvision/detector_models/megadetector_v4.1.pb #classifier_model - path to classifier model classifier_model: /home/johnsmith/cougarvision/classifier_models/EfficientNetB5_456_Unfrozen_05_0.26_0.92.h5 checkpoint_frequency: -1 @@ -11,36 +15,46 @@ checkpoint_frequency: -1 log_dir: /home/johnsmith/cougarvision/logs/ #classes - path to the class list for the classifier model classes: /home/johnsmith/cougarvision/labels/sw_classes.txt +#Run version that allows for images cast to two screens? True/False +visualize_output: True +#for visualizing output, folder for cougarvision to put all images +path_to_unlabeled_output: /path/to/unlabeled/img/folder +#for visualizing output, folder for cougarvision detections with bounding boxes +path_to_labeled_output: /path/to/labeled/img/folder #the emails that will receive email alerts, can be multiple emails -consumer_emails: [] -# the amiul (s) you with to receive the developmemt email alert- contains confidence value of detection -dev_emails: [] +consumer_emails: [your@email.com] +#emails to recieve daily check-in and confidence of detections +dev_emails: [your@email.com] #threshold confidence score confidence: 0.7 #cpu threads threads: 8 -# Web Scraping #strike force api url strikeforce_api: https://api.strikeforcewireless.com/api/v2/ #strikeforce wireless username -username_scraper: [yourusername.cam@gmail.com] +username_scraper: yourusername.cam@gmail.com #strikeforce wireless password -password_scraper: [yourpassword] -#authorization token from Strike Force, can be obtained with login credentials and using strikeforceget.py in /cougarvision_utils folder -auth_token: [""] +password_scraper: yourpassword +#authorization token from Strike Force +auth_token: #save_dir - path to where the images get stored (must create folder) save_dir: /home/johnsmith/images/ -#dictionary containing the strikeforce unique ID with the actual 4 digit camera name, ID can be obtained using strikeforcegetcameras.py within the /cougarvision_utils folder -camera_names: {'': , ...} +#dict of camera names and their respective strikeforce wireless ids, these must be retrieved from +#strikeforce wireless web dev tools in the code and currently the cam names must be 4 chars long +camera_names: {'': , '': ...} #animals that the detector will send alerts for if detected (from class list) -alert_targets: [cougar, bobcat, skunk, deer, dog, coyote] +alert_targets: [cougar, bobcat, skunk, deer, dog, coyote, rabbit, elephant] #checkn_interval - time interval between still-alive emails to let us know no errors have crashed #cougarvision if it's running constantly checkin_interval: 24 -#email to send still-alive email to -admin: admin@email.com #x-csrf token retreived from earthranger interactive api example requests, must be logged into an #existing earthranger account with interactive api access -token: '' +token: [insert token] #authorization bearer token retreived from same earthranger interactive api example requests -authorization: 'Bearer ' +authorization: Bearer [insert authorization] +#the time between repeating running fetch_and_alert, if visualize_output = True, it will be in seconds and if it = False it will be in minutes +run_scheduler: 10 +#color of the bounding box +color: 'LawnGreen' +#color: 'Orange' +#color: 'Azure' diff --git a/cougarvision_utils/detect_img.py b/cougarvision_utils/detect_img.py index 892a36e..33d77c0 100644 --- a/cougarvision_utils/detect_img.py +++ b/cougarvision_utils/detect_img.py @@ -13,22 +13,25 @@ from datetime import datetime as dt import re import sys +import logging import yaml from PIL import Image -from animl import parse_results, classify, split +from animl import inference, split from sageranger import is_target, attach_image, post_event -from animl.detectMD import detect_MD_batch +from animl.detect import detect_MD_batch, parse_MD from cougarvision_utils.cropping import draw_bounding_box_on_image from cougarvision_utils.alert import smtp_setup, send_alert +from cougarvision_visualize.visualize_helper import get_last_file_number +from cougarvision_visualize.visualize_helper import create_folder with open("config/cameratraps.yml", 'r') as stream: - CAM_CONFIG = yaml.safe_load(stream) - sys.path.append(CAM_CONFIG['camera_traps_path']) + camera_traps_config = yaml.safe_load(stream) + sys.path.append(camera_traps_config['camera_traps_path']) -def detect(images, config, c_model, d_model): +def detect(images, config, c_model, classes, d_model): ''' This function takes in a dataframe of images and runs a detector model, classifies the species of interest, and sends alerts either to email or an @@ -41,12 +44,13 @@ def detect(images, config, c_model, d_model): config: the unpacked config values from fetch_and_alert.yml that contains necessary parameters the function needs ''' + # use_variation = int(config['use_variation']) email_alerts = bool(config['email_alerts']) er_alerts = bool(config['er_alerts']) log_dir = config['log_dir'] + class_list = config['classes'] checkpoint_f = config['checkpoint_frequency'] confidence = config['confidence'] - classes = config['classes'] targets = config['alert_targets'] username = config['username'] password = config['password'] @@ -55,6 +59,9 @@ def detect(images, config, c_model, d_model): host = 'imap.gmail.com' token = config['token'] authorization = config['authorization'] + color = config['color'] + visualize_output = config['visualize_output'] + labeled_img = config['path_to_labeled_output'] if len(images) > 0: # extract paths from dataframe image_paths = images[:, 2] @@ -64,29 +71,28 @@ def detect(images, config, c_model, d_model): checkpoint_path=None, confidence_threshold=confidence, checkpoint_frequency=checkpoint_f, - results=None, quiet=False, image_size=None) # Parse results - data_frame = parse_results.from_MD(results, None, None) + data_frame = parse_MD(results, None, None) # filter out all non animal detections if not data_frame.empty: - animal_df = split.getAnimals(data_frame) - other_df = split.getEmpty(data_frame) + animal_df = split.get_animals(data_frame) + otherdf = split.get_empty(data_frame) # run classifier on animal detections if there are any if not animal_df.empty: # create generator for images - predictions = classify.predict_species(animal_df, c_model, - batch=4) - # Parse results - max_df = parse_results.from_classifier(animal_df, - predictions, - classes, - None) - # Creates a data frame with all relevant data - cougars = max_df[max_df['prediction'].isin(targets)] + start = time.time() + predictions = inference.predict_species(animal_df.reset_index(drop=True), c_model, classes, file_col="file") + end = time.time() + cls_time = end - start + print("Time to classify: ") + print(cls_time) + logging.debug('Time to classify: ' + str(cls_time)) + # checks to see if predicted class is in targets + cougars = predictions[predictions['prediction'].isin(targets)] # drops all detections with confidence less than threshold - cougars = cougars[cougars['conf'] >= confidence] + cougars = cougars[cougars['confidence'] >= confidence] # reset dataframe index cougars = cougars.reset_index(drop=True) # create a row in the dataframe containing only the camera name @@ -96,8 +102,7 @@ def detect(images, config, c_model, d_model): for idx in range(len(cougars.index)): label = cougars.at[idx, 'prediction'] # uncomment this line to use conf value for dev email alert - prob = str(cougars.at[idx, 'conf']) - label = cougars.at[idx, 'class'] + prob = str(cougars.at[idx, 'confidence']) img = Image.open(cougars.at[idx, 'file']) draw_bounding_box_on_image(img, cougars.at[idx, 'bbox2'], @@ -115,6 +120,15 @@ def detect(images, config, c_model, d_model): image_bytes = BytesIO() img.save(image_bytes, format="JPEG") img_byte = image_bytes.getvalue() + if visualize_output is True: + folder_path = create_folder(labeled_img) + last_file_number = get_last_file_number(folder_path) + new_file_number = last_file_number + 1 + new_file_name = f"{folder_path}/image_{new_file_number}.jpg" + + with open(new_file_name, "wb") as folder: + folder.write(img_byte) + cam_name = cougars.at[idx, 'cam_name'] if label in targets and er_alerts is True: is_target(cam_name, token, authorization, label) @@ -129,7 +143,10 @@ def detect(images, config, c_model, d_model): token, authorization, label) - print(response) + logging.info(response) + + logging.info('Sending detection email') + if email_alerts is True: smtp_server = smtp_setup(username, password, host) dev = 0 diff --git a/cougarvision_utils/get_images.py b/cougarvision_utils/get_images.py index d5428a8..4126b8f 100644 --- a/cougarvision_utils/get_images.py +++ b/cougarvision_utils/get_images.py @@ -15,37 +15,39 @@ import json import urllib.request import os.path +import logging import requests import numpy as np -import logging +from cougarvision_visualize.visualize_helper import get_last_file_number +from cougarvision_visualize.visualize_helper import create_folder ''' -#request examples +request examples -#get list of camaras +get list of camaras request <- "cameras" parameters <- "" -#recent photo count +recent photo count request <- "photos/recent/count" parameters <- "" -#get recent photos across cameras +get recent photos across cameras request <- "photos/recent" parameters <- "limit=100" -#get photos from specific camera (will need to loop through pages) +get photos from specific camera (will need to loop through pages) request <- "photos" parameters <- "page=3&sort_date=desc&camera_id[]=59681" -#get photos from specific camera filtered by date (will need -# to loop through pages) +get photos from specific camera filtered by date (will need +to loop through pages) request <- "photos" parameters <- "page=1&sort_date=desc&camera_id[]= -#60272&date_start=2022-09-01&date_end=2022-10-07" +60272&date_start=2022-09-01&date_end=2022-10-07" -#get subscriptions +get subscriptions request <- "subscriptions" parameters <- "" ''' @@ -68,11 +70,21 @@ def request_strikeforce(username, auth_token, base, request, parameters): strikeforce ''' call = base + request + "?" + parameters - response = requests.get(call, headers={"X-User-Email": username, - "X-User-Token": auth_token}) - print(response.text) - info = json.loads(response.text) - return info + try: + logging.debug("Getting new Strikeforce image data from: " + username) + response = requests.get(call, headers={"X-User-Email": username, + "X-User-Token": auth_token}) + try: + info = json.loads(response.text) + return info + except json.decoder.JSONDecodeError: + logging.warning('An error occurred while decoding JSON') + info = 0 + return info + except requests.exceptions.ConnectionError: + logging.warning("Connection Error, max retries exceeded") + info = 0 + return info def fetch_image_api(config): @@ -91,12 +103,13 @@ def fetch_image_api(config): accounts = config['username_scraper'] tokens = config['auth_token'] path = "./last_id.txt" - password = config['password_scraper'] + visualize_output = config['visualize_output'] + unlabeled_img = config['path_to_unlabeled_output'] checkfile = os.path.exists(path) if checkfile is False: new_file = open("last_id.txt", "x") new_file.close() - first_id = str(0) # function to get the most recent id from sf) + first_id = str(0) # function to get the most recent id from sf) new_file = open('last_id.txt', 'w') new_file.writelines(first_id) new_file.close() @@ -112,6 +125,13 @@ def fetch_image_api(config): for account, token in zip(accounts, tokens): data = request_strikeforce(account, token, base, "photos/recent", "limit=12") + if data == 0: + new_photos = [] + logging.warning('Returning to main loop after failed http request') + error_message = "Warning: Failed http request, will retry " + error_message = error_message + "from main loop, check connection" + print(error_message) + return new_photos photos += data['photos']['data'] new_photos = [] @@ -121,10 +141,9 @@ def fetch_image_api(config): print(info) try: camera = camera_names[photos[i]['relationships'] - ['camera']['data']['id']] + ['camera']['data']['id']] except KeyError: - logging.warning('Cannot retrieve photo from camera\ - as there is no asssociated ID in the config file') + logging.warning('skipped img: no associated cam ID') continue newname = config['save_dir'] + camera newname += "_" + info['file_thumb_filename'] @@ -133,6 +152,15 @@ def fetch_image_api(config): new_photos.append([photos[i]['id'], info['file_thumb_url'], newname]) + if visualize_output is True: + file_path = create_folder(unlabeled_img) + newname = file_path + 'image' + new_file_num = get_last_file_number(file_path) + new_file_num = new_file_num + 1 + new_file_num = str(new_file_num) + newname += "_" + new_file_num + urllib.request.urlretrieve(info['file_thumb_url'], newname) + new_photos = np.array(new_photos) if len(new_photos) > 0: # update last image new_last = max(new_photos[:, 0]) diff --git a/cougarvision_utils/strikeforceget.py b/cougarvision_utils/strikeforceget.py deleted file mode 100644 index f1628d5..0000000 --- a/cougarvision_utils/strikeforceget.py +++ /dev/null @@ -1,19 +0,0 @@ -'''Script for getting strikeforce auth_token''' -import requests -import json - -username = "" -password = "" - - -base = "https://api.strikeforcewireless.com/api/v2/" -request = "users/sign-in/" -call = base + request -body = json.dumps({"user": {"email": username, "password": password}}) -encode = 'json' -response = requests.post(url=call, data=body, - headers={"Content-Type": "application/json"}) -response = response.text -response = json.loads(response) -authentication_token = response["meta"]["authentication_token"] -print(authentication_token) diff --git a/cougarvision_utils/strikeforcegetcameras.py b/cougarvision_utils/strikeforcegetcameras.py deleted file mode 100644 index 82c87cd..0000000 --- a/cougarvision_utils/strikeforcegetcameras.py +++ /dev/null @@ -1,36 +0,0 @@ -'''Script for obtaining strikeforce camera IDs''' -import requests -import json - - -def getData(base, request, parameters, username, authentication_token): - '''Function for retrieving camera info from strikeforce - Args: - base: strikeforce api link - request: cameras - parameters: none - username: strikeforce username email - authentication_token: auth token obtained from strikeforceget.py - - Returns: - json with all camera info''' - call = base + request + "?" + parameters - headers = {"X-User-Email": username, "X-User-Token": authentication_token} - response = requests.get(call, headers=headers) - data = response.text - return json.loads(data) - - -base = "https://api.strikeforcewireless.com/api/v2/" -request = "cameras" -parameters = "" -username = "" -authentication_token = "" - -data = getData(base, request, parameters, username, authentication_token) -pretty_json = json.dumps(data, indent=4) -cameras = [] -list_of_cam_info = data['data'] -for i in range(len(list_of_cam_info)): - cameras = list_of_cam_info[i]['id'] - print(cameras) diff --git a/cougarvision_visualize/display.py b/cougarvision_visualize/display.py new file mode 100644 index 0000000..19f8703 --- /dev/null +++ b/cougarvision_visualize/display.py @@ -0,0 +1,157 @@ +'''CougarVision Visualize Output +This script is intended to be run alongside fetch_and_alert.py in a second +terminal or tmux terminal. It displays the most recent classified detections +on a 3x3 grid on one screen and most recent images on a second 9x9 grid. + - cougarvision conda environment must be activated + - fetch_and_alert.py must be run with visualize_output: param set to 'True' + - path_to_unlabeled_output: and path_to_labeled_output: parameters filled + out in the config file. + - the command line argument to run is python3 display.py + + *fetch_and_alert.py will create the folders for you + if you only include the paths but the folders are not yet created. +This script assumes 2 monitors and will display a blank screen on either +display if the minimum number of images is not met, 9 for screen 1 and 81 +for screen 2. Later versions will account for this and still display images, +but for now if that is an issue you can fill the folder with black images with +the correct nomenclature: image_1.jpg, image_2.jpg... and it will replace the +black images as they come in. + +''' + + +import os +import time +import argparse +import numpy as np +import yaml +import cv2 +from screeninfo import get_monitors + + +def get_screen_resolutions(): + '''Function to get the screen resolutions for both monitors''' + monitors = get_monitors() + resolutions = [(monitor.width, monitor.height) for monitor in monitors] + return resolutions + + +def get_newest_images(f_p, num_images): + '''Function to return the newest x num of images from folder''' + fil = [f for f in os.listdir(f_p) if os.path.isfile(os.path.join(f_p, f))] + + if not fil: + return [] + + def sort_key_func(file_name): + try: + return int(os.path.splitext(file_name.split('_')[1])[0]) + except ValueError: + return float('-inf') + + fil.sort(key=sort_key_func, reverse=True) + newest_files = fil[:num_images] + images = [cv2.imread(os.path.join(f_p, file)) for file in newest_files] + images = [img for img in images if img is not None] + return images + + +def display_images(images, window_name='CougarVision'): + '''Function to display labeled 9 recent images in 3x3 grid''' + resolutions = get_screen_resolutions() + screen_height = resolutions[0][1] + screen_width = resolutions[0][0] + + num_images_row = 3 + num_images_col = 3 + + max_w_image = screen_width // num_images_row + max_h_image = screen_height // num_images_col + + display_img = np.zeros((screen_height, screen_width, 3), np.uint8) + + for i, img in enumerate(images): + if img is not None: + x_offset = (i % num_images_row) * max_w_image + y_offset = (i // num_images_row) * max_h_image + + resized_image = cv2.resize(img, (max_w_image, max_h_image)) + + y_slice = slice(y_offset, y_offset + max_h_image) + x_slice = slice(x_offset, x_offset + max_w_image) + display_img[y_slice, x_slice] = resized_image + + cv2.imshow(window_name, display_img) + + +def display_more_images(images, window_2='Newest Image'): + '''Function to display the 81 unlabeled images on 2nd monitor 9x9''' + resolutions = get_screen_resolutions() + screen_height = resolutions[1][1] + screen_width = resolutions[1][0] + + num_images_row = 9 + num_images_col = 9 + + max_w_image = screen_width // num_images_row + max_h_image = screen_height // num_images_col + + display_img = np.zeros((screen_height, screen_width, 3), np.uint8) + + for i, img in enumerate(images): + if img is not None: + x_offset = (i % num_images_row) * max_w_image + y_offset = (i // num_images_row) * max_h_image + + resized_image = cv2.resize(img, (max_w_image, max_h_image)) + + y_slice = slice(y_offset, y_offset + max_h_image) + x_slice = slice(x_offset, x_offset + max_w_image) + display_img[y_slice, x_slice] = resized_image + + cv2.imshow(window_2, display_img) + + +if __name__ == "__main__": + WINDOW_NAME = 'CougarVision' + WINDOW_2 = "Newest Image" + + RESOLUTIONS = get_screen_resolutions() + + cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_NORMAL) + cv2.namedWindow(WINDOW_2, cv2.WINDOW_NORMAL) + + cv2.moveWindow(WINDOW_NAME, 0, 0) + cv2.moveWindow(WINDOW_2, RESOLUTIONS[0][0], 0) + + cv2.setWindowProperty(WINDOW_NAME, cv2.WND_PROP_FULLSCREEN, + cv2.WINDOW_FULLSCREEN) + cv2.setWindowProperty(WINDOW_2, cv2.WND_PROP_FULLSCREEN, + cv2.WINDOW_FULLSCREEN) + + PARSER = argparse.ArgumentParser(description='Retrieves images from \ + email & web scraper & runs detection') + PARSER.add_argument('config', type=str, help='Path to config file') + ARGS = PARSER.parse_args() + CONFIG_FILE = ARGS.config + + with open(CONFIG_FILE, 'r', encoding='utf-8') as stream: + CONFIG = yaml.safe_load(stream) + LABELED = CONFIG['path_to_labeled_output'] + UNLABELED = CONFIG['path_to_unlabeled_output'] + + while True: + NEW_IMG = get_newest_images(LABELED, 9) + if len(NEW_IMG) >= 9: + display_images(NEW_IMG, WINDOW_NAME) + + NEWER_IMG = get_newest_images(UNLABELED, 81) + if len(NEWER_IMG) >= 81: + display_more_images(NEWER_IMG, WINDOW_2) + + time.sleep(1) + + if cv2.waitKey(1) & 0xFF == ord('q'): + break + + cv2.destroyAllWindows() diff --git a/cougarvision_visualize/visualize_helper.py b/cougarvision_visualize/visualize_helper.py new file mode 100644 index 0000000..5b8c8d8 --- /dev/null +++ b/cougarvision_visualize/visualize_helper.py @@ -0,0 +1,25 @@ +import os +import re + + +def get_last_file_number(folder_path): + max_num = 0 + for filename in os.listdir(folder_path): + # Extract digits from the filename using regex + num = re.findall(r'\d+', filename) + if num: # If there are digits in the filename + max_num = max(max_num, int(num[-1])) # Use the last set of digits as the number + return max_num + + +def create_folder(folder_path): + """ + Check if a folder exists at the specified path, and create it if it doesn't. + + folder_path: The path of the folder to check and potentially create. + return: path + """ + if not os.path.exists(folder_path): + os.makedirs(folder_path) + return folder_path + diff --git a/fetch_and_alert.py b/fetch_and_alert.py index 9798d74..f2d5d1f 100644 --- a/fetch_and_alert.py +++ b/fetch_and_alert.py @@ -26,11 +26,12 @@ import yaml import schedule + from cougarvision_utils.detect_img import detect from cougarvision_utils.alert import checkin from cougarvision_utils.get_images import fetch_image_api -from sageranger.post_monthly import post_monthly_obs -from animl.classify import load_classifier +# from sageranger.post_monthly import post_monthly_obs +from animl.classifiers import load_model from animl import megadetector @@ -54,19 +55,35 @@ DETECTOR = CONFIG['detector_model'] DEV_EMAILS = CONFIG['dev_emails'] HOST = 'imap.gmail.com' +RUN_SCHEDULER = CONFIG['run_scheduler'] +VISUALIZE_OUTPUT = CONFIG['visualize_output'] +LABELS = CONFIG['classes'] + + +def logger(): + '''Function to define logging file parameters''' + msg_intro = "%(levelname)s:%(asctime)s:%(module)s:%(funcName)s:" + msg_intro = msg_intro + " %(message)s" + logging.basicConfig(filename='cougarvision.log', + format=msg_intro, + level=logging.INFO, + force=True) + + +# Initialize logger now because it will protect against +# handlers that get created when classifer and detector are intialized +logger() # Set interval for checking in CHECKIN_INTERVAL = CONFIG['checkin_interval'] - +print("Loading classifier") # load models once -CLASSIFIER_MODEL = load_classifier(CLASSIFIER) +CLASSIFIER_MODEL, CLASSES = load_model(CLASSIFIER, LABELS) +print("Finished loading classifier") +print("Begin loading detector") DETECTOR_MODEL = megadetector.MegaDetector(DETECTOR) - - -def logger(): - '''Function for creating log file''' - logging.basicConfig(filename='cougarvision.log', level=logging.INFO) +print("Finished loading detector") def fetch_detect_alert(): @@ -77,19 +94,26 @@ def fetch_detect_alert(): images = fetch_image_api(CONFIG) print('Finished fetching images') print('Starting Detection') - detect(images, CONFIG, CLASSIFIER_MODEL, DETECTOR_MODEL) + + for i in images: + detect(i, CONFIG, CLASSIFIER_MODEL, CLASSES, DETECTOR_MODEL) + print('Finished Detection') print("Sleeping since: " + str(dt.now())) def main(): ''''Runs main program and schedules future runs''' - logger() fetch_detect_alert() - schedule.every(10).minutes.do(fetch_detect_alert) + + if VISUALIZE_OUTPUT is True: + schedule.every(RUN_SCHEDULER).seconds.do(fetch_detect_alert) + else: + schedule.every(RUN_SCHEDULER).minutes.do(fetch_detect_alert) schedule.every(CHECKIN_INTERVAL).hours.do(checkin, DEV_EMAILS, USERNAME, PASSWORD, HOST) - schedule.every(30).days.do(post_monthly_obs, TOKEN, AUTH) + # schedule.every(30).days.do(post_monthly_obs, TOKEN, AUTH) + while True: schedule.run_pending()