# Copyright (C) 2019 Majormode.  All rights reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

import collections
import io
import math
import os

from PIL import Image
from PIL import ImageDraw
from majormode.perseus.model import enum
from majormode.perseus.model.geolocation import GeoPoint
from majormode.perseus.utils import cast
import exifread


ErrorType = enum.Enum(
    'HeterogeneousMetaDatumValue',
    'MissingMetaDatum',
    'PrimaryMarkerNotHorizontallyCentered',
    'PrimaryMarkerNotVerticallyCentered',
    'SecondaryMarkerNotHorizontallyCentered',
 )

# Define several useful Exif tags used to check whether the cameras
# mounted on a same rig have the same configuration.
EXIF_TAG_EXPOSURE_TIME = 0x829A
EXIF_TAG_F_NUMBER = 0x829D
EXIF_TAG_FOCAL_LENGTH = 0x920A
EXIF_TAG_ISO_SPEED_RATINGS = 0x8827
EXIF_TAG_ORIENTATION = 0x0112

EXIF_ESSENTIAL_TAGS = [
    EXIF_TAG_EXPOSURE_TIME,
    EXIF_TAG_F_NUMBER,
    EXIF_TAG_FOCAL_LENGTH,
    EXIF_TAG_ISO_SPEED_RATINGS,
    EXIF_TAG_ORIENTATION
]


class BoundingBox(object):
    """
    Represent a rectangular border with the smallest area that fully
    encloses a part of a marker distinguishable by its chroma key.
    """
    def __init__(self, x, y):
        """
        Build an instance ``BoundingBox`` initially composed of one pixel of
        the photo that is quite similar to the chroma key of the marker.

        :param x: horizontal offset, i.e., abscissa, of the pixel.
        :param y: vertical offset, i.e., ordinate, of the pixel
        """
        self.x_left = self.x_right = x
        self.y_top = self.y_bottom = y


def calculate_bearing(from_point, to_point):
    """
    Return the approximate bearing in degrees East of true North when
    traveling along the shortest path between a first point and a second
    point nearby.  The shortest path is defined using a line.  Points that
    far from each other produce meaningless results.


    @note: if you are using a method to calculate the approximate bearing
        between two points using the WGS84 ellipsoid. you should consider
        using the "Inverse Formula" as described in the section 4 of this
        document <http://www.ngs.noaa.gov/PUBS_LIB/inverse.pdf>_.


    :param from_point: a GeoPoint instance corresponding to the origin
        location.

    :param to_point: a GeoPoint corresponding to the destination location.


    :return: the number of degrees in the angle measured in a clockwise
        direction from the north line to the line passing through the
        the origin point in the direction of the destination point.
    """
    angle = abs(
        math.degrees(
            math.atan(
                (to_point.latitude - from_point.latitude)
                / (to_point.longitude - from_point.longitude))))

    if from_point.latitude > to_point.latitude:
        if from_point.longitude <= to_point.longitude:
            # North
            #   :
            #   :
            # O +------
            #   :\ /  |
            #   : \   |
            #   :  \  |
            #   :   \ |
            #   :    \|
            #   :     + P    90 + α
            #
            bearing = 90 + angle

        else:
            #     North
            #       :
            #       :
            # ------+ O
            # |  \ /:
            # |   / :
            # |  /  :
            # | /   :
            # |/    :
            # + P   :    180 + (90 - α)
            #
            bearing = 180 + (90 - angle)
    else:
        if from_point.longitude <= to_point.longitude:
            # North
            #   :
            #   :
            #   :     + P    90 - α
            #   :    /|
            #   :   / |
            #   :  /  |
            #   : /   |
            #   :/\   |
            # O +------
            #
            bearing = 90 - angle

        else:
            #       North
            #         :
            #         :
            # P +     :    270 + α
            #   |\    :
            #   | \   :
            #   |  \  :
            #   |   \ :
            #   |   /\:
            #   ------+ O
            #
            bearing = 270 + angle

    return bearing


# def check_photos_settings(file_path_names, exif_tags):
#     """
#     Check that the specified photos were taken with the same settings, as
#     referenced in their Exif metadata, such as exposure time, f-number,
#     the focal length, the ISO speed ratings, etc.
#
#     If some discrepancies are found, the function determines what the most
#     common value for a each particular Exif metadatum among all the
#     photos, and it determines which photo(s) have deviant values.
#
#     The function also checks that the specified photos have all the
#     required metadata defined.
#
#
#     :param file_path_names: A list of the path and file names of the
#         photos to check settings.
#
#     :param exif_tags: A list of Exif tags to be verified.
#
#
#     :return: A list of errors that were detected, if any.  The detected
#         errors are identified as follows::
#
#             (ErrorType.MissingMetadatum, tag, [ file_path_name, ... ])
#
#             (ErrorType.HeterogeneousMetadatumValue, tag, most_used_value,
#                 [ (file_path_name, incorrect_value), ... ])
#
#
#     :raise ValueError: If the given list contains zero or one file path
#         name only.
#     """
#     if len(file_path_names) < 2:
#         raise ValueError('The list MUST contain more than 1 photo')
#
#     # Build a dictionary of dictionaries that group photos per value for
#     # each Exif tag:
#     #   Exif tag -> Exif value -> [list of photo file path names]
#     tag_value_photos = dict()
#
#     for file_path_name in file_path_names:
#         with open(file_path_name, 'rb') as handle:
#             file_exif_tags = dict([
#                 (exif_tag.tag, exif_tag)
#                 for exif_tag in exifread.process_file(handle).itervalues()
#                 if hasattr(exif_tag, 'tag')
#             ])
#
#         for (key, tag) in file_exif_tags.items():
#             if key in exif_tags:
#                 value_photos = tag_value_photos.get(key)  # Is this tag already stored?
#                 if value_photos is None:
#                     tag_value_photos[key] = value_photos = collections.defaultdict(list)
#                 value_photos[str(tag)].append(file_path_name)
#
#     # Determine value inconsistencies for each tag accross all the
#     # specified photos.
#     errors = []
#
#     for (tag, value_photos) in tag_value_photos.items():
#         # Check that all the photos have the same value for this Exif tag, and
#         # that all the photos contain this tag.
#         if len(value_photos.keys()) == 1: # Perfect!
#             if len(value_photos.values()[0]) < len(file_path_names):
#                 errors.append((ErrorType.MissingMetaDatum, tag, set(file_path_names) - set(value_photos.values()[0])))
#
#         # All the photos are not defined with the same value for this Exif
#         # tag.  Determine the most frequently defined value of this tag
#         # across all the photos where this tag is defined.
#         else:
#             sorted_values = list(sorted(value_photos.keys(), key=lambda value: len(value_photos[value]), reverse=True))
#             most_used_value = sorted_values[0]
#             less_used_values = sorted_values[1:]
#             errors.append((ErrorType.HeterogeneousMetadatumValue, tag, most_used_value,
#                 [ (file_path_name, value)
#                   for value in less_used_values
#                       for file_path_name in value_photos[value] ]))
#
#     return errors


# def detect_markers(
#         file_path_name,
#         marker_color,
#         central_strip_width_percent,
#         max_marker_count=None,
#         expected_marker_count=None,
#         debug=False):
#     """
#     Detect the possible presence of a primary and secondary markers in the
#     specified photo.
#
#     The primary marker is expected to be in the center of the photo, while
#     the subsequent markers are expected to be below and vertically aligned
#     with this primary marker.
#
#
# :param photo: an instance containing the following members:
#        * ``photo_id``: identification of the photo.
# :param marker_color: tuple ``(R, G, B)`` describing the color of
#        the marker chroma key.
# :param central_strip_width_percent: width of the central strip of the
#        photo to analyze, expressed as a percentage relative to the
#        total width of the photo.
# :param expected_marker_count: indicate the number of markers that are
#        expected to be detected in the photo.
# :param debug: indicate whether to write on the file system, in the
#        same folder, an image file with rectangles bounding every
#        detected marker.
#
#     :return: a list composed of a first tuple ``(shift_x, shift_y)`` and
#              possible additional tuples ``(center_x, center_y, shift_angle, d_number)``
#              where:
#              * ``(shift_x, shift_y)`` represent the horizontal and
#                vertical shifts of the primary marker relative to the
#                center of the photo.
#              * ``(center_x, center_y, shift_angle, d_number)`` represent
#                the abscissa and ordinate of the subsequent marker, its
#                displacement angle in degrees relative to the primary
#                marker, and its distance in pixels to the primary marker.
#
#
# :raise ValueError: if the specified RGB components of
#            the marker color are not in the range ``[0, 255]``, or if the
#            specified width percentage of the central strip is not in the
#            range ``]0.0, 1.0]``.
#     """
#     (r, g, b) = marker_color
#     if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255):
#         raise ValueError('The specified RGB components of the marker color are not in the range [0, 255]')
#
#     if not 0.0 < central_strip_width_percent <= 1.0:
#         raise ValueError('The specified width percentage of the central strip is not in the range ]0.0, 1.0]')
#
#     # Load and rotate the photo, if its orientation is not top left side.
#     picture = Image.open(file_path_name)
#
#     exif_tags = dict([ (exif_tag.tag, exif_tag)
#         for exif_tag in exifread.process_file(open(file_path_name, 'rb')).itervalues()
#              if hasattr(exif_tag, 'tag') ])
#
#     exif_tag_orientation = exif_tags.get(EXIF_TAG_ORIENTATION)
#     if exif_tag_orientation:
#         rotation_angle = {
#             3: 180,
#             6: 270,
#             8: 90 }.get(exif_tag_orientation.values[0])
#
#         if rotation_angle:
#             picture = picture.rotate(rotation_angle)
#
#     # Detect the central marker around the center of this photo.
#     (width, height) = picture.size
#     pixels = picture.load()
#
#     marker_area_width = int(width * central_strip_width_percent)
#     marker_area_height = int(height * central_strip_width_percent)
#
#     marker_area_x_left = (width - marker_area_width) / 2
#     marker_area_x_right = (width + marker_area_width) / 2
#     marker_area_y_top = 0 # [PATCH] Since we are using the 3-dots marker system # (height - marker_area_height) / 2
#     marker_area_y_bottom = height - 1 #(height + marker_area_height) / 2
#
#     marker_area_width = (marker_area_x_right - marker_area_x_left) + 1
#     marker_area_height = (marker_area_y_bottom - marker_area_y_top) + 1
#
#     print (marker_area_x_left, marker_area_y_top), (marker_area_x_right, marker_area_y_bottom)
#
#     marker_color_map = [ is_similar_color(pixels[x, y], marker_color)
#         for y in range(marker_area_y_top, marker_area_y_bottom + 1)
#             for x in range(marker_area_x_left, marker_area_x_right + 1) ]
#
#     # Find all the bounding boxes that encompass zone of marker color.
#     bounding_boxes = [ bounding_box for bounding_box in find_bounding_boxes(marker_color_map, marker_area_width, marker_area_height)
#         if (bounding_box.x_right - bounding_box.x_left + 1) * (bounding_box.y_bottom - bounding_box.y_top + 1) > 16 ]
#
#     if len(bounding_boxes) == 0:
#         raise Exception('No marker has been detected')
#
#     if len(bounding_boxes) > 1:
#         # Remove noises that are 1/3 smaller than the biggest marker zone
#         # detected.
#         bounding_box_surfaces = [
#             (bounding_box, (bounding_box.x_right - bounding_box.x_left + 1) * (bounding_box.y_bottom - bounding_box.y_top + 1))
#                 for bounding_box in bounding_boxes ]
#         biggest_bounding_box_surface = sorted(bounding_box_surfaces, key=lambda (bounding_box, surface): surface, reverse=True)[0][1]
#         bounding_boxes = [ bounding_box for (bounding_box, surface) in bounding_box_surfaces
#             if float(surface) / biggest_bounding_box_surface > 0.33 ]
#
#     if expected_marker_count and len(bounding_boxes) != expected_marker_count:
#         raise Exception('The number of markers that has been detected (%d) does not correspond to the number detected' % \
#             (len(bounding_boxes), expected_marker_count))
#
#     # Make sure that the bounding boxes are ordered from top to bottom.
#     bounding_boxes.sort(key=lambda bounding_box: bounding_box.y_top)
#
#     if max_marker_count and len(bounding_boxes) > max_marker_count:
#         print '[INFO] Found +%d marker(s) more than expected' % (len(bounding_boxes) - max_marker_count)
#         bounding_boxes = bounding_boxes[:max_marker_count]
#
#     # Check that all the markers are strictly inside the central strip
#     # that has been analyzed, i.e., not partially detected, except for
#     # other than the central marker and the second marker, as for zoom-in
#     # shot any other markers below might be partially in the photo.
#     for (i, bounding_box) in enumerate(bounding_boxes):
#         if bounding_box.x_left == 0 or \
#            bounding_box.x_right == marker_area_width - 1 or \
#            bounding_box.y_top == 0 or \
#            (i < 1 and  bounding_box.y_bottom == marker_area_height - 1):
#             raise Exception('A detected marker is truncated; the specified width of the central strip might be too small')
#
#     # Determine the coordinates of the central marker, and the abscissa
#     # and distance in pixels of the other markers relative to the central
#     # marker.
#     markers = []
#
#     bounding_box = bounding_boxes[0]
#     central_marker_center_x = bounding_box.x_left + (bounding_box.x_right - bounding_box.x_left + 1) / 2 + marker_area_x_left
#     central_marker_center_y = bounding_box.y_top + (bounding_box.y_bottom - bounding_box.y_top + 1) / 2 + marker_area_y_top
#     central_marker_delta_x = central_marker_center_x - width / 2
#     central_marker_delta_y = central_marker_center_y - height / 2
#
#     markers.append((central_marker_delta_x, central_marker_delta_y))
#
#     if debug:
#         print '\t- Central marker coordinates: [(%d, %d), (%d, %d)]' % (bounding_box.x_left, bounding_box.y_bottom, bounding_box.x_right, bounding_box.y_top)
#         print '\t- Central marker center: (%d, %d)' % (central_marker_center_x, central_marker_center_y)
#         print '\t- Central marker area coordinates: (%d, %d)' % (marker_area_x_left, marker_area_y_top)
#
#     for bounding_box in bounding_boxes[1:]:
#         marker_center_x = bounding_box.x_left + (bounding_box.x_right - bounding_box.x_left + 1) / 2 + marker_area_x_left
#         marker_center_y = bounding_box.y_top + (bounding_box.y_bottom - bounding_box.y_top + 1) / 2 + marker_area_y_top
#         shift_angle = math.degrees(math.atan(
#             float(marker_center_x - central_marker_center_x)
#                 / float(marker_center_y - central_marker_center_y)))
#
#         d_number = math.sqrt((marker_center_x - central_marker_center_x) ** 2
#             + (marker_center_y - central_marker_center_y) ** 2)
#
#         markers.append((marker_center_x, marker_center_y, shift_angle, d_number))
#
#         if debug:
#             print '\t- Subsequent marker coordinates: [(%d, %d), (%d, %d)]' % (bounding_box.x_left, bounding_box.y_bottom, bounding_box.x_right, bounding_box.y_top)
#             print '\t- Subsequent marker center: (%d, %d)' % (marker_center_x, marker_center_y)
#             print '\t- Subsequent marker shift angle: %f' % shift_angle
#
#     if debug:
#         draw = ImageDraw.Draw(picture)
#
#         for bounding_box in bounding_boxes:
#             for x in range(bounding_box.x_left, bounding_box.x_right + 1):
#                 pixels[x + marker_area_x_left, bounding_box.y_top + marker_area_y_top] = (255, 0, 0)
#                 pixels[x + marker_area_x_left, bounding_box.y_bottom + marker_area_y_top] = (255, 0, 0)
#             for y in range(bounding_box.y_top, bounding_box.y_bottom + 1):
#                 pixels[bounding_box.x_left + marker_area_x_left, y + marker_area_y_top] = (255, 0, 0)
#                 pixels[bounding_box.x_right + marker_area_x_left, y + marker_area_y_top] = (255, 0, 0)
#
#             marker_center_x = bounding_box.x_left + (bounding_box.x_right - bounding_box.x_left + 1) / 2 + marker_area_x_left
#             marker_center_y = bounding_box.y_top + (bounding_box.y_bottom - bounding_box.y_top + 1) / 2 + marker_area_y_top
#             for x in range(marker_center_x - 100, marker_center_x + 100 + 1):
#                 pixels[x, marker_center_y] = (255, 0, 0)
#             for y in range(marker_center_y - 100, marker_center_y + 100 + 1):
#                 pixels[marker_center_x, y] = (255, 0, 0)
#
#             draw.line((central_marker_center_x, central_marker_center_y,
#                     marker_center_x, marker_center_y), fill=(0, 0, 255))
#
#         picture.save('%s_marker.jpg' % os.path.splitext(file_path_name)[0])
#         print 'Saving debug version in %s_marker.jpg' % os.path.splitext(file_path_name)[0]
#
#     return markers
#
#
# def detect_markers_v2(file_path_name, marker_color, central_strip_width_percent,
#         max_marker_count=None,
#         expected_marker_count=None,
#         debug=False):
#     """
#     Detect the possible presence of a primary and secondary markers in the
#     specified photo.
#
#     The primary marker is expected to be in the center of the photo, while
#     the subsequent markers are expected to be below and vertically aligned
#     with this primary marker.
#
#     :param photo: an instance containing the following members:
#            * ``photo_id``: identification of the photo.
#     :param marker_color: tuple ``(R, G, B)`` describing the color of
#            the marker chroma key.
#     :param central_strip_width_percent: width of the central strip of the
#            photo to analyze, expressed as a percentage relative to the
#            total width of the photo.
#     :param expected_marker_count: indicate the number of markers that are
#            expected to be detected in the photo.
#     :param debug: indicate whether to write on the file system, in the
#            same folder, an image file with rectangles bounding every
#            detected marker.
#
#     :return: a list composed of a first tuple ``(shift_x, shift_y)`` and
#              possible additional tuples ``(center_x, center_y, shift_angle, d_number)``
#              where:
#              * ``(shift_x, shift_y)`` represent the horizontal and
#                vertical shifts of the primary marker relative to the
#                center of the photo.
#              * ``(center_x, center_y, shift_angle, d_number)`` represent
#                the abscissa and ordinate of the subsequent marker, its
#                displacement angle in degrees relative to the primary
#                marker, and its distance in pixels to the primary marker.
#
#     :raise InvalidArgumentException: if the specified RGB components of
#            the marker color are not in the range ``[0, 255]``, or if the
#            specified width percentage of the central strip is not in the
#            range ``]0.0, 1.0]``.
#     """
#     (r, g, b) = marker_color
#     if not ((r >= 0 and r <= 255) and
#             (g >= 0 and g <= 255) and
#             (b >= 0 and b <= 255)):
#         raise InvalidArgumentException('The specified RGB components of the marker color are not in the range [0, 255]')
#
#     if not (central_strip_width_percent > 0.0 and central_strip_width_percent <= 1.0):
#         raise InvalidArgumentException('The specified width percentage of the central strip is not in the range ]0.0, 1.0]')
#
#     # Load and rotate the photo, if its orientation is not top left side.
#     picture = Image.open(file_path_name)
#
#     exif_tags = dict([ (exif_tag.tag, exif_tag)
#         for exif_tag in exifread.process_file(open(file_path_name, 'rb')).itervalues()
#              if hasattr(exif_tag, 'tag') ])
#
#     exif_tag_orientation = exif_tags.get(EXIF_TAG_ORIENTATION)
#     if exif_tag_orientation:
#         rotation_angle = {
#             3L: 180,
#             6L: 270,
#             8L: 90 }.get(exif_tag_orientation.values[0])
#
#         if rotation_angle:
#             picture = picture.rotate(rotation_angle)
#
#     # Detect the central marker around the center of this photo.
#     (width, height) = picture.size
#     pixels = picture.load()
#
#     marker_area_width = int(width * central_strip_width_percent)
#     marker_area_height = int(height * central_strip_width_percent)
#
#     marker_area_x_left = (width - marker_area_width) / 2
#     marker_area_x_right = (width + marker_area_width) / 2
#     marker_area_y_top = 0 # [PATCH] Since we are using the 3-dots marker system # (height - marker_area_height) / 2
#     marker_area_y_bottom = height - 1 #(height + marker_area_height) / 2
#
#     marker_area_width = (marker_area_x_right - marker_area_x_left) + 1
#     marker_area_height = (marker_area_y_bottom - marker_area_y_top) + 1
#
#     marker_color_map = [ is_similar_color(pixels[x, y], marker_color)
#         for y in range(marker_area_y_top, marker_area_y_bottom + 1)
#             for x in range(marker_area_x_left, marker_area_x_right + 1) ]
#
#     # Find all the bounding boxes that encompass zone of marker color.
#     bounding_boxes = [ bounding_box for bounding_box in find_bounding_boxes(marker_color_map, marker_area_width, marker_area_height)
#         if (bounding_box.x_right - bounding_box.x_left + 1) * (bounding_box.y_bottom - bounding_box.y_top + 1) > 16 ]
#
#     if len(bounding_boxes) == 0:
#         raise Exception('No marker has been detected')
#
#     if len(bounding_boxes) > 1:
#         # Remove noises that are 1/3 smaller than the biggest marker zone
#         # detected.
#         bounding_box_surfaces = [
#             (bounding_box, (bounding_box.x_right - bounding_box.x_left + 1) * (bounding_box.y_bottom - bounding_box.y_top + 1))
#                 for bounding_box in bounding_boxes ]
#         biggest_bounding_box_surface = sorted(bounding_box_surfaces, key=lambda (bounding_box, surface): surface, reverse=True)[0][1]
#         bounding_boxes = [ bounding_box for (bounding_box, surface) in bounding_box_surfaces
#             if float(surface) / biggest_bounding_box_surface > 0.33 ]
#
#     if expected_marker_count and len(bounding_boxes) != expected_marker_count:
#         raise Exception('The number of markers that has been detected (%d) does not correspond to the number detected' % \
#             (len(bounding_boxes), expected_marker_count))
#
#     # Make sure that the bounding boxes are ordered from top to bottom.
#     bounding_boxes.sort(key=lambda bounding_box: bounding_box.y_top)
#
#     if max_marker_count and len(bounding_boxes) > max_marker_count:
#         print '[INFO] Found +%d marker(s) more than expected' % (len(bounding_boxes) - max_marker_count)
#         bounding_boxes = bounding_boxes[:max_marker_count]
#
#     # Check that all the markers are strictly inside the central strip
#     # that has been analyzed, i.e., not partially detected, except for
#     # other than the central marker and the second marker, as for zoom-in
#     # shot any other markers below might be partially in the photo.
#     for (i, bounding_box) in enumerate(bounding_boxes):
#         if bounding_box.x_left == 0 or \
#            bounding_box.x_right == marker_area_width - 1 or \
#            bounding_box.y_top == 0 or \
#            (i < 1 and  bounding_box.y_bottom == marker_area_height - 1):
#             raise Exception('A detected marker is truncated; the specified width of the central strip might be too small')
#
#     # Determine the coordinates of the central marker, and the abscissa
#     # and distance in pixels of the other markers relative to the central
#     # marker.
#     markers = []
#
#     bounding_box = bounding_boxes[1] # The central dot marker is the 2nd dot.
#     central_marker_center_x = bounding_box.x_left + (bounding_box.x_right - bounding_box.x_left + 1) / 2 + marker_area_x_left
#     central_marker_center_y = bounding_box.y_top + (bounding_box.y_bottom - bounding_box.y_top + 1) / 2 + marker_area_y_top
#     shift_x = central_marker_center_x - width / 2
#     shift_y = central_marker_center_y - height / 2
#
#     if debug:
#         print '\t- Central marker coordinates: [(%d, %d), (%d, %d)]' % (bounding_box.x_left, bounding_box.y_bottom, bounding_box.x_right, bounding_box.y_top)
#         print '\t- Central marker center: (%d, %d)' % (central_marker_center_x, central_marker_center_y)
#         print '\t- Central marker area coordinates: (%d, %d)' % (marker_area_x_left, marker_area_y_top)
#
#     bounding_box = bounding_boxes[0] # top marker
#     top_marker_center_x = bounding_box.x_left + (bounding_box.x_right - bounding_box.x_left + 1) / 2 + marker_area_x_left
#     top_marker_center_y = bounding_box.y_top + (bounding_box.y_bottom - bounding_box.y_top + 1) / 2 + marker_area_y_top
#
#     bounding_box = bounding_boxes[2] # bottom marker
#     bottom_marker_center_x = bounding_box.x_left + (bounding_box.x_right - bounding_box.x_left + 1) / 2 + marker_area_x_left
#     bottom_marker_center_y = bounding_box.y_top + (bounding_box.y_bottom - bounding_box.y_top + 1) / 2 + marker_area_y_top
#
#     shift_angle = math.degrees(math.atan(
#         float(bottom_marker_center_x - top_marker_center_x)
#             / float(bottom_marker_center_y - top_marker_center_y)))
#
#     d_number = math.sqrt((bottom_marker_center_x - top_marker_center_x) ** 2
#         + (bottom_marker_center_y - top_marker_center_y) ** 2)
#
#     return (shift_x, shift_y, shift_angle, d_number)
#
#
# def display_markers(marker_color_map, width, bounding_boxes):
#     """
#     Display a collection of bounding boxes.
#
#     @note: the bounding boxes MUST be ordered by offset.
#
#     :param marker_color_map: a bitmap that indicates for each pixel the
#            presence or not of a marker.
#     :param width: width of the bitmap.
#     :param boxing_boxes: a list of ``BoundingBox`` instances.
#     """
#     def get_bounding_box_index(x, y):
#         for bounding_box in bounding_boxes:
#             if x >= bounding_box.x_left and x <= bounding_box.x_right and \
#                y >= bounding_box.y_top and y <= bounding_box.y_bottom:
#                 return bounding_boxes.index(bounding_box)
#         return '.'
#
#     x_left = sorted(bounding_boxes, key=lambda bounding_box: bounding_box.x_left)[0].x_left
#     x_right = sorted(bounding_boxes, key=lambda bounding_box: bounding_box.x_right, reverse=True)[0].x_right
#     y_top = sorted(bounding_boxes, key=lambda bounding_box: bounding_box.y_top)[0].y_top
#     y_bottom = sorted(bounding_boxes, key=lambda bounding_box: bounding_box.y_bottom, reverse=True)[0].y_bottom
#
#     for y in range(y_top, y_bottom + 1):
#         print ''.join([ str(get_bounding_box_index(x, y))
#                 if marker_color_map[x + y * width] else '.'
#             for x in range(x_left, x_right + 1) ])
#
#
# def find_bounding_boxes(marker_color_map, width, height):
#     """
#     Determine bounding boxes that encloses the different elements of the
#     given map that are set to ``1``.
#
#     :param marker_color_map:  a bitmap that indicates for each pixel the
#            presence or not of a marker.
#     :param width: number of elements per row.
#     :param height: number of rows.
#
#     :return: a dictionary where the key corresponds to an offset
#              ``x + y * width`` of an element in the map and the value
#              corresponds to an instance ``BoundingBox`` representing the
#              bounding box that encloses this particular element.
#     """
#     bounding_boxes = dict()
#
#     def __replace_bounding_boxes(from_bounding_box, with_bounding_box):
#         """
#         Replace a bounding box into another, which consists in merging the
#         first bounding box into the second.
#
#         @note: this function updates the local variable ``bounding_boxes``.  It
#                uses the local variables ``width`` and ``height``.
#
#         :param from_bounding_box: the first bounding box to be replaced by the
#                second specified.
#         :param with_bounding_box: the bounding box to replace the first
#                specified.
#
#         :return: ``True`` if the function did merge the first bounding box into
#                  the second; ``False`` if no merge was actually necessary as
#                  the two specified bounding boxes are the same.
#         """
#         if from_bounding_box == with_bounding_box:
#             return False
#
#         bounding_boxes.update(dict(
#             [ (offset, with_bounding_box)
#                   for offset in range(from_bounding_box.x_left + from_bounding_box.y_top * width,
#                                       from_bounding_box.x_right + from_bounding_box.y_bottom * width + 1)
#                   if bounding_boxes.get(offset) == from_bounding_box ]))
#
#         with_bounding_box.x_left = min(from_bounding_box.x_left, with_bounding_box.x_left)
#         with_bounding_box.x_right = max(from_bounding_box.x_right, with_bounding_box.x_right)
#         with_bounding_box.y_top = min(from_bounding_box.y_top, with_bounding_box.y_top)
#         with_bounding_box.y_bottom = max(from_bounding_box.y_bottom, with_bounding_box.y_bottom)
#
#         return True
#
#     for y in range(height):
#         for x in range(width):
#             if marker_color_map[x + y * width]:
#                 # Determine whether this point belong to an existing bounding zone or
#                 # a new bounding box, based on the nearby previous analyzed points,
#                 # such as:
#                 #
#                 #     ???.
#                 #     ?#..
#                 bounding_box = None
#                 for (delta_x, delta_y) in [(-1, -1), (0, -1), (1, -1), (-1, 0)]:
#                     if x + delta_x >= 0 and y + delta_y >= 0 and x + delta_x < width and y + delta_y < height \
#                        and bounding_boxes.get((x + delta_x) + (y + delta_y) * width):
#                         bounding_box = bounding_boxes[(x + delta_x) + (y + delta_y) * width]
#                         bounding_box.x_left = min(bounding_box.x_left, x)
#                         bounding_box.x_right = max(bounding_box.x_right, x)
#                         bounding_box.y_top = min(bounding_box.y_top, y)
#                         bounding_box.y_bottom = max(bounding_box.y_bottom, y)
#                         break
#
#                 bounding_boxes[x + y * width] = bounding_box or BoundingBox(x, y)
#
#                 # Determine if this point unified two disconnected existing bounding
#                 # boxes, such as the following configuration:
#                 #
#                 #     ?.?.
#                 #     ?X..
#                 if x > 0 and y > 0  and x + 1 < width \
#                    and (bounding_boxes.get(x - 1 + y * width) or bounding_boxes.get(x - 1 + (y - 1) * width)) \
#                    and bounding_boxes.get(x + 1 + (y - 1) * width):
#                     __replace_bounding_boxes(
#                         bounding_boxes.get(x - 1 + y * width) or bounding_boxes[x - 1 + (y - 1) * width], \
#                         bounding_boxes[x + 1 + (y - 1) * width])
#
#     return list(set(bounding_boxes.values()))


def __search_exif_tag_value(exif_tags, tag_subname, optional=False, default_value=None):
    for tag_name, tag_value in exif_tags.items():
        offset = tag_name.find(tag_subname)
        if offset == -1 or \
           (offset > 0 and tag_name[offset - 1] != ' ') or \
           (offset + len(tag_subname) < len(tag_name) and tag_name[offset + len(tag_subname)] != ' '):
            continue

        return tag_value

    if not optional:
        raise KeyError('Undefined key with subname "%s"' % tag_subname)

    return default_value


def get_photo_capture_time(file, strict=True):
    """
    Return the capture time of a photo.


    :param file: An file-like object (in-memory bytes buffer) of the
        photo's image file.

    :param strict: Indicate whether the photo MUST have an Exif tag that
        indicates when the photo has been captured.

    :return: An object `ISO8601DateTime` representing the time when the
        photo has been captured.


    :raise ValueError: If the photo's image file doesn't contain an Exif
        tag that indicates when the photo has been captured.
    """
    # Retrieve tags from the Exchangeable image file format (Exif) included
    # in the photo.
    file.seek(0)
    exif_tags = exifread.process_file(file)

    # Retrieve the date and time when the original image data were
    # generated, which, for a digital still camera, is the date and time the
    # picture was taken or recorded. The format is "YYYY:MM:DD HH:MM:SS"
    # with time shown in 24-hour format, and the date and time separated by
    # one blank character (hex 20).
    #
    # When the date and time are unknown, all the character spaces except
    # colons (":") may be filled with blank characters, or else the
    # interoperability field may be filled with blank characters.
    exif_tag_value = exif_tags.get('EXIF DateTimeOriginal')
    if not exif_tag_value and strict:
        raise ValueError("the photo does not have information that indicates when the photo has been taken")

    captured_date_value, captured_time_value = exif_tag_value.printable.split(' ')
    capture_time = cast.string_to_timestamp(
        '{} {}+00'.format(
            captured_date_value.replace(':', '-'),
            captured_time_value))

    return capture_time


def get_photo_location(file):
    """
    Parse out the GPS coordinates from the Exif tags of a photo file.


    :param file: Path and file name of a photo's image, or an object
        `BytesIO` (in-memory bytes buffer) of the photo's image file, to
        retrieve the GPS coordinates of the location where the photo has
        been captured.


    :return: An object `GeoPoint` representing the geographical location
        where the photo has been captured, or `None` if the photo's image
        file doesn't contain an Exif tag that indicates where the photo
        has been captured.
    """
    # Retrieve the Exif tags from the photo's image file.
    if isinstance(file, str):
        with open(file, 'rb') as handle:
            exif_tags = exifread.process_file(handle)
    elif isinstance(file, io.BytesIO):
        file.seek(0)
        exif_tags = exifread.process_file(file)
    else:
        raise ValueError("the argument 'file' MUST be either a file path name or an object BytesIO")

    try:
        lat_dms = __search_exif_tag_value(exif_tags, 'GPSLatitude').values  # GPS GPSLatitude
        latitude = GeoPoint.convert_dms_to_dd(
            lat_dms[0].num, lat_dms[0].den,
            lat_dms[1].num, lat_dms[1].den,
            lat_dms[2].num, lat_dms[2].den)
        if __search_exif_tag_value(exif_tags, 'GPSLatitudeRef').printable == 'S':
            latitude *= -1

        long_dms = __search_exif_tag_value(exif_tags, 'GPSLongitude').values  # GPS GPSLongitude
        longitude = GeoPoint.convert_dms_to_dd(
                long_dms[0].num, long_dms[0].den,
                long_dms[1].num, long_dms[1].den,
                long_dms[2].num, long_dms[2].den)
        if __search_exif_tag_value(exif_tags, 'GPSLongitudeRef').printable == 'W':
            longitude *= -1
    except KeyError:
        return None

    try:
        acc = __search_exif_tag_value(exif_tags, 'GPSHPositioningError').values[0]
        accuracy = float(acc.num) / acc.den
    except KeyError:
        accuracy = None

    # Retrieve the altitude of the location where this photo has been
    # taken, if defined.
    try:
        alt = __search_exif_tag_value(exif_tags, 'GPSAltitude').values[0]
        altitude = float(alt.num) / alt.den
        if __search_exif_tag_value(exif_tags, 'GPS GPSAltitudeRef') == 1:
            altitude *= -1
    except KeyError:
        altitude = None

    # Retrieve the angle of the direction that the camera points to,
    # either from the EXIF GPS tag ``GPSDestBearing``, or the tag
    # ``GPSImgDirection``, in this preference order, when available.
    try:
        _bearing_tag = __search_exif_tag_value(exif_tags, 'GPSDestBearing', optional=True)
        if _bearing_tag:
            _bearing_ = _bearing_tag.values[0]
            bearing = float(_bearing_.num) / _bearing_.den
        else:
            _bearing_ = __search_exif_tag_value(exif_tags, 'GPSImgDirection').values[0]
            bearing = float(_bearing_.num) / _bearing_.den
            if __search_exif_tag_value(exif_tags, 'GPSImgDirectionRef').printable == 'T':
                bearing += 180
    except KeyError:
        bearing = None

    # Retrieve the date and the time of the location fix.
    try:
        _date_ = __search_exif_tag_value(exif_tags, 'GPSDate').values.replace(':', '-')
        _time_ = __search_exif_tag_value(exif_tags, 'GPSTimeStamp').values

        _date_time_ = '%sT%02d:%02d:%02d+00' % \
                (_date_, _time_[0].num, _time_[1].num, float(_time_[2].num) / _time_[2].den)

        fix_time = cast.string_to_timestamp(_date_time_)
    except KeyError:
        fix_time = None

    # Build a geo-point with the information collected.
    return GeoPoint(
        latitude,
        longitude,
        accuracy=accuracy,
        altitude=altitude,
        bearing=bearing,
        fix_time=fix_time)


# def is_similar_color(pixel_color, reference_color, color_distance_tolerance=20.0):
#     """"
#     Indicate whether the given pixel color matches the given color.
#
#     :param pixel_color: a tuple ``(R, G, B)`` representing the color of a
#            given pixel.
#     :param reference_color: a tuple ``(R, G, B)`` representing the
#            reference color.
#     :param color_distance_tolerance: maximum acceptable difference,
#            expressed in percentage, from a pixel  of the photo with the
#            reference color to consider this pixel color similar to the
#            latter.
#
#     :return: ``True`` if the given pixel color matches the given reference
#              color; ``False`` otherwise.
#     """
#     (R1, G1, B1) = pixel_color
#     (R2, G2, B2) = (15, 15, 15) #reference_color
#     diff = (abs(R1 - R2) + abs(G1 - G2) + abs(B1 - B2)) / 3.0
#     return diff <= color_distance_tolerance

#     pixel_color = convert_color(AdobeRGBColor(R, G, B), LabColor)
#     (R, G, B) = reference_color
#     reference_color = convert_color(AdobeRGBColor(R, G, B), LabColor)
#     return abs(delta_e_cie1976(pixel_color, reference_color)) < color_distance_tolerance
