[PYTHON] The story of outputting the planetarium master in pdf format with Pycairo

This article was written according to the Advent calendar, which is written by a DMM group candidate who graduated in 2020.

DMM Group '20 Graduate Advent Calendar 2019

I regret that I should have written a little more self-introduction or something, but after writing the contents below, planetarium development was a fairly core activity in my student life, so this article I will introduce myself instead.

0. Overview

As usual, a certain circle that belongs to the rotten edge will exhibit the planetarium at the technical college festival, and I will make the stellar master that is necessary for making the light source device. (All the coordinate conversion of the stars output to the star master was done by our super super super talented junior. I & this article just stippling it.) I used to output a similar master in Java and Gnuplot, but the result was somewhat bimyo. Since Gnuplot is a graph display tool, it seems that it is not good at outputting fine points like planetarium stars. So, this time, I decided to use the Python wrapper Pycairo of cairo, which is a lower level image processing library, to output the stellar master. The drawn one (the first page) is shown below.

example.png

By printing this pdf format (JIS A4 size x 12 sheets) on a transparent printing paper called an OHP sheet and then developing it on a film with high contrast performance called a squirrel film, a pinhole type planetarium light source device can be used. You can create a filter. This time's goal is to make this film.

  1. cairo? Pycairo?

1.1. What is cairo?

cairo is a low-level library for drawing 2D vector images.

When I looked it up, it seems that it is used in various major oss.

1.2. What is Pycairo?

As mentioned earlier, Pycairo is cairo's Python wrapper. This is Pycairo, but I feel that there are relatively few Japanese documents. Well, English documents are all over the net, so I don't have a problem with the amount of information.

-Pycairo documentation

If you have any questions on the internet, you can read these documents and try them out. Especially, if you read Cairo Tutorial, you can understand the operating principle of the cairo library itself, so it is a must read.

3. Environment construction

3.1. Preparation

Please install python3 and numpy. (Procedure omitted) It is also recommended to install virtualenv and Jupyter Notebook (Colaboratory extension)

3.2. Installing Pycairo

Complete with the following command without any special

pip install pycairo

4. How to use Pycairo

Only the functions used for the development of the stellar master are listed.

4.1. Preparation

4.1.1. To draw A4 size

JIS A4 size is specified as "210 mm x 297 mm" and is explained in the metric system, while Pycairo is specified as "1pt = 1 / 72th of 1in" for the smallest unit of 1pt, which is imperial. I feel murderous in the law. So, I have no choice but to define a bridging function between the metric system and the imperial system.

def inch(meter):
  return 0.0393701 * meter * 72.0

4.1.2. Vectorization of rgb strings

In Pycairo, RGB color information is passed as a numerical value (0.0 to 1.0), but since it is troublesome, we also define a function to convert the rgb character string to a vector.

def color(color_string):
  return np.array([int(color_string[1:3], 16), int(color_string[3:5], 16), int(color_string[5:7], 16)])

4.1.3. Basic drawing

def draw_face(context, color_as_vector, width=100, height=100, margine=0):
  context.save()

  context.set_source_rgb(
      color_as_vector[0], 
      color_as_vector[1],
      color_as_vector[2])
  context.translate(margine, margine) #Move to the coordinates (reference point) that is the reference point for drawing
  context.rectangle(0,0,width, height) #Draw by passing each parameter from the reference point
  context.fill()

  context.restore()

with cairo.PDFSurface("example.pdf", canvas_width, canvas_height) as surface:
  for face_num in range(12):
    context = cairo.Context(surface)
    context.scale(1, 1)

    draw_face(context,
              color_as_vector=color('#000000'), 
              width=canvas_width-inch(30),
              height=canvas_height-inch(30),
              margine=inch(15))
    
    surface.show_page() #Create a new page&switching

Create a PDF object with cairo.PDFSurface. Others include cairo.PSSurface, cairo.SVGSurface, and cairo.ImageSurface. Also, surface.show_page is a function to create a new page of the same size in pdf. Then note that the process of creating a shape is written between context.save and context.restore.

4.1.4. Preparation of source data

This time, I received a csv file from my junior, so I will import it as data in the star catalog.

def gen_star_catalogue(file_name=None, face_num=0):
  face_vector = [[] for _ in range(face_num)]

  with open(file_name, mode='r') as csv_file:
    for line in csv.reader(csv_file, delimiter=' '):
      face_vector[int(line[0])].append([ast.literal_eval(line[1]),
                                        ast.literal_eval(line[2]),
                                        ast.literal_eval(line[3])])
  
  for face in range(face_num):
      face_vector[face] = np.array(face_vector[face])
      
  return face_vector

4.2. Drawing a regular polygon

This time, I output a regular pentagon diagram, but in order to increase the flexibility of the code, I will implement a code that outputs a set of vertices of a regular polygon that is larger than a triangle.

スクリーンショット 2019-11-26 16.56.55.png

Assume a regular polygon with a circumscribed circle of radius radius centered on center_pos. It calculates the coordinates of each vertex of the n-sided polygon, and then outputs a group of vertices multiplied by a rotation matrix that rotates by any raduis at any point in two dimensions.

Generating a regular N-sided vertex vector


def gen_regular_polygon(center_pos=None, n=3, radius=1, rotate=0):
  theta = 2 * math.pi / n
  rot_rad = 2 * math.pi * rotate / 360.0

  verts = np.array([[[math.cos(i*theta) * radius + center_pos[0]],
                     [math.sin(i*theta) * radius + center_pos[1]],
                     [1]]
                    for i in range(n)])

  rcos = math.cos(rot_rad)
  rsin = math.sin(rot_rad)
  r31 = -center_pos[0]*rcos + center_pos[1]*rsin + center_pos[0]
  r32 = -center_pos[0]*rsin - center_pos[1]*rcos + center_pos[1]

  rot_vec = np.array([[rcos, -rsin, r31],
                      [rsin, rcos, r32],
                      [0, 0, 1]])
  
  verts = np.concatenate([[np.dot(rot_vec, vert)] for vert in verts])
  
  verts = np.apply_along_axis(lambda x: np.array([x[0], x[1]]), arr=verts, axis=1).reshape([n, 2])
  
  return verts

Also, if you make it a 50-sided polygon, a polygon close to a circle will be drawn as shown in the figure below. スクリーンショット 2019-11-26 17.32.56.png

4.3. Creating a margin

You can screw it down with the power of mathematics. However, I only use simple formulas.

\vec{n}=\frac{\vec{oc}}{|\vec{oc}|}
\vec{m_a}=len\cdot\vec{n}+\vec{a}
\vec{m_b}=len\cdot\vec{n}+\vec{b}

After that, you can make a margin by connecting these in order and drawing a line.

def draw_margines(context=None, verts=None, center_pos=None, edge_size=1, line_color_as_vec=None, frame_width=1, margin_face_num_list=None):
  vert_multi = verts.repeat(2, axis=0)
  vert_pairs = np.roll(vert_multi, -2).reshape([verts.shape[0], 2, verts.shape[1]])

  midpoints = np.apply_along_axis(func1d=lambda x: np.sum(a=x, axis=0)/2, axis=1, arr=vert_pairs)
  orth_vecs = midpoints - center_pos
  euclid_dists = np.mean(np.apply_along_axis(func1d=lambda x: np.linalg.norm(x), axis=1, arr=orth_vecs))

  normals = orth_vecs / euclid_dists
  normals_pairs = normals.repeat(2, axis=0).reshape([normals.shape[0], 2, 2])

  edges_arr = edge_size * normals_pairs + vert_pairs

  # [One end A,Edge A side corner,Edge B side corner,One end B]Arrange in the order of, and pass this to the context
  edges_arr = np.array([[vert_pairs[x,0], edges_arr[x,0], edges_arr[x,1], vert_pairs[x,1]] for x in range(edges_arr.shape[0])])

  context.save()
  for edges in edges_arr:
    first_edge = edges[0]
    context.move_to(first_edge[0], first_edge[1])
    for edge in edges[1:]:
      context.line_to(edge[0], edge[1])
    context.set_source_rgb(line_color_as_vec[0],line_color_as_vec[1],line_color_as_vec[2])
    context.set_line_width(frame_width)
    context.stroke()
  context.restore()

4.4. Point output

Is it more accurate to say "circle" output than point output?

def draw_stars(context=None, stars=None, radius=1.0, center_pos=None, brightness=1.0, color_as_vector=None):
  context.save()

  for star in stars:
    context.set_source_rgb(color_as_vector[0], 
                           color_as_vector[1], 
                           color_as_vector[2])
    context.arc(star[0]*radius+center_pos[0],
                star[1]*radius+center_pos[1],
               np.math.sqrt(star[2]) *brightness,
                0, 2*math.pi)
    context.fill()

  context.restore()

4.5. Character output using fonts

It is possible to use the font installed in the OS. Let's set the font name in context.select_font_face in advance. Maybe draw without it.

def draw_text(context=None, text=None, font_size=1, pos_as_vec=None, rotate=0, color_as_vec=None):
  context.save()
  context.set_font_size(font_size)
  context.move_to(pos_as_vec[0], pos_as_vec[1]) #Move to drawing point
  context.rotate(2*math.pi*rotate/360) #rotation
  context.set_source_rgb(color_as_vec[0], color_as_vec[1], color_as_vec[2])
  context.show_text(text) #Character drawing
  context.restore()

with cairo.PDFSurface("example.pdf", canvas_width, canvas_height) as surface:
  for face_num in range(12):
    context = cairo.Context(surface)
    context.scale(1, 1)
    context.select_font_face("Futura")

    draw_text(context=context,
              text='Face/{:0=2}'.format(face_num+1),
              font_size=100,
              pos_as_vec=np.array([inch(250),inch(170)]),
              rotate=-90,
              color_as_vec=color("#ffffff"))
    
    draw_text(context=context,
              text="Copyright(C) 2019 Space Science Research Club, National Institute of Technology, Kitakyushu College All rights reserved.",
              font_size=10,
              pos_as_vec=np.array([inch(40),inch(193)]),
              rotate=0,
              color_as_vec=color("#ffffff"))
    
    surface.show_page()

5. Summary

Finally, compare the image drawn this time with the image drawn with Gnuplot last time. スクリーンショット 2019-12-01 11.21.00.png

Black-and-white inversion is derived due to the difference in the purpose of use of the master, but even if it is subtracted, not only the stars are plotted as round dots, but also the difference in the grade of each star is carefully regarded as the size of the circle. You can see that it can be displayed in. As you can see, Pycairo is a relatively low-level library, which is why it is a library that allows flexible graphic display. However, since the Pycairo library itself is designed to draw by procedural description, it is somewhat difficult to separate the side effect drawing part from the pure description in the functional language sense when writing code. I felt it in the neck. This feels like a technical challenge. However, I think that the ability to create flexible graphic documents by using Pycairo, which can output pdf, can be applied to general work.

Finally, I will paste the screenshot of the PDF document drawn this time and the entire source code. This time I used Goole Colaboratory with Jupyter Notebook, so the source will be adapted accordingly.

スクリーンショット 2019-12-01 14.36.24.png

import cairo, math, csv, ast
from IPython.display import SVG, display
import numpy as np

#Drawing a string
def draw_text(context=None, text=None, font_size=1, pos_as_vec=None, rotate=0, color_as_vec=None):
  context.save()
  context.set_font_size(font_size)
  context.move_to(pos_as_vec[0], pos_as_vec[1])
  context.rotate(2*math.pi*rotate/360)
  context.set_source_rgb(color_as_vec[0], color_as_vec[1], color_as_vec[2])
  context.show_text(text)
  context.restore()

#Background color drawing
def draw_face(context, color_as_vector, width=100, height=100, margine=0):
  context.save()

  context.set_source_rgb(
      color_as_vector[0], 
      color_as_vector[1],
      color_as_vector[2])
  context.translate(margine, margine)
  context.rectangle(0,0,width, height)
  context.fill()

  context.restore()

#Draw vertex vector
def draw_frame(
    context,
    verts_array,
    fill_color_as_rgb_vec,
    frame_color_as_rgb_vec,
    frame_width):

  if verts_array.shape[0] < 3:
    print("ERROR")
    exit()
  else:
    context.save()

    first_vert = verts_array[0]
    tail_vert_array = verts_array[1:]

    context.move_to(first_vert[0], first_vert[1])

    for vert in tail_vert_array:
      context.line_to(vert[0], vert[1])

    context.close_path()

    context.set_source_rgb(
        fill_color_as_rgb_vec[0],
        fill_color_as_rgb_vec[1],
        fill_color_as_rgb_vec[2])
    context.fill_preserve()

    context.set_source_rgb(
        frame_color_as_rgb_vec[0],
        frame_color_as_rgb_vec[1],
        frame_color_as_rgb_vec[2]
    )
    context.set_line_width(frame_width)
    context.stroke()

    context.restore()

#Draw a star
def draw_stars(context=None, stars=None, radius=1.0, center_pos=None, brightness=1.0, color_as_vector=None):
  context.save()

  for star in stars:
    context.set_source_rgb(color_as_vector[0], 
                           color_as_vector[1], 
                           color_as_vector[2])
    context.arc(star[0]*radius+center_pos[0],
                star[1]*radius+center_pos[1],
               np.math.sqrt(star[2]) *brightness,
                0, 2*math.pi)
    context.fill()

  context.restore()

#Generating a regular N-sided vertex vector
def gen_regular_polygon(center_pos=None, n=3, radius=1, rotate=0):
  theta = 2 * math.pi / n
  rot_rad = 2 * math.pi * rotate / 360.0

  verts = np.array([[[math.cos(i*theta) * radius + center_pos[0]],
                     [math.sin(i*theta) * radius + center_pos[1]],
                     [1]]
                    for i in range(n)])

  rcos = math.cos(rot_rad)
  rsin = math.sin(rot_rad)
  r31 = -center_pos[0]*rcos + center_pos[1]*rsin + center_pos[0]
  r32 = -center_pos[0]*rsin - center_pos[1]*rcos + center_pos[1]

  rot_vec = np.array([[rcos, -rsin, r31],
                      [rsin, rcos, r32],
                      [0, 0, 1]])
  
  verts = np.concatenate([[np.dot(rot_vec, vert)] for vert in verts])
  
  verts = np.apply_along_axis(lambda x: np.array([x[0], x[1]]), arr=verts, axis=1).reshape([n, 2])
  
  return verts

#Meter-inch conversion
def inch(meter):
  return 0.0393701 * meter * 72.0

#Vectorization of agb strings
def color(color_string):
  return np.array([int(color_string[1:3], 16), int(color_string[3:5], 16), int(color_string[5:7], 16)])

#Drawing of glue
def draw_margines(context=None, verts=None, center_pos=None, edge_size=1, line_color_as_vec=None, frame_width=1, margin_face_num_list=None):
  vert_multi = verts.repeat(2, axis=0)
  vert_pairs = np.roll(vert_multi, -2).reshape([verts.shape[0], 2, verts.shape[1]])

  midpoints = np.apply_along_axis(func1d=lambda x: np.sum(a=x, axis=0)/2, axis=1, arr=vert_pairs)
  orth_vecs = midpoints - center_pos
  euclid_dists = np.mean(np.apply_along_axis(func1d=lambda x: np.linalg.norm(x), axis=1, arr=orth_vecs))

  normals = orth_vecs / euclid_dists
  normals_pairs = normals.repeat(2, axis=0).reshape([normals.shape[0], 2, 2])

  edges_arr = edge_size * normals_pairs + vert_pairs

  edges_arr = np.array([[vert_pairs[x,0], edges_arr[x,0], edges_arr[x,1], vert_pairs[x,1]] for x in range(edges_arr.shape[0])])

  context.save()
  for edges in edges_arr:
    first_edge = edges[0]
    context.move_to(first_edge[0], first_edge[1])
    for edge in edges[1:]:
      context.line_to(edge[0], edge[1])
    context.set_source_rgb(line_color_as_vec[0],line_color_as_vec[1],line_color_as_vec[2])
    context.set_line_width(frame_width)
    context.stroke()
  context.restore()

  inner_product = np.apply_along_axis(lambda x: np.dot(x, np.array([0,1])), axis=1, arr=normals)
  thetas = np.apply_along_axis(lambda x: np.arccos(x)/(2*np.pi)*360, axis=0, arr=inner_product)
  sign = np.apply_along_axis(lambda x: -1 if x[0]>0 else 1, axis=1, arr=normals) 
  signed_thetas = sign * thetas
  print(signed_thetas)

  context.save()
  for index, theta in enumerate(signed_thetas):
    draw_text(context=context,
              text='Face/{:0=2}'.format(margin_face_num_list[index]),
              font_size=15,
              pos_as_vec=orth_vecs[index] + center_pos + normals[index] * edge_size*0.7,
              rotate=theta,
              color_as_vec=color("#ffffff"))
  context.restore()

#Cutting out a star chart
def gen_star_catalogue(file_name=None, face_num=0):
  face_vector = [[] for _ in range(face_num)]

  with open(file_name, mode='r') as csv_file:
    for line in csv.reader(csv_file, delimiter=' '):
      face_vector[int(line[0])].append([ast.literal_eval(line[1]),
                                        ast.literal_eval(line[2]),
                                        ast.literal_eval(line[3])])
  
  for face in range(face_num):
      face_vector[face] = np.array(face_vector[face])
      
  return face_vector

#Glue allowance index allocation
margin_index = [[6, 5, 4, 3, 2],   [7, 6, 1, 3, 8],   [8, 2, 1, 4, 9], 
                [9, 3, 1, 5, 10],  [10, 4, 1, 6, 11], [11, 5, 1, 2, 7],
                [2, 8, 12, 11, 6], [3, 9, 12, 7, 2],  [4, 10, 12, 8, 3], 
                [5, 11, 12, 9, 4], [6, 7, 12, 10, 5], [10, 11, 7, 8, 9]]

#Hyperparameters
normal_scale = 100
canvas_height = inch(210)
canvas_width  = inch(297)
face_center_position = np.array([inch(125), inch(105)])
face_radius = inch(74.85727113)
face_mid_dist = inch(77.15727113)
face_rotate_list = [0]+[180]*10+[0]

#Drafting table data array
star_catalogue = gen_star_catalogue(file_name="./starout.txt", face_num=12)

#PDF file output
with cairo.PDFSurface("example.pdf", canvas_width, canvas_height) as surface:
  for face_num in range(12):
    context = cairo.Context(surface)
    context.scale(1, 1)
    context.select_font_face("Futura")

    draw_face(context,
              color_as_vector=color('#000000'), 
              width=canvas_width-inch(30),
              height=canvas_height-inch(30),
              margine=inch(15))

    verts = gen_regular_polygon(
        center_pos=face_center_position,
        n=5,
        radius=face_radius,
        rotate=face_rotate_list[face_num])

    draw_frame(context=context,
               verts_array=verts,
               fill_color_as_rgb_vec=color('#ffffff'),
               frame_color_as_rgb_vec=color('#000000'),
               frame_width=0.02)
    
    draw_margines(context=context,
                  verts=verts,
                  center_pos=face_center_position,
                  edge_size=inch(10),
                  line_color_as_vec=color("#ff0000"),
                  frame_width=1,
                  margin_face_num_list=margin_index[face_num])
    
    stars = star_catalogue[face_num]

    draw_stars(context=context,
               stars=stars,
               radius=face_mid_dist,
               center_pos=face_center_position,
               brightness=0.5,
               color_as_vector=color('#000000'))
    
    draw_text(context=context,
              text='Face/{:0=2}'.format(face_num+1),
              font_size=100,
              pos_as_vec=np.array([inch(250),inch(170)]),
              rotate=-90,
              color_as_vec=color("#ffffff"))
    
    draw_text(context=context,
              text="Copyright(C) 2019 Space Science Research Club, National Institute of Technology, Kitakyushu College All rights reserved.",
              font_size=10,
              pos_as_vec=np.array([inch(40),inch(193)]),
              rotate=0,
              color_as_vec=color("#ffffff"))
    
    
    surface.show_page()


Postscript

When printing a planetarium master, I use transparencies that are almost fossil, but printers at home and school are clogged or the print density is very thin (even if it is the darkest in the setting). There are many things like that. If you fall into this situation, it's quick and easy to go to a printing shop that can print transparencies such as Kinko's, and here (at least at local stores) you can print deeply on transparencies. Is done. However, when printing transparencies, the printing shop only supports black-and-white printing in the first place, and even if it is possible, the colors are super light, so the special color information is not utilized, so be careful about that point. Should be. Also, not only OHP sheets, but also the sheets and hands may get dirty with ink at the moment of printing, so it is a good idea to provide a zone that does not print around as shown in the above figure.

Recommended Posts

The story of outputting the planetarium master in pdf format with Pycairo
The story of participating in AtCoder
The story of the "hole" in the file
The story of an error in PyOCR
The story of a Parking Sensor in 10 minutes with GrovePi + Starter Kit
The story of doing deep learning with TPU
[Automation] Extract the table in PDF with Python
The story of finding the optimal n in N fist
The story of reading HSPICE data in Python
The story of viewing media files in Django
The story that fits in with pip installation
The story of stopping the production service with the hostname command
The story of replacing Nvidia GTX 1650 with Linux Mint 20.1.
The story of building the fastest Linux environment in the world
The story of sharing the pyenv environment with multiple users
Convert the image in .zip to PDF with Python
Read the linked list in csv format with graph-tool
The story of sys.path.append ()
A story about changing the master name of BlueZ
The story of debugging in the local environment because the compilation did not work with Read the Docs
Flow of extracting text in PDF with Cloud Vision API
Try scraping the data of COVID-19 in Tokyo with Python
The story of implementing the popular Facebook Messenger Bot with python
How to output a document in pdf format with Sphinx
Calculate the square root of 2 in millions of digits with python
The story of displaying images with OpenCV or PIL (only)
The story of rubyist struggling with python :: Dict data with pycall
[Homology] Count the number of holes in data with Python
The story of making a question box bot with discord.py
The story of downgrading the version of tensorflow in the demo of Mask R-CNN.
The story of building Zabbix 4.4
[Apache] The story of prefork
Summary of character string format in Python3 Whether to live with the old model or the new model
The story of creating a bot that displays active members in a specific channel of slack with python
Process the contents of the file in order with a shell script
A story stuck with the installation of the machine learning library JAX
Output the contents of ~ .xlsx in the folder to HTML with Python
Format the CSV file of "National Holiday" of the Cabinet Office with pandas
The story of visualizing popular Qiita tags with Bar Chart Race
Visualize the frequency of word occurrences in sentences with Word Cloud. [Python]
The story of making a module that skips mail with python
Winner of the one with the highest degree of similarity to the original story in "The winner of the one who wrote the most stinky Hototogisu"
The story of Python and the story of NaN
Master the rich features of IPython
Master the type with Python [Python 3.9 compatible]
The meaning of ".object" in Django
The story of remounting the application server
The story of writing a program
Master the weakref module in Python
A story that visualizes the present of Qiita with Qiita API + Elasticsearch + Kibana
The story of making a university 100 yen breakfast LINE bot with Python
The story of having a hard time introducing OpenCV with M1 MAC
Play the comment of Nico Nico Douga on the terminal in conjunction with the video
Receive a list of the results of parallel processing in Python with starmap
The story of making a sound camera with Touch Designer and ReSpeaker
Display the status of COVID 19 infection in Japan with Splunk (GitHub version)
I made a mistake in fetching the hierarchy with MultiIndex of pandas
With the advent of systemd-homed in 2020, Linux user management will change dramatically.
The story of trying to push SSH_AUTH_SOCK obsolete on screen with LD_PRELOAD
The story of using mysqlclient because PyMySQL cannot be used with Django 2.2
A story about downloading the past question PDF of the Fundamental Information Technology Engineer Examination in Python at once