import numpy as np
from django.db.models import Q, Avg, F
from .models import *
from .utils import remarks
from .utils.crawler import *

API_URL = 'https://greyhoundbet.racingpost.com/search/blocks.sd?'

def race_by_grade(races):
    """ Separa as corridas do dia em categorias """
    output = {"A": [], "D": [], "S": [], "HP": [], "OR": [], "ALL": []}
    for race in races:
        if "A" in race.grade:
            output['A'].append(race)
        elif "D" in race.grade:
            output['D'].append(race)
        elif "S" in race.grade:
            output['S'].append(race)
        elif "HP" in race.grade:
            output['HP'].append(race)
        elif "OR" in race.grade:
            output['OR'].append(race)

        output['ALL'].append(race)

    return output

def bends(race):
    output = {
        'bends': {},
        'maxbends': {
            1: 0,
            2: 0,
            3: 0,
            4: 0,
            'fin': 0
        }
    }
    for t in Trap.objects.filter(race=race):
        output['bends'][t.position] = calc_bends(t.dog, race.distance, 10)

    for i in output['bends']:
        if output['bends'][i]['bend1'] > output['maxbends'][1]:
            output['maxbends'][1] = output['bends'][i]['bend1']

        if output['bends'][i]['bend2'] > output['maxbends'][2]:
            output['maxbends'][2] = output['bends'][i]['bend2']

        if output['bends'][i]['bend3'] > output['maxbends'][3]:
            output['maxbends'][3] = output['bends'][i]['bend3']

        if output['bends'][i]['bend4'] > output['maxbends'][4]:
            output['maxbends'][4] = output['bends'][i]['bend4']

        if output['bends'][i]['fin'] > output['maxbends']['fin']:
            output['maxbends']['fin'] = output['bends'][i]['fin']

    for i in output['bends']:
        if output['maxbends'][1]:
            output['bends'][i]['bend1'] = 100 - round((output['bends'][i]['bend1'] * 100)/output['maxbends'][1], 2)
        if output['maxbends'][2]:
            output['bends'][i]['bend2'] = 100 - round((output['bends'][i]['bend2'] * 100)/output['maxbends'][2], 2)
        if output['maxbends'][3]:
            output['bends'][i]['bend3'] = 100 - round((output['bends'][i]['bend3'] * 100) / output['maxbends'][3], 2)
        if output['maxbends'][4]:
            output['bends'][i]['bend4'] = 100 - round((output['bends'][i]['bend4'] * 100) / output['maxbends'][4], 2)
        if output['maxbends']['fin']:
            output['bends'][i]['fin'] = 100 - round((output['bends'][i]['fin'] * 100) / output['maxbends']['fin'], 2)

    return output

def calc_bends(dog, distance, count):
    bends = {"bend1": 0,
             "bend2": 0,
             "bend3": 0,
             "bend4": 0,
             "fin": 0}

    for history in History.objects.filter(dog=dog, distance=distance).exclude(bends='').order_by('-date')[:count]:
        bend = history.bends

        if bend:
            bends['bend1'] += int(bend[0] or 0)
            if len(bend) > 1:
                bends['bend2'] += int(bend[1] or 0)
            if len(bend) > 2:
                bends['bend3'] += int(bend[2] or 0)
            if len(bend) > 3:
                bends['bend4'] += int(bend[3] or 0)
            bends['fin'] += int(history.placing)

    return bends

def direction(trap, distance, count):
    remarks = {
        'rls': 0,
        'mid': 0,
        'wide': 0,
    }

    for history in History.objects.filter(dog=trap.dog, distance=distance).order_by('-date')[:count]:
        if 'Rls' in history.remarks and 'Mid' in history.remarks:
            remarks['rls'] += 1
            remarks['mid'] += 1
        elif 'Rls' in history.remarks:
            remarks['rls'] += 1
        elif 'Mid' in history.remarks:
            remarks['mid'] += 1
        elif 'W' in history.remarks:
            remarks['wide'] += 1

    # Todos valores iguais, retorna center
    if all(value == remarks['rls'] for value in remarks.values()):
        return 'center'

    direction = max(remarks, key=remarks.get)
    if (trap.position == 1 or trap.position == 2) and direction == 'rls':
        return 'left'
    elif (trap.position == 1 or trap.position == 2) and (direction == 'mid' or direction == 'wide'):
        return 'right'
    elif (trap.position == 3 or trap.position == 4) and direction == 'rls':
        return 'left'
    elif (trap.position == 3 or trap.position == 4) and direction == 'mid':
        return 'center'
    elif (trap.position == 3 or trap.position == 4) and direction == 'right':
        return 'right'
    elif (trap.position == 5 or trap.position == 6) and (direction == 'rls' or direction == 'mid'):
        return 'left'
    elif (trap.position == 5 or trap.position == 6) and direction == 'wide':
        return 'center'

    return None

def avb_comparative(race_id, a, b):
    """ Efetua o comparativo entre dois galgos """
    output = {
        'a': {},
        'b': {},
        'dif': {},
        'win': {},
        'alerts': [],
        'histories': {},
        'histories_grade': {},
        'histories_trap': {},
        'infos': {},
        'trap': {}
    }

    race = Race.objects.get(race_id=race_id)
    traps = Trap.objects.filter(Q(position=a) | Q(position=b), race=race)
    for trap in traps:
        output['histories'][str(trap.position)] = History.objects.filter(dog=trap.dog).order_by('-date')[:10]
        output['histories_grade'][str(trap.position)] = History.objects.filter(dog=trap.dog, grade=race.grade).order_by('-date')[:10]
        output['histories_trap'][str(trap.position)] = History.objects.filter(dog=trap.dog, trap=trap.position).order_by('-date')[:10]
        output['infos'][str(trap.position)] = Info.objects.get(dog=trap.dog, race=race)
        output['trap'][str(trap.position)] = trap

    # embate
    embate_message = embate(race, traps[0].dog, traps[1].dog)
    output['embate'] = embate_message

    # A
    output['a']['name'] = traps[0].dog.name
    output['a']['trap'] = traps[0].position
    output['a']['sex'] = traps[0].dog.gender
    output['a']['away'] = days_away(traps[0].dog, race)

    rating_a = rating(traps[0].dog, race)
    output['a']['rating'] = rating_a

    avg_pos_a = avg_pos(traps[0].dog, race)
    output['a']['avg_pos'] = avg_pos_a

    last_split_a = last_splits(traps[0].dog, race, 1)
    output['a']['last_split'] = last_split_a

    top_split_a = top_split(traps[0].dog, race)
    output['a']['topsplit'] = top_split_a

    avg_split_a = avg_split(traps[0].dog, race)
    output['a']['avg_split'] = avg_split_a

    last_time_a = last_times(traps[0].dog, race, 1)
    output['a']['last_time'] = 0
    if last_time_a:
        output['a']['last_time'] = last_time_a[0]

    avg_time_a = avg_time(traps[0].dog, race)
    output['a']['avg_time'] = avg_time_a

    avg_winner_time_a = calc_wintime(traps[0].dog, race)
    output['a']['avg_winner_time'] = avg_winner_time_a

    brt_a, brt_date_a = best_recent_time(traps[0].dog, race)
    output['a']['brt'] = brt_a
    output['a']['brt_date'] = brt_date_a

    topspeed_a = topspeed(traps[0].dog, race)
    output['a']['topspeed'] = topspeed_a

    avg_speed_a = avg_speed(traps[0].dog, race)
    output['a']['avg_speed'] = avg_speed_a

    trainer_a = trainer(traps[0].dog, race)
    output['a']['trainer'] = trainer_a

    points_a, grade_a = last_grade(traps[0].dog)
    output['a']['points'] = points_a
    output['a']['grade'] = grade_a

    output['a']['avg_recovery'] = round(avg_time_a - avg_split_a, 2) if avg_split_a else 0
    output['a']['recovery'] = last_recovery(traps[0].dog)

    remarks_points_a = calc_remarks(traps[0].dog)
    output['a']['remarks'] = remarks_points_a

    # B
    output['b']['name'] = traps[1].dog.name
    output['b']['trap'] = traps[1].position
    output['b']['sex'] = traps[1].dog.gender
    output['b']['away'] = days_away(traps[1].dog, race)

    rating_b = rating(traps[1].dog, race)
    output['b']['rating'] = rating_b

    avg_pos_b = avg_pos(traps[1].dog, race)
    output['b']['avg_pos'] = avg_pos_b

    last_split_b = last_splits(traps[1].dog, race, 1)
    output['b']['last_split'] = last_split_b

    top_split_b = top_split(traps[1].dog, race)
    output['b']['topsplit'] = top_split_b

    avg_split_b = avg_split(traps[1].dog, race)
    output['b']['avg_split'] = avg_split_b

    last_time_b = last_times(traps[1].dog, race, 1)
    output['b']['last_time'] = 0
    if last_time_b:
        output['b']['last_time'] = last_time_b[0]

    avg_time_b = avg_time(traps[1].dog, race)
    output['b']['avg_time'] = avg_time_b

    avg_winner_time_b = calc_wintime(traps[1].dog, race)
    output['b']['avg_winner_time'] = avg_winner_time_b

    brt_b, brt_date_b = best_recent_time(traps[1].dog, race)
    output['b']['brt'] = brt_b
    output['b']['brt_date'] = brt_date_b

    topspeed_b = topspeed(traps[1].dog, race)
    output['b']['topspeed'] = topspeed_b

    avg_speed_b = avg_speed(traps[1].dog, race)
    output['b']['avg_speed'] = avg_speed_b

    trainer_b = trainer(traps[1].dog, race)
    output['b']['trainer'] = trainer_b

    points_b, grade_b = last_grade(traps[1].dog)
    output['b']['points'] = points_b
    output['b']['grade'] = grade_b

    output['b']['avg_recovery'] = round(avg_time_b - avg_split_b, 2) if avg_split_b else 0
    output['b']['recovery'] = last_recovery(traps[1].dog)

    remarks_points_b = calc_remarks(traps[1].dog)
    output['b']['remarks'] = remarks_points_b

    # rating
    output['win']['rating'], output['dif']['rating'] = diff_max(rating_a, rating_b, traps[0].position, traps[1].position)

    # avg pos
    output['win']['avg_pos'], output['dif']['avg_pos'] = diff(
        avg_pos_a, avg_pos_b, traps[0].position, traps[1].position)

    # last split
    output['win']['last_split'], output['dif']['last_split'] = diff(
        last_split_a, last_split_b, traps[0].position, traps[1].position)

    # top split
    output['win']['topsplit'], output['dif']['topsplit'] = diff(
        top_split_a, top_split_b, traps[0].position, traps[1].position)

    # avg split
    output['win']['avg_split'], output['dif']['avg_split'] = diff(
        avg_split_a, avg_split_b, traps[0].position, traps[1].position)

    # last time
    output['win']['last_time'], output['dif']['last_time'] = diff(
        output['a']['last_time'], output['b']['last_time'], traps[0].position, traps[1].position)

    # avg time
    output['win']['avg_time'], output['dif']['avg_time'] = diff(
        avg_time_a, avg_time_b, traps[0].position, traps[1].position)

    # avg winner time
    output['win']['avg_winner_time'], output['dif']['avg_winner_time'] = diff(
        avg_winner_time_a, avg_winner_time_b, traps[0].position, traps[1].position)

    # brt
    output['win']['brt'], output['dif']['brt'] = diff(
        brt_a, brt_b, traps[0].position, traps[1].position)

    # topspeed
    output['win']['topspeed'], output['dif']['topspeed'] = diff_max(
        topspeed_a, topspeed_b, traps[0].position, traps[1].position)

    # avg speed
    output['win']['avg_speed'], output['dif']['avg_speed'] = diff_max(
        avg_speed_a, avg_speed_b, traps[0].position, traps[1].position)

    # trainer
    output['win']['trainer'], output['dif']['trainer'] = diff_max(
        trainer_a, trainer_b, traps[0].position, traps[1].position)

    # grade
    output['win']['grade'], output['dif']['grade'] = diff_max(
        points_a, points_b, traps[0].position, traps[1].position)

    # alerts
    output['alerts'] = alerts_avb(traps[0].dog, race.distance, a)
    output['alerts'] += alerts_avb(traps[1].dog, race.distance, b)

    # avg recovey
    output['win']['avg_recovery'], output['dif']['avg_recovery'] = diff(
        output['a']['avg_recovery'], output['b']['avg_recovery'], traps[0].position, traps[1].position)

    # points recovery
    rec_points_a = calc_points_recovery(output['a']['recovery'])
    rec_points_b = calc_points_recovery(output['b']['recovery'])
    output['win']['recovery'], output['dif']['recovery'] = diff_max(
        rec_points_a, rec_points_b, traps[0].position, traps[1].position)

    # calc remarks
    output['win']['remarks'], output['dif']['remarks'] = diff_max(
        remarks_points_a, remarks_points_b, traps[0].position, traps[1].position)

    points = {'a': 0, 'b': 0}
    for attr in output['win']:
        if output['win'][attr] == traps[0].position:
            points['a'] += 1
        elif output['win'][attr] == traps[1].position:
            points['b'] += 1

    output['points'] = points

    return output

def calc_points_recovery(recovery):
    points = 0
    for data in recovery:
        if data == 'up':
            points += 2
        elif data == 'down':
            points -= 2
        else:
            points += 1

    return points

def calc_remarks(dog):
    sum_remarks = 0

    for history in History.objects.filter(dog=dog).order_by('-date')[:5]:
        list_remarks = history.remarks.split(',')
        for remark in list_remarks:
            if remark in remarks.remarks.keys():
                sum_remarks += remarks.remarks[remark]

    return sum_remarks

def calc_remarks_trap(dog, race):
    points = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0}
    loops = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0}

    for history in History.objects.filter(dog=dog, duration__gt=0, trap__gt=0, distance=race.distance).order_by('-date'):
        list_remarks = history.remarks.split(',')
        for remark in list_remarks:
            if remark in remarks.remarks.keys():
                points[str(history.trap)] += remarks.remarks[remark]
                loops[str(history.trap)] += 1

    for key, point in points.items():
        if loops[key] > 0:
            points[key] = round(points[key] / loops[key], 2)

    return points

def diff(value_a, value_b, position_a, position_b):
    """ Retorna o menor valor entre dois valores """
    if not value_a or not value_b:
        return 101, 0

    if value_a < value_b:
        return position_a, round((value_b - value_a), 2)
    elif value_a > value_b:
        return position_b, round((value_a - value_b), 2)
    else:
        return 100, 0

def diff_max(value_a, value_b, position_a, position_b):
    """ Retorna o maior valor entre 2 valores """
    # Cod 100 - Equal
    # Cod 101 - No image
    if not value_a or not value_b:
        return 101, 0

    if value_a > value_b:
        return position_a, round((value_a - value_b), 2)
    elif value_a < value_b:
        return position_b, round((value_b - value_a), 2)
    else:
        return 100, 0

def avb_graphic(race_id, a, b):
    output = {
        a: {},
        b: {}
    }
    race = Race.objects.get(race_id=race_id)
    traps = Trap.objects.filter(Q(position=a) | Q(position=b), race=race)

    for trap in traps:
        output[trap.position]['name'] = trap.dog.name
        output[trap.position]['times'] = last_times(trap.dog, race, 10)
        output[trap.position]['splits'] = last_splits(trap.dog, race, 10)

    return output

def rating(dog, race):
    info = Info.objects.filter(dog=dog, race=race).first()
    return info.rating

def avg_pos(dog, race):
    """ Retorna a média de posição """
    history = History.objects.filter(dog=dog, distance=race.distance, grade__in=filter_grade(race.grade)).aggregate(avg_pos=Avg('placing'))
    if history['avg_pos']:
        return round(history['avg_pos'], 2)

    return 0

def top_split(dog, race):
    """ Retorna o melhor split do galgo """
    history = History.objects.filter(dog=dog, race__track__track_id=race.track.track_id, distance=race.distance,
                                     grade__in=filter_grade(race.grade), split__gt=0).values_list('split', flat=True)
    if history:
        history = min(remove_outlier(list(map(float, history))))
        return history

    return 0

def avg_split(dog, race):
    """ Retorna a média de split do galgo """
    history = History.objects.filter(dog=dog, race__track__track_id=race.track.track_id, distance=race.distance,
                                     grade__in=filter_grade(race.grade), split__gt=0).values_list('split', flat=True)
    if history:
        history = remove_outlier(list(map(float, history)))
        return round((sum(history) / len(history)), 2)

    return 0

def avg_time(dog, race):
    """ Retorna a média de tempo do galgo """
    history = History.objects.filter(dog=dog, distance=race.distance, grade__in=filter_grade(
        race.grade), duration__gt=0).values_list('duration', flat=True)
    if history:
        history = remove_outlier(list(map(float, history)))
        return round((sum(history) / len(history)), 2)

    return 0

def last_times(dog, race, count):
    """ Retorna os últimos tempo do galgo """
    history = History.objects.filter(dog=dog, distance=race.distance, grade__in=filter_grade(
        race.grade), duration__gt=0).order_by('-date').values_list('duration', flat=True)[:count]

    if not history:
        history = History.objects.filter(dog=dog, distance=race.distance, duration__gt=0).order_by('-date').first()
        if history:
            return [float(history.duration)]
        return [0]

    return list(map(float, history))

def last_splits(dog, race, count):
    """ Retorna uma lista com a quantidade espcifica dos ultimos splits """
    history = History.objects.filter(dog=dog, race__track__track_id=race.track.track_id, distance=race.distance, grade__in=filter_grade(
        race.grade), split__gt=0).order_by('-date').values_list('split', flat=True)

    if history:
        if count > 1:
            return remove_outlier(list(map(float, history)))[:count]
        elif count == 1:
            return list(map(float, history))[0]

    return 0

def days_away(dog, race):
    info = Info.objects.get(dog=dog, race=race)
    return info.day_away

def best_recent_time(dog, race):
    info = Info.objects.get(dog=dog, race=race)
    return float(info.best_real_time or 0), info.best_real_time_date

def topspeed(dog, race):
    info = Info.objects.get(dog=dog, race=race)
    return info.top_speed

def forecast(dog, race):
    info = Info.objects.get(dog=dog, race=race)
    if info.forecast:
        return float(info.forecast)

    return 3

def avg_speed(dog, race):
    races = last_times(dog, race, 1000)
    total_races = len(races)
    if not total_races or (total_races == 1 and races[0] == 0):
        return 0
    total_time = sum(races)
    avg_time = total_time/total_races
    speed = (race.distance/1000)/(avg_time/3600)
    return round(speed, 2)

def trainer(dog, race):
    dog_info = Info.objects.get(dog=dog, race=race)
    dogs = Dog.objects.filter(info__trainer__name=dog_info.trainer.name).distinct()
    races = Race.objects.filter(info__trainer__name=dog_info.trainer.name).distinct()
    count = History.objects.filter(dog__in=dogs, race__in=races, placing=1).count()

    return count

def track_stats(track_id, distance):
    stats = TrackStats.objects.get(track_id=track_id, distance=distance)
    output = {
        'traps': [stats.trap1, stats.trap2, stats.trap3, stats.trap4, stats.trap5, stats.trap6],
        'sex': [stats.female, stats.male],
        'avg_weight': stats.avg_weight
    }
    return output

def avg_time_in_trap(dog, distance):
    times = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0}
    loops = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0}

    history = History.objects.filter(
        dog=dog, duration__gt=0, trap__gt=0, distance=distance).all()

    for history in history:
        times[str(history.trap)] += history.duration
        loops[str(history.trap)] += 1

    for key, time in times.items():
        if loops[key] > 0:
            times[key] = round(times[key] / loops[key], 2)

    return times

def alerts_avb(dog, distance, trap):
    alert = list()

    # Verificando o tempo do dog na trap
    avg_time = avg_time_in_trap(dog, distance)
    max_key = max(avg_time, key=avg_time.get)

    min_key = None
    min_value = 9999
    for key, value in avg_time.items():
        if value and value < min_value:
            min_key = key
            min_value = value

    if max_key == str(trap) and min_key != max_key:
        tmp = {"type": "alert alert-danger alert-dismissible",
               "msg": f"O galgo da trap {trap} possui a pior média de tempo correndo nessa trap. Sua melhor média de tempo é correndo na trap {min_key}"}
        alert.append(tmp)

    if min_key == str(trap) and min_key != max_key:
        tmp = {"type": "alert alert-success",
               "msg": f"O galgo da trap {trap} possui a melhor média de tempo correndo nessa trap. Sua pior média de tempo é correndo na trap {max_key}"}
        alert.append(tmp)

    return alert

def last_grade(dog):
    grade_a = ['A12', 'A11', 'A10', 'A9', 'A8',
               'A7', 'A6', 'A5', 'A4', 'A3', 'A2', 'A1']
    grade_d = ['D5', 'D4', 'D3', 'D2', 'D1']
    grade_s = ['S10', 'S9', 'S8', 'S7', 'S6', 'S5', 'S4', 'S3', 'S2', 'S1']
    grade_t = ['T5', 'T4', 'T3', 'T2', 'T1']

    grades = grade_a + grade_d + grade_s + grade_t
    history = History.objects.filter(dog=dog).order_by('-date').all()[:5]

    output = ""
    points = 0
    for data in history:

        if data.grade in grades:
            output += data.grade + " - "

            if data.grade != "A0" and "A" in data.grade:
                points += grade_a.index(data.grade)
            elif "D" in data.grade:
                points += grade_d.index(data.grade)
            elif data.grade != "HS1" and "S" in data.grade:
                points += grade_s.index(data.grade)
            elif data.grade != "IT" and data.grade != "NT" and "T" in data.grade:
                points += grade_t.index(data.grade)
            else:
                points += 1

    return points, output[:-3]

def last_recovery(dog):
    """ Retorna a recuperação do galgo nas últimas corridas """
    output = list()
    history = History.objects.filter(dog=dog).order_by('-date').all()[:12]

    for history in history:
        output.append(history.recovery())

    return output

def filter_grade(grade):
    grade_a = ['A11', 'A10', 'A9', 'A8', 'A7',
               'A6', 'A5', 'A4', 'A3', 'A2', 'A1']
    grade_b = ['B7', 'B6', 'B5', 'B4', 'B3', 'B2', 'B1']
    grade_d = ['D4', 'D3', 'D2', 'D1']
    grade_hp = ['HP']
    grade_or = ['OR']
    grade_s = ['S8', 'S7', 'S6', 'S5', 'S4', 'S3', 'S2', 'S1']
    grade_t = ['T4', 'T3', 'T2', 'T1']

    if "A" in grade:
        return grade_a
    elif "B" in grade:
        return grade_b
    elif "D" in grade:
        return grade_d
    elif "S" in grade:
        return grade_s
    elif "HP" in grade:
        return grade_hp
    elif "OR" in grade:
        return grade_or
    else:
        return grade_a + grade_t

def calc_wintime(dog, race):
    history = History.objects.filter(dog=dog, distance=race.distance, grade__in=filter_grade(race.grade), duration__gt=0, winner_time__gt=0).order_by('-date')[:10]
    sum_wintime = 0
    loop = 0
    for hist in history:
        if hist.going != "N":
            sum_wintime += float(hist.winner_time) + (int(hist.going) / 100)
        else:
            sum_wintime += float(hist.winner_time)

        loop += 1

    if loop:
        return round(sum_wintime/loop, 2)

    return 0

def remove_outlier(values):
    fator = 2.0  # 1.5 é o fator de multiplicacao
    # retorna o terceiro e primeiro quartil
    q75, q25 = np.percentile(values, [75, 25])
    iqr = q75 - q25  # calcula o iqr(interquartile range)

    # calcula o valor minimo para aplicar no filtro
    lowpass = q25 - (iqr * fator)
    # calcula o valor maximo para aplicar no filtro
    highpass = q75 + (iqr * fator)

    # descobre onde estao os valores menores que o valor minimo
    outliers = np.argwhere(values < lowpass)
    values = np.delete(values, outliers)  # deleta esses valores

    # descobre onde estao os valores maiores que o valor maximo
    outliers = np.argwhere(values > highpass)
    values = np.delete(values, outliers)  # deleta esses valores

    return list(values)

def get_greyhound_history(dog):
    URL = f'{API_URL}dog_id={str(dog.dog_id)}&blocks=search-dog-header%2Csearch-dog-details'
    data = fetch_data(URL)

    for hist_json in data['search-dog-details']['forms']:
        if hist_json['rInstId']:
            race_id = hist_json['rInstId']
        else:
            race_id = int(hist_json['raceTime'].replace(
                "-", "").split(" ")[0] + hist_json['trackId'] + hist_json['winnersTimeS'].replace(".", ""))

        if hist_json['rGradeCde']:
            grade = hist_json['rGradeCde']
        else:
            grade = hist_json['trialFlag']

        track = Track.objects.filter(
            track_id=int(hist_json['trackId'])).first()
        if not track:
            track_name = filter_name_abbr(hist_json['trackShortName'])
            track = Track.objects.create(track_id=int(hist_json['trackId']),
                                         name=track_name,
                                         country="GB")

        race = Race.objects.filter(race_id=race_id).first()
        if not race:
            race = Race.objects.create(
                track=track,
                race_id=race_id,
                distance=int(only_digits(hist_json['distMetre'])),
                grade=grade,
                date=to_datetime(hist_json['raceTime'])
            )

        history, created = History.objects.get_or_create(
            history_id=hist_json['rInstId'] or None,
            dog=dog,
            race=race,
            distance=int(only_digits(hist_json['distMetre'])),
            grade=grade,
            date=to_datetime(hist_json['raceTime']),
            winner_time=hist_json['winnersTimeS'],
            going=str(hist_json['goingType']),
            trap=int(only_digits(hist_json['trapNum'])),
            split=float(only_digits(hist_json['secTimeS'])),
            bends=hist_json['bndPos'].replace("-", ""),
            placing=int(only_digits(hist_json['rOutcomeId'])),
            odds_frctn_numer=int(only_digits(hist_json['oddsFrctnNumer'])),
            odds_frctn_denom=int(only_digits(hist_json['oddsFrctnDenom'])),
            duration=float(only_digits(hist_json['calcRTimeS'])),
            remarks=hist_json['closeUpCmnt'],
            weight=float(only_digits(hist_json['weight']))
        )

        if not created:
            break

def filter_dogs(data):
    goal = data.pop('goal')
    trap_position = data.pop('position', None)
    gender = data.pop('gender', None)
    track = data.pop('track', None)
    grade = data.pop('grade', None)
    distance = data.pop('distance', None)
    result = data.pop('result', None)
    rating = data.pop('rating', None)

    exclude_tipdetails = data.pop('exclude_tipdetails', None)
    exclude_racingpost = data.pop('exclude_racingpost', None)
    exclude_sportinglife = data.pop('exclude_sportinglife', None)
    exclude_timeform = data.pop('exclude_timeform', None)

    exclude_query = Q()
    if exclude_tipdetails:
        exclude_query |= Q(tipdetails__in=data.pop('tipdetails', []))
    if exclude_racingpost:
        exclude_query |= Q(racingpost__in=data.pop('racingpost', []))
    if exclude_sportinglife:
        exclude_query |= Q(sportinglife__in=data.pop('sportinglife', []))
    if exclude_timeform:
        exclude_query |= Q(timeform__in=data.pop('timeform', []))

    query = {f'{k}__in':v for k, v in data.items()}
    trap_position and query.setdefault('trap__position__in', trap_position)
    gender and query.setdefault('trap__dog__gender__in', gender)
    track and query.setdefault('trap__race__track__in', track)
    grade and query.setdefault('trap__race__grade__in', grade)
    distance and query.setdefault('trap__race__distance__in', distance)
    result and query.setdefault('trap__race__result__placing__in', result)

    if result:
        query.setdefault('trap__race__result__trap', F('trap'))

    if rating:
        rating = rating.split(":")
        query.setdefault('trap__race__info__rating__gte', int(rating[0]))
        query.setdefault('trap__race__info__rating__lte', int(rating[1]))
        query.setdefault('trap__race__info__dog', F('trap__dog'))

    scores = Score.objects \
        .prefetch_related('trap', 'trap__race', 'trap__dog') \
        .filter(**query).exclude(exclude_query).order_by('trap__race__date')

    output = {
        'list': [],
        'results': dict()
    }
    green = 0
    red = 0
    wait = 0
    for data in scores:
        result =  verify_result(data.trap, goal,  data.trap.race.result())

        if 'green' in result:
            green += 1
        elif 'red' in result:
            red += 1
        elif 'wait' in result:
            wait += 1

        tmp = {
            "race": data.trap.race,
            "dog": data.trap.dog,
            'info': Info.objects.get(race=data.trap.race, dog=data.trap.dog),
            "trap": data.trap,
            "status": result,
            "score": data
        }
        output['list'].append(tmp)

    output['results'] = {
        'green': green,
        'red': red,
        'percent': round(green/(green + red), 2) * 100 if (green+red) else 0,
        'wait': wait
    }

    return output

def last_weight(dog):
    hist = History.objects.filter(dog=dog).order_by('-date').first()
    if hist:
        return hist.weight
    return 0

def transform_in_rank(data, reverse=False):
        points = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0}

        i = 6
        lastValue = None
        for key in sorted(data, key=data.get, reverse=reverse):
            if lastValue and lastValue != data[key]:
                i -= 1

            lastValue = data[key]
            points[key] = i

        for point in sorted(points, key=points.get, reverse=reverse):
            points[str(point)] = (7 - points[str(point)])

        return points

def verify_result(trap, objetive, result):
    if result:
        if objetive == "back":
            if trap.position == int(result[0]):
                return '<i title="Green" class="fas fa-check-circle green"></i>'
        elif objetive == "backplaced":
            if trap.position == int(result[0]) or trap.position == int(result[1]):
                return '<i title="Green" class="fas fa-check-circle green"></i>'
        elif objetive == "lay":
            if trap.position != int(result[0]):
                return '<i title="Green" class="fas fa-check-circle green"></i>'
        elif objetive == "layplaced":
            if trap.position != int(result[0]) and trap.position != int(result[1]):
                return '<i title="Green" class="fas fa-check-circle green"></i>'
        elif objetive == "top3":
                if len(result) >= 3:
                    if trap.position == int(result[0]) or trap.position == int(result[1]) or trap.position == int(result[2]):
                        return '<i title="Green" class="fas fa-check-circle green"></i>'

        return '<i title="Red" class="fas fa-times red"></i>'

    return '<i title="Sem Resultado" class="fas fa-spinner wait"></i>'

def embate(race, dog_a, dog_b):
    hist1 = History.objects.filter(dog=dog_a).exclude(grade__in=['T1', 'T2', 'T3', 'T4', 'T5']).order_by('-date')
    hist2 = History.objects.filter(dog=dog_b).exclude(grade__in=['T1', 'T2', 'T3', 'T4', 'T5']).order_by('-date')
    messages = list()
    for h1 in hist1:
        for h2 in hist2:
            if h1.race.date == h2.race.date:
                if h1.placing < h2.placing:
                    msg = f'{h1.dog.name} correndo pela trap {h1.trap} enfrentou {h2.dog.name} correndo pela trap {h2.trap} no dia {h1.date.strftime("%d-%m-%Y")} e ganhou o AvB.<br> {h1.dog.name} chegou na posição {h1.placing} com o tempo de {h1.duration} e {h2.dog.name} chegou em {h2.placing} com o tempo de {h2.duration}'
                else:
                    msg = f'{h2.dog.name} correndo pela trap {h2.trap} enfrentou {h1.dog.name} correndo pela trap {h1.trap} no dia {h2.date.strftime("%d-%m-%Y")} e ganhou o AvB.<br> {h2.dog.name} chegou na posição {h2.placing} com o tempo de {h2.duration} e {h1.dog.name} chegou em {h1.placing} com o tempo de {h1.duration}'

                if h1.race.video and h1.race.video.url:
                    msg += f' <br><a href="javascript:void(0)" data-toggle="modal" data-target="#modalVideo" data-msg="{h1.dog.name} na trap {h1.trap} e {h2.dog.name} na trap {h2.trap}" data-video="{h1.race.video.url}"><img width="22" src="/static/images/player.png"> Assistir Corrida</a>'
                messages.append({ "race": h1.race, "message": msg})
    return messages


import time
import datetime
import math

import numpy as np
import os
import sys
import argparse
import json
import glfw
import cv2
from OpenGL.GL import *

from psd_tools import PSDImage

path = os.getcwd()
sys.path.append(path)

import src.face_tracker as face_tracker
import src.matrix as matrix

config_data = {}

voice_mode_file = None
should_close_window = False
is_first_run = True

def extract_layers_from_psd(psd):
    all_layers = []

    def dfs(layer, path=''):
        if layer.is_group():
            for i in layer:
                dfs(i, path + layer.name + '/')
        else:
            a, b, c, d = layer.bbox
            npdata = layer.numpy()
            npdata[:, :, 0], npdata[:, :, 2] = npdata[:, :, 2].copy(), npdata[:, :, 0].copy()
            all_layers.append({'layer_path': path + layer.name, 'layer_location': (b, a, d, c), 'npdata': npdata})

    for layer in psd:
        dfs(layer)
    return all_layers, psd.size

def add_depth_to_layers(all_layers):
    depth_info = config_data['psd_layer_depths']
    for layer in all_layers:
        if layer['layer_path'] in depth_info:
            layer['layer_depth'] = depth_info[layer['layer_path']]

motion_buffer = None

def use_motion_buffer():
    global motion_buffer
    buffer_strength = config_data['motion_buffer_strength']
    face_orientation = face_tracker.get_current_face_orientation()
    if motion_buffer is None:
        motion_buffer = face_orientation
    else:
        motion_buffer = motion_buffer * buffer_strength + face_orientation * (1 - buffer_strength)
    return motion_buffer

def show_transparent_window():
    glfw.window_hint(glfw.DECORATED, False)
    glfw.window_hint(glfw.TRANSPARENT_FRAMEBUFFER, True)
    glfw.window_hint(glfw.FLOATING, True)

def gl_drawing_loop(all_layers, psd_size):
    def generate_texture(img):
        w, h = img.shape[:2]
        d = 2 ** int(max(math.log2(w), math.log2(h)) + 1)
        texture = np.zeros([d, d, 4], dtype=img.dtype)
        texture[:w, :h] = img
        return texture, (w / d, h / d)

    glfw.init()
    show_transparent_window()
    glfw.window_hint(glfw.RESIZABLE, False)
    window = glfw.create_window(*config_data['renderer_window_size'], config_data['config_name'], None, None)
    glfw.make_context_current(window)
    monitor_size = glfw.get_video_mode(glfw.get_primary_monitor()).size
    glfw.set_window_pos(window, monitor_size.width - config_data['renderer_window_size'][0] +
                        config_data['renderer_window_position_offset'][0],
                        monitor_size.height - config_data['renderer_window_size'][1] +
                        config_data['renderer_window_position_offset'][1])

    glViewport(0, 0, *config_data['renderer_window_size'])

    glEnable(GL_TEXTURE_2D)
    glEnable(GL_BLEND)
    glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA)

    for layer in all_layers:
        texture_id = glGenTextures(1)
        glBindTexture(GL_TEXTURE_2D, texture_id)
        texture, texture_location = generate_texture(layer['npdata'])
        width, height = texture.shape[:2]
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_BGRA, GL_FLOAT, texture)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
        glGenerateMipmap(GL_TEXTURE_2D)
        layer['layer_id'] = texture_id
        layer['texture_location'] = texture_location

    while not (glfw.window_should_close(window) or should_close_window):
        glfw.poll_events()
        glClearColor(0, 0, 0, 0)
        glClear(GL_COLOR_BUFFER_BIT)
        horizontal_rotation_val, vertical_rotation_val = use_motion_buffer()

        for layer in all_layers:
            if layer['layer_path'] in config_data['psd_eye_layers'] + config_data['psd_mouth_layers']:
                if layer['layer_path'] != config_data['psd_eye_layer_prefix'] \
                        + config_data['psd_eye_layer_suffix'] % face_tracker.get_current_eye_size() \
                        and layer['layer_path'] != config_data['psd_mouth_layer_prefix'] + \
                        config_data['psd_mouth_layer_suffix'] % face_tracker.get_current_mouth_size():
                    continue

            a, b, c, d = layer['layer_location']
            z = layer['layer_depth']
            if type(z) in [int, float]:
                z1, z2, z3, z4 = [z, z, z, z]
            else:
                [z1, z2], [z3, z4] = z
            q, w = layer['texture_location']
            p1 = np.array([a, b, z1, 1, 0, 0, 0, z1])
            p2 = np.array([a, d, z2, 1, z2 * w, 0, 0, z2])
            p3 = np.array([c, d, z3, 1, z3 * w, z3 * q, 0, z3])
            p4 = np.array([c, b, z4, 1, 0, z4 * q, 0, z4])

            model = matrix.scale(2 / psd_size[0], 2 / psd_size[1], 1) @ \
                    matrix.translate(-1, -1, 0) @ \
                    matrix.rotate_ax(-math.pi / 2, axis=(0, 1))
            glBindTexture(GL_TEXTURE_2D, layer['layer_id'])
            glColor4f(1, 1, 1, 1)
            glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)
            glBegin(GL_QUADS)
            for p in [p1, p2, p3, p4]:
                a = p[:4]
                b = p[4:8]
                a = a @ model
                a[0:2] *= a[2]
                if not layer['layer_path'] == config_data['psd_body_layer_name']:
                    a = a @ matrix.translate(0, 0, -1) \
                        @ matrix.rotate_ax(horizontal_rotation_val, axis=(0, 2)) \
                        @ matrix.rotate_ax(vertical_rotation_val, axis=(2, 1)) \
                        @ matrix.translate(0, 0, 1)
                a = a @ matrix.perspective(999)
                glTexCoord4f(*b)
                glVertex4f(*a)
            glEnd()
        glfw.swap_buffers(window)
        if config_data['debug']:
            cv2.imshow("Camera Debug", face_tracker.get_debug_camera_image())

    glfw.destroy_window(window)
    cv2.destroyWindow("Camera Debug")

def dir_path(string):
    if os.path.isfile(string):
        return string
    else:
        raise NotADirectoryError(string)

def manual_start(_config_data_, is_debug_enabled=False):
    global config_data
    global should_close_window
    global is_first_run
    should_close_window = False

    config_data = _config_data_
    config_data['debug'] = is_debug_enabled

    if not is_first_run:
        print_logging_info('face tracker and renderer has been resumed')

    if is_debug_enabled:
        print_logging_info('debug mode is ON')
        global face_tracker_initialize_time, psd_load_time

    print_logging_info('loaded config: ' + config_data['config_name'])

    if is_debug_enabled:
        print_logging_info('initializing face tracker...')
        face_tracker_initialize_time = time.time()

    face_tracker.set_config_data(config_data)

    face_tracker.resume_face_tracker()

    while face_tracker.get_camera_image() is None:
        time.sleep(0.1)

    if is_debug_enabled:
        face_tracker_initialize_time = time.time() - face_tracker_initialize_time
        print_logging_info('face tracker initialized in ' + '%.0fms' % (face_tracker_initialize_time * 1000))

    if is_debug_enabled:
        print_logging_info('loading PSD file...')
        psd_load_time = time.time()

    if is_first_run:
        global psd, all_layers, size
        psd = PSDImage.open(config_data['psd_file_path'])
        all_layers, size = extract_layers_from_psd(psd)
        add_depth_to_layers(all_layers)

    if is_debug_enabled:
        psd_load_time = time.time() - psd_load_time
        print_logging_info('PSD file loaded in ' + '%.0fms' % (psd_load_time * 1000))
        print_logging_info('OpenGL drawing will start NOW...')

    gl_drawing_loop(all_layers, size)
    is_first_run = False

def manual_stop():
    global should_close_window
    should_close_window = True
    face_tracker.pause_face_tracker()
    print_logging_info('face tracker and renderer has been stopped or paused')

class Colors:
    HEADER = '\033[95m'
    OKBLUE = '\033[94m'
    OKCYAN = '\033[96m'
    OKGREEN = '\033[92m'
    WARNING = '\033[93m'
    FAIL = '\033[91m'
    ENDC = '\033[0m'
    BOLD = '\033[1m'
    UNDERLINE = '\033[4m'

class ConsoleTextHeaders:
    INFO = '[' + Colors.OKBLUE + 'INFO' + Colors.ENDC + '] '

def print_logging_info(string):
    print(str(datetime.datetime.now().time()) + ' ' + ConsoleTextHeaders.INFO + string)

if __name__ == '__main__':
    parser = argparse.ArgumentParser()

    parser.add_argument('config',
                        type=dir_path,
                        help='path to the config file (json)')

    parser.add_argument('-d', '--debug',
                        dest='debug',
                        action='store_true',
                        help='toggle debug mode (show face landmarks)')

    args = parser.parse_args()

    if args.debug:
        print_logging_info('debug mode is ON')
        global face_tracker_initialize_time, psd_load_time

    config_file = open(args.config, encoding='utf8')
    config_data = json.load(config_file)
    config_file.close()

    config_data['debug'] = args.debug

    print_logging_info('loaded config: ' + config_data['config_name'])

    face_tracker.set_config_data(config_data)

    if args.debug:
        print_logging_info('initializing face tracker...')
        face_tracker_initialize_time = time.time()

    while face_tracker.get_camera_image() is None:
        time.sleep(0.1)

    if args.debug:
        face_tracker_initialize_time = time.time() - face_tracker_initialize_time
        print_logging_info('face tracker initialized in ' + '%.0fms' % (face_tracker_initialize_time * 1000))

    if args.debug:
        print_logging_info('loading PSD file...')
        psd_load_time = time.time()

    psd = PSDImage.open(config_data['psd_file_path'])

    all_layers, size = extract_layers_from_psd(psd)
    add_depth_to_layers(all_layers)

    if args.debug:
        psd_load_time = time.time() - psd_load_time
        print_logging_info('PSD file loaded in ' + '%.0fms' % (psd_load_time * 1000))
        print_logging_info('OpenGL drawing will start NOW...')

    gl_drawing_loop(all_layers, size)
    exit(0)


# Script that parses the Site of Interest annotations from a MAPS XML file
# Developed with data from MAPS Viewer 3.6

import xml.etree.ElementTree as ET
import csv
import ast
import numpy as np
import pandas as pd
import math
from pathlib import Path
import logging
import copy
import multiprocessing
import unicodedata
import random

class XmlParsingFailed(Exception):
    def __init__(self, message):
        logging.error(message)

class MapsXmlParser:
    """XML Parser class that processes MAPS XMLs and reveals the image tiles belonging to each annotation

    This class takes XML files from the Thermo MAPS Application, extracts the location of all its annotations and
    compares it to the location of all the image tiles. This gets a bit more complicated, because tiles only have a
    relative position in pixels within their layers and those layers are (potentially) turned at arbitrary angles
    relative to the global coordinate system (which is in meters).
    It is important that the functions are run in the correct order to extract & process this information. Therefore,
    use it by initializing the class and the calling parser.parse_xml().

    Args:
        project_folder (str): The path to the project folder of the MAPS project, containing the XML file, as a string
        name_of_highmag_layer (str): Name of the image layer in MAPS for which tiles containing annotations should
            be found. Defaults to 'highmag'
        use_unregistered_pos (bool): Whether to use the unregistered position of the tiles (where MAPS thinks it
            acquired them => True, default) or a calculated position (e.g. through stitching in MAPS) should be used for
            the tiles (False)
        stitch_radius (int): The number of images in each direction from the tile containing the annotation should be
            stitched. Parser doesn't do the stitching, just extracts relevant information about neighboring tiles.
            Defaults to 1 => 3x3 images would be stitched.

    Attributes:
        project_folder_path (str): The path to the project folder of the MAPS project, containing the XML file, as a
            string
        layers (dict): Contains information about the layers (squares/acquisitions). The keys are local paths to
            the metadata about the layer and the values are dictionaries again. Each layer contains the information
            about the stage position of the center of the layer (in meters, StagePosition_center_x &
            StagePosition_center_y), about the rotation of the layer relative to the global stage positions (in degrees,
            rotation), the number of columns of tiles (images) in the layer (columns), the vertical & horizontal overlap
            between the tiles (overlapVertical, overlapHorizontal), the number of rows of tiles (images) in the layer
            (rows), the horizontal field width of each tile (its width in meters, tileHfw), the horizontal field width
            of the whole layer (its width in meters, totalHfw), the name of the layer (layer_name), the vertical field
            width of each tile (its width in meters, tileVfw), and the global stage position of the corner of the layer
            (in meters, StagePosition_corner_x & StagePosition_corner_y)
        tiles (dict): Contains information about individual tiles. The keys are the combined layer name & filename of
            the tile and the values are a dictionary again. Each tile contains the information about what layer it
            belongs to (layer, key to the layer dict), the path to the image as a Path variable (img_path), its
            filename, the name of the layer (layer_name) and its relative position x & y within that layer
            (RelativeTilePosition_x & RelativeTilePosition_y)
        annotations (dict): Contains the information about all the annotations. The keys are the names of the
            annotations (MAPS enforces uniqueness), its values are a dictionary containing the StagePosition_x &
            StagePosition_y positions of the annotation (in m => global coordinate system for the experiment)
        annotation_tiles (dict): Contains the relevant output of the XML parsing. The keys are the names of the
            annotation. The values are the key to the corresponding layer in the layer dict (layer), the path to the
            image as a Path variable (img_path), its filename, the name of the layer (layer_name) and the relative
            position x & y of the tile within that layer (RelativeTilePosition_x & RelativeTilePosition_y), the
            absolute stage position of the annotation (Annotation_StagePosition_x and Annotation_StagePosition_y), the
            position of the annotation within the tile image (in pixels, Annotation_tile_img_position_x &
            Annotation_tile_img_position_y), a list of the surrounding tile names (surrounding_tile_names) and a list of
            booleans of whether each of the surrounding tiles exist, including the tile itself (surrounding_tile_exists)
        stitch_radius (int): The number of images in each direction from the tile containing the annotation should be
            stitched.
        pixel_size (float): The pixel size of the acquisition in the name_of_highmag_layer [in meters]
        img_height (int): The height of the highmag image in pixels
        img_width (int): The width of the highmag image in pixels

    """

    def __init__(self, project_folder: str, name_of_highmag_layer: str = 'highmag', use_unregistered_pos: bool = True,
                 stitch_radius: int = 1):
        self.project_folder_path = Path(project_folder)
        self.layers = {}
        self.tiles = {}
        self.annotations = {}
        self.annotation_tiles = {}
        self.stitch_radius = stitch_radius
        self.pixel_size = 0.0

        # Usage of width and height may be switched, as Talos images are always square and
        # I couldn't test non-square images
        self.img_height = 0
        self.img_width = 0

        # Internal variables
        self._name_of_highmag_layer = name_of_highmag_layer
        xml_file_name = 'MapsProject.xml'
        xml_file_path = self.project_folder_path / xml_file_name
        self._xml_file = self.load_xml(xml_file_path)
        self._tile_names = []
        self._tile_center_stage_positions = []

        if use_unregistered_pos:
            self._position_to_extract = 'UnalignedPosition'
        else:
            self._position_to_extract = 'CalculatedPosition'

    @staticmethod
    def load_xml(xml_file_path):
        """Loads and returns the MAPS XML File

        Args:
            xml_file_path (Path): Path to the XML file as a pathlib path

        Returns:
            root: The root of the XML file parsed with xml.etree.ElementTree

        """
        try:
            root = ET.parse(xml_file_path).getroot()
        except FileNotFoundError:
            raise XmlParsingFailed("Can't find the MAPS XML File at the location {}".format(xml_file_path))
        return root

    def parse_xml(self):
        """Run function for the class

        parse_xml calls all the necessary functions of the class in the correct order to parse the XML file. Call this
        function after initializing a class object, then access the results via the annotation_tiles variable that
        contains the relevant output of the XML parsing

        Returns:
            dict: annotation_tiles

        """
        self.extract_layers_and_annotations()
        self.get_relative_tile_locations()
        self.calculate_absolute_tile_coordinates()
        self.find_annotation_tile()
        self.determine_surrounding_tiles()
        return self.annotation_tiles

    @staticmethod
    def convert_windows_pathstring_to_path_object(string_path):
        """Converts a windows path string to a path object

        Some paths are provided in the XML file and the metadata as Windows paths. This function creates pathlib Path
        objects out of them.

        Args:
            string_path (str): String of a Windows path containing double backslashes

        Returns:
            path: Path object of the string_path

        """
        folders = string_path.split('\\')
        path = Path()
        for folder in folders:
            path = path / folder
        return path

    def convert_img_path_to_local_path(self, img_path):
        """Converts a local path of the microscope computer to a path of the image in the project folder

        MAPS saves the image paths of the local storage of the image files. In our setup, this path starts with
        'D:\\ProjectName', even though the files aren't actually on the D drive anymore but were copied to a share, into
        the project_folder_path. This function strips 'D:\\ProjectName' away from the path and returns a Path object for
        the location of the images on the share.

        Args:
            img_path (str): Original path to the images on the microscope computer, starting with 'D:\\ProjectName'

        Returns:
            path: Path object of the corrected path on the share

        """
        folders = img_path.split('\\')
        path = self.project_folder_path
        try:
            layersdata_index = folders.index('LayersData')
        except ValueError:
            raise XmlParsingFailed('Could not find the folder LayersData that should contain the raw data in the '
                                   'filepath for the iamge_files: {}'.format(img_path))
        for folder in folders[layersdata_index:]:
            path = path / folder
        return path

    @staticmethod
    def create_logger(log_file_path, multiprocessing_logger: bool = False):
        """ Returns a logger and creates it if it doesn't yet exist

        Gets the correct logger for normal or multiprocessing. If it already exists, just returns this logger. If it
        doesn't exist yet, sets it up correctly.

        Args:
            log_file_path (str): Path to the log file
            multiprocessing_logger (bool): Whether a multiprocessing logger is needed. Defaults to False.

        Returns:
            logger: Logger object that has the correct handlers

        """
        if multiprocessing_logger:
            logger = multiprocessing.get_logger()
            logger.setLevel(logging.INFO)
            logger.propagate = True
        else:
            logger = logging.getLogger(__name__)

        # If the logger doesn't have any handlers yet, add them. Otherwise, just return the logger
        if not len(logger.handlers):
            logger.setLevel(logging.INFO)

            formatter = logging.Formatter('%(asctime)s : %(levelname)s : %(message)s')

            # Create file logging handler
            fh = logging.FileHandler(log_file_path)
            fh.setLevel(logging.INFO)
            fh.setFormatter(formatter)
            logger.addHandler(fh)

            # Create console logging handler
            ch = logging.StreamHandler()
            ch.setLevel(logging.INFO)
            logger.addHandler(ch)

        return logger

    def extract_layers_and_annotations(self, check_for_annotations: bool = True):
        """Extract the information about all the layers in the high magnification acquisition layers and the annotations

        Go through the XML file and submit all LayerGroups for processing. The LayerGroups contain both the Layer with
        the _name_of_highmag_layer (=> the actual images) as well as the Annotation Layers containing the annotations.
        At the end of processing, check whether the self.layers and the self.annotations dictionaries were filled. If
        one of them was not filled, the parser could not find the highmag images or the annotations and raises an
        exception.

        Args:
            check_for_annotations (bool): Whether the parsing should check for the existence of annotations and throw an
                error if none are found. Defaults to True, thus checking for annotations.

        """
        # Extract the information about all the layers (high magnification acquisition layers)
        for xml_category in self._xml_file:
            if xml_category.tag.endswith('LayerGroups'):
                for sub_category in xml_category:
                    if sub_category.tag.endswith('LayerGroup'):
                        self._process_layer_group(sub_category)

        # If the parsing did not find any tiles, raise an error
        if not self.layers:
            raise XmlParsingFailed('Parsing the XML File did not find any tiles (images). Therefore, cannot map '
                                   'annotations')

        if check_for_annotations and not self.annotations:
            raise XmlParsingFailed('No annotations were found on tiles. Are there annotations in the MAPS project? '
                                   'Are those annotations on the highmag tiles? If so, there may be an issue with the '
                                   'calculation of the positions of the annotations. Check whether '
                                   'find_annotation_tile gave any warnings.')

    def _process_layer_group(self, layer_group):
        """Recursively go through the LayerGroup and send TileLayer & AnnotationLayer for processing

        Goes through a given LayerGroup. If there are nested LayerGroups, it calls the function recursively. If a Layer
        is a TileLayer and its name is the name of the highmag layer, it sends it for processing to
        self._process_tile_layer. If a Layer is a Annotation Layer (independent of whether it's in a Layer Group that
        has the highmag name or not), it is sent for processing in self._extract_annotation_locations.

        Args:
            layer_group: Part of the XML object that contains the information for a LayerGroup

        """
        log_file_path = str(self.project_folder_path / (self.project_folder_path.name + '.log'))
        logger = self.create_logger(log_file_path)
        for ggc in layer_group:
            # Get the path to the metadata xml files for all the highmag layers,
            # the pixel size and the StagePosition of the layers
            if ggc.tag.endswith('displayName') and ggc.text == self._name_of_highmag_layer:
                logger.info('Extracting images from {} layers'.format(ggc.text))

                for highmag in layer_group:
                    if highmag.tag.endswith('Layers'):
                        for layer in highmag:
                            # Check if this layers is a TileLayer or any other kind of layers.
                            # Only proceed to process TileLayers
                            layer_type = layer.attrib['{http://www.w3.org/2001/XMLSchema-instance}type']
                            if layer_type == 'TileLayer':
                                self._process_tile_layer(layer)
                            elif layer_type == 'LayerGroup':
                                # If there are nested LayerGroups, recursively call the function again
                                # with this LayerGroup
                                self._process_layer_group(layer)
                            elif layer_type == 'AnnotationLayer':
                                self._extract_annotation_locations(layer)
                            else:
                                logger.warning('XML Parser does not know how to deal with {} Layers and '
                                               'therefore does not parse them'.format(layer_type))
            else:
                if ggc.tag.endswith('Layers'):
                    for layer in ggc:
                        layer_type = layer.attrib['{http://www.w3.org/2001/XMLSchema-instance}type']
                        if layer_type == 'AnnotationLayer':
                            self._extract_annotation_locations(layer)
                        elif layer_type == 'LayerGroup':
                            # If there are nested LayerGroups, recursively call the function again
                            # with this LayerGroup
                            self._process_layer_group(layer)

    def _process_tile_layer(self, layer):
        """Extracts all necessary information of a highmag Tile Layer and saves it to self.layers

        Args:
            layer: Part of the XML object that contains the information for a TileLayer

        """
        layer_type = layer.attrib['{http://www.w3.org/2001/XMLSchema-instance}type']
        assert (layer_type == 'TileLayer')
        for layer_content in layer:
            if layer_content.tag.endswith('metaDataLocation'):
                metadata_location = layer_content.text
                self.layers[metadata_location] = {}
        try:
            for layer_content in layer:
                if layer_content.tag.endswith('totalHfw'):
                    # noinspection PyUnboundLocalVariable
                    self.layers[metadata_location]['totalHfw'] = float(list(layer_content.attrib.values())[1])

                if layer_content.tag.endswith('tileHfw'):
                    # noinspection PyUnboundLocalVariable
                    self.layers[metadata_location]['tileHfw'] = float(list(layer_content.attrib.values())[1])

                if layer_content.tag.endswith('overlapHorizontal'):
                    # noinspection PyUnboundLocalVariable
                    self.layers[metadata_location]['overlapHorizontal'] = float(layer_content[0].text) / 100.
                if layer_content.tag.endswith('overlapVertical'):
                    # noinspection PyUnboundLocalVariable
                    self.layers[metadata_location]['overlapVertical'] = float(layer_content[0].text) / 100.

                if layer_content.tag.endswith('rotation'):
                    # noinspection PyUnboundLocalVariable
                    self.layers[metadata_location]['rotation'] = float(list(layer_content.attrib.values())[1])

                if layer_content.tag.endswith('rows'):
                    # noinspection PyUnboundLocalVariable
                    self.layers[metadata_location]['rows'] = int(layer_content.text)

                if layer_content.tag.endswith('columns'):
                    # noinspection PyUnboundLocalVariable
                    self.layers[metadata_location]['columns'] = int(layer_content.text)

                if layer_content.tag.endswith('scanResolution'):
                    for scanres_info in layer_content:
                        if scanres_info.tag.endswith('height'):
                            height = int(scanres_info.text)
                            if self.img_height == height or self.img_height == 0:
                                self.img_height = height
                            else:
                                raise Exception(
                                    'Image height needs to be constant for the whole {} layer. It was {} before and '
                                    'is {} in the current layer'.format(self._name_of_highmag_layer,
                                                                        self.img_height, height))
                        if scanres_info.tag.endswith('width'):
                            width = int(scanres_info.text)
                            if self.img_width == width or self.img_width == 0:
                                self.img_width = width
                            else:
                                raise Exception(
                                    'Image width needs to be constant for the whole {} layer. It was {} before and '
                                    'is {} in the current layer'.format(self._name_of_highmag_layer,
                                                                        self.img_width, width))

                if layer_content.tag.endswith('pixelSize'):
                    pixel_size = float(layer_content.attrib['Value'])
                    if self.pixel_size == pixel_size or self.pixel_size == 0:
                        self.pixel_size = pixel_size
                    else:
                        raise Exception('Pixel size needs to be constant for the whole {} layer. It was {} before and '
                                        'is {} in the current layer'.format(self._name_of_highmag_layer,
                                                                            self.pixel_size, pixel_size))

                if layer_content.tag.endswith('StagePosition'):
                    for positon_info in layer_content:
                        if positon_info.tag == '{http://schemas.datacontract.org/2004/07/Fei.Applications.SAL}x':
                            # noinspection PyUnboundLocalVariable
                            self.layers[metadata_location][
                                'StagePosition_center_x'] = float(positon_info.text)
                        elif positon_info.tag == '{http://schemas.datacontract.org/2004/07/Fei.Applications.SAL}y':
                            # noinspection PyUnboundLocalVariable
                            self.layers[metadata_location][
                                'StagePosition_center_y'] = float(positon_info.text)
        except NameError:
            raise XmlParsingFailed("Can't find the metaDataLocation in the MAPS XML File")

    def _extract_annotation_locations(self, annotation_layer):
        """Extract annotation metadata from the XML file and saves them to the self.annotations dictionary

        Only Sites Of Interest Annotations are processed. Areas of Interest are ignored. Gets the
        x & y position of the annotation & the annotation name and saves them in the annotations dictionary.
        The keys are the names of the annotations (MAPS enforces uniqueness), its values are a dictionary containing the
        StagePosition_x & StagePosition_y positions of the annotation (in m => global coordinate system for the
        experiment)

        """
        for potential_annotation_content in annotation_layer:
            # Only check Sites Of Interest, not Area of Interest. Both are Annotation Layers, but Areas of Interest
            # have the isArea value as true
            if potential_annotation_content.tag.endswith('isArea') and potential_annotation_content.text == 'false':
                for annotation_content in annotation_layer:
                    if annotation_content.tag.endswith('RealDisplayName'):
                        annotation_name = self.create_valid_name(annotation_content.text)
                        self.annotations[annotation_name] = {}
                try:
                    for annotation_content in annotation_layer:
                        if annotation_content.tag.endswith('StagePosition'):
                            for a in annotation_content:
                                if a.tag == '{http://schemas.datacontract.org/2004/07/Fei.Applications.SAL}x':
                                    # noinspection PyUnboundLocalVariable
                                    self.annotations[annotation_name]['StagePosition_x'] = float(a.text)
                                elif a.tag == '{http://schemas.datacontract.org/2004/07/Fei.Applications.SAL}y':
                                    # noinspection PyUnboundLocalVariable
                                    self.annotations[annotation_name]['StagePosition_y'] = float(a.text)
                except NameError:
                    raise XmlParsingFailed("Can't find the Annotations Names in the MAPS XML File")

    def get_relative_tile_locations(self):
        """Read in all the metadata files for the different layers to get the relative tile positions

        Each layer has its own metadata XML file that contains the relative positions of all the tiles in the layer.
        This function goes through all of them, extracts the information and saves it to the tiles dictionary.
        The keys are the combined layer name & filename of the tile and the values are a dictionary again. Each tile
        contains the information about what layer it belongs to (layer, key to the layer dict), the path to the image as
        a Path variable (img_path), its filename, the name of the layer (layer_name) and its relative position x & y
        within that layer (RelativeTilePosition_x & RelativeTilePosition_y)

        """
        metadata_filename = 'StitchingData.xml'
        for metadata_location in self.layers:
            tile_image_folder_path = ''
            metadata_path = self.project_folder_path.joinpath(
                self.convert_windows_pathstring_to_path_object(metadata_location)) / metadata_filename
            try:
                metadata_root = ET.parse(metadata_path).getroot()
            except FileNotFoundError:
                log_file_path = str(self.project_folder_path / (self.project_folder_path.name + '.log'))
                logger = self.create_logger(log_file_path)
                logger.warn('Could not find the Metadata file for layer {}. Skipping it. If this layer contained ' +
                            'annotations, those cannot be stitched afterwards.'.format(metadata_path))
                continue

            for metadata_child in metadata_root:
                if metadata_child.tag.endswith('tileSet'):
                    for metadata_grandchild in metadata_child:
                        if metadata_grandchild.tag.endswith('TileImageFolder'):
                            tile_image_folder_path = metadata_grandchild.text
                            current_layer = tile_image_folder_path.split('\\')[-1]
                            self.layers[metadata_location]['layer_name'] = current_layer

                        if metadata_grandchild.tag.endswith('_tileCollection'):
                            for ggc in metadata_grandchild:
                                if ggc.tag.endswith('_innerCollection'):
                                    for gggc in ggc:
                                        for keyvalue in gggc:
                                            if keyvalue.tag.endswith('Value'):
                                                for value in keyvalue:
                                                    if value.tag.endswith('ImageFileName'):
                                                        # noinspection PyUnboundLocalVariable
                                                        tile_name = current_layer + '_' + value.text
                                                        self.tiles[tile_name] = {'layers': metadata_location,
                                                                                'img_path': self.convert_img_path_to_local_path(
                                                                                tile_image_folder_path),
                                                                                'filename': value.text,
                                                                                'layer_name': current_layer}

                                                for value in keyvalue:
                                                    if value.tag.endswith('PositioningDetails'):
                                                        for positioning_detail in value:
                                                            if positioning_detail.tag.endswith(
                                                                    self._position_to_extract):
                                                                for position in positioning_detail:

                                                                    if position.tag == '{http://schemas.datacontract.' \
                                                                                'org/2004/07/System.Drawing}x':
                                                                        # noinspection PyUnboundLocalVariable
                                                                        self.tiles[tile_name][
                                                                            'RelativeTilePosition_x'] = float(
                                                                            position.text)
                                                                    elif position.tag == '{http://schemas.' \
                                                                                'datacontract.org/2004/07/' \
                                                                                'System.Drawing}y':
                                                                        # noinspection PyUnboundLocalVariable
                                                                        self.tiles[tile_name][
                                                                            'RelativeTilePosition_y'] = float(
                                                                            position.text)

    def calculate_absolute_tile_coordinates(self):
        """Calculate the absolute stage positions of all tiles based on their relative positions

        Calculate the absolute stage position of the center of each tile based on the relative tile positions, the
        rotation of the layer and the absolute stage position of the center of the layer. The resulting position is
        saved to the _tile_center_stage_positions and the corresponding tile name to the _tile_names list.

        """
        for current_layer_key in self.layers:
            current_layer = self.layers[current_layer_key]

            # Unsure if it's height/width or width/height because they are always the same on Talos
            current_layer['tileVfw'] = self.img_height / self.img_width * current_layer['tileHfw']

            horizontal_field_width = (current_layer['columns'] - 1) * current_layer['tileHfw'] * (
                    1 - current_layer['overlapHorizontal']) + current_layer['tileHfw']
            vertical_field_width = (current_layer['rows'] - 1) * current_layer['tileVfw'] * (
                    1 - current_layer['overlapHorizontal']) + current_layer['tileVfw']

            relative_0_x = current_layer['StagePosition_center_x'] - math.sin(
                current_layer['rotation'] / 180 * math.pi) * vertical_field_width / 2 + math.cos(
                current_layer['rotation'] / 180 * math.pi) * horizontal_field_width / 2
            relative_0_y = current_layer['StagePosition_center_y'] - math.cos(
                current_layer['rotation'] / 180 * math.pi) * vertical_field_width / 2 - math.sin(
                current_layer['rotation'] / 180 * math.pi) * horizontal_field_width / 2
            relative_0 = np.array([relative_0_x, relative_0_y])

            self.layers[current_layer_key]['StagePosition_corner_x'] = relative_0[0]
            self.layers[current_layer_key]['StagePosition_corner_y'] = relative_0[1]

            for current_tile_name in self.tiles:
                current_tile = self.tiles[current_tile_name]
                if current_tile['layers'] == current_layer_key:
                    relative_x_stepsize = np.array([self.pixel_size * math.cos(current_layer['rotation']
                                                                               / 180 * math.pi),
                                                    self.pixel_size * math.sin(current_layer['rotation']
                                                                               / 180 * math.pi)])
                    relative_y_stepsize = np.array([self.pixel_size * math.sin(current_layer['rotation']
                                                                               / 180 * math.pi),
                                                    self.pixel_size * math.cos(current_layer['rotation']
                                                                               / 180 * math.pi)])

                    # absolute_tile_pos is the position of the corner of the tile in absolute Stage Position
                    # coordinates, the tile_center_stage_position are the Stage Position coordinates of the tile
                    absolute_tile_pos = relative_0 + current_tile['RelativeTilePosition_x'] * relative_x_stepsize \
                                        + current_tile['RelativeTilePosition_y'] * relative_y_stepsize
                    tile_center_stage_position = absolute_tile_pos + self.img_width / 2 * relative_x_stepsize \
                                                 + self.img_height / 2 * relative_y_stepsize

                    self._tile_center_stage_positions.append(tile_center_stage_position)
                    self._tile_names.append([current_layer['layer_name'], current_tile_name])

    # noinspection PyTypeChecker
    def find_annotation_tile(self):
        """Find the image tile in which each annotation is

        Based on the absolute stage position of the annotations and the calculated stage positions of the center of the
        tiles, this function calculates the tiles within which all annotation are and saves this information to the
        annotation_tiles dictionary. The keys are the names of the annotation. The values are the key to the
        corresponding layer in the layer dict (layer), the path to the image as a Path variable (img_path), its
        filename, the name of the layer (layer_name) and the relative position x & y of the tile within that layer
        (RelativeTilePosition_x & RelativeTilePosition_y), the absolute stage position of the annotation
        (Annotation_StagePosition_x and Annotation_StagePosition_y), the position of the annotation within the tile
        image (in pixels, Annotation_tile_img_position_x & Annotation_tile_img_position_y).
        The surrounding_tile_names and surrounding_tile_exists are added in determine_surrounding_tiles

        """
        # If the min distance is smaller than the diagonal distance to the edge of the image from its center,
        # we have found the closest tile and the annotation is within that tile.
        # If the distance is larger than the diagonal distance to the edge, the annotation is not inside of any tile
        # and thus shouldn't be exported
        log_file_path = str(self.project_folder_path / (self.project_folder_path.name + '.log'))
        logger = self.create_logger(log_file_path)
        distance_threshold = np.square(self.img_height / 2 * self.pixel_size) \
                             + np.square(self.img_width / 2 * self.pixel_size)

        for annotation_name in self.annotations:
            a_coordinates = np.array([self.annotations[annotation_name]['StagePosition_x'],
                                      self.annotations[annotation_name]['StagePosition_y']])
            distance_map = np.square(np.array(self._tile_center_stage_positions) - a_coordinates)
            quadratic_distance = distance_map[:, 0] + distance_map[:, 1]
            tile_index = np.argmin(quadratic_distance)
            current_tile = self.tiles[self._tile_names[tile_index][1]]

            if quadratic_distance[tile_index] < distance_threshold:
                self.annotation_tiles[annotation_name] = copy.deepcopy(current_tile)
                self.annotation_tiles[annotation_name]['pixel_size'] = self.pixel_size
                self.annotation_tiles[annotation_name]['Annotation_StagePosition_x'] = \
                    self.annotations[annotation_name]['StagePosition_x']
                self.annotation_tiles[annotation_name]['Annotation_StagePosition_y'] = \
                    self.annotations[annotation_name]['StagePosition_y']
                # Calculate the position of the fork within the image
                distance_to_center = (np.array(self._tile_center_stage_positions[tile_index]) - a_coordinates)

                # Calculation of annotation position is complicated, because of image rotation.
                rotation = self.layers[self.annotation_tiles[annotation_name]['layers']]['rotation']
                relative_x_stepsize = np.array([self.pixel_size * math.cos(rotation / 180 * math.pi),
                                                self.pixel_size * math.sin(rotation / 180 * math.pi)])
                relative_y_stepsize = np.array([self.pixel_size * math.sin(rotation / 180 * math.pi),
                                                self.pixel_size * math.cos(rotation / 180 * math.pi)])

                # Calculation based on the solution for the linear algebra problem Ax=b solved with Wolfram Alpha for x,
                # A being the relative step_sizes, x the x & y shifts & b being the distance to center.
                # This solution works for relative_y_stepsize[1] * relative_x_stepsize[0] !=
                # relative_x_stepsize[1] * relative_y_stepsize[0] and relative_y_stepsize[1] != 0
                # This generally seems to hold up for all rotations I have tried
                try:
                    x_shift = (relative_y_stepsize[1] * distance_to_center[0] - distance_to_center[1] *
                               relative_y_stepsize[0]) / (relative_y_stepsize[1] * relative_x_stepsize[0] -
                                                          relative_x_stepsize[1] * relative_y_stepsize[0])
                    y_shift = (relative_x_stepsize[1] * distance_to_center[0] - distance_to_center[1] *
                               relative_x_stepsize[0]) / (relative_x_stepsize[1] * relative_y_stepsize[0] -
                                                          relative_y_stepsize[1] * relative_x_stepsize[0])

                except ZeroDivisionError:
                    logger.warning('Formula for the calculation of the annotation position within the image '
                                   'does not work for these parameters, a rotation of {} leads to divison by 0. The '
                                   'annotation marker is placed in the middle of the image because the location '
                                   'could not be calculated'.format(rotation))
                    x_shift = 0
                    y_shift = 0

                annotation_img_position = [int(round(self.img_height / 2 - x_shift)),
                                           int(round(self.img_width / 2 - y_shift))]
                self.annotation_tiles[annotation_name]['Annotation_tile_img_position_x'] = annotation_img_position[0]
                self.annotation_tiles[annotation_name]['Annotation_tile_img_position_y'] = annotation_img_position[1]

            else:
                logger.warning('Annotation {} is not within any of the tiles and will be ignored'
                               .format(annotation_name))

    def determine_surrounding_tiles(self):
        """Checks whether each annotation tile has surrounding tiles to be stitched with it

        For each annotation tile, it checks whether the surrounding tiles in a given stitch_radius exist. It saves a
        boolean list of their existence (including its own existence) to surrounding_tile_exists and the a list of names
        of the surrounding filenames to surrounding_tile_names of the annotation_tiles dictionary

        """
        # Take in the center tile and determine the names & existence of stitch_radius tiles around it.
        # In default stitch-radius = 1, it searches for the 8 tiles surrounding the center tile
        for annotation_name in self.annotation_tiles:
            center_filename = self.annotation_tiles[annotation_name]['filename']

            self.annotation_tiles[annotation_name]['surrounding_tile_names'] = []
            self.annotation_tiles[annotation_name]['surrounding_tile_exists'] = []

            x = int(center_filename[5:8])
            y = int(center_filename[9:12])
            for i in range(-self.stitch_radius, self.stitch_radius + 1):
                for j in range(-self.stitch_radius, self.stitch_radius + 1):
                    # Create the filenames
                    new_filename = center_filename[:5] + f'{x + i:03}' + '-' + f'{y + j:03}' + center_filename[12:]
                    self.annotation_tiles[annotation_name]['surrounding_tile_names'].append(new_filename)

                    # Check whether those files exist
                    img_path = self.annotation_tiles[annotation_name]['img_path'] / new_filename

                    if img_path.is_file():
                        self.annotation_tiles[annotation_name]['surrounding_tile_exists'].append(True)
                    else:
                        self.annotation_tiles[annotation_name]['surrounding_tile_exists'].append(False)

    @staticmethod
    def save_annotation_tiles_to_csv(annotation_tiles, base_header, csv_path, batch_size=0):
        """Saves the information about all annotations to a csv file

        Goes through the annotation_tiles dictionary and saves it to a csv file. Overwrites any existing file in
        the same location. Can write everything into one csv file (default) or into multiple batches of a given size

        Args:
            annotation_tiles (dict): annotation tiles dictionary with a structure like self.annotation_tiles
            base_header (list): list of strings that will be headers but will not contain any content
            csv_path (Path): pathlib Path to the csv file that will be created. Must end in .csv
            batch_size (int): The size of each batch. Defaults to 0. If it's 0, everything is saved into one csv file.
                Otherwise, the dataframe is divided into batches of batch_size and saved to separate csv files

        Returns:
            list: list of all the paths to the saved csv files

        """
        assert (str(csv_path).endswith('.csv'))
        csv_files = []
        # Initialize empty csv file (or csv files if batch mode is used)
        base_header_2 = ['Image'] + base_header
        # If there are annotations, save them. Otherwise, log a warning.
        if annotation_tiles:
            header_addition = list(list(annotation_tiles.values())[0].keys())
            if batch_size == 0:
                csv_header = pd.DataFrame(columns=base_header_2 + header_addition)
                csv_header.to_csv(str(csv_path), index=False)
                csv_files.append(str(csv_path))
            else:
                nb_batches = int(math.ceil(len(annotation_tiles.keys()) / batch_size))
                for j in range(nb_batches):
                    csv_batch_path = str(csv_path)[:-4] + '_{}.csv'.format(f'{j:05}')
                    csv_header = pd.DataFrame(columns=base_header_2 + header_addition)
                    csv_header.to_csv(csv_batch_path, index=False)
                    csv_files.append(csv_batch_path)

            for i, annotation_name in enumerate(annotation_tiles):
                current_annotation = {'Image': annotation_name}
                for header in base_header:
                    current_annotation[header] = ''

                for info_key in annotation_tiles[annotation_name]:
                    current_annotation[info_key] = annotation_tiles[annotation_name][info_key]
                    if type(current_annotation[info_key]) == list:
                        current_annotation[info_key] = '[' + ','.join(map(str, current_annotation[info_key])) + ']'

                current_annotation_pd = pd.DataFrame(current_annotation, index=[0])

                # Default: everything is saved into one csv file
                if batch_size == 0:
                    with open(str(csv_path), 'a') as f:
                        current_annotation_pd.to_csv(f, header=False, index=False)
                else:
                    current_batch = int(i / batch_size)
                    csv_batch_path = str(csv_path)[:-4] + '_{}.csv'.format(f'{current_batch:05}')
                    with open(str(csv_batch_path), 'a') as f:
                        current_annotation_pd.to_csv(f, header=False, index=False)

            return csv_files

        else:
            return []

    @staticmethod
    def load_annotations_from_csv(base_header, csv_path):
        """Load the annotations saved by sites_of_interest_parser.save_annotation_tiles_to_csv

        Recreates a dictionary for all the contents except the columns of the base_header
        Parses the string of a list for surrounding_tile_exists & surrounding_tile_names back to a list

        Args:
            base_header (list): list of column headers that will be ignored when loading the data
            csv_path (Path): pathlib Path to the csv file that will be created. Must end in .csv

        Returns:
            dict: annotation_tiles

        """
        annotation_tiles = {}
        annotation_dataframe = pd.read_csv(str(csv_path))
        column_names = list(annotation_dataframe.columns.values)
        # Loop through all the rows of the dataframe, add them one by one to the annotation tiles
        for index, row in annotation_dataframe.iterrows():
            annotation_tiles[row['Image']] = {}

            # Loop through all the columns of the dataframe. If they are not part of the base header or the Image name,
            # add them to the dictionary
            for column in column_names:
                if column not in base_header + ['Image']:
                    if column == 'surrounding_tile_names':
                        content_list = row[column].strip('[]').split(',')
                        annotation_tiles[row['Image']][column] = content_list
                    elif column == 'surrounding_tile_exists':
                        content_list = [i == 'True' for i in row[column].strip('[]').split(',')]
                        annotation_tiles[row['Image']][column] = content_list
                    elif column == 'img_path':
                        annotation_tiles[row['Image']][column] = Path(row[column])
                    else:
                        annotation_tiles[row['Image']][column] = row[column]

        return annotation_tiles

    def parse_classifier_output(self, file_path, annotation_shift: int = 128):
        """Parses the output of the classifier and returns the annotation_tiles

        Goes through the csv file produced by the classifier, parses the corresponding MAPS XML File and builds the
        necessary MapsXmlParser dictionaries so that they can be saved to csv afterwards. Excpects the following
        structure in the classifier csv:
        Dictionary with TileSets as keys and a dictionary as value. The value dictionary contains dictionaries for each
        tile with annotations. The name of the tile is the key and the top left corner of the annotations are the
        values, saved as a list of tuples (x,y)

        Args:
            file_path (str): Path to the classifier output csv
            annotation_shift (int): How much the center of the annotation is shifted from the top left corner. Defaults
                to 128 (for 256x256 images from the classifier)

        Returns:
            self.annotation_tiles: Dictionary of all annotation tiles

        """
        # Load the csv containing classifier info
        base_annotation_name = 'Fork_'
        annotation_index = 0
        annotations_per_tileset = {}
        with open(file_path) as csv_file:
            csv_reader = csv.reader(csv_file, delimiter=',')
            for tile_set in csv_reader:
                tile_set_name = tile_set[0]
                tile = ast.literal_eval(tile_set[1])
                tile_name = list(tile.keys())[0]
                tile_key = tile_set_name + '_' + tile_name
                annotations_per_tileset[tile_key] = tile
        # Parse the MAPS experiment (without annotations) to get the positions of all tiles
        self.extract_layers_and_annotations(check_for_annotations=False)
        self.get_relative_tile_locations()
        self.calculate_absolute_tile_coordinates()

        # Map the annotations to the corresponding tiles
        for current_tile_set in annotations_per_tileset:
            for tile in annotations_per_tileset[current_tile_set]:
                current_tile = self.tiles[current_tile_set]
                for annotation_key in annotations_per_tileset[current_tile_set][tile]:
                    # Get Annotation_Names => Create increasing names
                    annotation_index += 1
                    annotation_name = base_annotation_name + str(annotation_index).zfill(5)
                    annotation = annotations_per_tileset[current_tile_set][tile][annotation_key]
                    # The annotation tuple could contain more info (e.g. probability) that is not currently
                    # saved anywhere

                    self.annotation_tiles[annotation_name] = copy.deepcopy(current_tile)
                    self.annotation_tiles[annotation_name]['pixel_size'] = self.pixel_size
                    # If relevant, the StagePositions could be calculated and added here. As they are only used to find
                    # the tile of interest and the classifier output already provides that, it's not done here
                    self.annotation_tiles[annotation_name]['Annotation_StagePosition_x'] = None
                    self.annotation_tiles[annotation_name]['Annotation_StagePosition_y'] = None
                    self.annotation_tiles[annotation_name]['Annotation_tile_img_position_x'] = annotation[0] \
                                                                                + annotation_shift
                    self.annotation_tiles[annotation_name]['Annotation_tile_img_position_y'] = annotation[1] \
                                                                                + annotation_shift
        self.determine_surrounding_tiles()
        return self.annotation_tiles

    def create_valid_name(self, name: str):
        """Creates a valid annotation name based on a string input

        Because the annotation name will become the filename of the stitched images, this function parses annotation
        names and ensures that they are safe filenames. Characters like ?!~ etc. are replaced by _random_number.
        The random number is chosen between 0 and 9 (to make it unlikely to overwrite an existing annotation name.
        See here for more: https://stackoverflow.com/questions/295135/turn-a-string-into-a-valid-filename

        Args:
            name (str): Name of the annotation parsed from MAPS

        Returns:
            output_name: String of the name that is safe to be used as a filename

        """
        name = unicodedata.normalize('NFKD', name)
        valid_chars = '-_.() abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
        output_name = ''
        for c in name:
            if c in valid_chars:
                output_name += c
            else:
                output_name += '_' + str(random.randint(0, 9))

        if output_name != name:
            log_file_path = str(self.project_folder_path / (self.project_folder_path.name + '.log'))
            logger = self.create_logger(log_file_path)
            logger.warning('{} is not a valid filename, because it cannot be made into a filename. It has been changed '
                           'to {}'.format(name, output_name))
        return output_name