epicyon/city.py

371 lines
14 KiB
Python

__filename__ = "city.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.5.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@libreserver.org"
__status__ = "Production"
__module_group__ = "Metadata"
import os
import datetime
import random
import math
from random import randint
from utils import acct_dir
from utils import remove_eol
# states which the simulated city dweller can be in
PERSON_SLEEP = 0
PERSON_WORK = 1
PERSON_PLAY = 2
PERSON_SHOP = 3
PERSON_EVENING = 4
PERSON_PARTY = 5
BUSY_STATES = (PERSON_WORK, PERSON_SHOP, PERSON_PLAY, PERSON_PARTY)
def _get_decoy_camera(decoy_seed: int) -> (str, str, int):
"""Returns a decoy camera make and model which took the photo
"""
cameras = [
["Apple", "iPhone SE"],
["Apple", "iPhone XR"],
["Apple", "iPhone 7"],
["Apple", "iPhone 8"],
["Apple", "iPhone 11"],
["Apple", "iPhone 11 Pro"],
["Apple", "iPhone 12"],
["Apple", "iPhone 12 Mini"],
["Apple", "iPhone 12 Pro Max"],
["Apple", "iPhone 13"],
["Apple", "iPhone 13 Mini"],
["Apple", "iPhone 13 Pro"],
["Apple", "iPhone 14"],
["Apple", "iPhone 14 Pro"],
["Samsung", "Galaxy Note 20 Ultra"],
["Samsung", "Galaxy S20 Plus"],
["Samsung", "Galaxy S20 FE 5G"],
["Samsung", "Galaxy Z FOLD 2"],
["Samsung", "Galaxy S12 Plus"],
["Samsung", "Galaxy S12"],
["Samsung", "Galaxy S11 Plus"],
["Samsung", "Galaxy S10 Plus"],
["Samsung", "Galaxy S10e"],
["Samsung", "Galaxy Z Flip"],
["Samsung", "Galaxy A54"],
["Samsung", "Galaxy A51"],
["Samsung", "Galaxy S10"],
["Samsung", "Galaxy S10 Plus"],
["Samsung", "Galaxy S10e"],
["Samsung", "Galaxy S10 5G"],
["Samsung", "Galaxy A60"],
["Samsung", "Note 13"],
["Samsung", "Note 13 Plus"],
["Samsung", "Note 12"],
["Samsung", "Note 12 Plus"],
["Samsung", "Note 11"],
["Samsung", "Note 11 Plus"],
["Samsung", "Note 10"],
["Samsung", "Note 10 Plus"],
["Samsung", "Galaxy S23 Ultra"],
["Samsung", "Galaxy S23"],
["Samsung", "Galaxy S22 Ultra"],
["Samsung", "Galaxy S22"],
["Samsung", "Galaxy S21 Ultra"],
["Samsung", "Galaxy Note 20 Ultra"],
["Samsung", "Galaxy S21"],
["Samsung", "Galaxy S21 Plus"],
["Samsung", "Galaxy S20 FE"],
["Samsung", "Galaxy Z Fold 2"],
["Samsung", "Galaxy A52 5G"],
["Samsung", "Galaxy A71 5G"],
["Google", "Pixel 7 Pro"],
["Google", "Pixel 7"],
["Google", "Pixel 6 Pro"],
["Google", "Pixel 6"],
["Google", "Pixel 5"],
["Google", "Pixel 4a"],
["Google", "Pixel 4 XL"],
["Google", "Pixel 3 XL"],
["Google", "Pixel 4"],
["Google", "Pixel 4a 5G"],
["Google", "Pixel 3"],
["Google", "Pixel 3a"]
]
randgen = random.Random(decoy_seed)
index = randgen.randint(0, len(cameras) - 1)
serial_number = randgen.randint(100000000000, 999999999999999999999999)
return cameras[index][0], cameras[index][1], serial_number
def _get_city_pulse(curr_time_of_day, decoy_seed: int) -> (float, float):
"""This simulates expected average patterns of movement in a city.
Jane or Joe average lives and works in the city, commuting in
and out of the central district for work. They have a unique
life pattern, which machine learning can latch onto.
This returns a polar coordinate for the simulated city dweller:
Distance from the city centre is in the range 0.0 - 1.0
Angle is in radians
"""
randgen = random.Random(decoy_seed)
variance = 3
data_decoy_state = PERSON_SLEEP
weekday = curr_time_of_day.weekday()
min_hour = 7 + randint(0, variance)
max_hour = 17 + randint(0, variance)
if curr_time_of_day.hour > min_hour:
if curr_time_of_day.hour <= max_hour:
if weekday < 5:
data_decoy_state = PERSON_WORK
elif weekday == 5:
data_decoy_state = PERSON_SHOP
else:
data_decoy_state = PERSON_PLAY
else:
if weekday < 5:
data_decoy_state = PERSON_EVENING
else:
data_decoy_state = PERSON_PARTY
randgen2 = random.Random(decoy_seed + data_decoy_state)
angle_radians = \
(randgen2.randint(0, 100000) / 100000) * 2 * math.pi
# some people are quite random, others have more predictable habits
decoy_randomness = randgen.randint(1, 3)
# occasionally throw in a wildcard to keep the machine learning guessing
if randint(0, 100) < decoy_randomness:
distance_from_city_center = randint(0, 100000) / 100000
angle_radians = (randint(0, 100000) / 100000) * 2 * math.pi
else:
# what consitutes the central district is fuzzy
central_district_fuzz = (randgen.randint(0, 100000) / 100000) * 0.1
busy_radius = 0.3 + central_district_fuzz
if data_decoy_state in BUSY_STATES:
# if we are busy then we're somewhere in the city center
distance_from_city_center = \
(randgen.randint(0, 100000) / 100000) * busy_radius
else:
# otherwise we're in the burbs
distance_from_city_center = busy_radius + \
((1.0 - busy_radius) * (randgen.randint(0, 100000) / 100000))
return distance_from_city_center, angle_radians
def parse_nogo_string(nogo_line: str) -> []:
"""Parses a line from locations_nogo.txt and returns the polygon
"""
nogo_line = remove_eol(nogo_line)
polygon_str = nogo_line.split(':', 1)[1]
if ';' in polygon_str:
pts = polygon_str.split(';')
else:
pts = polygon_str.split(',')
if len(pts) <= 4:
return []
polygon = []
for index in range(int(len(pts)/2)):
if index*2 + 1 >= len(pts):
break
longitude_str = pts[index*2].strip()
latitude_str = pts[index*2 + 1].strip()
if 'E' in latitude_str or 'W' in latitude_str:
longitude_str = pts[index*2 + 1].strip()
latitude_str = pts[index*2].strip()
if 'E' in longitude_str:
longitude_str = \
longitude_str.replace('E', '')
longitude = float(longitude_str)
elif 'W' in longitude_str:
longitude_str = \
longitude_str.replace('W', '')
longitude = -float(longitude_str)
else:
longitude = float(longitude_str)
latitude = float(latitude_str)
polygon.append([latitude, longitude])
return polygon
def spoof_geolocation(base_dir: str,
city: str, curr_time, decoy_seed: int,
cities_list: [],
nogo_list: []) -> (float, float, str, str,
str, str, int):
"""Given a city and the current time spoofs the location
for an image
returns latitude, longitude, N/S, E/W,
camera make, camera model, camera serial number
"""
locations_filename = base_dir + '/custom_locations.txt'
if not os.path.isfile(locations_filename):
locations_filename = base_dir + '/locations.txt'
nogo_filename = base_dir + '/custom_locations_nogo.txt'
if not os.path.isfile(nogo_filename):
nogo_filename = base_dir + '/locations_nogo.txt'
man_city_radius = 0.1
variance_at_location = 0.0004
default_latitude = 51.8744
default_longitude = 0.368333
default_latdirection = 'N'
default_longdirection = 'W'
if cities_list:
cities = cities_list
else:
if not os.path.isfile(locations_filename):
return (default_latitude, default_longitude,
default_latdirection, default_longdirection,
"", "", 0)
cities = []
try:
with open(locations_filename, 'r', encoding='utf-8') as loc_file:
cities = loc_file.readlines()
except OSError:
print('EX: unable to read locations ' + locations_filename)
nogo = []
if nogo_list:
nogo = nogo_list
else:
if os.path.isfile(nogo_filename):
nogo_list = []
try:
with open(nogo_filename, 'r', encoding='utf-8') as nogo_file:
nogo_list = nogo_file.readlines()
except OSError:
print('EX: unable to read ' + nogo_filename)
for line in nogo_list:
if line.startswith(city + ':'):
polygon = parse_nogo_string(line)
if polygon:
nogo.append(polygon)
city = city.lower()
for city_name in cities:
if city in city_name.lower():
city_fields = city_name.split(':')
latitude = city_fields[1]
longitude = city_fields[2]
area_km2 = 0
if len(city_fields) > 3:
area_km2 = int(city_fields[3])
latdirection = 'N'
longdirection = 'E'
if 'S' in latitude:
latdirection = 'S'
latitude = latitude.replace('S', '')
if 'W' in longitude:
longdirection = 'W'
longitude = longitude.replace('W', '')
latitude = float(latitude)
longitude = float(longitude)
# get the time of day at the city
approx_time_zone = int(longitude / 15.0)
if longdirection == 'E':
approx_time_zone = -approx_time_zone
curr_time_adjusted = curr_time - \
datetime.timedelta(hours=approx_time_zone)
cam_make, cam_model, cam_serial_number = \
_get_decoy_camera(decoy_seed)
valid_coord = False
seed_offset = 0
while not valid_coord:
# patterns of activity change in the city over time
(distance_from_city_center, angle_radians) = \
_get_city_pulse(curr_time_adjusted,
decoy_seed + seed_offset)
# The city radius value is in longitude and the reference
# is Manchester. Adjust for the radius of the chosen city.
if area_km2 > 1:
man_radius = math.sqrt(1276 / math.pi)
radius = math.sqrt(area_km2 / math.pi)
city_radius_deg = (radius / man_radius) * man_city_radius
else:
city_radius_deg = man_city_radius
# Get the position within the city, with some randomness added
latitude += \
distance_from_city_center * city_radius_deg * \
math.cos(angle_radians)
longitude += \
distance_from_city_center * city_radius_deg * \
math.sin(angle_radians)
longval = longitude
if longdirection == 'W':
longval = -longitude
valid_coord = not point_in_nogo(nogo, latitude, longval)
if not valid_coord:
seed_offset += 1
if seed_offset > 100:
break
# add a small amount of variance around the location
fraction = randint(0, 100000) / 100000
distance_from_location = fraction * fraction * variance_at_location
fraction = randint(0, 100000) / 100000
angle_from_location = fraction * 2 * math.pi
latitude += distance_from_location * math.cos(angle_from_location)
longitude += distance_from_location * math.sin(angle_from_location)
# gps locations aren't transcendental, so round to a fixed
# number of decimal places
latitude = int(latitude * 100000) / 100000.0
longitude = int(longitude * 100000) / 100000.0
return (latitude, longitude, latdirection, longdirection,
cam_make, cam_model, cam_serial_number)
return (default_latitude, default_longitude,
default_latdirection, default_longdirection,
"", "", 0)
def get_spoofed_city(city: str, base_dir: str,
nickname: str, domain: str) -> str:
"""Returns the name of the city to use as a GPS spoofing location for
image metadata
"""
city = ''
city_filename = acct_dir(base_dir, nickname, domain) + '/city.txt'
if os.path.isfile(city_filename):
try:
with open(city_filename, 'r', encoding='utf-8') as city_file:
city1 = city_file.read()
city = remove_eol(city1)
except OSError:
print('EX: unable to read ' + city_filename)
return city
def _point_in_polygon(poly: [], x_coord: float, y_coord: float) -> bool:
"""Returns true if the given point is inside the given polygon
"""
num = len(poly)
inside = False
p2x = 0.0
p2y = 0.0
xints = 0.0
p1x, p1y = poly[0]
for i in range(num + 1):
p2x, p2y = poly[i % num]
if y_coord > min(p1y, p2y):
if y_coord <= max(p1y, p2y):
if x_coord <= max(p1x, p2x):
if p1y != p2y:
xints = \
(y_coord - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
if p1x == p2x or x_coord <= xints:
inside = not inside
p1x, p1y = p2x, p2y
return inside
def point_in_nogo(nogo: [], latitude: float, longitude: float) -> bool:
"""Returns true of the given geolocation is within a nogo area
"""
for polygon in nogo:
if _point_in_polygon(polygon, latitude, longitude):
return True
return False