#! /usr/bin/python3 ''' Copyright (C) 2021 Scott Pakin, scott-ink@pakin.org 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, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. ''' import base64 import inkex import io import PIL.Image import random import sys class PixelsToObjects(inkex.EffectExtension): "Copy an object to the coordinates of each pixel in an image." def add_arguments(self, pars): 'Process program parameters passed in from the UI.' pars.add_argument('--tab', help='The selected UI tab when OK was pressed') pars.add_argument('--scale', type=float, help='Factor by which to scale image coordinates') pars.add_argument('--color-stroke', type=inkex.Boolean, help="Color the object's stroke to match the pixel color") pars.add_argument('--color-fill', type=inkex.Boolean, help='Fill the object with the pixel color') pars.add_argument('--ignore-bg', type=inkex.Boolean, help='Ignore background-colored pixels') pars.add_argument('--obj-select', choices=['coords', 'rr', 'random'], help='Specify how to iterate among multiple selected objects') def _get_selection(self): 'Return an image and an object.' # Bin the objects in the current selection. img = None objs = [] for s in self.svg.selection.values(): if isinstance(s, inkex.Image): if img == None: img = s # First image encountered else: objs.append(s) # All remaining images are consider "other" objects else: objs.append(s) # All non-images are "other" objects # Ensure the selection is valid. if img == None or objs == []: inkex.utils.errormsg(_('Pixels to Objects requires that one image and at least one additional object be selected.')) sys.exit(1) return img, objs def _generate_coords2obj(self, objs): '''Return a function that maps pixel coordinates to an object in a given list of SVG objects.''' n_objs = len(objs) obj_select = self.options.obj_select if obj_select == 'coords': return lambda x, y: objs[(x + y)%n_objs] if obj_select == 'rr': idx = 0 def coords2obj_rr(x, y): nonlocal idx obj = objs[idx] idx = (idx + 1)%len(objs) return obj return coords2obj_rr if obj_select == 'random': return lambda x, y: objs[random.randint(0, n_objs - 1)] inkex.errormessage(_('internal error: unhandled object-selection type "%s"' % obj_select)) sys.exit(1) def _copy_object_to(self, obj, xy): '''Copy an object and center the copy on the given coordinates, optionally recoloring its stroke and/or fill''' # Create a transform that moves the object to the origin. xform = inkex.Transform(obj.get('transform')) pos = obj.bounding_box(obj.composed_transform()).center xform.add_translate(-pos) # Modify the transform to move the object to the target coordinates. xform.add_translate(xy) # Duplicate the object and apply the transform. new_obj = obj.duplicate() new_obj.update(transform=xform) return new_obj def _read_image(self, img_elt): 'Read an image from either the SVG file itself or an external file.' # Read the image from an external file. fname = img_elt.get('sodipodi:absref') if fname != None: # Fully qualified filename. Read it directly. return PIL.Image.open(fname) xlink = img_elt.get('xlink:href') if not xlink.startswith('data:'): # Unqualified filename. Try reading it directly although there's a # good chance this will fail. return PIL.Image.open(fname) # Read an image embedded in the SVG file itself. try: mime, dtype_data = xlink[5:].split(';', 1) dtype, data64 = dtype_data.split(',', 1) except ValueError: inkex.errormsg('failed to parse embedded image data') sys.exit(1) if dtype != 'base64': inkex.errormsg('embedded image is encoded as %s, but this plugin supports only base64' % dtype) sys.exit(1) raw_data = base64.decodebytes(data64.encode('utf-8')) return PIL.Image.open(io.BytesIO(raw_data)) def _guess_background_color(self, img): 'Return the most commonly occurring color, assuming it represents the background.' wd, ht = img.size tally, bg_color = max(img.getcolors(wd*ht)) return bg_color def _pixels_to_objects(self, img, coords2obj, bg_color, scale, change_stroke, change_fill): '''Perform the core functionality of this extension, using pixel coordinates to place object replicas.''' # Create a new group for all the objects we'll create. group = inkex.Group() parent = self.svg.selection.first().getparent() parent.add(group) # Convert each pixel to a replica of the object, optionally # recoloring it. wd, ht = img.size for y in range(ht): for x in range(wd): xy = (x, y) pix = img.getpixel(xy) if pix == bg_color: continue obj = coords2obj(x, y) new_obj = self._copy_object_to(obj, inkex.Vector2d(xy)*scale) if change_stroke: new_obj.style['stroke'] = '#%02x%02x%02x' % (pix[0], pix[1], pix[2]) new_obj.style['stroke-opacity'] = '%.10g' % (pix[3]/255.0) if change_fill: new_obj.style['fill'] = '#%02x%02x%02x' % (pix[0], pix[1], pix[2]) new_obj.style['fill-opacity'] = '%.10g' % (pix[3]/255.0) group.add(new_obj) def effect(self): '''Given a bitmap image and an object, copy the object to each coordinate of the image that contains a colored pixel.''' img_elt, objs = self._get_selection() img = self._read_image(img_elt).convert('RGBA') if self.options.ignore_bg: bg_color = self._guess_background_color(img) else: bg_color = None scale = self.options.scale change_stroke = self.options.color_stroke change_fill = self.options.color_fill coords2obj = self._generate_coords2obj(objs) self._pixels_to_objects(img, coords2obj, bg_color, scale, change_stroke, change_fill) img.close() if __name__ == '__main__': PixelsToObjects().run()