Last active
May 16, 2023 08:12
-
-
Save Bodobolero/f6dd134f3a349d69ce6f7eb68513d82d to your computer and use it in GitHub Desktop.
Interactive apple map with photo thumbs as a route for a date range
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Copyright see https://forum-new.omz-software.com/topic/8104/show-location-of-foto-library-for-date-range-on-a-map | |
# todo sqlite3 | |
# - settîngs pin's visible or not | |
# - settings route color/width | |
# coding: utf-8 | |
# | |
# MKMapView part initially copied from OMZ MapView demo | |
# https://gist.github.com/omz/451a6685fddcf8ccdfc5 | |
# then "cleaned" to keep the easiest code as possible | |
# | |
# For use of objc_util calls and crashes trace, more than help received from | |
# @dgelssus and @JonB in Pythonista forum | |
# https://forum.omz-software.com/topic/3507/need-help-for-calling-an-objective_c-function | |
# | |
# Display MKPolyline in Mapkit from Robert Kerr | |
# http://blog.robkerr.com/adding-a-mkpolyline-overlay-using-swift-to-an-ios-mapkit-map/ | |
# | |
import console | |
import dialogs | |
import editor | |
from objc_util import * | |
import ctypes | |
import ui | |
from PIL import Image | |
import photos | |
import os | |
import sqlite3 | |
import random | |
class CLLocationCoordinate2D (Structure): | |
_fields_ = [('latitude', c_double), ('longitude', c_double)] | |
class MKCoordinateSpan (Structure): | |
_fields_ = [('d_lat', c_double), ('d_lon', c_double)] | |
class MKCoordinateRegion (Structure): | |
_fields_ = [('center', CLLocationCoordinate2D), ('span', MKCoordinateSpan)] | |
MKPolyline = ObjCClass('MKPolyline') | |
MKPolylineRenderer = ObjCClass('MKPolylineRenderer') | |
MKPointAnnotation = ObjCClass('MKPointAnnotation') | |
MKAnnotationView = ObjCClass('MKAnnotationView') | |
map_pin_size = 40 | |
def mapView_viewForAnnotation_(self,cmd,mk_mapview,mk_annotation): | |
global route_points, sql_cursor | |
try: | |
# not specially called in the same sequence as pins created | |
# should have one MK(Pin)AnnotationView by type (ex: pin color) | |
annotation = ObjCInstance(mk_annotation) | |
mapView = ObjCInstance(mk_mapview) | |
if annotation.isKindOfClass_(MKPointAnnotation): | |
tit = str(annotation.title()) | |
subtit = str(annotation.subtitle()) | |
id = subtit | |
pinView = mapView.dequeueReusableAnnotationViewWithIdentifier_(id) | |
if not pinView: | |
# Modify pin image: use MKAnnotationView | |
pinView = MKAnnotationView.alloc().initWithAnnotation_reuseIdentifier_(annotation,id) | |
sql_cursor.execute("SELECT * FROM thumbs WHERE seq=?", (id,)) | |
record = sql_cursor.fetchone() | |
seq,img_bytes = record | |
del record | |
ui_image = ui.Image.from_data(img_bytes) | |
del img_bytes | |
pinView.setImage_(ui_image.objc_instance) | |
del ui_image | |
pinView.canShowCallout = True | |
else: | |
pinView.annotation = annotation | |
return pinView.ptr | |
return None | |
except Exception as e: | |
print('Error on line {}'.format(sys.exc_info()[-1].tb_lineno), type(e).__name__, e) | |
def mapView_rendererForOverlay_(self,cmd,mk_mapview,mk_overlay): | |
try: | |
overlay = ObjCInstance(mk_overlay) | |
mapView = ObjCInstance(mk_mapview) | |
if overlay.isKindOfClass_(MKPolyline): | |
pr = MKPolylineRenderer.alloc().initWithPolyline(overlay); | |
pr.strokeColor = UIColor.blueColor().colorWithAlphaComponent(0.5); | |
pr.lineWidth = 2; | |
return pr.ptr | |
pass | |
return None | |
except Exception as e: | |
print('exception: ',e) | |
# Build method of MKMapView Delegate | |
methods = [mapView_rendererForOverlay_, mapView_viewForAnnotation_] | |
protocols = ['MKMapViewDelegate'] | |
try: | |
MyMapViewDelegate = ObjCClass('MyMapViewDelegate') | |
except: | |
MyMapViewDelegate = create_objc_class('MyMapViewDelegate', NSObject, methods=methods, protocols=protocols) | |
# ask the user for start date and end date | |
def pickDateInterval(): | |
startDate = dialogs.date_dialog(title='When did your trip start?',done_button_title='Select trip start date') | |
endDate = dialogs.date_dialog(title='When did you trip end?', done_button_title='Select trip end date') | |
print(f'startDate:{startDate} endDate:{endDate}') | |
return (startDate, endDate) | |
# go to the foto library and extract all images with location data in a given date range | |
def getAssetsWithLocationInDateInterval(startDate, endDate): | |
all_assets = photos.get_assets(media_type='image', include_hidden=False) | |
print("Number of all assets:") | |
print(len(all_assets)) | |
location_assets = [asset for asset in all_assets if asset.location != None] | |
print("Number of assets with location:") | |
print(len(location_assets)) | |
timed_assets = [asset for asset in location_assets if ( asset.creation_date.date() >= startDate and asset.creation_date.date() <= endDate) ] | |
#timed_assets = location_assets | |
print("Number of assets with location in date interval:") | |
print(len(timed_assets)) | |
return timed_assets | |
class MapView(ui.View): | |
@on_main_thread | |
def __init__(self, *args, **kwargs): | |
ui.View.__init__(self, *args, **kwargs) | |
MKMapView = ObjCClass('MKMapView') | |
frame = CGRect(CGPoint(0, 0), CGSize(self.width, self.height)) | |
self.mk_map_view = MKMapView.alloc().initWithFrame_(frame) | |
flex_width, flex_height = (1<<1), (1<<4) | |
self.mk_map_view.setAutoresizingMask_(flex_width|flex_height) | |
self_objc = ObjCInstance(self) | |
self_objc.addSubview_(self.mk_map_view) | |
self.mk_map_view.release() | |
# Set Delegate of mk_map_view | |
self.map_delegate = MyMapViewDelegate.alloc().init().autorelease() | |
self.mk_map_view.setDelegate_(self.map_delegate) | |
@on_main_thread | |
def add_pin(self, lat, lon, title, subtitle): | |
global all_points | |
'''Add a pin annotation to the map''' | |
coord = CLLocationCoordinate2D(lat, lon) | |
all_points.append(coord) # store all pin's for MKPolyline | |
annotation = MKPointAnnotation.alloc().init().autorelease() | |
annotation.setTitle_(title) | |
annotation.setSubtitle_(str(subtitle)) | |
annotation.setCoordinate_(coord, restype=None, argtypes=[CLLocationCoordinate2D]) | |
self.mk_map_view.addAnnotation_(annotation) | |
@on_main_thread | |
def set_region(self, lat, lon, d_lat, d_lon, animated=False): | |
'''Set latitude/longitude of the view's center and the zoom level (specified implicitly as a latitude/longitude delta)''' | |
region = MKCoordinateRegion(CLLocationCoordinate2D(lat, lon), MKCoordinateSpan(d_lat, d_lon)) | |
self.mk_map_view.setRegion_animated_(region, animated, restype=None, argtypes=[MKCoordinateRegion, c_bool]) | |
@on_main_thread | |
def addPolyLineToMap(self): | |
global all_points | |
global all_points_array | |
all_points_array = (CLLocationCoordinate2D * len(all_points))(*all_points) | |
polyline = ObjCInstance(MKPolyline.polylineWithCoordinates_count_( | |
all_points_array, | |
len(all_points), | |
restype=c_void_p, | |
argtypes=[POINTER(CLLocationCoordinate2D), c_ulong], | |
)) | |
self.mk_map_view.addOverlay_(polyline) | |
def will_close(self): | |
if os.path.exists('thumbs.db'): | |
os.remove('thumbs.db') | |
def main(): | |
global all_points, route_points, sql_cursor, sql_connect | |
#----- Main process ----- | |
console.clear() | |
# Hide script | |
back = MapView(frame=(0, 0, 540, 620)) | |
back.background_color='white' | |
back.name = 'Display route of localized photos' | |
back.present('full_screen', hide_title_bar=False) | |
# ask from and to date of range | |
startDate, endDate = pickDateInterval() | |
# get assets in dates range | |
assets = getAssetsWithLocationInDateInterval(startDate, endDate) | |
# Loop on all photos | |
route_points = [] | |
min_date = min(assets[0].creation_date,assets[1].creation_date).date() | |
max_date = max(assets[0].creation_date,assets[1].creation_date).date() | |
# create db file if does not exist, and connect to it | |
sql_connect = sqlite3.connect('thumbs.db',check_same_thread=False) | |
sql_connect.execute("DROP TABLE IF EXISTS thumbs") | |
sql_cursor = sql_connect.cursor() | |
sql_cursor.execute("CREATE TABLE thumbs (seq,img_bytes)") | |
sql_connect.commit() | |
n = 0 | |
for p in assets: | |
p_date = p.creation_date.date() | |
if (len(assets) > 2) or (len(assets) == 2 and p_date >= min_date and p_date <= max_date): | |
# Photo belongs to the route period | |
if p.location: | |
# Photo has GPS tags | |
lat = p.location['latitude'] | |
lon = p.location['longitude'] | |
# store reduced photo to win memory | |
img = p.get_ui_image(size=(map_pin_size,map_pin_size), crop=True) | |
wh = img.size | |
# normal photo | |
h = map_pin_size | |
w = int(h * wh[0] / wh[1]) | |
with ui.ImageContext(w,h) as ctx: | |
img.draw(0,0,w,h) | |
del img | |
ui_img = ctx.get_image() | |
sql_cursor.execute("INSERT INTO thumbs VALUES (?,?)", (str(n),ui_img.to_png())) | |
del ui_img | |
# store latitude, longitude and taken date | |
route_points.append((lat,lon,p_date, n)) | |
n += 1 | |
if n >= 4000: | |
break | |
del assets | |
if len(route_points) < 2: | |
console.hud_alert('At least two localized photos neded','error') | |
back.close() | |
return | |
back.name = back.name + f' ({len(route_points)})' | |
# Sort points by ascending taken date | |
route_points = sorted(route_points,key = lambda x: x[2]) | |
# Compute min and max of latitude and longitude | |
min_lat = min(route_points,key = lambda x:x[0])[0] | |
max_lat = max(route_points,key = lambda x:x[0])[0] | |
min_lon = min(route_points,key = lambda x:x[1])[1] | |
max_lon = max(route_points,key = lambda x:x[1])[1] | |
# Display map, center and zoom so all points are visible | |
back.set_region((min_lat+max_lat)/2,(min_lon+max_lon)/2, 1.2*(max_lat-min_lat), 1.2*(max_lon-min_lon), animated=True) | |
# Display pin's | |
all_points = [] | |
for point in route_points: | |
back.add_pin(point[0],point[1],str(point[2]), str(point[3])) | |
# Display polygon line of sorted locations | |
back.addPolyLineToMap() | |
# Protect against import | |
if __name__ == '__main__': | |
main() | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment