diff --git a/extensions/fablabchemnitz/pixels2objects/pixels2objects.inx b/extensions/fablabchemnitz/pixels2objects/pixels2objects.inx new file mode 100644 index 00000000..61a1c461 --- /dev/null +++ b/extensions/fablabchemnitz/pixels2objects/pixels2objects.inx @@ -0,0 +1,40 @@ + + + Pixels To Objects + fablabchemnitz.de.pixels2objects + + + false + false + true + + + + + + 1 + + + + + + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/pixels2objects/pixels2objects.py b/extensions/fablabchemnitz/pixels2objects/pixels2objects.py new file mode 100755 index 00000000..29c067c9 --- /dev/null +++ b/extensions/fablabchemnitz/pixels2objects/pixels2objects.py @@ -0,0 +1,174 @@ +#! /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()