Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make animated visualizations for the target area #108 #138

Open
wants to merge 16 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added reporter/animate/.DS_Store
Binary file not shown.
5 changes: 5 additions & 0 deletions reporter/animate/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# coding=utf-8
"""Init for test package.
:copyright: (c) 2018 by Pierre-Alix Tremblay
:license: GPLv3, see LICENSE for more details.
"""
Binary file added reporter/animate/bin/encode
Binary file not shown.
Binary file added reporter/animate/bin/render
Binary file not shown.
Binary file added reporter/animate/bin/snap
Binary file not shown.
184 changes: 184 additions & 0 deletions reporter/animate/frame.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import subprocess
import re
import os
from reporter import config
from reporter.animate.pathor import Pathor
# from reporter.utilities import LOGGER


class Frame:

"""
Frame is an object representing a frame of the final GIF
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Terminate with full stop and then add newline before extended description

Frame is an object representing a frame of the final GIF.

A frame has an ID (a date).
A frame gathers all ways that have the same date.
"""
def __init__(self, frame_id):
"""
Constructor

:param frame_id: the id of the frame with the format:
YYYY-MM (e.g: 2010-01)
:type frame_id: string
"""
self.frame_id = frame_id
self.paths = Pathor(self.frame_id)

def build(self, osm_data):
"""
Build a frame.png (image.png + label.png) from a frame_id:
- Fetch the coordinates that belongs to the frame
- encode and render the image
- render the label
- render the frame

:param osm_data: Data that every frame must access: ways and bounds
:type osm_data: OsmData instance

"""
self.osm_data = osm_data
coordinates = self.osm_data.filter_coordinates_for_frame(self.frame_id)

with open(self.paths.ways, 'w') as frame:
frame.write(''.join(coordinates))

self.encode_image()
self.render_image()
self.resize_image()
self.render_label()
self.render_frame()

def width(self):
"""
Run the command `identify` on the image.png

:returns: the width of the image.png
:rtype: string
"""
image_output = subprocess.check_output(['identify', self.paths.image])
return re.search('PNG (\\d+)x\\d+', str(image_output)).group(1)

def height(self):
"""
Run the command `identify` on the image.png

:returns: the height of the image.png
:rtype: string
"""
image_output = subprocess.check_output(['identify', self.paths.image])
return re.search('PNG \\d+x(\\d+)', str(image_output)).group(1)

def encode_image(self):
"""
Encode coordinates

:returns: None
"""
if os.path.exists(self.paths.ways):
command = 'cat {} | {}encode -o {} -z {}'.format(
self.paths.ways,
config.BIN_PATH,
self.paths.encoded_frame,
config.ZOOM_LEVEL)

os.system(command)

def render_image(self):
"""
Render an image with the encoded frame and the boundaries

:returns: None
"""
if (os.path.exists(self.paths.encoded_frame) and
self.osm_data.bounds is not None):
command1 = '{}render -t 0 -A -- "{}" {}'.format(
config.BIN_PATH,
self.paths.encoded_frame,
config.ZOOM_LEVEL)

command2 = '{} {} {} {}'.format(
self.osm_data.bounds['minlat'],
self.osm_data.bounds['minlon'],
self.osm_data.bounds['maxlat'],
self.osm_data.bounds['maxlon'])

command3 = '> {}'.format(self.paths.image)

os.system('{} {} {}'.format(command1, command2, command3))

def resize_image(self):
command = 'convert {} -resize 940x350 {}'.format(
self.paths.image, self.paths.image)
os.system(command)

def render_label(self):
"""
Create a .png file of the label

:returns: None
"""
command1 = 'convert -size {}x50 -gravity Center' \
' -background black'.format(self.width())
command2 = '-stroke white -fill white label:\'{}\' {}'.format(
self.frame_id, self.paths.label)

os.system('{} {}'.format(command1, command2))

def render_frame(self):
"""
Render a frame using the image and the label

:returns: None
"""
command = 'convert -append {} {} {}'.format(
self.paths.image,
self.paths.label,
self.paths.frame)

os.system(command)


def build_init_frame():
""" Build the init frame.
We need to build the first frame before building the init frame.
The init frame is an image with black background.
It has the same height/width as the first frame
"""

# get the first frame directory
first_frame_id = os.listdir(config.TMP_PATH)[0]
# create a instance of this Frame
first_frame = Frame(first_frame_id)

# create the directory for the init frame: 0000-00
init_frame_dir = '{}/0000-00'.format(config.TMP_PATH)
os.makedirs(init_frame_dir)

frame = '{}/frame.png'.format(init_frame_dir)

# create the 0000-00/frame.png
command = 'convert -size {}x{} canvas:black {}'.format(
first_frame.width(),
str(int(first_frame.height()) + 50),
frame)

os.system(command)


def build_gif(gif_file):
""" Build the final Gif

:param gif_file: the path of the final GIF
:type gif_file: string

:returns: None
"""
frames = '{}*/frame.png'.format(config.TMP_PATH)

command = 'convert -coalesce -dispose 1 -delay 20 -loop 0 {} {}'.format(
frames, gif_file)
os.system(command)

command = 'convert {} \\( +clone -set delay 500 \\)' \
' +swap +delete {}'.format(gif_file, gif_file)
os.system(command)
110 changes: 110 additions & 0 deletions reporter/animate/osm_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import re
import os
from bs4 import BeautifulSoup
from reporter.animate.util import (
format_timestamp,
mkdir_tmp
)
# from reporter.utilities import LOGGER
from reporter import config


class OsmData:
""" OsmData object to store data from the OSM file:
- <way> tags
- <bounds> tag
- datamap file content (from the snap command)
Those data will be used by every Frame to be built.
"""
def __init__(self, osm_file):
""" Constructor

:param osm_file: The path of the OSM file
:type osm_file: str

:returns: A OsmData instance
:rtype: OsmData
"""
try:
soup = BeautifulSoup(open(osm_file), 'html.parser')
except FileNotFoundError:
self.ways = None
self.bounds = None
self.datamap = None
else:
self.ways = soup.find_all('way')
self.bounds = soup.find('bounds')
self.datamap = snap_datamap(osm_file)

def frames(self):
"""
Create an array of unique date (YYYY-MM)

:returns: A sorted set/map of unique date (frame)
:rtype: set
"""
return sorted(set(map(
lambda way:
format_timestamp(way['timestamp']), self.ways
)))

def filter_coordinates_for_frame(self, frame):
"""
Find coordinates that belongs to a frame

:param frame: the id of the frame (e.g: '2010-01')
:type frame: str

:returns: A list of coordinates
:rtype: list
"""
ways = self.map_ways_for_frame(frame)
return list(filter(
lambda line:
re.search("id=(\\d+)", line).group(1) in ways, self.datamap
))

def map_ways_for_frame(self, frame):
"""
Map a list containing only the id of the Way objects

:param frame: the id of the frame (e.g: '2010-01')
:type frame: str

:returns: A list of way id (str)
:rtype: list
"""
return list(map(
lambda way: way['id'], self._filter_ways_for_frame(frame)
))

def _filter_ways_for_frame(self, frame):
"""
Filter Way objects that belongs to a frame

:param frame: the id of the frame (e.g: '2010-01')
:type frame: str

:returns: A list of Way objects
:rtype: list
"""
return list(filter(
lambda way:
frame == format_timestamp(way['timestamp']), self.ways
))


def snap_datamap(osm_file):
mkdir_tmp()
if os.path.exists(osm_file):
command = 'cat {} | {}snap > {}'.format(
osm_file,
config.BIN_PATH,
config.DATAMAP)

os.system(command)
try:
with open(config.DATAMAP, 'r') as datamapfile:
return datamapfile.readlines()
except FileNotFoundError:
return None
66 changes: 66 additions & 0 deletions reporter/animate/osm_gif.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import os
from reporter import config
from reporter.animate.osm_data import OsmData
from reporter.animate.frame import (
Frame,
build_init_frame,
build_gif
)
# from reporter.utilities import LOGGER


class OsmGif:
"""
OsmGif transforms an OSM file into a GIF file
"""

def __init__(self, osm_file):
"""
Constructor

:param osm_file: the path of an OSM file
:type osm_file: string

:returns: an instance of OsmGif
:rtype: OsmGif
"""
self.osm_file = osm_file
self.filename = os.path.splitext(os.path.basename(self.osm_file))[0]
self.gif_file = '{}{}.gif'.format(config.GIF_PATH, self.filename)

def run(self):
"""
Where the magic happens.
"""

# if the gif already exists, we return immediately
if os.path.isfile(self.gif_file):
return

# get an OsmData object
osm_data = OsmData(self.osm_file)

# build each frame
for frame_id in osm_data.frames():
Frame(frame_id).build(osm_data)

# build the init frame
build_init_frame()

# build the final gif
build_gif(self.gif_file)


def osm_to_gif(osm_file):
"""
Animate an OSM file: create an object OsmGif and run

:param osm_file: path of an OSM file
:type osm_file: string

:returns: path of the gif file
:rtype: string
"""
osm_gif = OsmGif(osm_file)
osm_gif.run()
return osm_gif.gif_file
Loading