-
Notifications
You must be signed in to change notification settings - Fork 17
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
pierrealixt
wants to merge
16
commits into
kartoza:develop
Choose a base branch
from
pierrealixt:fix-108
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
d1e6fcc
config; add OSM_GIF variables: gif_dir, gif_path, zoom_level, tmp_pat…
pierrealixt 4cfac7d
queries; add bbox argument to queries -> returns <bounds> tag oin xml…
pierrealixt a261e2b
UI: html/javascript to animate an map
pierrealixt a987909
test helpers: path to test_data files
pierrealixt e314d55
views: add the route /animate
pierrealixt 32b3226
pip: add requirements: bs4, beautifulsoup4,python-dateutil
pierrealixt 9e84759
animate: from an OSM xml file, create a gif file
pierrealixt 56d2d4e
new directory to store gif files
pierrealixt 002231c
loader.gif
pierrealixt f18f4f9
tests for animate
pierrealixt 4332331
add beautifulsoup, bs4 and python-dateutil to requirements-dev.txt
pierrealixt 5e3ae31
fix pep8 issues
pierrealixt 830afb8
remove gif
pierrealixt c321e50
resize the image to 940x350 -> to fit the web map, smaller gif
pierrealixt 6fd1147
if the bbox changed, we should refresh first
pierrealixt ae48841
workaround to pass tests in test_app (file_handle not assigned)
pierrealixt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Binary file not shown.
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
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 not shown.
Binary file not shown.
Binary file not shown.
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
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 | ||
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) |
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
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 |
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
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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.