#import Process
import pickle
import os
import cv2
import sys
import copy
sys.path.append(os.getcwd())
import numpy as np

os.environ['MKL_THREADING_LAYER'] = 'GNU'
os.environ['MKL_SERVICE_FORCE_INTEL'] = '1'
def kj_feature(bbox, image):
    print('bbox con:',bbox)
    x1, y1, x2, y2 = bbox
    imagex, imagey = image
    newa = [x1 / imagex, y1 / imagey, x2 / imagex, y2 / imagey, (x2 - x1) / imagex, (y2 - y1) / imagey]
    return newa

def genimage_feature(img_path, bbox_list):
    os.system('rm -rf ./tools/mini_tsv/images/*')
    os.system('rm -rf ./imagebbox.npy')
    os.system('rm -rf ./imagesize.npy')
    os.system('rm -rf ./featureout.npy')
    os.system('rm -rf ./output/X152C5_test/inference/vinvl_vg_x152c4/predictions.*')
    os.system('rm -rf ./tools/mini_tsv/visualgenome/train.*')

    # subprocess.run(f'cp "{img_path}" "./tools/mini_tsv/"')
    os.system(f'cp {img_path} ./tools/mini_tsv/images')
    os.system('python ./tools/mini_tsv/tsv_demo.py')

    tmp = [0, 0]
    size = list(cv2.imread(img_path).shape[:2][::-1])
    tmp.extend(size)
    bbox_list.insert(0, tmp)
   # print("bbox_list",size,bbox_list)
    with open("log.txt",'a') as tf:
    #     line = "image_features shape" + str(new_image_feaures.shape)
     #    tf.writelines(line)
         line = "start\r\n"
         tf.writelines(line)
         line = "bbox_listtest" + str(size) + str(np.array(bbox_list).shape) + "\r\n"
         tf.writelines(line)
    #sys.exit(1)
    np.save("imagebbox.npy", bbox_list)
    # size = tuple(bbox_list[0][-2:])
    np.save("imagesize.npy", size)
    #subprocess.run('python tools/test_sg_net.py --config-file sgg_configs/vgattr/vinvl_x152c4.yaml TEST.IMS_PER_BATCH 2 MODEL.WEIGHT pretrained_model/vinvl_vg_x152c4.pth MODEL.ROI_HEADS.NMS_FILTER 1 MODEL.ROI_HEADS.SCORE_THRESH 0.2 DATA_DIR "./tools/mini_tsv/" TEST.IGNORE_BOX_REGRESSION True MODEL.ATTRIBUTE_ON True TEST.OUTPUT_FEATURE True')
    r = os.popen("./run.sh")
    text = r.read()
    r.close()

    #os.system('./run.sh')
    #Process.run('./run.sh')
#    cmdline = "python tools/test_sg_net.py --config-file sgg_configs/vgattr/vinvl_x152c4.yaml TEST.IMS_PER_BATCH 2 MODEL.WEIGHT pretrained_model/vinvl_vg_x152c4.pth MODEL.ROI_HEADS.NMS_FILTER 1 MODEL.ROI_HEADS.SCORE_THRESH 0.2 DATA_DIR ./tools/mini_tsv/ TEST.IGNORE_BOX_REGRESSION True MODEL.ATTRIBUTE_ON True TEST.OUTPUT_FEATURE True"
 #   print(cmdline)
 #   os.system(cmdline)
    #subprocess.run(['python', 'tools/test_sg_net.py', '--config-file', 'sgg_configs/vgattr/vinvl_x152c4.yaml', 'TEST.IMS_PER_BATCH', 2, 'MODEL.WEIGHT', 'pretrained_model/vinvl_vg_x152c4.pth', 'MODEL.ROI_HEADS.NMS_FILTER', 1, 'MODEL.ROI_HEADS.SCORE_THRESH', '0.2', 'DATA_DIR', "./tools/mini_tsv/", 'TEST.IGNORE_BOX_REGRESSION', 'True', 'MODEL.ATTRIBUTE_ON', 'True', 'TEST.OUTPUT_FEATURE', 'True'])

    image_features = np.load('./featureout.npy')
    #print("end bbox list",bbox_list)
    new_image_features = []
    for num, image_feature in enumerate(image_features):
        spatial_feature = kj_feature(bbox_list[num], size)
        image_feature = np.append(image_feature, spatial_feature)
        new_image_features.append(image_feature)
    new_image_features = np.array(new_image_features)
    _, img_name = os.path.split(img_path)
    with open("log.txt",'a') as tf:
         line = "end\r\n"
         tf.writelines(line)
         line = "image_features shape" + str(new_image_features.shape) + "\r\n"
         tf.writelines(line)
         line = "bbox_list shape" +  str(np.array(bbox_list).shape) + "\r\n"
         tf.writelines(line)
         line = "======end\r\n"
         tf.writelines(line)

    print("image_features shape",new_image_features.shape,np.array(bbox_list).shape)
    #picklefile = './img_feature.bin'
    #with open(picklefile, 'ab') as f:
    #    pickle.dump({'img_name': img_name, 'bbox_list': bbox_list, 'img_feature': new_image_features}, f)
    return new_image_features

image_featurea={}
image_features=[]
image_featurea['img_name'] = ""
image_featurea['bbox_list'] = ""
image_featurea['img_feature'] = ""

def find_imagekey(img_path, bbox_list):
  global image_features
  #print("img_path, bbox_list",img_path, bbox_list)
  for i in range(len(image_features)):
     #print("image_features",image_features[i]['img_name'],image_features[i]['bbox_list'])
     if image_features[i]['img_name'] == img_path and image_features[i]['bbox_list'] == bbox_list:
          return i
  return None

def readimagefrombin_feature_old(img_path, bbox_list):
    # img_dir = '/inputs'
    # img_path = os.path.join(img_dir, imagename)
    #picklefile = './img_feature.bin'
    global image_features
    global image_featurea
    image_featureb = {}
    #if os.path.exists(picklefile):
    index = find_imagekey(img_path, bbox_list)
    if index is not None:
        return image_features[index]['img_feature']
    else:
        print("don't find the index")
        #return
        bbox_list_new_1 = copy.deepcopy(bbox_list)
        bbox_list_new_2 = copy.deepcopy(bbox_list)
        image_featureb['img_name']  = img_path
        image_featureb['bbox_list'] = bbox_list_new_1
        feature = genimage_feature(img_path, bbox_list_new_2)
        image_featureb['img_feature'] = feature
        image_features.append(image_featureb)
        return feature

def readimage_feature(img_path, bbox_list):
    global image_features
    global image_feature
    # img_dir = '/inputs'
    # img_path = os.path.join(img_dir, imagename)
    #picklefile = './img_feature.bin'
    #if os.path.exists(picklefile):
    if image_feature['img_name'] == img_path and image_feature['bbox_list'] == bbox_list:
        print("find old one")
        return image_feature['img_feature']
    else:
        return
        image_feature['img_name']  = img_path
        image_feature['bbox_list'] = bbox_list

        #feature = genimage_feature(img_path, bbox_list)
        image_feature['img_feature'] = feature
        image_features.append(image_feature)
        return feature

def init_imagef(startid):
#    return
    global image_features
    filename = "image" + str(startid) + ".bin"
    if os.path.exists(filename):
           print("find the features file")
           with open(filename, 'rb') as f:
                 image_features = pickle.load(f)
def save_imagef(startid):
    return
    global image_features
    filename = "image" + str(startid) + ".bin"
    with open(filename, 'wb') as f:
        pickle.dump(image_features, f)

if __name__ == '__main__':
	# global image_features
#    with open("test.bin", 'rb') as f:
#       image_features = pickle.load(f)
	#print("len of image_features",len(image_features))
     #   print("image_features",image_features[0]['img_name'],image_features[0]['bbox_list'])
    readimagefrombin_feature('./cloth_store_1416238_woman_10_0.png', [[23,33,100,100],[55,34,300,300]])
    readimagefrombin_feature('./cloth_store_1416238_woman_10_0.png', [[24,45,100,100],[55,34,300,300]])
    print(image_features)
    with open("test.bin", 'wb') as f:
        pickle.dump(image_features, f)


#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Created on Tue Mar  5 17:29:21 2019

@author: cyril
"""
from PIL import Image #PIL

import random
import pyautogui
import time
from scipy.stats import beta
from Button import Button
from Table import Table
from DealerButton import DealerButton
from Card import Card
from Box import Box
import numpy as np
import sys
from extra_functions import itemExists, computeBoxAngle
import os
from functools import reduce

from Player import Player
from NumberContainer import NumberContainer
from Number import Number

import constants
import glob_file

def initializeTable():
    table_found = False
    while(not(table_found)):
        print('Looking for table')
        table_img = screenTable(library='xlib')
        glob_file.table.update(table_img)
        if(not(glob_file.table.never_spotted)):
            table_found=True
            glob_file.dealer_button.set_relevant_box(glob_file.table.box)
            glob_file.dealer_button.set_table_center(glob_file.table.center_pos)
            print('Found and initialized table at position: '+str(glob_file.table.center_pos))
        else:
            time.sleep(1)

    #num_container = NumberContainer(id_='Number_container', image_file=None, detection_confidence = constants.NUMBERS_BET_DET_CONFIDENCE)
    #num_container.search(table_img)
    return

def updateTableState():

    #new table scan
    print('\n### New screen Scan ###')
    table_img = screenTable(library='xlib')

    unique_screen_items = [glob_file.fast_fold, glob_file.fold, glob_file.check, glob_file.call,
        glob_file.bet, glob_file.raise_to, glob_file.bet_sizer, glob_file.bet_value_box, glob_file.dealer_button]

    #Search or update all buttons
    for screen_item in unique_screen_items:
        screen_item.update(table_img)

    #Search for or update all cards
    if (not(glob_file.all_cards_found)):
        searchAllOpenCards(table_img)
    else:
        for card in glob_file.cards:
            card.update(table_img)
    printCardsInfo()

    #Search for or update all players
    if(not(glob_file.all_players_found)):
        searchAllPlayers(table_img)
    else:
        for player in glob_file.players:
            player.update(table_img)
    glob_file.players[0].set_availability(availability = isHeroAvailable())
    printPlayersInfo()

    searchAllBets(table_img)
    printBetsInfo()

    return

def searchAllOpenCards(table_img):

    glob_file.all_cards_found = False
    glob_file.cards=[]

    card_colors = ['heart','diamond','club','spade']
    known_positions=[]
    for i, card_color in enumerate(card_colors):
        if(True):
            for j, box in enumerate(pyautogui.locateAll(constants.CARD_PATH+card_color+'.png', table_img, confidence=constants.CARD_DET_CONFIDENCE)):
                known=False
                for known_position in known_positions:
                    #avoid spotting twice the same
                    if((box.left>(known_position["left"]-box.width/2) and box.left<(known_position["left"]+box.width/2)) and (box.top>(known_position["top"]-box.height/4) and box.top<(known_position["top"]+box.height/4))): #
                        known=True
                        break

                if(not(known)):
                    known_positions.append({'left':box.left,'top':box.top})
                    new_card =Card(id_='card_'+card_color+'_'+str(j), color = card_color[0], box= box, table_img = table_img)
                    glob_file.cards.append(new_card)
        else:
            pass

    glob_file.cards.sort(key=lambda x: x.box.left)
    glob_file.cards.sort(key=lambda x: x.isHeroCard, reverse=True)

    #print("There are : "+str(len(cards))+ " cards")
    if(len(glob_file.cards)>=2):
        if(glob_file.cards[0].box.left>glob_file.cards[1].box.left):
            card_0, card_1 = glob_file.cards[1], glob_file.cards[0]
            glob_file.cards[1], glob_file.cards[0] = card_1, card_0
    if(len(glob_file.cards)==7):
        #All cards were found, creating classes for speedup
        glob_file.all_cards_found = True
        for i,card in enumerate(glob_file.cards):
            card.setId(i)
        print("# All cards found and classes created #")
    if(len(glob_file.cards)>7):
        print("[Warning] Too many cards spotted!")

    return

def searchAllPlayers(table_img):
    glob_file.all_players_found=False
    glob_file.players = []
    player_boxes=[]

    known_positions=[]

    #players_found=0
    for file in os.listdir(constants.PLAYER_IMAGE_PATH):
        for j, box in enumerate(pyautogui.locateAll(constants.PLAYER_IMAGE_PATH+file, table_img, confidence=constants.PLAYER_DET_CONFIDENCE)):
            #print(box)
            known=False
            for known_position in known_positions:
                #avoid spotting twice the same
                if((box.left>(known_position["left"]-10) and box.left<(known_position["left"]+10)) and (box.top>(known_position["top"]-10) and box.top<(known_position["top"]+10))): #
                    known=True
                    break

            if(not(known)):
                player_boxes.append(box)
                known_positions.append({'left':box.left,'top':box.top})

    if(len(player_boxes)==constants.NB_PLAYERS):
        glob_file.all_players_found = True
        print("# All players found and classes created #")

    if(len(player_boxes)<=constants.NB_PLAYERS):
        print(str(len(player_boxes))+" players found")
        #for i in range(constants.NB_PLAYERS-len(player_boxes)):
        #    player_boxes.append(Box(0,0,0,0))
    #elif len(player_boxes)==constants.NB_PLAYERS:

        player_boxes.sort(key=lambda box: computeBoxAngle(box, glob_file.table.center_pos), reverse = True)
       #player_boxes.sort(key=lambda box: box.top, reverse=True)
       # player_2, player_3,player_5 = player_boxes[3], player_boxes[5], player_boxes[2]
       # player_boxes[2], player_boxes[3], player_boxes[5] = player_2, player_3, player_5
        for i, box in enumerate(player_boxes):
            #computeBoxAngle(box, glob_file.table.center_pos)
            glob_file.players.append(Player(id_=i, image_file_path = constants.HOLE_CARDS_IMAGE,
            detection_confidence= constants.HOLE_CARDS_DET_CONFIDENCE, player_box=box, table_img=table_img))

    else:
        print("[Warning] Found too many players")
        pass
    return

def searchAllBets(table_img):
    glob_file.all_bet_containers_found=False
    glob_file.bet_containers = []
    numbers_list = []
    for player in glob_file.players: player.bet_value = 0

    table_img_portion = table_img #table_img.crop((self.box.left-100,self.box.top-100,self.box.left+self.box.width+100,self.box.top+self.box.height+100))
    try:
        #Attempt to locate bet
        for i, file in enumerate(range(10)): #os.listdir(constants.NUMBERS_BET_PATH)
            file = str(i)+'.png'
            for j, box in enumerate(pyautogui.locateAll(constants.NUMBERS_BET_PATH+file, table_img_portion, confidence=constants.NUMBERS_BET_DET_CONFIDENCE)):
                if(box!=None):
                    numbers_list.append(Number(value=i, box=box))
                else:
                    pass
    except:
        pass
    numbers_list.sort(key=lambda number: number.left)
    numbers_list.sort(key=lambda number: number.top)
    #print([number.value for number in numbers_list])

    bet_container_id = 0
    for i in range(len(numbers_list)):
        if(i==0):
            number = numbers_list[i]
            glob_file.bet_containers.append(NumberContainer(id_=bet_container_id, type = 'BET'))
            glob_file.bet_containers[bet_container_id].addNumber(number)
        else:
            previous_number =  numbers_list[i-1]
            number = numbers_list[i]

            if (previous_number.left)<=number.left and (previous_number.left+20)>=number.left and previous_number.top==number.top:
                glob_file.bet_containers[bet_container_id].addNumber(number)
            else:
                bet_container_id+=1
                glob_file.bet_containers.append(NumberContainer(id_=bet_container_id, type = 'BET'))
                glob_file.bet_containers[bet_container_id].addNumber(number)

    #print(glob_file.players[0].center_pos)
    for bet_container in glob_file.bet_containers:
        bet_container.computeValue()
        #print(bet_container.value)
        bet_container.attributeEntity(glob_file.players, glob_file.table.center_pos)
        #if bet_container.corresponding_entity!='POT':
        #    glob_file.players[bet_container.corresponding_entity].bet_value = bet_container.value

    glob_file.bet_containers.sort(key=lambda x: -1 if x.corresponding_entity == 'POT' else x.corresponding_entity)

    if(len(glob_file.bet_containers)==constants.NB_PLAYERS):
        glob_file.all_bet_containers_found = True

    return

def printCardsInfo():
    #still not found all cards
    if(len(glob_file.cards)<=6):
        if(len(glob_file.cards)>=2):
            print("-> Hero has: "+glob_file.cards[0].value+ glob_file.cards[0].color+ ", "+glob_file.cards[1].value+ glob_file.cards[1].color+ "")
        if(len(glob_file.cards)>=5):
            print("Flop is: "+glob_file.cards[2].value+ glob_file.cards[2].color+", "+glob_file.cards[3].value+ glob_file.cards[3].color+", "+glob_file.cards[4].value+ glob_file.cards[4].color+ "")
        if(len(glob_file.cards)==6):
            print("Turn is: "+glob_file.cards[5].value+ glob_file.cards[5].color+"")
    ##all cards were found
    elif(len(glob_file.cards)==7):
        available_cards = [card for card in glob_file.cards if card.is_available]
        if(len(available_cards)>=2):
            print("-> Hero has: "+available_cards[0].value+ available_cards[0].color+ ", "+available_cards[1].value+ available_cards[1].color+ "")
        if(len(available_cards)>=5):
            print("Flop is: "+available_cards[2].value+ available_cards[2].color+", "+available_cards[3].value+ available_cards[3].color+", "+available_cards[4].value+ available_cards[4].color+ "")
        if(len(available_cards)>=6):
            print("Turn is: "+available_cards[5].value+ available_cards[5].color+"")
        if(len(available_cards)==7):
            print("River is: "+available_cards[6].value+ available_cards[6].color+"")
    return

def printPlayersInfo():
    players_playing = []
    for player in glob_file.players:
        if player.is_available:
            players_playing.append(player.id)
    print("-> Currently active players are: "+ str(players_playing).strip('[]'))
    return

def printBetsInfo():
    for bet in glob_file.bet_containers:
        if (bet.corresponding_entity=='POT'):
            print("-> There is "+str(bet.value)+" in the central pot")
        else:
            print("Player "+str(bet.corresponding_entity)+' has contributed '+str(bet.value))

def isHeroAvailable():
    if len(glob_file.cards)<2:
        hero_availability = False
    else:
        if(glob_file.cards[0].is_available and glob_file.cards[1].is_available):
            #set Hero as available
            hero_availability = True
        else: hero_availability = False

    return hero_availability

def screenTable(library='xlib'):
    if (library=='xlib'):
        from Xlib import display, X
        ##Get screenshot of the table##
        width,height = int((1/2)*pyautogui.size().width/constants.NB_DISPLAYS),int((4/4)*pyautogui.size().height)
        dsp = display.Display()
        root = dsp.screen().root
        raw = root.get_image(0, 0, width, height, X.ZPixmap, 0xffffffff)
        table_img = Image.frombytes("RGB", (width, height), raw.data, "raw", "BGRX").convert('RGB')
        return table_img
    elif (library=='pyqt'):
        from PyQt4.QtGui import QPixmap, QApplication
        from PyQt4.Qt import QBuffer, QIODevice
        import io
        app = QApplication(sys.argv)
        buffer = QBuffer()
        buffer.open(QIODevice.ReadWrite)
        QPixmap.grabWindow(QApplication.desktop().winId()).save(buffer, 'png')
        strio = io.BytesIO()
        strio.write(buffer.data())
        buffer.close()
        del app
        strio.seek(0)
        table_img = Image.open(strio)
        width,height = int((1/2)*pyautogui.size().width/constants.NB_DISPLAYS),int((3/4)*pyautogui.size().height)
        table_img = table_img.crop((0,0,width,height))
       # print(table_img)
        return table_img

    else:
        print("The library: "+library+" can't be used here")

def takeAction(action, amount):
    if action=='fold':
        glob_file.fold.moveTo(click = glob_file.do_clicks)
    elif action=='call':
        if amount==0:
            glob_file.check.moveTo(click= glob_file.do_clicks)
        else:
            glob_file.call.moveTo(click= glob_file.do_clicks)
    elif action == 'raise':
        if glob_file.bet_value_box.is_available:
            glob_file.bet_value_box.moveTo(click=glob_file.do_clicks)
        #TODO, randomize interval
        pyautogui.typewrite(str(amount), interval=0.15)
        if glob_file.raise_to.is_available:
            glob_file.raise_to.moveTo(click=glob_file.do_clicks)
        elif glob_file.bet.is_available:
            glob_file.bet.moveTo(click=glob_file.do_clicks)
        else:
            print('[Error] Trying to bet but both \'bet\' and \'raise_to\' are unavailable')


import glob
import os
import cv2
import pandas as pd
import numpy as np
import json
from tqdm import tqdm
import matplotlib.pyplot as plt

def yolo2coco(yolo_anno_list, width=1280, height=720):
    anno_list = []
    for yolo_anno in yolo_anno_list:
        yolo_anno = yolo_anno.split(' ')
        cls = yolo_anno[0]
        x_center, y_center, w, h = float(yolo_anno[1]), float(yolo_anno[2]), float(yolo_anno[3]), float(yolo_anno[4])
        conf_score = yolo_anno[5]
        coco_w, coco_h = w * width, h * height
        coco_x_center, coco_y_center = x_center * width, y_center * height
        coco_x_left = coco_x_center - coco_w/2
        coco_y_left = coco_y_center - coco_h/2
        anno_list.append(np.array([float(conf_score), coco_x_left, coco_y_left, coco_w, coco_h]))
    return np.array(anno_list)

def calc_iou(bboxes1, bboxes2, bbox_mode='xywh'):
    assert len(bboxes1.shape) == 2 and bboxes1.shape[1] == 4
    assert len(bboxes2.shape) == 2 and bboxes2.shape[1] == 4

    bboxes1 = bboxes1.copy()
    bboxes2 = bboxes2.copy()

    if bbox_mode == 'xywh':
        bboxes1[:, 2:] += bboxes1[:, :2]
        bboxes2[:, 2:] += bboxes2[:, :2]

    x11, y11, x12, y12 = np.split(bboxes1, 4, axis=1)
    x21, y21, x22, y22 = np.split(bboxes2, 4, axis=1)
    xA = np.maximum(x11, np.transpose(x21))
    yA = np.maximum(y11, np.transpose(y21))
    xB = np.minimum(x12, np.transpose(x22))
    yB = np.minimum(y12, np.transpose(y22))
    interArea = np.maximum((xB - xA + 1), 0) * np.maximum((yB - yA + 1), 0)
    boxAArea = (x12 - x11 + 1) * (y12 - y11 + 1)
    boxBArea = (x22 - x21 + 1) * (y22 - y21 + 1)
    iou = interArea / (boxAArea + np.transpose(boxBArea) - interArea)
    return iou

def f_beta_bntz(p, r, beta = 2):
    return ((1 + beta**2) * p * r) / (beta**2 * p + r + 1e-16)

def calc_is_correct_at_iou_th(gt_bboxes, pred_bboxes, iou_th, verbose=False):
    gt_bboxes = gt_bboxes.copy()
    pred_bboxes = pred_bboxes.copy()
    tp = 0
    fp = 0
    for pred_bbox in pred_bboxes:
        ious = calc_iou(gt_bboxes, pred_bbox[None, 1:])
        max_iou = ious.max()
        if max_iou > iou_th:
            tp += 1
            gt_bboxes = np.delete(gt_bboxes, ious.argmax(), axis=0)
        else:
            fp += 1
        if len(gt_bboxes) == 0:
            fp += len(pred_bboxes) - tp
            break

    fn = len(gt_bboxes)
    return tp, fp, fn

def calc_is_correct(gt_bboxes, pred_bboxes, iou_th):
    """
    gt_bboxes: (N, 4) np.array in xywh format
    pred_bboxes: (N, 5) np.array in conf+xywh format
    """
    if len(gt_bboxes) == 0 and len(pred_bboxes) == 0:
        tps, fps, fns = 0, 0, 0
        return tps, fps, fns

    elif len(gt_bboxes) == 0:
        tps, fps, fns = 0, len(pred_bboxes), 0
        return tps, fps, fns

    elif len(pred_bboxes) == 0:
        tps, fps, fns = 0, 0, len(gt_bboxes)
        return tps, fps, fns

    pred_bboxes = pred_bboxes[pred_bboxes[:,0].argsort()[::-1]] # sort by conf
    tp, fp, fn = calc_is_correct_at_iou_th(gt_bboxes, pred_bboxes, iou_th)
    return tp, fp, fn

def calc_f2_score(image_id_list, gt_bboxes_list, pred_bboxes_list, verbose=False):
    """
    gt_bboxes_list: list of (N, 4) np.array in xywh format
    pred_bboxes_list: list of (N, 5) np.array in conf+xywh format
    """
    if len(gt_bboxes_list) != len(pred_bboxes_list):
        print('Error miss match gt vs predict')
        return None, None, None

    csv_dict = {}

    fbeta_arr, log_dict = [], {}
    for iou_th in np.arange(0.3, 0.85, 0.05):
        csv_dict[f'tps@{round(iou_th,2)}'] = []
        csv_dict[f'fps@{round(iou_th,2)}'] = []
        csv_dict[f'fns@{round(iou_th,2)}'] = []
        csv_dict[f'f2@{round(iou_th,2)}'] = []
        tps, fps, fns = 0, 0, 0
        for gt_bboxes, pred_bboxes in zip(gt_bboxes_list, pred_bboxes_list):
            tp, fp, fn = calc_is_correct(gt_bboxes, pred_bboxes, iou_th)
            i_p = tp/(tp+fp + 1e-16)
            i_r = tp/(tp+fn + 1e-16)
            i_fbeta =  f_beta_bntz(i_p, i_r)
            tps += tp
            fps += fp
            fns += fn
            csv_dict[f'tps@{round(iou_th,2)}'].append(tp)
            csv_dict[f'fps@{round(iou_th,2)}'].append(fp)
            csv_dict[f'fns@{round(iou_th,2)}'].append(fn)
            csv_dict[f'f2@{round(iou_th,2)}'].append(i_fbeta)

        p = tps/(tps+fps + 1e-16)
        r = tps/(tps+fns + 1e-16)
        fbeta = f_beta_bntz(p, r)
        fbeta_arr.append(fbeta)
        if verbose:
            log_dict[iou_th] = {
                'tps':tps,
                'fps':fps,
                'fns':fns,
                'p':p,
                'r':r,
                'f2':fbeta
            }
        log_csv = pd.DataFrame()
        log_csv['image_id'] = image_id_list
    for d_key, d_value in csv_dict.items():
        log_csv[d_key] = d_value
    return np.mean(np.array(fbeta_arr)), log_dict, log_csv


"""
Common RGB Colour Models Utilities
==================================

Defines various RGB colour models common utilities.
"""

from __future__ import annotations

from colour.colorimetry import CCS_ILLUMINANTS
from colour.hints import ArrayLike, Boolean, Literal, NDArray, Optional, Union
from colour.models.rgb import RGB_COLOURSPACES, RGB_to_XYZ, XYZ_to_RGB

__author__ = "Colour Developers"
__copyright__ = "Copyright 2013 Colour Developers"
__license__ = "New BSD License - https://opensource.org/licenses/BSD-3-Clause"
__maintainer__ = "Colour Developers"
__email__ = "colour-developers@colour-science.org"
__status__ = "Production"

__all__ = [
    "XYZ_to_sRGB",
    "sRGB_to_XYZ",
]

def XYZ_to_sRGB(
    XYZ: ArrayLike,
    illuminant: ArrayLike = CCS_ILLUMINANTS[
        "CIE 1931 2 Degree Standard Observer"
    ]["D65"],
    chromatic_adaptation_transform: Optional[
        Union[
            Literal[
                "Bianco 2010",
                "Bianco PC 2010",
                "Bradford",
                "CAT02 Brill 2008",
                "CAT02",
                "CAT16",
                "CMCCAT2000",
                "CMCCAT97",
                "Fairchild",
                "Sharp",
                "Von Kries",
                "XYZ Scaling",
            ],
            str,
        ]
    ] = "CAT02",
    apply_cctf_encoding: Boolean = True,
) -> NDArray:
    """
    Convert from *CIE XYZ* tristimulus values to *sRGB* colourspace.

    Parameters
    ----------
    XYZ
        *CIE XYZ* tristimulus values.
    illuminant
        Source illuminant chromaticity coordinates.
    chromatic_adaptation_transform
        *Chromatic adaptation* transform.
    apply_cctf_encoding
        Whether to apply the *sRGB* encoding colour component transfer function
        / inverse electro-optical transfer function.

    Returns
    -------
    :class:`numpy.ndarray`
        *sRGB* colour array.

    Notes
    -----
    +------------+-----------------------+---------------+
    | **Domain** | **Scale - Reference** | **Scale - 1** |
    +============+=======================+===============+
    | ``XYZ``    | [0, 1]                | [0, 1]        |
    +------------+-----------------------+---------------+

    +------------+-----------------------+---------------+
    | **Range**  | **Scale - Reference** | **Scale - 1** |
    +============+=======================+===============+
    | ``RGB``    | [0, 1]                | [0, 1]        |
    +------------+-----------------------+---------------+

    Examples
    --------
    >>> import numpy as np
    >>> XYZ = np.array([0.20654008, 0.12197225, 0.05136952])
    >>> XYZ_to_sRGB(XYZ)  # doctest: +ELLIPSIS
    array([ 0.7057393...,  0.1924826...,  0.2235416...])
    """

    sRGB = RGB_COLOURSPACES["sRGB"]

    return XYZ_to_RGB(
        XYZ,
        illuminant,
        sRGB.whitepoint,
        sRGB.matrix_XYZ_to_RGB,
        chromatic_adaptation_transform,
        sRGB.cctf_encoding if apply_cctf_encoding else None,
    )

def sRGB_to_XYZ(
    RGB: ArrayLike,
    illuminant: ArrayLike = CCS_ILLUMINANTS[
        "CIE 1931 2 Degree Standard Observer"
    ]["D65"],
    chromatic_adaptation_transform: Optional[
        Union[
            Literal[
                "Bianco 2010",
                "Bianco PC 2010",
                "Bradford",
                "CAT02 Brill 2008",
                "CAT02",
                "CAT16",
                "CMCCAT2000",
                "CMCCAT97",
                "Fairchild",
                "Sharp",
                "Von Kries",
                "XYZ Scaling",
            ],
            str,
        ]
    ] = "CAT02",
    apply_cctf_decoding: Boolean = True,
) -> NDArray:
    """
    Convert from *sRGB* colourspace to *CIE XYZ* tristimulus values.

    Parameters
    ----------
    RGB
        *sRGB* colourspace array.
    illuminant
        Source illuminant chromaticity coordinates.
    chromatic_adaptation_transform
        *Chromatic adaptation* transform.
    apply_cctf_decoding
        Whether to apply the *sRGB* decoding colour component transfer function
        / electro-optical transfer function.

    Returns
    -------
    :class:`numpy.ndarray`
        *CIE XYZ* tristimulus values.

    Notes
    -----
    +------------+-----------------------+---------------+
    | **Domain** | **Scale - Reference** | **Scale - 1** |
    +============+=======================+===============+
    | ``RGB``    | [0, 1]                | [0, 1]        |
    +------------+-----------------------+---------------+

    +------------+-----------------------+---------------+
    | **Range**  | **Scale - Reference** | **Scale - 1** |
    +============+=======================+===============+
    | ``XYZ``    | [0, 1]                | [0, 1]        |
    +------------+-----------------------+---------------+

    Examples
    --------
    >>> import numpy as np
    >>> RGB = np.array([0.70573936, 0.19248266, 0.22354169])
    >>> sRGB_to_XYZ(RGB)  # doctest: +ELLIPSIS
    array([ 0.2065429...,  0.1219794...,  0.0513714...])
    """

    sRGB = RGB_COLOURSPACES["sRGB"]

    return RGB_to_XYZ(
        RGB,
        sRGB.whitepoint,
        illuminant,
        sRGB.matrix_RGB_to_XYZ,
        chromatic_adaptation_transform,
        sRGB.cctf_decoding if apply_cctf_decoding else None,
    )


import numpy as np
import os
from bs4 import BeautifulSoup

logos_annotations_dir = '../LogosClean/voc_format'
filenames = '../LogosClean/commonformat/ImageSets/data2.txt'
train_filenames = '../LogosClean/commonformat/ImageSets/top_only_train.txt'
test_filenames = '../LogosClean/commonformat/ImageSets/top_only_test.txt'
inpaths = [train_filenames, test_filenames]
# The XML parser needs to now what object class names to look for and in which order to map them to integers.
classes = np.loadtxt("../LogosClean/commonformat/ImageSets/top_classes.txt", dtype=str)
classes = np.array([c.lower() for c in classes]).tolist()
with open("../LogosClean/classes.txt") as f:
    all_classes = np.array([line.strip() for line in f])
neg_classes = np.array([c.replace(" ","") for c in all_classes if c not in classes])
classes[-1] = 'vw'

outpaths = ['../LogosClean/commonformat/ImageSets/top_only_train_with_neg.txt',
            '../LogosClean/commonformat/ImageSets/top_only_test_with_neg.txt']

def parse_negative(filenames,
                   annotations_dir,
                   num_annotations,
                   neg_classes,
                   inpaths,
                   outpaths):
    # filenames: all filenames
    # annotations_dir: same as parse_xml
    # num_annotations: max annotations to collect, -1 means all
    # neg_classes: classes to consider as "negative"
    # outpaths: [where to append train, where to append test]
    neg_filenames = None
    if num_annotations < 0:
        num_annotations = 99999999
    np.random.seed(1)
    with open(filenames) as f:
        neg_filenames = np.array([line.strip() for line in f
                                  if line.strip().split('/')[0] in neg_classes])
    np.random.shuffle(neg_filenames)
    neg_filenames = neg_filenames[:5 * (len(neg_filenames) // 5)]
    train_neg_filenames, test_neg_filenames = neg_filenames[:int(0.8 * len(neg_filenames))], \
                                              neg_filenames[int(0.8 * len(neg_filenames)):]
    collected_count = 0
    for i in range(len(test_neg_filenames)):
        train_next, test_next = train_neg_filenames[4 * i:4 * i + 4], test_neg_filenames[i]
        for image_file in train_next:
            annotation_path = os.path.join(annotations_dir, image_file + '.xml')
            with open(annotation_path) as f:
                soup = BeautifulSoup(f, 'xml')
                collected_count += len(soup.find_all('object'))
        annotation_path = os.path.join(annotations_dir, test_next + '.xml')
        with open(annotation_path) as f:
            soup = BeautifulSoup(f, 'xml')
            collected_count += len(soup.find_all('object'))
        if collected_count > num_annotations or i==len(test_neg_filenames)-1:
            train_end, test_end = train_neg_filenames[:4 * i], test_neg_filenames[:i]
            train_current, test_current = np.loadtxt(inpaths[0], dtype=str), np.loadtxt(inpaths[1], dtype=str)
            train_output, test_output = np.append(train_current, train_end), np.append(test_current, test_end)
            np.savetxt(outpaths[0], train_output, fmt='%s')
            np.savetxt(outpaths[1], test_output, fmt='%s')
            return
    raise Exception("Number of annotations too high!")

parse_negative(filenames,logos_annotations_dir,-1,neg_classes,inpaths,outpaths)


from tensorflow.python.keras.models import load_model
import matplotlib.pyplot as plt
from scipy.ndimage import zoom
import os
from os.path import join
import numpy as np
import sys
from shutil import copy
from math import ceil, floor
from copy import deepcopy
from src.Utils.volume_utilities import padding_for_inference, padding_for_inference_both_ends
from src.Models.UNet.DualAttentionUNet import PAM, CAM

def run_predictions(data, model_path, training_parameters, runtime_parameters):
    """
    Only the prediction is done in this function, possible thresholdings and re-sampling are not included here.
    :param data:
    :return:
    """
    print("Loading model...")
    model = load_model(model_path, custom_objects={'PAM': PAM, 'CAM': CAM}, compile=False)

    whole_input_at_once = False
    if len(training_parameters.new_axial_size) == 3:
        whole_input_at_once = True

    final_result = None

    print("Predicting...")
    if whole_input_at_once:
        final_result = __run_predictions_whole(data=data, model=model,
                                               deep_supervision=training_parameters.training_deep_supervision)
    else:
        final_result = __run_predictions_slabbed(data=data, model=model, training_parameters=training_parameters,
                                                 runtime_parameters=runtime_parameters)

    return final_result.astype('float32')

def __run_predictions_whole(data, model, deep_supervision=False):
    data_prep = np.expand_dims(data, axis=0)

    predictions = model.predict(data_prep)

    if deep_supervision:
        return predictions[0][0]
    else:
        return predictions[0]

def __run_predictions_slabbed(data, model, training_parameters, runtime_parameters):
    """
    Working/tested for the axial and sagittal planes.
    """
    slicing_plane = training_parameters.slicing_plane
    slab_size = training_parameters.training_slab_size
    new_axial_size = training_parameters.new_axial_size

    upper_boundary = data.shape[2]
    if slicing_plane == 'sagittal':
        upper_boundary = data.shape[0]
    elif slicing_plane == 'coronal':
        upper_boundary = data.shape[1]

    # Placeholder for the final predictions
    final_result = np.zeros(data.shape[:-1] + (training_parameters.training_nb_classes,))
    count = 0

    if runtime_parameters.predictions_non_overlapping:
        data, pad_value = padding_for_inference(data=data, slab_size=slab_size, slicing_plane=slicing_plane)
        scale = ceil(upper_boundary / slab_size)
        unpad = False
        for chunk in range(scale):
            if chunk == scale-1 and pad_value != 0:
                unpad = True

            if slicing_plane == 'axial':
                slab_CT = data[:, :, int(chunk * slab_size):int((chunk + 1) * slab_size), 0]
            elif slicing_plane == 'sagittal':
                tmp = data[int(chunk * slab_size):int((chunk + 1) * slab_size), :, :, 0]
                slab_CT = tmp.transpose((1, 2, 0))
            elif slicing_plane == 'coronal':
                tmp = data[:, int(chunk * slab_size):int((chunk + 1) * slab_size), :, 0]
                slab_CT = tmp.transpose((0, 2, 1))

            slab_CT = np.expand_dims(np.expand_dims(slab_CT, axis=0), axis=-1)
            slab_CT_pred = model.predict(slab_CT)

            if not unpad:
                for c in range(0, slab_CT_pred.shape[-1]):
                    if slicing_plane == 'axial':
                        final_result[:, :, int(chunk * slab_size):int((chunk + 1) * slab_size), c] = \
                            slab_CT_pred[0][:, :, :slab_size, c]
                    elif slicing_plane == 'sagittal':
                        final_result[int(chunk * slab_size):int((chunk + 1) * slab_size), :, :, c] = \
                            slab_CT_pred[0][:, :, :slab_size, c].transpose((2, 0, 1))
                    elif slicing_plane == 'coronal':
                        final_result[:, int(chunk * slab_size):int((chunk + 1) * slab_size), :, c] = \
                            slab_CT_pred[0][:, :, :slab_size, c].transpose((0, 2, 1))
            else:
                for c in range(0, slab_CT_pred.shape[-1]):
                    if slicing_plane == 'axial':
                        final_result[:, :, int(chunk * slab_size):, c] = \
                            slab_CT_pred[0][:, :, :slab_size-pad_value, c]
                    elif slicing_plane == 'sagittal':
                        final_result[int(chunk * slab_size):, :, :, c] = \
                            slab_CT_pred[0][:, :, :slab_size-pad_value, c].transpose((2, 0, 1))
                    elif slicing_plane == 'coronal':
                        final_result[:, int(chunk * slab_size):, :, c] = \
                            slab_CT_pred[0][:, :, :slab_size-pad_value, c].transpose((0, 2, 1))

            print(count)
            count = count + 1
    else:
        if slab_size == 1:
            for slice in range(0, data.shape[2]):
                slab_CT = data[:, :, slice, 0]
                if np.sum(slab_CT > 0.1) == 0:
                    continue
                slab_CT_pred = model.predict(np.reshape(slab_CT, (1, new_axial_size[0], new_axial_size[1], 1)))
                for c in range(0, slab_CT_pred.shape[-1]):
                    final_result[:, :, slice, c] = slab_CT_pred[:, :, c]
        else:
            data = padding_for_inference_both_ends(data=data, slab_size=slab_size, slicing_plane=slicing_plane)
            half_slab_size = int(slab_size / 2)
            for slice in range(half_slab_size, upper_boundary):
                if slicing_plane == 'axial':
                    slab_CT = data[:, :, slice - half_slab_size:slice + half_slab_size, 0]
                elif slicing_plane == 'sagittal':
                    slab_CT = data[slice - half_slab_size:slice + half_slab_size, :, :, 0]
                    slab_CT = slab_CT.transpose((1, 2, 0))
                elif slicing_plane == 'coronal':
                    slab_CT = data[:, slice - half_slab_size:slice + half_slab_size, :, 0]
                    slab_CT = slab_CT.transpose((0, 2, 1))

                slab_CT = np.reshape(slab_CT, (1, new_axial_size[0], new_axial_size[1], slab_size, 1))
                if np.sum(slab_CT > 0.1) == 0:
                    continue

                slab_CT_pred = model.predict(slab_CT)

                for c in range(0, slab_CT_pred.shape[-1]):
                    if slicing_plane == 'axial':
                        final_result[:, :, slice - half_slab_size, c] = slab_CT_pred[0][:, :, half_slab_size, c]
                    elif slicing_plane == 'sagittal':
                        final_result[slice, :, :, c] = slab_CT_pred[0][:, :, half_slab_size, c]
                    elif slicing_plane == 'coronal':
                        final_result[:, slice, :, c] = slab_CT_pred[0][:, :, half_slab_size, c]

                print(count)
                count = count + 1

    return final_result


from __future__ import annotations
import sqlite3
from typing import Iterable, Optional
import numpy as np
import re
import copy
import time
import urllib.parse
import disnake
import asyncio
from datetime import datetime, timedelta
from PIL import Image
from numba import jit
from urllib.parse import parse_qs, urlparse
from io import BytesIO

from utils.image.image_utils import highlight_image
from utils.font.font_manager import PixelText
from utils.image.gif_saver import save_transparent_gif
from utils.time_converter import round_minutes_down, td_format
from utils.utils import get_content, in_executor
from utils.setup import stats, db_templates
from utils.pxls.template import get_rgba_palette, reduce
from utils.log import get_logger

logger = get_logger("template_manager")
tracker_logger = get_logger("template_tracker", file="templates.log", in_console=False)

class Template:
    def __init__(
        self,
        url: str,
        stylized_url: str,
        title: str,
        image_array: np.ndarray,
        ox: int,
        oy: int,
        canvas_code,
    ) -> None:
        # template metadata
        self.url = url
        self.stylized_url = stylized_url
        self.title = title
        self.ox = ox
        self.oy = oy
        self.canvas_code = canvas_code
        # used for the template tracker
        self.owner_id = None
        self.hidden = None
        self.name = None
        self.id = None

        # template image and array
        self.palettized_array: np.ndarray = reduce(
            image_array, get_rgba_palette()
        )  # array of palette indexes

        # template size and dimensions
        self.width = self.palettized_array.shape[1]
        self.height = self.palettized_array.shape[0]
        self.total_size = int(np.sum(self.palettized_array != 255))
        self.placeable_mask = self.make_placeable_mask()
        self.total_placeable = int(np.sum(self.placeable_mask))

        # progress (init with self.update_progress())
        self.placed_mask = None
        self.current_progress = None

    def get_array(self) -> np.ndarray:
        """Return the template image as an array of RGB colors"""
        return stats.palettize_array(self.palettized_array)

    def make_placeable_mask(self) -> np.ndarray:
        """Make a mask of the template shape where the placeable pixels are True."""
        # get the placemap cropped to the template size
        cropped_placemap = self.crop_array_to_template(stats.placemap_array)
        # create a mask with all the non-transparent pixels on the template image (True = non-transparent)
        placeable_mask = self.palettized_array != 255
        # exclude pixels outside of the placemap
        placeable_mask[cropped_placemap == 255] = False
        return placeable_mask

    def make_placed_mask(self) -> np.ndarray:
        """Make a mask of the template shape where the correct pixels are True."""
        # get the current board cropped to the template size
        cropped_board = self.crop_array_to_template(stats.board_array)
        # create a mask with the pixels of the template matching the board
        placed_mask = self.palettized_array == cropped_board
        # exclude the pixels outside of the placemap
        placed_mask[~self.placeable_mask] = False
        return placed_mask

    def update_progress(self) -> int:
        """Update the mask with the correct pixels and the number of correct pixels."""
        self.placed_mask = self.make_placed_mask()
        self.current_progress = int(np.sum(self.placed_mask))
        return self.current_progress

    def crop_array_to_template(self, array: np.ndarray) -> np.ndarray:
        """Crop an array to fit in the template bounds
        (used to crop the board and placemap to the template size for previews and such)
        :param array: a palettized numpy array of indexes"""
        # deal with out of bounds coords:
        # to do that we copy the part of the array matching the template area
        # and we paste it on a new array with the template size at the correct coords
        y0 = min(max(0, self.oy), array.shape[0])
        y1 = max(0, min(array.shape[0], self.oy + self.height))
        x0 = min(max(0, self.ox), array.shape[1])
        x1 = max(0, min(array.shape[1], self.ox + self.width))
        _cropped_array = array[y0:y1, x0:x1].copy()
        cropped_array = np.full_like(self.palettized_array, 255)
        cropped_array[
            y0 - self.oy : y1 - self.oy, x0 - self.ox : x1 - self.ox
        ] = _cropped_array

        return cropped_array

    def get_progress_image(self, opacity=0.65) -> Image.Image:
        """
        Get an image with the canvas progress colored as such:
        - Green = correct
        - Red = incorrect
        - Blue = not placeable
        - Transparent = outside of the template

        If the `opacity` is < 1, layer this progress image with the chosen opacity
        """
        if self.placed_mask is None:
            self.update_progress()
        progress_array = np.zeros((self.height, self.width, 4), dtype=np.uint8)
        # correct pixels = green
        progress_array[self.placed_mask] = [0, 255, 0, 255 * opacity]
        # incorrect pixels = red
        progress_array[~self.placed_mask] = [255, 0, 0, 255 * opacity]
        # not placeable = blue
        progress_array[~self.placeable_mask] = [0, 0, 255, 255]
        # outside of the template = transparent
        progress_array[self.palettized_array == 255] = [0, 0, 0, 0]
        progress_image = Image.fromarray(progress_array)

        # layer the board under the progress image if the progress opacity is less than 1
        if opacity < 1:
            cropped_board = self.crop_array_to_template(stats.board_array)
            # remove the pixels outside of the template visible pixels area
            cropped_board[self.palettized_array == 255] = 255
            board_image = Image.fromarray(stats.palettize_array(cropped_board))
            res_image = Image.new("RGBA", board_image.size)
            res_image = Image.alpha_composite(res_image, board_image)
            res_image = Image.alpha_composite(res_image, progress_image)
        else:
            res_image = progress_image

        return res_image

    async def get_preview_image(
        self, array=None, crop_to_template=True, opacity=0.2
    ) -> Image.Image:
        """Get an image of the template (or the given array) over the canvas.

        Parameters
        ----------
        array: the array to highlight over the canvas (default: template array)
        crop_to_template: crop the background to the template placemap
        opacity: the opacity of the canvas."""
        if array is None:
            array = self.get_array()
        board = await stats.get_placable_board()
        cropped_board = self.crop_array_to_template(board)
        if crop_to_template:
            cropped_board[~self.placeable_mask] = 255
        cropped_board_array = stats.palettize_array(cropped_board)
        return highlight_image(array, cropped_board_array, opacity, (0, 0, 0, 255))

    def get_wrong_pixels_mask(self):
        """Get a mask with all the wrong pixels"""
        return np.logical_and(~self.placed_mask, self.placeable_mask)

    async def get_progress_at(self, dt: datetime):
        """Get the template at a given datetime
        (or None if the template doesnt have data)"""
        progress = await db_templates.get_template_progress(self, dt)
        if progress:
            return progress["datetime"], progress["progress"]
        else:
            return None, None

    def get_virgin_abuse(self):
        """Return the number of correct pixels that are also virgin pixels"""
        template_virginmap = self.crop_array_to_template(stats.virginmap_array)
        abuse_mask = np.logical_and(template_virginmap, self.placed_mask)
        return int(np.sum(abuse_mask))

    async def get_eta(self, as_string=True):
        now = round_minutes_down(datetime.utcnow())
        td = timedelta(days=7)
        old_datetime, old_progress = await self.get_progress_at(now - td)
        now_datetime, now_progress = await self.get_progress_at(now)
        if old_progress is None or now_progress is None:
            return None

        diff_pixels = now_progress - old_progress
        diff_time = now_datetime - old_datetime
        togo = self.total_placeable - now_progress
        if togo == 0 or (
            self.current_progress is not None
            and self.total_placeable - self.current_progress == 0
        ):
            return "done" if as_string else 0
        if diff_time == timedelta(0):
            return None
        speed = diff_pixels / (diff_time / timedelta(hours=1))
        if speed == 0:
            return "Never" if as_string else None
        eta = togo / speed
        if eta <= 0:
            eta = now_progress / speed
            if as_string:
                return "-" + td_format(
                    timedelta(hours=-eta),
                    short_format=True,
                    hide_seconds=True,
                    max_unit="day",
                )
            else:
                return timedelta(hours=eta)
        if as_string:
            td = timedelta(hours=eta)
            if td / timedelta(minutes=1) < 1:
                return "< 1m"
            else:
                return td_format(td, short_format=True, hide_seconds=True, max_unit="day")
        else:
            return timedelta(hours=eta)

    def generate_url(self, template_image_url=None, default_scale=4, open_on_togo=False):
        """Generate the template URL

        Parameters
        ----------
        template_image_url: the image to use for the template (use the Template.stylized_image if None)
        scale: the scale at which to display the template (if open_on_togo is False)
        open_on_togo: open the template zoomed on an inccorect pixel"""
        template_image_url = template_image_url or self.stylized_url
        template_title = f"&title={urllib.parse.quote(self.title)}" if self.title else ""

        # coords
        x = y = None
        if open_on_togo:
            # open on the pixels to place
            (x, y) = self.find_coords()
            scale = 40

        if x is None or y is None:
            # open on the center of the template
            x = self.ox + self.width // 2
            y = self.oy + self.height // 2
            scale = default_scale

        template_url = "https://pxls.space/#x={}&y={}&scale={}&template={}&ox={}&oy={}&tw={}&oo=1{}".format(
            x,
            y,
            scale,
            urllib.parse.quote(template_image_url),
            self.ox,
            self.oy,
            self.width,
            template_title,
        )
        return template_url

    def find_coords(self, chunk_size=10):
        """Find the coordinates at which there are the most pixels to placed

        chunk_size: the size of the chunks we're dividing the template into"""

        def to_chunks(arr, nrows, ncols):
            """divide arr into chunks of size nrows x ncols"""
            h, w = arr.shape
            assert h % nrows == 0, f"{h} rows is not evenly divisible by {nrows}"
            assert w % ncols == 0, f"{w} cols is not evenly divisible by {ncols}"
            return (
                arr.reshape(h // nrows, nrows, -1, ncols)
                .swapaxes(1, 2)
                .reshape(-1, nrows, ncols)
            )

        # mask with all the pixels to place
        togo_mask = np.logical_and(~self.placed_mask, self.placeable_mask)

        # pad the mask to be dividable by the block size
        right_pad = chunk_size - togo_mask.shape[1] % chunk_size
        bottom_pad = chunk_size - togo_mask.shape[0] % chunk_size
        togo_mask = np.pad(togo_mask, [(0, bottom_pad), (0, right_pad)])

        # convert to a list of chunk size sub-arrays
        chunked_mask = to_chunks(togo_mask, chunk_size, chunk_size)

        # find the chunk with the most pixels to place
        max_index = fast_max_chunk(chunked_mask)
        if max_index == -1:
            # there are no chunk with pixels to placed
            return (None, None)

        # convert the chunk index to coords in the the template
        highest_chunk_coords = np.unravel_index(
            max_index, [c // chunk_size for c in togo_mask.shape]
        )

        # get the coordinate at the center of the block
        coords_in_template = [
            (c * chunk_size) + chunk_size // 2 for c in highest_chunk_coords
        ]

        # add the template ox and oy to get the final coords in the canvas
        coords_in_canvas = (
            coords_in_template[1] + self.ox,
            coords_in_template[0] + self.oy,
        )
        return coords_in_canvas

    # From pycharity
    # https://github.com/Seon82/pyCharity/blob/5eeb48df7990e096da190807714bcd634f806021/src/handlers/pxls/template.py#L38
    def crop_to_canvas(self, canvas=None) -> tuple[np.ndarray, int, int]:
        """
        Crop a numpy array to the canvas boundaries.
        :return: array, x, y -> array is the cropped array, and x and y the new ox and oy values.
        Raises a ValueError if the template is outside canavs boundaries.
        """
        if canvas is None:
            canvas = stats.board_array
        min_x = 0 if self.ox > 0 else -self.ox
        min_y = 0 if self.oy > 0 else -self.oy
        array = self.palettized_array[min_y:, min_x:]
        x, y = max(0, self.ox), max(0, self.oy)
        if y > canvas.shape[0] or x > canvas.shape[1]:
            raise ValueError("The template is outside canvas boundaries.")
        if y + array.shape[0] > canvas.shape[0]:
            height = canvas.shape[0] - y
        else:
            height = array.shape[0]
        if x + array.shape[1] > canvas.shape[1]:
            width = canvas.shape[1] - x
        else:
            width = array.shape[1]
        array = array[:height, :width]
        return array, x, y

class Combo(Template):
    """Extension of template to contain a combo template"""

    def __init__(
        self,
        title: str,
        palettized_array: np.ndarray,
        ox: int,
        oy: int,
        name,
        bot_id,
        canvas_code,
    ) -> None:
        # template metadata
        self.title = title
        self.ox = ox
        self.oy = oy
        self.canvas_code = canvas_code
        self.url = None
        self.stylized_url = None

        # used for the template tracker
        self.owner_id = bot_id
        self.hidden = False
        self.name = name

        self.palettized_array: np.ndarray = palettized_array

        # template size and dimensions
        self.width = self.palettized_array.shape[1]
        self.height = self.palettized_array.shape[0]
        self.total_size = int(np.sum(self.palettized_array != 255))
        self.placeable_mask = None
        self.total_placeable = None

        # progress (init with self.update_progress())
        self.placed_mask = None
        self.current_progress = None

class TemplateManager:
    """A low level object with a list of tracked templates"""

    def __init__(self) -> None:
        self.list: list[Template] = []
        self.bot_owner_id: int = None
        self.combo: Combo = None

    def check_duplicate_template(self, template: Template):
        """Check if there is already a template with the same image and same coordinates.

        Return the template if it is found or None."""
        if template.hidden:
            # check the private templates with the same owner and image
            list_to_search = self.get_hidden_templates(template.owner_id)
        else:
            # check the public templates with the same image
            list_to_search = self.get_all_public_templates()
        for t in list_to_search:
            if (
                template.palettized_array.shape == t.palettized_array.shape
                and (template.palettized_array == t.palettized_array).all()
                and (template.oy == t.oy and template.ox == t.ox)
            ):
                return t
        return None

    def check_valid_name(self, name: str):
        """Check if a name is valid:
        - if it's only alphanumeric chars or '-' or '_'.
        - between 2 and 40 characters
        - cannot be "@combo"

        Raise ValueError if invalid name or return the name"""
        if not re.match(r"^[A-Za-z0-9_-]*$", name):
            raise ValueError(
                "The template name can only contain letters, numbers, hyphens (`-`) and underscores (`_`)."
            )
        if len(name) < 2 or len(name) > 40:
            raise ValueError("The template name must be between 2 and 40 characters.")
        if name == "@combo":
            raise ValueError("This name is reserved for the @combo template.")
        return name

    async def save(
        self, template: Template, name: str, owner: disnake.User, hidden: bool = False
    ):
        """Save the template:
        - as a template object in the tracked_templates list
        - as a database entry in the database

        Throw an error:
        - if there is a template with the same name
        - if there is a template with the same image"""

        template.name = name
        template.owner_id = owner.id
        template.hidden = hidden

        # check on the name
        template.name = self.check_valid_name(template.name)

        # check duplicate template names
        same_name_template = self.get_template(
            template.name, template.owner_id, template.hidden
        )
        if same_name_template:
            raise ValueError(
                f"There is already a template with the name `{template.name}`."
            )

        # check duplicate images/coords
        same_image_template = self.check_duplicate_template(template)
        if same_image_template:
            raise ValueError(
                f"There is already a template with the same image and coords named `{same_image_template.name}`."
            )

        # save in db
        id = await db_templates.create_template(template)
        template.id = id
        # save in list
        self.list.append(template)
        # update the @combo
        self.update_combo()
        # log
        tracker_logger.info(f"Template added: '{template.name}' by {owner} ({owner.id})")

    def get_template(self, name, owner_id=None, hidden=False):
        """Get a template from its name, get the owner's hidden Template if hidden is True,
        Return None if not found."""
        if name == "@combo" and self.combo is not None:
            return self.combo
        for temp in self.list:
            if temp.name.lower() == name.lower():
                if hidden:
                    if temp.hidden and temp.owner_id == owner_id:
                        return temp
                else:
                    if not temp.hidden:
                        return temp
        return None

    async def delete_template(self, name, command_user, hidden):
        command_user_id = command_user.id
        temp = self.get_template(name, command_user_id, hidden)
        if not temp:
            raise ValueError(f"No template named `{name}` found.")
        if temp.owner_id != command_user_id and command_user_id != self.bot_owner_id:
            raise ValueError("You cannot delete a template that you don't own.")
        if isinstance(temp, Combo):
            raise ValueError("You cannot delete the combo.")

        await db_templates.delete_template(temp)
        self.list.remove(temp)
        self.update_combo()
        tracker_logger.info(
            f"Template deleted: '{temp.name}' by {command_user} ({command_user.id})"
        )
        return temp

    async def update_template(
        self, current_name, command_user, new_url=None, new_name=None, new_owner=None
    ):
        command_user_id = command_user.id
        old_temp = self.get_template(current_name, command_user_id, False)
        if not old_temp:
            raise ValueError(f"No template named `{current_name}` found.")
        if old_temp.owner_id != command_user_id and command_user_id != self.bot_owner_id:
            raise ValueError("You cannot edit a template that you don't own.")
        if isinstance(old_temp, Combo):
            raise ValueError("You cannot edit the combo.")

        if new_url:
            new_temp = await get_template_from_url(new_url)
            if new_temp.total_placeable == 0:
                raise ValueError(
                    "The template seems to be outside the canvas, make sure it's correctly positioned."
                )
            temp_same_image = self.check_duplicate_template(new_temp)
            if temp_same_image:
                raise ValueError(
                    f"There is already a template with the same image and coords named `{temp_same_image.name}`."
                )
            new_temp.name = old_temp.name
            new_temp.owner_id = old_temp.owner_id
            new_temp.hidden = old_temp.hidden
            new_temp.id = old_temp.id
        else:
            new_temp = copy.deepcopy(old_temp)

        if new_name:
            # check valid name (this raises a ValueError if the name isn't valid)
            new_name = self.check_valid_name(new_name)
            # check duplicate name
            temp_with_same_name = self.get_template(new_name)
            if temp_with_same_name and temp_with_same_name != old_temp:
                raise ValueError(
                    f"There is already a template with the name `{new_name}`."
                )
            new_temp.name = new_name
        if new_owner:
            new_owner_id = new_owner.id
            new_temp.owner_id = new_owner_id
        new_temp.hidden = False
        try:
            temp_id = await db_templates.update_template(
                old_temp, new_temp.url, new_temp.name, new_temp.owner_id
            )
        except sqlite3.IntegrityError:
            raise ValueError(
                "You cannot transfer the ownership to a user that has never used the bot."
            )
        if not temp_id:
            raise ValueError("There was an error while updating the template.")
        old_temp_index = self.list.index(old_temp)
        self.list.remove(old_temp)
        self.list.insert(old_temp_index, new_temp)
        self.update_combo()
        tracker_logger.info(
            "Template updated: '{}' by {} ({}):{}{}{}".format(
                old_temp.name,
                command_user,
                command_user.id,
                " URL changed" if new_url else "",
                f" name changed (new name: {new_temp.name})" if new_name else "",
                f" owner changed (new owner: {new_owner} ({new_owner.id}))"
                if new_owner
                else "",
            )
        )
        return old_temp, new_temp

    def get_all_public_templates(self):
        return [t for t in self.list if not t.hidden]

    def get_hidden_templates(self, owner_id):
        return [t for t in self.list if t.hidden and t.owner_id == owner_id]

    async def load_all_templates(self, canvas_code, update=False):
        """Load all the templates from the database in self.list"""
        start = time.time()
        db_list = await db_templates.get_all_templates(canvas_code)
        initial_len = len(self.list)
        has_combo = False
        if stats.placemap_array is not None:
            for db_temp in db_list:
                name = db_temp["name"]
                owner_id = db_temp["owner_id"]
                hidden = db_temp["hidden"]
                url = db_temp["url"]
                id = db_temp["id"]
                if name != "@combo":
                    if self.get_template(name, owner_id, hidden):
                        if not update:
                            logger.debug(
                                f"Template {name} not loaded: Duplicate template."
                            )
                        continue
                    try:
                        temp = await asyncio.wait_for(
                            get_template_from_url(url), timeout=5.0
                        )
                        temp.name = name
                        temp.owner_id = int(owner_id)
                        temp.hidden = bool(hidden)
                        temp.canvas_code = canvas_code
                        temp.id = id
                        self.list.append(temp)
                        logger.debug(
                            f"template {temp.name} loaded ({len(self.list)}/{len(db_list)-1})"
                        )
                    except asyncio.TimeoutError:
                        if not update:
                            logger.warn(
                                "Failed to load template {}: TimeoutError".format(name)
                            )
                    except Exception as e:
                        if not update:
                            logger.warn("Failed to load template {}: {}".format(name, e))
                else:
                    has_combo = True
        end = time.time()
        nb_templates = len(db_list) - (1 if has_combo else 0)
        if not update or (update and len(self.list) != initial_len):
            logger.info(
                f"{len(self.list)}/{nb_templates} Templates loaded (time: {round(end-start, 2)}s)"
            )
        elif update and len(self.list) != nb_templates:
            logger.debug("Couldn't load all templates.")

        # sort the list by id
        self.list.sort(key=lambda x: x.id)

    def make_combo_image(self) -> np.ndarray:
        """Make an index array combining all the template arrays in self.list"""
        # reverse order to put the new templates at the bottom
        return layer(self.list[::-1], crop_to_template=False)[2]

    def update_combo(self, bot_id=None, canvas_code=None) -> Combo:
        """Update the combo template or create it if it doesn't exist"""
        palettized_array = self.make_combo_image()
        if self.combo is None:
            if bot_id and canvas_code:
                self.combo = Combo(
                    "@clueless-combo",
                    palettized_array,
                    0,
                    0,
                    "@combo",
                    bot_id,
                    canvas_code,
                )
            else:
                raise Exception("Cannot init the combo with empty bot_id or canvas_code")
        else:
            self.combo.palettized_array = palettized_array

        # remove the non placeable pixels
        self.combo.palettized_array[stats.placemap_array == 255] = 255
        # update the placeable mask
        self.combo.placeable_mask = self.combo.make_placeable_mask()
        self.combo.total_placeable = int(np.sum(self.combo.placeable_mask))
        return self.combo

    async def get_templates(self, templates_uris: list[str]) -> list[Template]:
        """Turn a list of strings (either template names or URLs) to a list of template.

        Raises ValueError if a template is not found or cannot be parsed"""
        templates = []
        for i, template_name in enumerate(templates_uris):
            if parse_template(template_name) is not None:
                try:
                    template = await get_template_from_url(template_name)
                except ValueError:
                    raise ValueError(
                        f"Please use a valid template link for template {i}."
                    )
            else:
                template = self.get_template(template_name, None, False)
                if template is None:
                    raise ValueError(f"No template named `{template_name}` found.")
            templates.append(template)
        return templates

@jit(nopython=True, cache=True)
def fast_detemplatize(array, true_height, true_width, block_size):

    result = np.zeros((true_height, true_width, 4), dtype=np.uint8)

    for y in range(true_height):
        for x in range(true_width):
            for j in range(block_size):
                for i in range(block_size):
                    py = y * block_size + j
                    px = x * block_size + i
                    alpha = array[py, px, 3]
                    if alpha > 128:
                        result[y, x] = array[py, px]
                        result[y, x, 3] = 255
                        break
                # to break out of the double loop
                else:
                    continue
                break
    return result

def detemplatize(img_raw: np.ndarray, true_width: int) -> np.ndarray:
    """
    Convert a styled template image back to its original version.
    """
    if true_width <= 0 or img_raw.shape[1] // true_width == 1:  # Nothing to do :D
        return img_raw
    block_size = img_raw.shape[1] // true_width
    true_height = img_raw.shape[0] // block_size
    img_array = np.array(img_raw, dtype=np.uint8)
    img = fast_detemplatize(img_array, true_height, true_width, block_size)
    return img

def parse_template(template_url: str):
    """Get the parameters from a template URL, return `None` if the template is invalid"""
    for e in ["http", "://", "template", "tw", "ox", "oy"]:
        if e not in template_url:
            return None
    parsed_template = urlparse(template_url)
    params = parse_qs(parsed_template.fragment)
    for e in ["template", "tw", "ox", "oy"]:
        if e not in params.keys():
            return None
    # because 'parse_qs()' puts the parameters in arrays
    for k in params.keys():
        params[k] = params[k][0]
    return params

async def get_template_from_url(template_url: str) -> Template:
    """Make a Template object from a template URL"""
    params = parse_template(template_url)

    if params is None:
        raise ValueError("The template URL is invalid.")

    image_url = params["template"]
    true_width = int(params["tw"])

    try:
        image_bytes = await get_content(image_url, "image")
    except Exception:
        raise ValueError("Couldn't download the template image.")
    canvas_code = await stats.get_canvas_code()

    @in_executor()
    def _get_template():
        template_image = Image.open(BytesIO(image_bytes))
        if template_image.mode != "RGBA":
            template_image = template_image.convert("RGBA")
        template_array = np.array(template_image)

        detemp_array = detemplatize(template_array, true_width)
        ox = int(params["ox"])
        oy = int(params["oy"])
        return Template(
            template_url,
            image_url,
            params.get("title"),
            detemp_array,
            ox,
            oy,
            canvas_code,
        )

    # run this part of the code in executor to make it not blocking
    template = await _get_template()
    return template

def crop_array_to_shape(array1, height, width, oy, ox):
    y0 = min(max(0, oy), array1.shape[0])
    y1 = max(0, min(array1.shape[0], oy + height))
    x0 = min(max(0, ox), array1.shape[1])
    x1 = max(0, min(array1.shape[1], ox + width))
    _cropped_array = array1[y0:y1, x0:x1].copy()
    cropped_array = np.full((height, width), 255)
    cropped_array[y0 - oy : y1 - oy, x0 - ox : x1 - ox] = _cropped_array
    return cropped_array

@in_executor()
def make_before_after_gif(
    old_temp: Template, new_temp: Template, extra_padding=5, with_text=True
) -> Image.Image:
    """
    Make a before/after GIF comparing 2 templates images layered over the canvas

    Parameters
    ----------
    old_temp: the template that will show first on the GIF
    new_temp: the template that will show last on the GIF
    extra_padding: the number of pixels to add around the image
    with_text: add a "Before" and "After" text on the image if set to True
    """

    if with_text:
        before_text = PixelText("Before", "roman", (255, 255, 255, 255), (0, 0, 0, 0))
        after_text = PixelText("After", "roman", (255, 255, 255, 255), (0, 0, 0, 0))
        text_height = before_text.font.max_height
    else:
        text_height = 0

    old_temp_x0 = old_temp.ox
    old_temp_x1 = old_temp.ox + old_temp.width
    old_temp_y0 = old_temp.oy
    old_temp_y1 = old_temp.oy + old_temp.height

    new_temp_x0 = new_temp.ox
    new_temp_x1 = new_temp.ox + new_temp.width
    new_temp_y0 = new_temp.oy
    new_temp_y1 = new_temp.oy + new_temp.height

    # origin coords
    min_y0 = min(old_temp_y0, new_temp_y0) - extra_padding - text_height
    min_x0 = min(old_temp_x0, new_temp_x0) - extra_padding
    # end coords
    max_y1 = max(old_temp_y1, new_temp_y1) + extra_padding
    max_x1 = max(old_temp_x1, new_temp_x1) + extra_padding
    # result images size
    max_height = max_y1 - min_y0
    max_width = max_x1 - min_x0

    # crop the current canvas to the result images size
    background_before = crop_array_to_shape(
        stats.board_array, max_height, max_width, min_y0, min_x0
    )
    background_before = stats.palettize_array(background_before)
    background_after = background_before.copy()

    # add padding to images so they can have the exact same size
    old_y0_offset = old_temp_y0 - min_y0
    old_x0_offset = old_temp_x0 - min_x0
    old_y1_offset = max_y1 - old_temp_y1
    old_x1_offset = max_x1 - old_temp_x1
    old_temp_padding = [
        (old_y0_offset, old_y1_offset),
        (old_x0_offset, old_x1_offset),
    ]
    array_before = np.pad(
        old_temp.palettized_array, old_temp_padding, constant_values=255
    )
    array_before = stats.palettize_array(array_before)

    new_y0_offset = new_temp_y0 - min_y0
    new_x0_offset = new_temp_x0 - min_x0
    new_y1_offset = max_y1 - new_temp_y1
    new_x1_offset = max_x1 - new_temp_x1
    new_temp_padding = [
        (new_y0_offset, new_y1_offset),
        (new_x0_offset, new_x1_offset),
    ]
    array_after = np.pad(new_temp.palettized_array, new_temp_padding, constant_values=255)
    array_after = stats.palettize_array(array_after)

    # paste the template images on the canvas and darken the canvas
    img_before = highlight_image(array_before, background_before, 0.3, (0, 0, 0, 255))
    img_after = highlight_image(array_after, background_after, 0.3, (0, 0, 0, 255))

    # add the text
    if with_text:
        before_text_image = before_text.get_image()
        after_text_image = after_text.get_image()
        img_before.paste(before_text_image, (2, 2), before_text_image)
        img_after.paste(after_text_image, (2, 2), after_text_image)

    # generate the GIF (can take long with a large image)
    frames = [img_before, img_after]
    diff_gif = BytesIO()
    save_transparent_gif(frames, 1200, diff_gif)
    diff_gif.seek(0)
    return diff_gif

@jit(nopython=True, cache=True)
def fast_max_chunk(chunked_mask):
    """find the index of the chunk with the most pixels to place in a chunk list"""
    max_index = -1
    chunk_pixels = 0
    for i, chunk in enumerate(chunked_mask):
        sum = np.sum(chunk)
        if sum > chunk_pixels:
            chunk_pixels = sum
            max_index = i
    return max_index

def layer(
    templates: Iterable[Template],
    placemap: Optional[np.ndarray] = None,
    crop_to_placemap=True,
    crop_to_template=True,
) -> tuple[int, int, np.ndarray]:
    """
    Sequentially layer each of the received templates, and return the
    corresponding ox, oy and palettized image. Result is cropped to the placemap.
    """
    if placemap is None:
        placemap = stats.placemap_array
    background = np.full_like(placemap, 255, dtype=np.uint8)
    max_x, max_y = 0, 0
    min_x, min_y = background.shape[1], background.shape[0]
    for template in templates:
        try:
            arr, ox, oy = template.crop_to_canvas()
        except ValueError:  # Template outside canvas
            continue
        # Don't update transparent pixels
        mask = arr != 255
        background[oy : oy + arr.shape[0], ox : ox + arr.shape[1]][mask] = arr[mask]
        min_x = min(ox, min_x)
        min_y = min(oy, min_y)
        max_x = max(ox + template.width, max_x)
        max_y = max(oy + template.height, max_y)
    if crop_to_placemap:
        background[placemap != 0] = 255
    if crop_to_template:
        return min_x, min_y, background[min_y:max_y, min_x:max_x]
    return 0, 0, background