from PyQt5.QtCore import *
from Savable import Savable # Need __init__
import xml.etree.ElementTree as ET
import os, exiftool
[docs]class PictureState():
"""
An Enumeration to handle all different state in which a picture may be
"""
NEW = 0 # The picture is newly added to the set of pictures
RECONSTRUCTION = 1 # A newly added picture used for a reconstruction
REJECTED = 2 # A picture rejected after a reconstruction, will not be used for next reconstruction
PROCESSED = 3 # A processed pictures, used for a reconstruction and not rejected
THUMBNAIL = 4 # Only a thumbnail corresponding to an existing picture to be imported
THUMBNAIL_DISCARDED = 5 # Discard a thumbnail in order to suppress the thumbnail on real import
DISCARDED = 6 # Discarded pictures will not be used for the reconstruction
[docs]class Picture(object):
"""
A container used to store all data about a particular picture. It reflects an xml
structure and is used to manipulate photos as dataModel along the use of the application
"""
def __init__(self, resourcesPath, path, latitude, longitude, date = None, \
status = PictureState.NEW):
"""
Initialize a picture.
Args:
path (str): path to the picture file
date (str): the date the photo has been taken
status (str): the status of the picture, see PictureState upon
"""
self.path, self.status, self._resourcesPath = path, status, resourcesPath
self.latitude, self.longitude = latitude, longitude
self.date = date
self.name = os.path.basename(self.path)
@pyqtProperty(str)
def icon(self):
"""
Retrieve the icon corresponding to the picture's status
Returns:
str: The path to the icon file.
"""
return os.path.join(self._resourcesPath, "Icons", str(self.status) + ".png")
@pyqtProperty(str)
def circleColor(self):
"""
Retrieve a color to display on the map widget, depending on the status
Return:
str: The desired color
"""
colors = {
PictureState.NEW: "#1db7ff",
PictureState.PROCESSED: "#98cd00",
PictureState.RECONSTRUCTION: "#505050",
PictureState.REJECTED: "#ff3237",
PictureState.DISCARDED: "#ff3237",
PictureState.THUMBNAIL: "#ff3237",
PictureState.THUMBNAIL_DISCARDED: "#ff3237",
}
return colors[self.status]
[docs] def serialize(self):
""" Serialize a Picture object.
"""
serial = dict()
serial['path'] = self.path
serial['latitude'] = self.latitude
serial['longitude'] = self.longitude
serial['status'] = self.status
serial['date'] = self.date
return serial
[docs]class PictureManager(QSortFilterProxyModel):
@pyqtSlot(result=int)
[docs] def count(self):
"""
An alias to be used in QML as a property. We can"t use the rowCount method
as it should remain available inside the class.
Returns:
int: The number of element in the model
"""
return self.rowCount()
@pyqtSlot(int, result=str)
[docs] def getName(self, index):
return self.sourceModel().data(self.sourceModel().index(index), PictureModel.NAME_ROLE)
@pyqtSlot(result=QVariant)
[docs] def computeCenter(self):
"""
Compute the coordinates of the center associated to all pictures
Returns:
list<float>: The coordinates, latitude then longitude
"""
extract = lambda coord: [ float(getattr(d, coord)) for d in self.sourceModel()._data if getattr(d, coord) != "0.0" ]
latitudes = extract('latitude'); longitudes = extract('longitude')
coords = {'latitude': 0, 'longitude': 0}
if(len(latitudes) > 0 and len(longitudes) > 0):
average = lambda list: sum(list) / len(list)
coords['latitude'] = average(latitudes)
coords['longitude'] = average(longitudes)
#Else, throw an error to inform the user ?
return coords
def _iterateOverRows(self, rows):
"""
Iterate over rows in the model that may change during the iteration
Args:
rows (list<QModelIndex>): The related rows in the proxy model
"""
processed = [] # Holds all rows already been processed
previousSize = self.count()
for row in rows:
if row.isValid():
processed.append(row.row())
if previousSize != self.count():
previouslyProcessed = [ p for p in processed if p < row.row() ]
row = self.index(row.row() - len(previouslyProcessed), 0)
previousSize = self.count()
yield row #YoloSwagg
[docs] def discardAll(self, rows):
"""
Change the status of pictures to DISCARDED or THUMBNAIL_DISCARDED
Args:
rows (list<QModelIndex>): The related rows in the proxy model
"""
state = True
for row in self._iterateOverRows(rows):
currentStatus = self.data(row, PictureModel.STATUS_ROLE)
newStatus = currentStatus == PictureState.THUMBNAIL and PictureState.THUMBNAIL_DISCARDED\
or PictureState.DISCARDED
state = state and self.setData(row, newStatus, PictureModel.STATUS_ROLE)
return state
[docs] def renewAll(self, rows):
"""
Change the status of pictures DISCARDED, THUMBNAIL_DISCARDED or REJECTED to NEW
Args:
rows (list<QModelIndex>): The related rows in the proxy model
"""
state = True
for row in self._iterateOverRows(rows):
currentStatus = self.data(row, PictureModel.STATUS_ROLE)
if currentStatus in [PictureState.REJECTED, PictureState.DISCARDED, PictureState.THUMBNAIL_DISCARDED]:
newStatus = currentStatus == PictureState.THUMBNAIL_DISCARDED and PictureState.THUMBNAIL or PictureState.NEW
state = state and self.setData(row, newStatus, PictureModel.STATUS_ROLE)
return state
[docs] def deleteAll(self, rows):
"""
Remove pictures from the model
Args:
rows (list<QModelIndex>): The related rows in the proxy model
"""
state = True
for row in self._iterateOverRows(rows):
state = state and self.removeRow(row.row())
return state
[docs] def move(self, initRow, finalRow):
"""
Move a row from an index to another. Exactly, put initRow after
finalRow if moving up to down, and before finalRow if moving down to up
Args:
initRow (QModelIndex): The starting index
finalRow (QModelIndex): The final index
"""
# Ensure the index is correct
if not initRow.isValid() or not finalRow.isValid():
return False
outOfBounds = lambda index, sup: index < 0 or index >= sup
if outOfBounds(initRow.row(), self.rowCount()) or \
outOfBounds(finalRow.row(), self.rowCount()): return False
# Find the corresponding index in the real model
source = self.sourceModel()
initSourceRow = 0; finalSourceRow = 0
for i in range(0, source.rowCount()):
srcName = source.data(source.index(i), PictureModel.NAME_ROLE)
filterInitName = self.data(initRow, PictureModel.NAME_ROLE)
filterFinalName = self.data(finalRow, PictureModel.NAME_ROLE)
initSourceRow = srcName == filterInitName and i or initSourceRow
finalSourceRow = srcName == filterFinalName and i or finalSourceRow
initIndex = initRow.row(); finalIndex = finalRow.row()
if(initIndex < finalIndex):
# Moving downside. FinalIndex should be the index of initRow new child.
finalIndex += 1; finalSourceRow += 1
elif(initIndex > finalIndex):
#Moving upside. We will insert a row before, so the index will be displaced
initSourceRow += 1
else:
# Both index are equal, do nothing, there is no move
return True
# Move picture from source model and notify the proxy
state = self.beginMoveRows(QModelIndex(), initIndex, initIndex, QModelIndex(), finalIndex)
source.insertRow(finalSourceRow)
source.setData(source.index(finalSourceRow), \
source.data(source.index(initSourceRow), source.ITEM_ROLE), \
source.ITEM_ROLE)
source.removeRow(initSourceRow)
self.endMoveRows()
return state
[docs]class PictureModel(QAbstractListModel, metaclass=MetaPictureModel):
"""
Represent and handle a list of pictures as a ListModel. Directly implements
QAbstractListModel.
Attributes:
PATH_ROLE (int): Role that handle the picture's path name of an item
NAME_ROLE (int): Role that handle the picture's name of an item
DATE_ROLE (int): Role that handle the picture's date of creation
STATUS_ROLE (int): Role that handle the picture's status of an item
ICON_ROLE (int): Role that handle the picture's icon of an item
ITEM_ROLE (int): Role related to the whole item / picture
LATITUDE_ROLE (int): Role related to the latitude of an item
LONGITUDE_ROLE (int): Roled related to the longitude of an item
COLOR_ROLE (int): Role that handle the color of an item on the map
"""
#Roles of our model, used in QML side to retrieve data from our model
PATH_ROLE = Qt.UserRole + 1
NAME_ROLE = Qt.UserRole + 2
DATE_ROLE = Qt.UserRole + 3
STATUS_ROLE = Qt.UserRole + 4
ICON_ROLE = Qt.UserRole + 5
LATITUDE_ROLE = Qt.UserRole + 6
LONGITUDE_ROLE = Qt.UserRole + 7
COLOR_ROLE = Qt.UserRole + 8
ITEM_ROLE = Qt.UserRole + 50
_roles = {
PATH_ROLE: "path",
NAME_ROLE: "name",
DATE_ROLE: "date",
STATUS_ROLE: "status",
ICON_ROLE: "icon",
ITEM_ROLE: "item",
LATITUDE_ROLE: "latitude",
LONGITUDE_ROLE: "longitude",
COLOR_ROLE: "circleColor"
}
def __init__(self, resourcesPath, listPictures = [], parent = None):
"""
Initialize a picture model.
Args:
resourcesPath (str): Path to the resources folder of the application.
listPictures (str): The list of pictures to use, could be empty and\
specified later
parent (str): Parent Element; May remains None in our case
"""
super(PictureModel, self).__init__(parent)
self._resourcesPath = resourcesPath
self._data = listPictures
[docs] def instantiateManager(self):
"""
Create an instance of this model manager. The manager add an indirection
that allow, for instance, filtering.
Returns:
PictureManager: The picture manager corresponding to that model
"""
manager = PictureManager()
manager.setSourceModel(self)
manager.setFilterRole(self.STATUS_ROLE) # Status will be used in order to filter pictures
return manager
[docs] def insertRow(self, row, parent = QModelIndex()):
"""
An implementation of the parent method insertRow. It add an empty row at the given index
See Qt Documentation for more details <3.
Args:
row (int): The index of tization between pictures. If more than one\
index are supplied, the first he future row. If superior to model size,\
the row will be appended.
parent (QModelIndex): The parent index, always default in our case.
Returns:
bool: Return True if the row have been successfully inserted.
"""
self.beginInsertRows(QModelIndex(), row, row)
state = self._data.insert(row, None)
self.endInsertRows()
return state
[docs] def data(self, index, role = NAME_ROLE):
"""
Retrieve a piece of information from an item (a picture) of the model
Args:
index (int): The index of the element
role (int): The role of the element we are interested in
Returns:
QVariant: The requested element or data related to this element.
"""
# Ensure the index
if not index.isValid():
return QVariant()
elif index.row() > len(self._data):
return QVariant()
# Ensure the role
if not role in PictureModel._roles:
return QVariant()
# Index and role are correct, retrieve the picture and send back the requested information
picture = self._data[index.row()]
if picture == None:
return QVariant()
if(role == self.ITEM_ROLE):
return picture
return getattr(picture, PictureModel._roles[role])
[docs] def roleNames(self):
"""
An accessor to roles names
"""
return self._roles
[docs] def add(self, picture, index = None):
"""
Add a picture to the model
picture -- The picture to add
index -- The index where the picture should be inserted. If None,
the picture will be appended at the end.
"""
# Append at the end
if index == None:
self.beginInsertRows(QModelIndex(), len(self._data), len(self._data))
self._data.append(picture)
self.endInsertRows()
# Insert in the list
else:
# Ensure Index
if not index.isValid():
return False
row = index.row()
if row > len(self._data):
return False
else:
self.beginInsertRows(QModelIndex(), row, row)
self._data.insert(row, picture)
self.endInsertRows()
return True
[docs] def removeRows(self, row, count, parent = QModelIndex()):
"""
Remove contiguous pictures from the model
Args
row (int) : The first picture's index
count (int) : Number of picture to remove
parent (QModelIndex) : The parent row
"""
# Ensure the index is correct
if len(self._data) <= 0:
return False
if row < 0 or count < 1 or row + count - 1 >= self.rowCount():
return False
self.beginRemoveRows(QModelIndex(), row, row + count - 1)
#Deleting row starting by the latest index
for i in range(0, count):
del self._data[row + count - i - 1]
self.endRemoveRows()
return True
[docs] def removeRow(self, row, parent = QModelIndex()):
"""
Remove a picture from the model
row -- The picture"s index
"""
self.removeRows(row, 1, parent)
[docs] def rowCount(self, parent = QModelIndex()):
"""
Return the number of element within that model
"""
return len(self._data)
[docs] def setData(self, index, value, role):
"""
Set a particular value in the data model using his role.
index -- The index of the related element
value -- The new value
role -- The related role
"""
# Ensure index and role
if not index.isValid() or index.row() > self.rowCount() or not role in PictureModel._roles:
return False
if(role == self.ITEM_ROLE):
self._data[index.row()] = value
else:
picture = self._data[index.row()]
setattr(picture, PictureModel._roles[role], value)
self.dataChanged.emit(index, index, [role])
return True
[docs] def printData(self):
for picture in self._data:
print (picture == None and "-----" or str(picture.status) + " - " + picture.name)
[docs] def thumbnails(self):
return [p for p in self._data if p.status==PictureState.THUMBNAIL]
[docs] def removeDiscardedThumbnails(self):
for i in range(0,self.rowCount()):
if self._data[self.rowCount()-i-1].status == PictureState.THUMBNAIL_DISCARDED :
del self._data[self.rowCount()-i-1]
[docs] def populate(self, picturesFiles, status = PictureState.NEW):
"""
Populate the model, i.e. add instance of pictures element. Element are added
with the status "NEW"
Args:
picturesFiles (list<str>) : List of path to the different pictures
status (int) : The initial status to assign to the item
"""
with exiftool.ExifTool() as exifparser:
for url in picturesFiles:
print(url)
# Get EXIF data
exifData = exifparser.get_tags(\
['EXIF:GPSLatitude', 'EXIF:GPSLongitude'], url)
if not ('EXIF:GPSLatitude' in exifData):
#May raise an error if no GPS data ?
exifData['EXIF:GPSLatitude'] = "0.0"
exifData['EXIF:GPSLongitude'] = "0.0"
self.add(Picture(self._resourcesPath, url, \
str(exifData['EXIF:GPSLatitude']), str(exifData['EXIF:GPSLongitude']), \
status = status))
[docs] def validFiles(self):
"""
Retrieve all files that may be used for the reconstruction; i.e. with status :
NEW or PROCESSED
Returns:
list<Picture>: The list of all valid pictures in that model
"""
return [ p for p in self._data if p.status in [PictureState.NEW, PictureState.PROCESSED] ]
[docs] def serialize(self):
""" Serialize a pictureModel object.
"""
serial = dict()
serial['pictures'] = []
for picture in self._data:
serial['pictures'].append(picture.serialize())
serial['resourcesPath'] = self._resourcesPath
return serial
@staticmethod
[docs] def deserialize(serial):
""" Recreate a pictureModel object from its serialization.
Args:
serial (dict()): The serialized version of a pictureModel object.
"""
pictureModel = PictureModel(serial['resourcesPath'])
for picture in serial['pictures']:
pictureModel.add(Picture(serial['resourcesPath'], picture['path'],\
picture['latitude'], picture['longitude'], picture['date'], picture['status']))
return pictureModel
[docs] def save(self, base_path, file_name):
""" Save the object in the file system.
"""
Savable.save(self, base_path, file_name)
@classmethod
[docs] def load(cls, base_path, file_name, object_class=None):
""" Recreate a pictureModel object from a file.
Args:
base_path (str): The path of the directory containing the file.
file_name (str): The name of the file to load.
object_class (class): The class of the object to recreate.
"""
if object_class == None:
object_class = cls
return Savable.load(base_path, file_name, object_class)