From: Antonio Ospite Date: Wed, 16 Apr 2014 11:37:49 +0000 (+0200) Subject: Initial import X-Git-Url: https://git.ao2.it/PoPiPaint.git/commitdiff_plain/d28a4cc26d7d3815fd7a70a2b938ed6ff19b9582?ds=sidebyside Initial import --- d28a4cc26d7d3815fd7a70a2b938ed6ff19b9582 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/CanvasFrame.py b/CanvasFrame.py new file mode 100755 index 0000000..9822c4d --- /dev/null +++ b/CanvasFrame.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python +# +# Copyright (C) 2014 Antonio Ospite +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import wx +from wx.lib.pubsub import Publisher as pub +from wx.lib.wordwrap import wordwrap + +import CanvasModel +import CanvasView + +ICON_FILE = "res/ao2.ico" + +IMAGE_WIDTH = 101 +IMAGE_HEIGHT = 30 +INTERNAL_RADIUS = 2 + + +class CanvasFrame(wx.Frame): + def __init__(self, *args, **kwargs): + base_image = kwargs.pop('base_image', None) + wx.Frame.__init__(self, *args, **kwargs) + + # Set up a sizer BEFORE every other action, in order to prevent any + # weird side effects; for instance self.SetToolBar() seems to resize + # child windows... + vsizer = wx.BoxSizer(orient=wx.VERTICAL) + self.SetSizer(vsizer) + + # Instantiate the Model and set up the view + self.model = CanvasModel.Canvas(IMAGE_WIDTH, IMAGE_HEIGHT, + INTERNAL_RADIUS) + + self.view = CanvasView.CanvasView(self, model=self.model, + base_image=base_image) + vsizer.Add(self.view, 0, wx.SHAPED) + + icon = wx.Icon(ICON_FILE, wx.BITMAP_TYPE_ICO) + self.SetIcon(icon) + + # Set up the menu bar + menu_bar = self._BuildMenu() + self.SetMenuBar(menu_bar) + + # Tool bar + tool_bar = self._BuildTools() + self.SetToolBar(tool_bar) + + # Status bar + status_bar = wx.StatusBar(self) + status_bar.SetWindowStyle(status_bar.GetWindowStyle() ^ wx.ST_SIZEGRIP) + status_bar.SetFieldsCount(3) + self.SetStatusBar(status_bar) + + # View callbacks + pub.subscribe(self.UpdateStatusBar, "NEW PIXEL") + pub.subscribe(self.UpdateView, "NEW PIXEL") + + # Controller Methods + self.view.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown) + self.view.Bind(wx.EVT_LEFT_UP, self.OnLeftUp) + self.view.Bind(wx.EVT_MOTION, self.OnMouseMotion) + + # other Events + self.Bind(wx.EVT_CLOSE, self.OnQuit) + + # The frame gets resized to fit all its elements + self.GetSizer().Fit(self) + # and centered on screen + self.Center(wx.BOTH | wx.CENTER_ON_SCREEN) + + def _BuildTools(self): + tool_bar = wx.ToolBar(self, style=wx.TB_HORZ_LAYOUT|wx.TB_TEXT) + + color_picker_label = wx.StaticText(tool_bar, label=" Color picker ") + tool_bar.AddControl(color_picker_label) + + self.color = wx.WHITE + + ID_COLOR_PICKER = wx.NewId() + color_picker = wx.ColourPickerCtrl(tool_bar, ID_COLOR_PICKER, self.color, + size=wx.Size(32,32), + name="Color Picker") + tool_bar.AddControl(color_picker) + wx.EVT_COLOURPICKER_CHANGED(self, ID_COLOR_PICKER, self.OnPickColor) + + tool_bar.AddSeparator() + + ID_SHOW_GRID = wx.NewId() + show_grid_checkbox = wx.CheckBox(tool_bar, ID_SHOW_GRID, label="Show grid", style=wx.ALIGN_RIGHT) + show_grid_checkbox.SetValue(self.view.draw_grid) + tool_bar.AddControl(show_grid_checkbox) + wx.EVT_CHECKBOX(tool_bar, ID_SHOW_GRID, self.OnShowGrid) + + tool_bar.Realize() + + return tool_bar + + def _BuildMenu(self): + menu_bar = wx.MenuBar() + + # File menu + file_menu = wx.Menu() + menu_bar.Append(file_menu, '&File') + + ID_NEW_BITMAP = wx.ID_NEW + file_menu.Append(ID_NEW_BITMAP, 'New Bitmap', 'Start a new bitmap') + wx.EVT_MENU(self, ID_NEW_BITMAP, self.OnNewBitmap) + + ID_LOAD_BITMAP = wx.ID_OPEN + file_menu.Append(ID_LOAD_BITMAP, 'Load Bitmap', 'Load a bitmap') + wx.EVT_MENU(self, ID_LOAD_BITMAP, self.OnLoadBitmap) + + ID_SAVE_BITMAP = wx.ID_SAVE + file_menu.Append(ID_SAVE_BITMAP, 'Save Bitmap', 'Save a bitmap') + wx.EVT_MENU(self, ID_SAVE_BITMAP, self.OnSaveBitmap) + + file_menu.AppendSeparator() + + # Export sub-menu + export_menu = wx.Menu() + file_menu.AppendMenu(wx.ID_ANY, 'E&xport', export_menu) + + ID_EXPORT_ANIMATION = wx.NewId() + export_menu.Append(ID_EXPORT_ANIMATION, 'Export animation', 'Export as animation') + wx.EVT_MENU(self, ID_EXPORT_ANIMATION, self.OnExportAnimation) + + ID_EXPORT_SNAPSHOT = wx.NewId() + export_menu.Append(ID_EXPORT_SNAPSHOT, 'Export snapshot', + 'Export a snapshot of the current canvas') + wx.EVT_MENU(self, ID_EXPORT_SNAPSHOT, self.OnExportSnapshot) + + # Last item of file_menu + ID_EXIT_MENUITEM = wx.ID_EXIT + file_menu.Append(ID_EXIT_MENUITEM, 'E&xit\tAlt-X', 'Exit the program') + wx.EVT_MENU(self, ID_EXIT_MENUITEM, self.OnQuit) + + # Help menu + help_menu = wx.Menu() + menu_bar.Append(help_menu, '&Help') + + ID_HELP_MENUITEM = wx.ID_HELP + help_menu.Append(ID_HELP_MENUITEM, 'About\tAlt-A', 'Show Informations') + wx.EVT_MENU(self, ID_HELP_MENUITEM, self.ShowAboutDialog) + + return menu_bar + + def addPixel(self, event): + x, y = event.GetLogicalPosition(self.view.dc) + self.SetStatusText("Last Click at %-3d,%-3d" % (x, y), 0) + + r, theta = CanvasView.cartesian2polar(x, y, self.view.offset_angle) + self.model.setPixelColor(r, theta, self.color) + + def OnNewBitmap(self, event): + if self.ShowConfirmationDialog() == wx.ID_YES: + self.model.Reset() + self.view.drawAllPixels() + self.view.Refresh() + + def OnLoadBitmap(self, event): + dialog = wx.FileDialog(self, "Load bitmap", "", "", + "PNG files (*.png)|*.png", + wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) + ret = dialog.ShowModal() + file_path = dialog.GetPath() + dialog.Destroy() + + if ret == wx.ID_CANCEL: + return + + if self.view.loadImage(file_path): + self.view.drawAllPixels() + self.view.Refresh() + else: + self.ShowErrorDialog("Image is not %dx%d" % (self.model.width, + self.model.height)) + + def OnSaveBitmap(self, event): + dialog = wx.FileDialog(self, "Save bitmap", "", "", + "PNG files (*.png)|*.png", + wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) + ret = dialog.ShowModal() + file_path = dialog.GetPath() + dialog.Destroy() + + if ret == wx.ID_CANCEL: + return + + bitmap = wx.BitmapFromBuffer(self.model.width, self.model.height, + self.model.pixels_array) + bitmap.SaveFile(file_path, wx.BITMAP_TYPE_PNG) + + def OnExportAnimation(self, event): + dialog = wx.FileDialog(self, "Save animation", "", "animation.h", + "C header files (*.h)|*.h", + wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) + ret = dialog.ShowModal() + file_path = dialog.GetPath() + dialog.Destroy() + + if ret == wx.ID_CANCEL: + return + + self.model.saveAsAnimation(file_path) + + def OnExportSnapshot(self, event): + dialog = wx.FileDialog(self, "Take snapwhot", "", "snapshot.png", + "PNG files (*.png)|*.png", + wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) + ret = dialog.ShowModal() + file_path = dialog.GetPath() + dialog.Destroy() + + if ret == wx.ID_CANCEL: + return + + self.view.pixels_buffer.SaveFile(file_path, wx.BITMAP_TYPE_PNG) + + def OnQuit(self, event): + if self.ShowConfirmationDialog("Exit the program?") == wx.ID_YES: + self.Destroy() + + def OnPickColor(self, event): + self.color = event.Colour.asTuple() + + def OnShowGrid(self, event): + self.view.draw_grid = event.Checked() + self.view.Refresh() + + def OnLeftDown(self, event): + self.addPixel(event) + self.view.CaptureMouse() + + def OnLeftUp(self, event): + self.view.ReleaseMouse() + + def OnMouseMotion(self, event): + if event.Dragging() and event.LeftIsDown(): + self.addPixel(event) + + def UpdateStatusBar(self, event): + if self.model.last_pixel: + x, y = self.model.last_pixel + r, theta = self.model.toPolar(x, y) + self.SetStatusText("r: %-4.1f theta: %-4.1f" % (r, theta), 1) + self.SetStatusText("x: %-2d y: %-2d" % (x, y), 2) + + def UpdateView(self, event): + if self.model.last_pixel: + self.view.drawPixel(self.model.last_pixel) + self.view.Refresh() + + def ShowConfirmationDialog(self, message=None): + if not message: + message = "With this operation you can loose your data.\n\n" + message += "Are you really sure you want to proceed?" + + dialog = wx.MessageDialog(self, message, "Warning!", + style=wx.YES_NO | wx.ICON_QUESTION) + ret = dialog.ShowModal() + dialog.Destroy() + + return ret + + def ShowErrorDialog(self, message): + dialog = wx.MessageDialog(self, message, "Error!", + style=wx.OK | wx.ICON_ERROR) + ret = dialog.ShowModal() + dialog.Destroy() + + def ShowAboutDialog(self, event): + info = wx.AboutDialogInfo() + info.Name = "PoPiPaint - Polar Pixel Painter" + info.Copyright = "(C) 2014 Antonio Ospite" + text = "A prototype program for the JMPrope project," + text += "the programmable jump rope with LEDs." + info.Description = wordwrap(text, 350, wx.ClientDC(self)) + info.WebSite = ("http://ao2.it", "http://ao2.it") + info.Developers = ["Antonio Ospite"] + info.License = "GNU/GPLv3" + wx.AboutBox(info) diff --git a/CanvasModel.py b/CanvasModel.py new file mode 100755 index 0000000..49f9f64 --- /dev/null +++ b/CanvasModel.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python +# +# Copyright (C) 2014 Antonio Ospite +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import sys +from array import array +import wx +from wx.lib.pubsub import Publisher as pub + + +class Canvas: + + def __init__(self, width, height, internal_radius, scale=10): + self.width = width + self.height = height + self.scale = scale + + self.internal_radius = float(internal_radius * scale) + self.external_radius = float(height + internal_radius) * scale + + self.radius = float(height * scale) + + self.ring_width = self.radius / height + self.sector_width = 360. / width + + self.Reset() + + def Reset(self): + self.pixels_array = array('B', [0] * self.width * self.height * 3) + self.last_pixel = None + + def toCartesian(self, r, theta): + x = int(theta / self.sector_width) + y = int(r / self.ring_width) - int(self.internal_radius / self.scale) + return (x, y) + + def toPolar(self, x, y): + r = y * self.ring_width + self.internal_radius + theta = x * self.sector_width + return (r, theta) + + def setPixelColor(self, r, theta, color): + if r < self.internal_radius or r > self.external_radius: + print "invalid coordinates (r: %f)" % r + return + + x, y = self.toCartesian(r, theta) + + self.last_pixel = (x, y) + + offset = y * self.width * 3 + x * 3 + self.pixels_array[offset + 0] = color[0] + self.pixels_array[offset + 1] = color[1] + self.pixels_array[offset + 2] = color[2] + + # now tell anyone who cares that the value has been changed + pub.sendMessage("NEW PIXEL", self.last_pixel) + + def getPixelColor(self, x, y): + offset = y * self.width * 3 + x * 3 + color = [self.pixels_array[offset + i] for i in range(0, 3)] + return tuple(color) + + def getColors(self): + colors = set() + for x in range(self.width): + for y in range(self.height): + colors.add(self.getPixelColor(x, y)) + return list(colors) + + # The code below has been copied from the C implementation in PatternPaint: + # https://github.com/Blinkinlabs/PatternPaint + def saveAsAnimation(self, filename): + output_file = open(filename, "w") + + colors = self.getColors() + + output_file.write("const PROGMEM prog_uint8_t animationData[] = {\n") + + output_file.write("// Length of the color table - 1, in bytes. length: 1 byte\n") + output_file.write(" %d,\n" % (len(colors) - 1)) + + output_file.write("// Color table section. Each entry is 3 bytes. length: %d bytes\n" % (len(colors) * 3)) + + color_map = {} + for i, c in enumerate(colors): + output_file.write(" %3d, %3d, %3d,\n" % (c[0], c[1], c[2])) + color_map[c] = i + + output_file.write("// Pixel runs section. Each pixel run is 2 bytes. length: -1 bytes\n") + + for x in range(self.width): + run_count = 0 + for y in range(self.height): + new_color = color_map[self.getPixelColor(x, y)] + if run_count == 0: + current_color = new_color + + if current_color != new_color: + output_file.write(" %3d, %3d,\n" % (run_count, current_color)) + run_count = 1 + current_color = new_color + else: + run_count += 1 + + output_file.write(" %3d, %3d,\n" % (run_count, current_color)) + + output_file.write("};\n\n") + + output_file.write("#define NUM_FRAMES %d\n" % self.width) + output_file.write("#define NUM_LEDS %d\n" % self.height) + output_file.write("Animation animation(NUM_FRAMES, animationData, NUM_LEDS);\n") + output_file.close() diff --git a/CanvasView.py b/CanvasView.py new file mode 100755 index 0000000..28a7d0d --- /dev/null +++ b/CanvasView.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python +# +# Copyright (C) 2014 Antonio Ospite +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import wx + +from math import * + +import CanvasModel + + +# Polar coordinate system: +# http://en.wikipedia.org/wiki/Polar_coordinate_system +# (r, theta) + +def cartesian2polar(x, y, offset_angle=0): + """return the polar coordinates relative to a circle + centered in (0,0) going counterclockwise. + + returned angle is in degrees + """ + r = sqrt(x*x + y*y) + theta = atan2(float(y), float(x)) + offset_angle + + # report theta in the [0,360) range + theta = degrees(theta) + if theta < 0: + theta = 360 + theta + + return r, theta + + +def polar2cartesian(r, theta, offset_angle=0): + """ + theta expected in degrees + """ + theta = radians(theta) - offset_angle + x = r * cos(theta) + y = r * sin(theta) + + return x, y + + +class CanvasView(wx.Window): + def __init__(self, *args, **kwargs): + self.model = kwargs.pop('model') + base_image = kwargs.pop('base_image', None) + wx.Window.__init__(self, *args, **kwargs) + + # This eliminates flickering on Windows + self.SetBackgroundStyle(wx.BG_STYLE_CUSTOM) + + w = h = int(self.model.external_radius * 2) + + self.offset_angle = -pi/2. + + self.SetSize((w, h)) + + self.SetFocus() + + self.Bind(wx.EVT_PAINT, self.OnPaint) + + # make a DC to draw into... + self.buffer = wx.EmptyBitmap(w, h) + self.dc = wx.BufferedDC(None, self.buffer) + + # Because we want the position of the mouse pointer + # relative to the center of the canvas + self.dc.SetDeviceOrigin(w/2., h/2.) + + self.draw_grid = True + + if base_image: + self.loadImage(base_image) + + # FIXME: fix the file path. + # + # Actually it'd be even better to make the grid drawn to a MemoryDC with + # drawGrid(), but this is not working with python-wxgtk2.8 because there + # are bugs regarding bitmaps with alpha channels and MemoryDC + self.grid_buffer = self.loadGrid("res/grid.png") + + self.pixels_buffer = wx.EmptyBitmap(w, h, depth=32) + self.drawAllPixels() + + def setPixelCoordinates(self, gc): + w, h = self.GetSize() + gc.Translate(w/2., h/2.) + gc.Rotate(-self.offset_angle) + + def MakePixelsGC(self, dc_buffer): + dc = wx.MemoryDC(dc_buffer) + gc = wx.GraphicsContext.Create(dc) + self.setPixelCoordinates(gc) + return gc + + def loadGrid(self, file_path): + image = wx.Image(file_path) + + im_w, im_h = image.GetSize() + w, h = self.GetSize() + if im_w != w or im_h != h: + return None + + return wx.BitmapFromImage(image) + + def loadImage(self, file_path): + image = wx.Image(file_path) + + w, h = image.GetSize() + if w != self.model.width or h != self.model.height: + return False + + bitmap = wx.BitmapFromImage(image) + bitmap.CopyToBuffer(self.model.pixels_array) + return True + + def drawGrid(self, gc): + pen_size = 1 + gc.SetPen(wx.Pen('#555555', pen_size)) + gc.SetBrush(wx.Brush(wx.BLACK, wx.TRANSPARENT)) + + for i in range(0, self.model.height): + r, theta = self.model.toPolar(0, i) + path = gc.CreatePath() + path.AddCircle(0, 0, r) + gc.DrawPath(path) + + # draw the outmost circle + r, theta = self.model.toPolar(0, self.model.height) + path = gc.CreatePath() + path.AddCircle(0, 0, r - pen_size) + gc.DrawPath(path) + + min_r = self.model.internal_radius + max_r = self.model.external_radius - pen_size + for i in range(0, self.model.width): + r, theta = self.model.toPolar(i, 0) + x1, y1 = polar2cartesian(min_r, theta) + x2, y2 = polar2cartesian(max_r, theta) + + path = gc.CreatePath() + path.MoveToPoint(x1, y1) + path.AddLineToPoint(x2, y2) + gc.DrawPath(path) + + def _drawPixel(self, gc, x, y, color=None): + pen_size = 1 + if color: + gc.SetPen(wx.Pen(color, pen_size)) + gc.SetBrush(wx.Brush(color)) + else: + gc.SetPen(wx.Pen(wx.RED, pen_size)) + gc.SetBrush(wx.Brush(wx.BLACK, wx.TRANSPARENT)) + + min_r, theta1 = self.model.toPolar(x, y) + + max_r = min_r + self.model.ring_width + + # prevent the outmost circle to overflow the canvas + if y == self.model.height - 1: + max_r -= pen_size + + theta2 = theta1 + self.model.sector_width + + # Draw the circular arc + path = gc.CreatePath() + path.AddArc(0, 0, min_r, radians(theta1), radians(theta2), True) + path.AddLineToPoint(polar2cartesian(max_r, theta2)) + path.AddArc(0, 0, max_r, radians(theta2), radians(theta1), False) + path.AddLineToPoint(polar2cartesian(min_r, theta1)) + path.CloseSubpath() + gc.DrawPath(path) + + def drawAllPixels(self): + gc = self.MakePixelsGC(self.pixels_buffer) + for y in range(0, self.model.height): + for x in range(0, self.model.width): + color = self.model.getPixelColor(x, y) + self._drawPixel(gc, x, y, color=color) + + def drawPixel(self, pixel): + if not pixel: + return + + x, y = pixel + + gc = self.MakePixelsGC(self.pixels_buffer) + color = self.model.getPixelColor(x, y) + self._drawPixel(gc, x, y, color) + + def drawPixelMarker(self, gc, pixel): + if not pixel: + return + + x, y = pixel + self._drawPixel(gc, x, y) + + def drawImage(self, gc): + gc.SetPen(wx.Pen('#CCCCCC', 1)) + gc.SetBrush(wx.Brush(wx.BLACK, wx.TRANSPARENT)) + + w, h = self.GetSize() + gc.DrawRectangle(w - self.model.width, 0, + self.model.width, self.model.height) + + bitmap = wx.BitmapFromBuffer(self.model.width, self.model.height, + self.model.pixels_array) + gc.DrawBitmap(bitmap, w - self.model.width, 0, + self.model.width, self.model.height) + + def drawTag(self, gc): + font = wx.Font(12, wx.FONTFAMILY_MODERN, wx.FONTSTYLE_NORMAL, + wx.FONTWEIGHT_BOLD) + gc.SetFont(font, "#0099FF") + w, h = self.GetSize() + text = "ao2.it" + twidth, theight, descent, externalLeading = gc.GetFullTextExtent(text) + gc.DrawText(text, w - twidth - 4, h - theight - 2) + + def OnPaint(self, event): + # make a DC to draw into... + if 'wxMSW' in wx.PlatformInfo: + dc = wx.BufferedPaintDC(self) + else: + dc = wx.PaintDC(self) + + dc.SetBackground(wx.Brush(wx.BLACK)) + dc.Clear() + + gc = wx.GraphicsContext.Create(dc) + w, h = self.GetSize() + + gc.DrawBitmap(self.pixels_buffer, 0, 0, w, h) + + if self.draw_grid: + gc.DrawBitmap(self.grid_buffer, 0, 0, w, h) + + gc.PushState() + self.setPixelCoordinates(gc) + self.drawPixelMarker(gc, self.model.last_pixel) + gc.PopState() + + self.drawImage(gc) + self.drawTag(gc) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..558d4d4 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +all: + +clean: + rm -f *.pyc *~ diff --git a/PoPiPaint.py b/PoPiPaint.py new file mode 100755 index 0000000..e6929d9 --- /dev/null +++ b/PoPiPaint.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# +# PoPiPaint - Polar Pixel Painter, a tool to draw polar patterns +# +# Copyright (C) 2014 Antonio Ospite +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import sys +import wx +import CanvasFrame + + +class PoPiPaApp(wx.PySimpleApp): + def __init__(self, *args, **kwargs): + self.base_image = kwargs.pop('base_image', None) + wx.PySimpleApp.__init__(self, *args, **kwargs) + + def OnInit(self): + # We do not want a resizeable frame! + framestyle = wx.DEFAULT_FRAME_STYLE & \ + ~(wx.RESIZE_BORDER | wx.MAXIMIZE_BOX) + frame = CanvasFrame.CanvasFrame(None, title="PoPiPa", style=framestyle, + base_image=self.base_image) + frame.Show() + return True + + +if __name__ == "__main__": + if len(sys.argv) > 1: + app = PoPiPaApp(base_image=sys.argv[1]) + else: + app = PoPiPaApp() + + app.MainLoop() diff --git a/README b/README new file mode 100644 index 0000000..4f85e51 --- /dev/null +++ b/README @@ -0,0 +1,4 @@ +PoPiPaint is the Polar Pixel Painter used to retouch and export polar patterns +for animating a JMPrope, the programmable jump rope. + +See the file TUTORIAL for a possible workflow for creating polar patterns. diff --git a/TODO b/TODO new file mode 100644 index 0000000..b859c7f --- /dev/null +++ b/TODO @@ -0,0 +1,4 @@ +Add other tools: + - Radial symmetry drawing tool (according to a given symmetry order) + - Fill ring, with the current color + - Fill radius, with the current color diff --git a/TUTORIAL b/TUTORIAL new file mode 100644 index 0000000..d8c8763 --- /dev/null +++ b/TUTORIAL @@ -0,0 +1,26 @@ +Short tutorial for producing polar patterns: + + 0. Draw an image into a circle. Examples done in Inkscape are in src_images/ + + 1. Export the image as a 640x640 bitmap, from command line: + $ inkscape -f svgfile.svg -w -h -e file.png + + 2. Open the exported image in GIMP (examples are in src_images/raster) and + reduce the colors (e.g. to 4 colors). The Posterize tool may be used to + reduce the colors. Consider also increasing the contrast. + + Examples of color reduced images are in src_images/raster_post-processed + + 3. Create the polar pixel map using either: + tools/depolar_resample_IMAGEMAGICK.sh + or + tools/depolar_resample_PIL.py + + 4. Some rough retouching on the polar pattern can be done in GIMP: + - A full circle is represented by a horizontal line; + - A radius is represented by a vertical line. + + 5. Do the final touches to the polar pattern with PoPiPaint> + Examples of fine tuned polar patterns are in patterns/ + + 6. Export the pattern as animation and upload it to the JMPrope. diff --git a/patterns/Adafuit-depolar.png b/patterns/Adafuit-depolar.png new file mode 100644 index 0000000..5caa748 Binary files /dev/null and b/patterns/Adafuit-depolar.png differ diff --git a/patterns/Debian-depolar.png b/patterns/Debian-depolar.png new file mode 100644 index 0000000..7ef260f Binary files /dev/null and b/patterns/Debian-depolar.png differ diff --git a/patterns/Firefox-depolar.png b/patterns/Firefox-depolar.png new file mode 100644 index 0000000..b5dbea2 Binary files /dev/null and b/patterns/Firefox-depolar.png differ diff --git a/patterns/JMPrope-depolar.png b/patterns/JMPrope-depolar.png new file mode 100644 index 0000000..8b26655 Binary files /dev/null and b/patterns/JMPrope-depolar.png differ diff --git a/patterns/Openhardware-depolar.png b/patterns/Openhardware-depolar.png new file mode 100644 index 0000000..4a9f215 Binary files /dev/null and b/patterns/Openhardware-depolar.png differ diff --git a/patterns/Opensource-depolar.png b/patterns/Opensource-depolar.png new file mode 100644 index 0000000..bfe33ae Binary files /dev/null and b/patterns/Opensource-depolar.png differ diff --git a/patterns/S.S.C.Napoli-depolar.png b/patterns/S.S.C.Napoli-depolar.png new file mode 100644 index 0000000..afda197 Binary files /dev/null and b/patterns/S.S.C.Napoli-depolar.png differ diff --git a/patterns/ao2-depolar.png b/patterns/ao2-depolar.png new file mode 100644 index 0000000..63cce4c Binary files /dev/null and b/patterns/ao2-depolar.png differ diff --git a/patterns/ao2it-depolar.png b/patterns/ao2it-depolar.png new file mode 100644 index 0000000..52de762 Binary files /dev/null and b/patterns/ao2it-depolar.png differ diff --git a/res/ao2.ico b/res/ao2.ico new file mode 100644 index 0000000..d6cb550 Binary files /dev/null and b/res/ao2.ico differ diff --git a/res/grid.png b/res/grid.png new file mode 100644 index 0000000..ff5ba69 Binary files /dev/null and b/res/grid.png differ diff --git a/src_images/Adafuit.svg b/src_images/Adafuit.svg new file mode 100644 index 0000000..f6cb1a4 --- /dev/null +++ b/src_images/Adafuit.svg @@ -0,0 +1,131 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/src_images/Debian.svg b/src_images/Debian.svg new file mode 100644 index 0000000..d0c81e2 --- /dev/null +++ b/src_images/Debian.svg @@ -0,0 +1,137 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/src_images/Firefox.svg b/src_images/Firefox.svg new file mode 100644 index 0000000..4fb1a2d --- /dev/null +++ b/src_images/Firefox.svg @@ -0,0 +1,2147 @@ + +image/svg+xml \ No newline at end of file diff --git a/src_images/JMPrope.svg b/src_images/JMPrope.svg new file mode 100644 index 0000000..5a09a36 --- /dev/null +++ b/src_images/JMPrope.svg @@ -0,0 +1,1448 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + JMP + rope + + diff --git a/src_images/NOTES.txt b/src_images/NOTES.txt new file mode 100644 index 0000000..0c330f0 --- /dev/null +++ b/src_images/NOTES.txt @@ -0,0 +1,2 @@ +The logos should be centered according to the center of the smallest enclosing +circle in order to use as many LEDs as possible. diff --git a/src_images/Openhardware.svg b/src_images/Openhardware.svg new file mode 100644 index 0000000..447839b --- /dev/null +++ b/src_images/Openhardware.svg @@ -0,0 +1,90 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/src_images/Opensource.svg b/src_images/Opensource.svg new file mode 100644 index 0000000..0824766 --- /dev/null +++ b/src_images/Opensource.svg @@ -0,0 +1,62 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/src_images/S.S.C.Napoli.svg b/src_images/S.S.C.Napoli.svg new file mode 100644 index 0000000..7ab7627 --- /dev/null +++ b/src_images/S.S.C.Napoli.svg @@ -0,0 +1,176 @@ + + + +image/svg+xmlN + \ No newline at end of file diff --git a/src_images/ao2it.svg b/src_images/ao2it.svg new file mode 100644 index 0000000..3661163 --- /dev/null +++ b/src_images/ao2it.svg @@ -0,0 +1,88 @@ + + + + + + + + + + image/svg+xml + + + + + + + + ao2.it + + + diff --git a/src_images/convert_all.sh b/src_images/convert_all.sh new file mode 100755 index 0000000..6ff4c84 --- /dev/null +++ b/src_images/convert_all.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +WIDTH=640 +HEIGHT=640 +OUTPUT_DIR="raster/" + +[ -d $OUTPUT_DIR ] || mkdir $OUTPUT_DIR + +for file in *.svg; +do + OUTPUT_FILE="$(basename "$file" .svg).png" + inkscape --export-background "#000000" -f "$file" -w $WIDTH -h $HEIGHT -e "$OUTPUT_DIR/$OUTPUT_FILE" +done diff --git a/src_images/raster/Adafuit.png b/src_images/raster/Adafuit.png new file mode 100644 index 0000000..b23e6d3 Binary files /dev/null and b/src_images/raster/Adafuit.png differ diff --git a/src_images/raster/Debian.png b/src_images/raster/Debian.png new file mode 100644 index 0000000..b320bb0 Binary files /dev/null and b/src_images/raster/Debian.png differ diff --git a/src_images/raster/Firefox.png b/src_images/raster/Firefox.png new file mode 100644 index 0000000..639cff0 Binary files /dev/null and b/src_images/raster/Firefox.png differ diff --git a/src_images/raster/JMPrope.png b/src_images/raster/JMPrope.png new file mode 100644 index 0000000..e2a65db Binary files /dev/null and b/src_images/raster/JMPrope.png differ diff --git a/src_images/raster/Openhardware.png b/src_images/raster/Openhardware.png new file mode 100644 index 0000000..8905010 Binary files /dev/null and b/src_images/raster/Openhardware.png differ diff --git a/src_images/raster/Opensource.png b/src_images/raster/Opensource.png new file mode 100644 index 0000000..fe627a1 Binary files /dev/null and b/src_images/raster/Opensource.png differ diff --git a/src_images/raster/S.S.C.Napoli.png b/src_images/raster/S.S.C.Napoli.png new file mode 100644 index 0000000..201aea5 Binary files /dev/null and b/src_images/raster/S.S.C.Napoli.png differ diff --git a/src_images/raster/ao2it.png b/src_images/raster/ao2it.png new file mode 100644 index 0000000..22af3bc Binary files /dev/null and b/src_images/raster/ao2it.png differ diff --git a/src_images/raster_post-processed/Adafuit.png b/src_images/raster_post-processed/Adafuit.png new file mode 100644 index 0000000..606fa6c Binary files /dev/null and b/src_images/raster_post-processed/Adafuit.png differ diff --git a/src_images/raster_post-processed/Debian.png b/src_images/raster_post-processed/Debian.png new file mode 100644 index 0000000..d1d938e Binary files /dev/null and b/src_images/raster_post-processed/Debian.png differ diff --git a/src_images/raster_post-processed/Firefox.png b/src_images/raster_post-processed/Firefox.png new file mode 100644 index 0000000..48de3a2 Binary files /dev/null and b/src_images/raster_post-processed/Firefox.png differ diff --git a/src_images/raster_post-processed/JMPrope.png b/src_images/raster_post-processed/JMPrope.png new file mode 100644 index 0000000..1b251ea Binary files /dev/null and b/src_images/raster_post-processed/JMPrope.png differ diff --git a/src_images/raster_post-processed/Openhardware.png b/src_images/raster_post-processed/Openhardware.png new file mode 100644 index 0000000..f7c7cc0 Binary files /dev/null and b/src_images/raster_post-processed/Openhardware.png differ diff --git a/src_images/raster_post-processed/Opensource.png b/src_images/raster_post-processed/Opensource.png new file mode 100644 index 0000000..a4c4103 Binary files /dev/null and b/src_images/raster_post-processed/Opensource.png differ diff --git a/src_images/raster_post-processed/S.S.C.Napoli.png b/src_images/raster_post-processed/S.S.C.Napoli.png new file mode 100644 index 0000000..c7b7a71 Binary files /dev/null and b/src_images/raster_post-processed/S.S.C.Napoli.png differ diff --git a/src_images/raster_post-processed/ao2.png b/src_images/raster_post-processed/ao2.png new file mode 100644 index 0000000..e02cd11 Binary files /dev/null and b/src_images/raster_post-processed/ao2.png differ diff --git a/src_images/raster_post-processed/ao2it.png b/src_images/raster_post-processed/ao2it.png new file mode 100644 index 0000000..f8184a4 Binary files /dev/null and b/src_images/raster_post-processed/ao2it.png differ diff --git a/tools/Makefile b/tools/Makefile new file mode 100644 index 0000000..fed2601 --- /dev/null +++ b/tools/Makefile @@ -0,0 +1,4 @@ +all: + +clean: + rm -f *.svg *~ diff --git a/tools/convert_to_RLE_animation.py b/tools/convert_to_RLE_animation.py new file mode 100755 index 0000000..8cba1e2 --- /dev/null +++ b/tools/convert_to_RLE_animation.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +# +# Copyright (C) 2014 Antonio Ospite +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import sys +import Image + + +def usage(program_name): + sys.stderr.write("usage: %s \n" % program_name) + sys.exit(1) + + +# The code below has been copied from the C implementation in PatternPaint: +# https://github.com/Blinkinlabs/PatternPaint +def save_animation(input_filename, output_filename): + output_file = open(output_filename, "w") + + img = Image.open(input_filename).convert('RGB') + w, h = img.size + colors = img.getcolors(w*h) + palette = img.getpalette() + pixels = img.getdata() + + output_file.write("const PROGMEM prog_uint8_t animationData[] = {\n") + + output_file.write("// Length of the color table - 1, in bytes. length: 1 byte\n") + output_file.write(" %d,\n" % (len(colors) - 1)) + + output_file.write("// Color table section. Each entry is 3 bytes. length: %d bytes\n" % (len(colors) * 3)) + + color_map = {} + for i, c in enumerate(colors): + output_file.write(" %3d, %3d, %3d,\n" % (c[1][0], c[1][1], c[1][2])) + color_map[c[1]] = i + + output_file.write("// Pixel runs section. Each pixel run is 2 bytes. length: -1 bytes\n") + + for x in range(0, w): + run_count = 0 + for y in range(0, h): + new_color = color_map[pixels.getpixel((x, y))] + if run_count == 0: + current_color = new_color + + if current_color != new_color: + output_file.write(" %3d, %3d,\n" % (run_count, current_color)) + run_count = 1 + current_color = new_color + else: + run_count += 1 + + output_file.write(" %3d, %3d,\n" % (run_count, current_color)) + + output_file.write("};\n\n") + + output_file.write("#define NUM_FRAMES %d\n" % w) + output_file.write("#define NUM_LEDS %d\n" % h) + output_file.write("Animation animation(NUM_FRAMES, animationData, NUM_LEDS);\n") + output_file.close() + +if __name__ == "__main__": + if len(sys.argv) < 3: + usage(sys.argv[0]) + + save_animation(sys.argv[1], sys.argv[2]) diff --git a/tools/depolar_resample_IMAGEMAGICK.sh b/tools/depolar_resample_IMAGEMAGICK.sh new file mode 100755 index 0000000..28c2220 --- /dev/null +++ b/tools/depolar_resample_IMAGEMAGICK.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# +# depolar_resample_IMAGEMAGICK.sh - build a pixel map for JMPrope +# +# Copyright (C) 2014 Antonio Ospite +# +# This program is free software. It comes without any warranty, to +# the extent permitted by applicable law. You can redistribute it +# and/or modify it under the terms of the Do What The Fuck You Want +# To Public License, Version 2, as published by Sam Hocevar. See +# http://sam.zoy.org/wtfpl/COPYING for more details. + +set -e + +INPUT_WIDTH=640 +INPUT_HEIGHT=640 +SCALE="0.1" +INTERNAL_RADIUS=2 + +[ -f "$1" -a "x$2" != "x" ] || { echo "usage: $(basename $0) [imagemagick options]" 1>&2; exit 1; } + +INPUT_FILENAME="$1" +shift + +OUTPUT_FILENAME="$1" +shift + +[ -e $OUTPUT_FILENAME ] && { echo "Error: $OUTPUT_FILENAME already exists, remove it first" 1>&2; exit 1; } + +WIDTH=$(identify -format "%[fx:w]" "$INPUT_FILENAME") +HEIGHT=$(identify -format "%[fx:w]" "$INPUT_FILENAME") + +if [ $WIDTH -ne $INPUT_WIDTH -o $HEIGHT -ne $INPUT_HEIGHT ]; +then + echo "Error: image must be ${INPUT_WIDTH}x${INPUT_HEIGHT}" 1>&2 + exit 1; +fi + +CX=$(identify -format "%[fx:h/2]" "$INPUT_FILENAME") +CY=$(identify -format "%[fx:h/2]" "$INPUT_FILENAME") + +OPTIONS="-interpolate NearestNeighbor -filter point -background black -flatten" + +convert "$INPUT_FILENAME" $OPTIONS \ + -set option:distort:scale $SCALE +distort DePolar "0 0 $CX,$CY 0,360" \ + -flop -crop +0+$INTERNAL_RADIUS $@ +repage "$OUTPUT_FILENAME" diff --git a/tools/depolar_resample_PIL.py b/tools/depolar_resample_PIL.py new file mode 100755 index 0000000..8c18b5b --- /dev/null +++ b/tools/depolar_resample_PIL.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python +# +# Copyright (C) 2014 Antonio Ospite +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import sys +from math import * + +import Image + + +def polar2cartesian(r, theta, offset_angle=0): + """ + theta expected in degrees + """ + theta = radians(theta) - offset_angle + x = r * cos(theta) + y = r * sin(theta) + + return x, y + + +def usage(program_name): + sys.stderr.write("usage: %s \n" % program_name) + sys.exit(1) + +if __name__ == "__main__": + if len(sys.argv) < 3: + usage(sys.argv[0]) + + map_width = 101 + map_height = 30 + internal_radius = 2 + scale = 10 + + width = (map_height + internal_radius) * 2 * scale + height = width + + img = Image.open(sys.argv[1]).convert('RGBA') + w, h = img.size + if w != width or h != height: + sys.stderr.write("Error: image must be 640x640") + + colors = img.getcolors(w*h) + palette = img.getpalette() + pixels = img.getdata() + + output_image = Image.new("RGB", (map_width, map_height), "black") + + external_radius = width / 2. - 1 + radius = external_radius - internal_radius + + for i in range(0, map_height): + for j in range(0, map_width): + r = radius / map_height * (i + 0.5) + internal_radius + theta = (360. / map_width) * (j + 0.5) + x, y = polar2cartesian(r, theta, -pi/2.) + px, py = int(x + radius), int(y + radius) + sample = img.getpixel((px, py)) + if sample[3] != 255: + sample = (0, 0, 0, 255) + output_image.putpixel((j, i), sample) + + output_image.save(sys.argv[2], "PNG") diff --git a/tools/polar_grid.py b/tools/polar_grid.py new file mode 100755 index 0000000..95c0f03 --- /dev/null +++ b/tools/polar_grid.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python +# +# Copyright (C) 2014 Antonio Ospite +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import sys +import cairo +from math import * + + +def cartesian2polar(x, y, offset_angle=0): + """return the polar coordinates relative to a circle + centered in (0,0) going counterclockwise. + + returned angle is in degrees + """ + r = sqrt(x*x + y*y) + theta = atan2(float(y), float(x)) + offset_angle + + # report theta in the [0,360) range + theta = degrees(theta) + if theta < 0: + theta = 360 + theta + + return r, theta + + +def polar2cartesian(r, theta, offset_angle=0): + """ + theta expected in degrees + """ + theta = radians(theta) - offset_angle + x = r * cos(theta) + y = r * sin(theta) + + return x, y + + +class Diagram(object): + + def __init__(self, filename, width, height): + self.filename = filename + self.width = width + self.height = height + + self.surface = cairo.SVGSurface(filename + '.svg', width, height, + units="px") + cr = self.cr = cairo.Context(self.surface) + + cr.set_line_width(1) + + # draw background + cr.rectangle(0, 0, width, height) + cr.set_source_rgb(1, 1, 1) + cr.fill() + + # center the origin + cr.translate(width/2., height/2.) + + def save(self): + self.surface.write_to_png(self.filename + '.png') + self.show() + + def show(self): + import Image + import StringIO + f = StringIO.StringIO() + self.surface.write_to_png(f) + f.seek(0) + im = Image.open(f) + im.show() + + def draw_line(self, x1, y1, x2, y2, stroke_color=[0, 0, 0, 1]): + cr = self.cr + + cr.move_to(x1, y1) + cr.line_to(x2, y2) + + rf, gf, bf, alpha= stroke_color + cr.set_source_rgba(rf, gf, bf, alpha) + cr.stroke() + + def draw_circle(self, cx, cy, size=10.0, stroke_color=[0, 0, 0, 1]): + cr = self.cr + + cr.arc(cx, cy, size, 0, 2*pi) + + rf, gf, bf, alpha = stroke_color + cr.set_source_rgba(rf, gf, bf, alpha) + cr.stroke() + + +class PolarGridDiagram(Diagram): + def __init__(self, filename, sectors, rings, internal_radius, scale): + width = (map_height + internal_radius) * 2 * scale + height = width + Diagram.__init__(self, filename, width, height) + + self.rings = rings + self.sectors = sectors + + self.internal_radius = internal_radius * scale + self.external_radius = width / 2. + self.radius = self.external_radius - self.internal_radius + + def draw_samples(self, stroke_color=[0, 0, 0, 0.5]): + for i in range(0, self.rings): + for j in range(0, self.sectors): + r = self.radius / self.rings * (i + 0.5) + self.internal_radius + theta = (360. / self.sectors) * (j + 0.5) + x, y = polar2cartesian(r, theta, -pi/2.) + self.draw_circle(x, y, 1, stroke_color) + + def draw_grid(self, stroke_color=[0, 0, 0, 0.5]): + line_width = self.cr.get_line_width() + + for i in range(0, self.rings): + self.draw_circle(0, 0, self.radius / self.rings * i + self.internal_radius, stroke_color) + + # outmost circle + self.draw_circle(0, 0, self.external_radius - line_width, stroke_color) + + min_r = self.internal_radius + max_r = self.external_radius - line_width + for i in range(0, self.sectors): + theta = (360. / self.sectors) * i + x1, y1 = polar2cartesian(min_r, theta, -pi/2.) + x2, y2 = polar2cartesian(max_r, theta, -pi/2.) + self.draw_line(x1, y1, x2, y2, stroke_color) + + def draw_image(self, image): + cr = self.cr + img = cairo.ImageSurface.create_from_png(image) + cr.set_source_surface(img, -self.external_radius, -self.external_radius) + cr.paint() + + def draw(self, bg): + if bg: + self.draw_image(bg) + + self.draw_grid() + self.draw_samples() + +if __name__ == '__main__': + + if len(sys.argv) > 1: + bg = sys.argv[1] + else: + bg = None + + map_width = 101 + map_height = 30 + internal_radius = 2 + scale = 10 + + grid = PolarGridDiagram('polar_grid', map_width, map_height, internal_radius, scale) + grid.draw(bg) + grid.show()