Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Creating an ASCII art world map

I'd like to render an ASCII art world map given this GeoJSON file.

My basic approach is to load the GeoJSON into Shapely, transform the points using pyproj to Mercator, and then do a hit test on the geometries for each character of my ASCII art grid.

It looks (edit: mostly) OK when centered one the prime meridian:

centered at lon = 0

But centered on New York City (lon_0=-74), and it suddenly goes haywire:

enter image description here

I'm fairly sure I'm doing something wrong with the projections here. (And it would probably be more efficient to transform the ASCII map coordinates to lat/lon than transform the whole geometry, but I am not sure how.)

import functools
import json
import shutil
import sys

import pyproj
import shapely.geometry
import shapely.ops


# Load the map
with open('world-countries.json') as f:
  countries = []
  for feature in json.load(f)['features']:
    # buffer(0) is a trick for fixing polygons with overlapping coordinates
    country = shapely.geometry.shape(feature['geometry']).buffer(0)
    countries.append(country)

mapgeom = shapely.geometry.MultiPolygon(countries)

# Apply a projection
tform = functools.partial(
  pyproj.transform,
  pyproj.Proj(proj='longlat'),  # input: WGS84
  pyproj.Proj(proj='webmerc', lon_0=0),  # output: Web Mercator
)
mapgeom = shapely.ops.transform(tform, mapgeom)

# Convert to ASCII art
minx, miny, maxx, maxy = mapgeom.bounds
srcw = maxx - minx
srch = maxy - miny
dstw, dsth = shutil.get_terminal_size((80, 20))

for y in range(dsth):
  for x in range(dstw):
    pt = shapely.geometry.Point(
      (srcw*x/dstw) + minx,
      (srch*(dsth-y-1)/dsth) + miny  # flip vertically
    )
    if any(country.contains(pt) for country in mapgeom):
      sys.stdout.write('*')
    else:
      sys.stdout.write(' ')
  sys.stdout.write('\n')

like image 216
rgov Avatar asked Mar 28 '19 20:03

rgov


1 Answers

I made edit at the bottom, discovering new problem (why there is no Canada and unreliability of Shapely and Pyproj)


Even though its not exactly solving the problem, I believe this attitude has more potential than using pyproc and Shapely and in future, if you will do more Ascii art, will give you more possibilites and flexibility. Firstly I will write pros and cons.

PS: Initialy I wanted to find problem in your code, but I had problems with running it, because pyproj was returning me some error.

PROS

1) I was able to extract all points (Canada is really missing) and rotate image

2) The processing is very fast and therefore you can create Animated Ascii art.

3) Printing is done all at once without need to loop

CONS (known Issues, solvable)

1) This attitude is definetly not translating geo-coordinates correctly - too plane, it should look more spherical

2) I didnt take time to try to find out solution to filling the borders, so only borders has '*'. Therefore this attitude needs to find algorithm to fill the countries. I think it shouldnt be problem since the JSON file contains countries separated

3) You need 2 extra libs beside numpy - opencv(you can use PIL instead) and Colorama, because my example is animated and I needed to 'clean' terminal by moving cursor to (0,0) instead of using os.system('cls')

4) I made it run only in python 3. In python 2 it works too but I am getting error with sys.stdout.buffer

Change font size on terminal to lowest point so the the printed chars fit in terminal. Smaller the font, better resolution

The animation should look like the map is 'rotating' enter image description here

I used little bit of your code to extract the data. Steps are in the commentaries

import json
import sys
import numpy as np
import colorama
import sys
import time
import cv2

#understand terminal_size as how many letters in X axis and how many in Y axis. Sorry not good name
if len(sys.argv)>1:   
    terminal_size = (int(sys.argv[1]),int(sys.argv[2]))
else:
    terminal_size=(230,175)
with open('world-countries.json') as f:
    countries = []
    minimal = 0 # This can be dangerous. Expecting negative values
    maximal = 0 # Expecting bigger values than 0
    for feature in json.load(f)['features']: # getting data  - I pretend here, that geo coordinates are actually indexes of my numpy array
        indexes = np.int16(np.array(feature['geometry']['coordinates'][0])*2)
        if indexes.min()<minimal:
            minimal = indexes.min()
        if indexes.max()>maximal:
            maximal = indexes.max()
        countries.append(indexes) 

    countries = (np.array(countries)+np.abs(minimal)) # Transform geo-coordinates to image coordinates
correction = np.abs(minimal) # because geo-coordinates has negative values, I need to move it to 0 - xaxis

colorama.init()

def move_cursor(x,y):
    print ("\x1b[{};{}H".format(y+1,x+1))

move = 0 # 'rotate' the globe
for i in range(1000):
    image = np.zeros(shape=[maximal+correction+1,maximal+correction+1]) #creating clean image

    move -=1 # you need to rotate with negative values
    # because negative one are by numpy understood. Positive one will end up with error
    for i in countries: # VERY STRANGE,because parsing the json, some countries has different JSON structure
        if len(i.shape)==2:
            image[i[:,1],i[:,0]+move]=255 # indexes that once were geocoordinates now serves to position the countries in the image
        if len(i.shape)==3:
            image[i[0][:,1],i[0][:,0]+move]=255


    cut = np.where(image==255) # Bounding box
    if move == -1: # creating here bounding box - removing empty edges - from sides and top and bottom - we need space. This needs to be done only once
        max_x,min_x = cut[0].max(),cut[0].min()
        max_y,min_y = cut[1].max(),cut[1].min()


    new_image = image[min_x:max_x,min_y:max_y] # the bounding box
    new_image= new_image[::-1] # reverse, because map is upside down
    new_image = cv2.resize(new_image,terminal_size) # resize so it fits inside terminal

    ascii = np.chararray(shape = new_image.shape).astype('|S4') #create container for asci image
    ascii[:,:]='' #chararray contains some random letters - dunno why... cleaning it
    ascii[:,-1]='\n' #because I pring everything all at once, I am creating new lines at the end of the image
    new_image[:,-1]=0 # at the end of the image can be country borders which would overwrite '\n' created one step above
    ascii[np.where(new_image>0)]='*' # transforming image array to chararray. Better to say, anything that has pixel value higher than 0 will be star in chararray mask
    move_cursor(0,0) # 'cleaning' the terminal for new animation
    sys.stdout.buffer.write(ascii) # print into terminal
    time.sleep(0.025) # FPS

Maybe it would be good to explain what is the main algorithm in the code. I like to use numpy whereever I can. The whole thing is that I pretend that coordinates in the image, or whatever it may be (in your case geo-coordinates) are matrix indexes. I have then 2 Matrixes - Real Image and Charray as Mask. I then take indexes of interesting pixels in Real image and for the same indexes in Charray Mask I assign any letter I want. Thanks to this, the whole algorithm doesnt need a single loop.

About Future posibilities

Imagine you will also have information about terrain(altitude). Let say you somehow create grayscale image of world map where gray shades expresses altitude. Such grayscale image would have shape x,y. You will prepare 3Dmatrix with shape = [x,y,256]. For each layer out of 256 in the 3D matrix, you assign one letter ' ....;;;;### and so on' that will express shade. When you have this prepared, you can take your grayscale image where any pixel will actually have 3 coordinates: x,y and shade value. So you will have 3 arrays of indexes out of your grascale map image -> x,y,shade. Your new charray will simply be extraction of your 3Dmatrix with layer letters, because:

#Preparation phase
x,y = grayscale.shape
3Dmatrix = np.chararray(shape = [x,y,256])
table = '    ......;;;;;;;###### ...'
for i in range(256):
    3Dmatrix[:,:,i] = table[i]
x_indexes = np.arange(x*y)
y_indexes = np.arange(x*y)
chararray_image = np.chararray(shape=[x,y])

# Ready to print
...

shades = grayscale.reshape(x*y)
chararray_image[:,:] = 3Dmatrix[(x_indexes ,y_indexes ,shades)].reshape(x,y)

Because there is no loop in this process and you can print chararray all at once, you can actually print movie into terminal with huge FPS

For example if you have footage of rotating earth, you can make something like this - (250*70 letters), render time 0.03658s

enter image description here

You can ofcourse take it into extreme and make super-resolution in your terminal, but resulting FPS is not that good: 0.23157s, that is approximately 4-5 FPS. Interesting to note is, that this attitude FPS is enourmous, but terminal simply cannot handle printing, so this low FPS is due to limitations of terminal and not of calculation as calculation of this high resolution took 0.00693s, that is 144 FPS.

enter image description here


BIG EDIT - contradicting some of above statements

I accidentaly opened raw json file and find out, there is CANADA and RUSSIA with full correct coordinates. I made mistake to rely on the fact that we both didnt have canada in the result, so I expected my code is ok. Inside JSON, the data has different NOT-UNIFIED structure. Russia and Canada has 'Multipolygon', so you need to iterate over it.

What does it mean? Dont rely on Shapely and pyproj. Obviously they cant extract some countries and if they cant do it reliably, you cant expect them to do anything more complicated.

After modifying the code, everything is allright

CODE: This is how to load the file correctly

...
with open('world-countries.json') as f:
    countries = []
    minimal = 0
    maximal = 0
    for feature in json.load(f)['features']: # getting data  - I pretend here, that geo coordinates are actually indexes of my numpy array

        for k in range((len(feature['geometry']['coordinates']))):
            indexes = np.int64(np.array(feature['geometry']['coordinates'][k]))
            if indexes.min()<minimal:
                minimal = indexes.min()
            if indexes.max()>maximal:
                maximal = indexes.max()
            countries.append(indexes) 

...

enter image description here

like image 116
Martin Avatar answered Nov 01 '22 08:11

Martin