#!/usr/bin/env python """trace multiple color images with potrace""" # color_trace_multi # Written by ukurereh # May 20, 2012 # 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 2 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., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # External program commands. Replace with paths to external programs as needed. PNGQUANT_PATH = 'pngquant' PNGNQ_PATH = 'pngnq' IMAGEMAGICK_CONVERT_PATH = 'convert' IMAGEMAGICK_IDENTIFY_PATH = 'identify' POTRACE_PATH = 'potrace' POTRACE_DPI = 90.0 # potrace docs say it's 72, but this seems to work best COMMAND_LEN_NEAR_MAX = 1900 # a low approximate (but not maximum) limit for # very large command-line commands VERBOSITY_LEVEL = 0 # not just a constant, also affected by -v/--verbose option VERSION = '1.00' import os, sys import shutil import subprocess import argparse from glob import iglob import functools import queue import multiprocessing import queue import tempfile import time from svg_stack import svg_stack def verbose(*args, level=1): if VERBOSITY_LEVEL >= level: print(*args) def process_command(command, stdinput=None, stdout_=False, stderr_=False): """run command in invisible shell, return stdout and/or stderr as specified Returns stdout, stderr, or a tuple (stdout, stderr) depending on which of stdout_ and stderr_ is True. Raises an exception if the command encounters an error. command: command with arguments to send to command line stdinput: data (bytes) to send to command's stdin, or None stdout_: True to receive command's stdout in the return value stderr_: True to receive command's stderr in the return value """ verbose(command) stdin_pipe = (subprocess.PIPE if stdinput is not None else None) stdout_pipe = (subprocess.PIPE if stdout_ is True else None) stderr_pipe = subprocess.PIPE #process = subprocess.Popen(command, stdin=stdin_pipe, stderr=stderr_pipe, stdout=stdout_pipe, #shell=True, creationflags=subprocess.SW_HIDE) process = subprocess.Popen(command, stdin=stdin_pipe, stderr=stderr_pipe, stdout=stdout_pipe, shell=True) stdoutput, stderror = process.communicate(stdinput) #print(stderror) returncode = process.wait() if returncode != 0: Exception(stderror.decode()) if stdout_ and not stderr_: return stdoutput elif stderr_ and not stdout_: return stderr elif stdout_ and stderr_: return (stdoutput, stderror) elif not stdout_ and not stderr_: return None def rescale(src, destscale, scale, filter='lanczos'): """rescale src image to scale, save to destscale full list of filters is available from ImageMagick's documentation. """ if scale == 1.0: #just copy it over shutil.copyfile(src, destscale) else: command = '"{convert}" "{src}" -filter {filter} -resize {resize}% "{dest}"'.format( convert=IMAGEMAGICK_CONVERT_PATH, src=src, filter=filter, resize=scale*100, dest=destscale) process_command(command) def quantize(src, destquant, colors, algorithm='mc', dither=None): """quantize src image to colors, save to destquant Uses chosen algorithm to quantize src image. src: path of source image, must be png destquant: path to save output quantized png image colors: number of colors to quantize to, 0 for no quantization algorithm: color quantization algorithm to use: - 'mc' = median-cut (default, for few colors, uses pngquant) - 'as' = adaptive spatial subdivision (uses imagemagick, may result in fewer colors) - 'nq' = neuquant (for lots of colors, uses pngnq) dither: dithering algorithm to use when quantizing. None: the default, performs no dithering 'floydsteinberg': available with 'mc', 'as', and 'nq' 'riemersma': only available with 'as' """ # build and execute shell command for quantizing an image file if colors == 0: #skip quantization, just copy directly to destquant shutil.copyfile(src, destquant) elif algorithm == 'mc': #median-cut if dither is None: ditheropt = '-nofs ' elif dither == 'floydsteinberg': ditheropt = '' else: raise ValueError("Invalid dither type '{0}' for 'mc' quantization".format(dither)) #using stdin/stdout to file since pngquant can't save to a custom output path command = '"{pngquant}" {dither}-force {colors}'.format( pngquant=PNGQUANT_PATH, dither=ditheropt, colors=colors) with open(src, 'rb') as srcfile: stdinput = srcfile.read() stdoutput = process_command(command, stdinput=stdinput, stdout_=True) with open(destquant, 'wb') as destfile: destfile.write(stdoutput) elif algorithm == 'as': #adaptive spatial subdivision if dither is None: ditheropt = 'None' elif dither in ('floydsteinberg', 'riemersma'): ditheropt = dither else: raise ValueError("Invalid dither type '{0}' for 'as' quantization".format(dither)) command = '"{convert}" "{src}" -dither {dither} -colors {colors} "{dest}"'.format( convert=IMAGEMAGICK_CONVERT_PATH, src=src, dither=ditheropt, colors=colors, dest=destquant) process_command(command) elif algorithm == 'nq': #neuquant ext = "~quant.png" destdir = os.path.dirname(destquant) if dither is None: ditheropt = '' elif dither == 'floydsteinberg': ditheropt = '-Q f ' else: raise ValueError("Invalid dither type '{0}' for 'nq' quantization".format(dither)) command = '"{pngnq}" -f {dither}-d "{destdir}" -n {colors} -e {ext} "{src}"'.format( pngnq = PNGNQ_PATH, dither=ditheropt, destdir=destdir, colors=colors, ext=ext, src=src) process_command(command) #rename output file to destquant (because pngnq can't save to a custom path) old_dest = os.path.join(destdir, os.path.splitext(os.path.basename(src))[0] + ext) os.rename(old_dest, destquant) else: #argparse should have caught this before it even reaches here raise NotImplementedError('Unknown quantization algorithm "{0}"'.format(algorithm)) def palette_remap(src, destremap, paletteimg, dither=None): """remap src to paletteimage's colors, save to destremap src: path of source image destremap: path to save output remapped image paletteimg: path of an image; it contains the colors to which src will be remapped dither: dithering algorithm to use when remapping. Options are None, 'floydsteinberg', and 'riemersma' """ if not os.path.exists(paletteimg): #because imagemagick doesn't check raise IOError("Remapping palette image {0} not found".format(paletteimg)) if dither is None: ditheropt = 'None' elif dither in ('floydsteinberg', 'riemersma'): ditheropt = dither else: raise ValueError("Invalid dither type '{0}' for remapping".format(dither)) command = '"{convert}" "{src}" -dither {dither} -remap "{paletteimg}" "{dest}"'.format( convert=IMAGEMAGICK_CONVERT_PATH, src=src, dither=ditheropt, paletteimg=paletteimg, dest=destremap) process_command(command) def make_palette(srcimage): """get unique colors from srcimage, return #rrggbb hex color strings""" command = '"{convert}" "{srcimage}" -unique-colors -compress none ppm:-'.format( convert = IMAGEMAGICK_CONVERT_PATH, srcimage=srcimage) stdoutput = process_command(command, stdout_=True) # separate stdout ppm image into its colors ppm_lines = stdoutput.decode().splitlines()[3:] del stdoutput #free up a little memory in advance colorvals = tuple() for line in ppm_lines: colorvals += tuple(int(s) for s in line.split()) #create i:j ranges that get every 3 values in colorvals irange = range(0, len(colorvals), 3) jrange = range(3, len(colorvals)+1, 3) hex_colors = [] for i,j in zip(irange,jrange): rgb = colorvals[i:j] hex_colors.append("#{0:02x}{1:02x}{2:02x}".format(*rgb)) hex_colors.reverse() #so it will generally go from light bg to dark fg return hex_colors def get_nonpalette_color(palette, start_black=True, additional=None): """return a color hex string not listed in palette start_black: start searching for colors starting at black, else white additional: if specified, a list of additional colors to avoid returning """ if additional is None: palette_ = tuple(palette) else: palette_ = tuple(palette) + tuple(additional) if start_black: color_range = range(int('ffffff', 16)) else: color_range = range(int('ffffff', 16), 0, -1) for i in color_range: color = "#{0:06x}".format(i) if color not in palette_: return color #will fail in the case that palette+additional includes all colors #000000-#ffffff raise Exception("All colors exhausted, could not find a nonpalette color") # def isolate_color(src, destlayer, target_color, palette, stack=False): # """fills the specified color of src with black, all else is white # src: source image path, must match palette's colors # destlayer: path to save output image # target_color: the color to isolate (from palette) # palette: list of "#010101" etc. (output from make_palette) # stack: if True, colors before coloridx are white, colors after are black # """ # coloridx = palette.index(target_color) # # to avoid problems when the palette contains black or white, background and # # foreground colors are chosen that are not in the palette (nor black or white) # bg_white = "#FFFFFF" # fg_black = "#000000" # bg_almost_white = get_nonpalette_color(palette, False, (bg_white, fg_black)) # fg_almost_black = get_nonpalette_color(palette, True, (bg_almost_white, bg_white, fg_black)) # # start off the piping of stdin/stdout # with open(src, 'rb') as srcfile: # stdinput = srcfile.read() # for i, col in enumerate(palette): # # fill this color with background or foreground? # if i == coloridx: # fill = fg_almost_black # elif i > coloridx and stack: # fill = fg_almost_black # else: # fill = bg_almost_white # # build the imagemagick filling command and execute it # command = '"{convert}" - -fill {fill} -opaque "{color}" -'.format( # convert = IMAGEMAGICK_CONVERT_PATH, fill=fill, color=col) # stdoutput = process_command(command, stdinput=stdinput, stdout_=True) # stdinput = stdoutput # # now color the foreground black and background white # command = '"{convert}" - -fill {fillbg} -opaque "{colorbg}" -fill {fillfg} -opaque {colorfg} "{dest}"'.format( # convert = IMAGEMAGICK_CONVERT_PATH, fillbg=bg_white, colorbg=bg_almost_white, # fillfg=fg_black, colorfg=fg_almost_black, dest=destlayer) # process_command(command, stdinput=stdinput) def isolate_color(src,target_tmp ,destlayer, target_color, palette, stack=False): #new version """fills the specified color of src with black, all else is white src: source image path, must match palette's colors destlayer: path to save output image target_color: the color to isolate (from palette) palette: list of "#010101" etc. (output from make_palette) stack: if True, colors before coloridx are white, colors after are black """ coloridx = palette.index(target_color) # to avoid problems when the palette contains black or white, background and # foreground colors are chosen that are not in the palette (nor black or white) bg_white = "#FFFFFF" fg_black = "#000000" bg_almost_white = get_nonpalette_color(palette, False, (bg_white, fg_black)) fg_almost_black = get_nonpalette_color(palette, True, (bg_almost_white, bg_white, fg_black)) # start off the piping of stdin/stdout with open(src, 'rb') as srcfile: stdinput = srcfile.read() # build a large combined command, execute it once it reaches sufficient length # (because executing each fill command separately is very slow) last_iteration = len(palette)-1 #new # command_pre = '"{convert}" - '.format(convert = IMAGEMAGICK_CONVERT_PATH) # command_post = ' -' # command_mid = '' command_pre = '"{convert}" "{src}" '.format(convert = IMAGEMAGICK_CONVERT_PATH,src=src) command_post = ' "{target}"'.format(target= target_tmp) command_mid = '' for i, col in enumerate(palette): # fill this color with background or foreground? if i == coloridx: fill = fg_almost_black elif i > coloridx and stack: fill = fg_almost_black else: fill = bg_almost_white command_mid += ' -fill "{fill}" -opaque "{color}"'.format(fill=fill, color=col) if len(command_mid) >= COMMAND_LEN_NEAR_MAX or (i == last_iteration and command_mid): command = command_pre + command_mid + command_post stdoutput = process_command(command, stdinput=stdinput, stdout_=True) stdinput = stdoutput command_mid = '' #reset # now color the foreground black and background white command = '"{convert}" "{src}" -fill "{fillbg}" -opaque "{colorbg}" -fill "{fillfg}" -opaque "{colorfg}" "{dest}"'.format( convert = IMAGEMAGICK_CONVERT_PATH,src=target_tmp, fillbg=bg_white, colorbg=bg_almost_white, fillfg=fg_black, colorfg=fg_almost_black, dest=destlayer) process_command(command, stdinput=stdinput) def fill_with_color(src, dest): command = '"{convert}" "{src}" -fill "{color}" +opaque none "{dest}"'.format( convert = IMAGEMAGICK_CONVERT_PATH, src=src, color="#000000", dest=dest) process_command(command) def get_width(src): """return width of src image in pixels""" command = '"{identify}" -ping -format "%w" "{src}"'.format( identify=IMAGEMAGICK_IDENTIFY_PATH, src=src) stdoutput = process_command(command, stdout_=True) width = int(stdoutput) return width def trace(src, desttrace, outcolor, despeckle=2, smoothcorners=1.0, optimizepaths=0.2, width=None): """runs potrace with specified color and options src: source image to trace desttrace: destination to which output svg is saved outcolor: fill color of traced path despeckle: supress speckles of this many pixels (same as potrace --turdsize) smoothcorners: corner smoothing: 0 for no smoothing, 1.334 for max (same as potrace --alphamax) optimizepaths: Bezier curve optimization: 0 for least, 5 for most (same as potrace --opttolerance) width: width of output svg in pixels, None for default. Keeps aspect ratio. """ if width is not None: width = width/POTRACE_DPI command = ('"{potrace}" --svg -o "{dest}" -C "{outcolor}" -t {despeckle} ' '-a {smoothcorners} -O {optimizepaths} {W}{width} "{src}"').format( potrace = POTRACE_PATH, dest=desttrace, outcolor=outcolor, despeckle=despeckle, smoothcorners=smoothcorners, optimizepaths=optimizepaths, W=('-W ' if width is not None else ''), width=(width if width is not None else ''), src=src) process_command(command) def check_range(min, max, typefunc, typename, strval): """for argparse type functions, checks the range of a value min: minimum acceptable value, also appears in error messages max: maximum acceptable value (or None for no maximum), also appears in error messages typefunc: function to convert strval to the desired value, e.g. float, int typename: name of the converted data type, e.g. "an integer", appears in error messages strval: string containing the desired value """ try: val = typefunc(strval) except ValueError: msg = "must be {typename}".format(typename=typename) raise argparse.ArgumentTypeError(msg) if (max is not None) and (not min <= val <= max): msg = "must be between {min} and {max}".format(min=min, max=max) raise argparse.ArgumentTypeError(msg) elif not min <= val: msg = "must be {min} or greater".format(min=min) raise argparse.ArgumentTypeError(msg) return val def get_args(cmdargs=None): """return parser and namespace of parsed command-line arguments cmdargs: if specified, a list of command-line arguments to use instead of those provided to this script (i.e. a string that has been shlex.split) """ parser = argparse.ArgumentParser(description="trace a color image with " "potrace, output color SVG file", add_help=False, prefix_chars='-/') # help also accessible via /? parser.add_argument( '-h', '--help', '/?', action='help', help="show this help message and exit") # file io arguments parser.add_argument('-i', '--input', metavar='src', nargs='+', required=True, help="path of input image(s) to trace, supports * and ? wildcards") parser.add_argument('-o', '--output', metavar='dest', help="path of output image to save to, supports * wildcard") parser.add_argument('-d', '--directory', metavar='destdir', help="outputs to destdir") # processing arguments parser.add_argument('-C', '--cores', metavar='N', type=functools.partial(check_range, 0, None, int, "an integer"), help="number of cores to use for image processing. " "Ignored if processing a single file with 1 color " "(default tries to use all cores)") # color trace options #make colors & palette mutually exclusive color_palette_group = parser.add_mutually_exclusive_group(required=True) color_palette_group.add_argument('-c', '--colors', metavar='N', type=functools.partial(check_range, 0, 256, int, "an integer"), help="[required unless -p is used instead] " "number of colors to reduce each image to before tracing, up to 256. " "Value of 0 skips color reduction (not recommended unless images " "are already color-reduced)") parser.add_argument('-q', '--quantization', metavar='algorithm', choices=('mc','as','nq'), default='mc', help="color quantization algorithm: mc, as, or nq. " "'mc' (Median-Cut, default); " "'as' (Adaptive Spatial Subdivision, may result in fewer colors); " "'nq' (NeuQuant, for hundreds of colors). Disabled if --colors 0") #make --floydsteinberg and --riemersma dithering mutually exclusive dither_group = parser.add_mutually_exclusive_group() dither_group.add_argument('-fs', '--floydsteinberg', action='store_true', help="enable Floyd-Steinberg dithering (for any quantization or -p/--palette)." " Warning: any dithering will greatly increase output svg's size and complexity.") dither_group.add_argument('-ri', '--riemersma', action='store_true', help="enable Rimersa dithering (only for Adaptive Spatial Subdivision quantization or -p/--palette)") color_palette_group.add_argument('-r', '--remap', metavar='paletteimg', help=("use a custom palette image for color reduction [overrides -c " "and -q]")) # image options parser.add_argument('-s', '--stack', action='store_true', help="stack color traces (recommended for more accurate output)") parser.add_argument('-p', '--prescale', metavar='size', type=functools.partial(check_range, 0, None, float, "a floating-point number"), default=2, help="scale image this much before tracing for greater detail (default: 2). " "The image's output size is not changed. (2 is recommended, or 3 for smaller " "details.)") # potrace options parser.add_argument('-D', '--despeckle', metavar='size', type=functools.partial(check_range, 0, None, int, "an integer"), default=2, help='supress speckles of this many pixels (default: 2)') parser.add_argument('-S', '--smoothcorners', metavar='threshold', type=functools.partial(check_range, 0, 1.334, float, "a floating-point number"), default=1.0, help="set corner smoothing: 0 for no smoothing, 1.334 for max " "(default: 1.0)") parser.add_argument('-O', '--optimizepaths', metavar='tolerance', type=functools.partial(check_range, 0, 5, float, "a floating-point number"), default=0.2, help="set Bezier curve optimization: 0 for least, 5 for most " "(default: 0.2)") parser.add_argument('-bg', '--background', action='store_true', help=("set first color as background and posibly optimize final svg")) # other options parser.add_argument('-v', '--verbose', action='store_true', help="print details about commands executed by this script") parser.add_argument('--version', action='version', version='%(prog)s {ver}'.format(ver=VERSION)) if cmdargs is None: args = parser.parse_args() else: args = parser.parse_args(cmdargs) # with multiple inputs, --output must use at least one * wildcard multi_inputs = False for i, input_ in enumerate(get_inputs_outputs(args.input)): if i: multi_inputs = True break if multi_inputs and args.output is not None and '*' not in args.output: parser.error("argument -o/--output: must contain '*' wildcard when using multiple input files") # 'riemersma' dithering is only allowed with 'as' quantization or --palette option if args.riemersma: if args.quantization != 'as' and args.palette is None: parser.error("argument -ri/--riemersma: only allowed with 'as' quantization") return args def escape_brackets(string): '''replace [ with [[], ] with []] (i.e. escapes [ and ] for globbing)''' letters = list(string) for i, letter in enumerate(letters[:]): if letter == '[': letters[i] = '[[]' elif letter == ']': letters[i] = '[]]' return ''.join(letters) def get_inputs_outputs(arg_inputs, output_pattern="{0}.svg", ignore_duplicates=True): """returns an iterator of (input, matching output) with *? shell expansion arg_inputs: command-line-given inputs, can include *? wildcards output_pattern: pattern to rename output file, with {0} for input's base name without extension e.g. pic.png + {0}.svg = pic.svg ignore_duplicates: don't process or return inputs that have been returned already. Warning: this stores all previous inputs, so can be slow given many inputs """ old_inputs = set() for arg_input in arg_inputs: if '*' in arg_input or '?' in arg_input: #preventing [] expansion here because glob has problems with legal [] filenames #([] expansion still works in a Unix shell, it happens before Python even executes) if '[' in arg_input or ']' in arg_input: arg_input = escape_brackets(arg_input) inputs_ = tuple(iglob(os.path.abspath(arg_input))) else: #ensures non-existing file paths are included so they are reported as such #(glob silently skips over non-existing files, but we want to know about them) inputs_ = (arg_input,) for input_ in inputs_: if ignore_duplicates: if input_ not in old_inputs: old_inputs.add(input_) basename = os.path.basename(os.path.splitext(input_)[0]) output = output_pattern.format(basename) yield input_, output else: basename = os.path.basename(os.path.splitext(input_)[0]) output = output_pattern.format(basename) yield input_, output def q1_job(q2, total, layers, settings, findex, input, output): """ Initializes files, rescales, and performs color reduction q2: the second job queue (isolation + tracing) total: a value to measure the total number of q2 tasks layers: an ordered list of traced layers as SVGFiles settings: a dictionary that must contain the following keys: colors, quantization, dither, remap, prescale, tmp See color_trace_multi for details of the values findex: an integer index for input file input: the input path, source png file output: the output path, dest svg file """ # create destination directory if it doesn't exist destdir = os.path.dirname(os.path.abspath(output)) if not os.path.exists(destdir): os.makedirs(destdir) # temporary files will reside next to the respective output file this_scaled = os.path.abspath(os.path.join(settings['tmp'], '{0}~scaled.png'.format(findex))) this_reduced = os.path.abspath(os.path.join(settings['tmp'], '{0}~reduced.png'.format(findex))) try: # when quantization is skipped, must use a scaling method that # doesn't increase the number of colors if settings['colors'] == 0: filter_ = 'point' else: filter_ = 'lanczos' rescale(input, this_scaled, settings['prescale'], filter=filter_) if settings['colors'] is not None: quantize(this_scaled, this_reduced, settings['colors'], algorithm=settings['quantization'], dither=settings['dither']) elif settings['remap'] is not None: palette_remap(this_scaled, this_reduced, settings['remap'], dither=settings['dither']) else: #argparse should have caught this raise Exception("One of the arguments 'colors' or 'remap' must be specified") palette = make_palette(this_reduced) # update total based on the number of colors in palette if settings['colors'] is not None: total.value -= settings['colors'] - len(palette) else: total.value -= settings['palettesize'] - len(palette) # initialize layers for the file at findex layers[findex] += [False] * len(palette) # get input image width width = get_width(input) # add jobs to the second job queue for i, color in enumerate(palette): q2.put({ 'width': width, 'color': color, 'palette': palette, 'reduced': this_reduced, 'output': output, 'findex': findex, 'cindex': i }) except (Exception, KeyboardInterrupt) as e: # delete temporary files on exception... remfiles(this_scaled, this_reduced) raise e else: #...or after tracing remfiles(this_scaled) def q2_job(layers, layers_lock, settings, width, color, palette, findex, cindex, reduced, output): """ Isolates a color and traces it layers: an ordered list of traced layers as SVGFiles layers_lock: a lock that must be acquired for reading and writing the layers object settings: a dictionary that must contain the following keys: stack, despeckle, smoothcorners, optimizepaths, tmp See color_trace_multi for details of the values width: the width of the input image color: the color to isolate findex: an integer index for input file cindex: an integer index for color reduced: the color-reduced input image output: the output path, dest svg file """ # temporary files will reside next to the respective output file this_isolated = os.path.abspath(os.path.join(settings['tmp'], '{0}-{1}~isolated.png'.format(findex, cindex))) this_layer = os.path.abspath(os.path.join(settings['tmp'], '{0}-{1}~layer.ppm'.format(findex, cindex))) trace_format = '{0}-{1}~trace.svg' this_trace = os.path.abspath(os.path.join(settings['tmp'], trace_format.format(findex, cindex))) try: # if color index is 0 and -bg flag is activated # simply fill image with matching color else isolate color if cindex == 0 and settings['background']: verbose("Index {}".format(color)) fill_with_color(reduced, this_layer) else: isolate_color(reduced, this_isolated, this_layer, color, palette, stack=settings['stack']) # trace for this color, add to svg stack trace(this_layer, this_trace, color, settings['despeckle'], settings['smoothcorners'], settings['optimizepaths'], width) except (Exception, KeyboardInterrupt) as e: # delete temporary files on exception... remfiles(reduced, this_isolated, this_layer, this_trace) raise e else: #...or after tracing remfiles(this_isolated, this_layer) layers_lock.acquire() try: # add layer layers[findex][cindex] = True # check if all layers of this file have been traced is_last = False not in layers[findex] finally: layers_lock.release() # save the svg document if it is ready if is_last: # start the svg stack layout = svg_stack.CBoxLayout() layer_traces = [os.path.abspath(os.path.join(settings['tmp'], trace_format.format(findex, l))) for l in range(len(layers[findex]))] # add layers to svg for t in layer_traces: layout.addSVG(t) # save stacked output svg doc = svg_stack.Document() doc.setLayout(layout) with open(output, 'w') as file: doc.save(file) remfiles(reduced, *layer_traces) def process_worker(q1, q2, progress, total, layers, layers_lock, settings): """ Function for handling process jobs q1: the first job queue (scaling + color reduction) q2: the second job queue (isolation + tracing) progress: a value to measure the number of completed q2 tasks total: a value to measure the total number of q2 tasks layers: a nested list. layers[file_index][color_index] is a boolean that indicates if the layer for the file at file_index with the color at color_index has been traced layers_lock: a lock that must be acquired for reading and writing the layers object in q2 jobs settings: a dictionary that must contain the following keys: quantization, dither, remap, stack, prescale, despeckle, smoothcorners, optimizepaths, colors, tmp See color_trace_multi for details of the values """ while True: # try and get a job from q2 before q1 to reduce the total number of # temporary files and memory while not q2.empty(): try: job_args = q2.get(block=False) q2_job(layers, layers_lock, settings, **job_args) q2.task_done() progress.value += 1 except queue.Empty: break # get a job from q1 since q2 is empty try: job_args = q1.get(block=False) q1_job(q2, total, layers, settings, **job_args) q1.task_done() except queue.Empty: time.sleep(.01) if q2.empty() and q1.empty(): break def color_trace_multi(inputs, outputs, colors, processcount, quantization='mc', dither=None, remap=None, stack=False, prescale=2, despeckle=2, smoothcorners=1.0, optimizepaths=0.2, background=False): """color trace input images with specified options inputs: list of input paths, source png files outputs: list of output paths, dest svg files colors: number of colors to quantize to, 0 for no quantization processcount: number of process to launch for image processing quantization: color quantization algorithm to use: - 'mc' = median-cut (default, for few colors, uses pngquant) - 'as' = adaptive spatial subdivision (uses imagemagick, may result in fewer colors) - 'nq' = neuquant (for lots of colors, uses pngnq) dither: dithering algorithm to use. (Remember, final output is affected by despeckle.) None: the default, performs no dithering 'floydsteinberg': available with 'mc', 'as', and 'nq' 'riemersma': only available with 'as' palette: source of custom palette image for color reduction (overrides colors and quantization) stack: whether to stack color traces (recommended for more accurate output) despeckle: supress speckles of this many pixels smoothcorners: corner smoothing: 0 for no smoothing, 1.334 for max optimizepaths: Bezier curve optimization: 0 for least, 5 for most background: Set first color as background across whole image reducing svg size """ tmp = tempfile.mkdtemp() # create a two job queues # q1 = scaling + color reduction q1 = multiprocessing.JoinableQueue() # q2 = isolation + tracing q2 = multiprocessing.JoinableQueue() # create a manager to share the layers between processes manager = multiprocessing.Manager() layers = [] for i in range(min(len(inputs), len(outputs))): layers.append(manager.list()) # and make a lock for reading and modifying layers layers_lock = multiprocessing.Lock() # create a shared memory counter of completed and total tasks for measuring progress progress = multiprocessing.Value('i', 0) if colors is not None: # this is only an estimate because quantization can result in less colors # than in the "colors" variable. This value is corrected by q1 tasks to converge # on the real total. total = multiprocessing.Value('i', len(layers) * colors) elif remap is not None: # get the number of colors in the palette image palettesize = len(make_palette(remap)) # this is only an estimate because remapping can result in less colors # than in the remap variable. This value is corrected by q1 tasks to converge # on the real total. total = multiprocessing.Value('i', len(layers) * palettesize) else: #argparse should have caught this raise Exception("One of the arguments 'colors' or 'remap' must be specified") # create and start processes processes = [] for i in range(processcount): p = multiprocessing.Process(target=process_worker, args=(q1, q2, progress, total, layers, layers_lock, locals())) p.name = "color_trace worker #" + str(i) p.start() processes.append(p) try: # so for each input and (dir-appended) output... for index, (i, o) in enumerate(zip(inputs, outputs)): verbose(i, ' -> ', o) # add a job to the first job queue q1.put({ 'input': i, 'output': o, 'findex': index }) # show progress until all jobs have been completed while progress.value < total.value: sys.stdout.write("\r%.1f%%" % (progress.value / total.value * 100)) sys.stdout.flush() time.sleep(0.25) sys.stdout.write("\rTracing complete!\n") # join the queues just in case progress is wrong q1.join() q2.join() except (Exception, KeyboardInterrupt) as e: # shut down subproesses for p in processes: p.terminate() shutil.rmtree(tmp) raise e # close all processes for p in processes: p.terminate() shutil.rmtree(tmp) def remfiles(*filepaths): """remove file paths if they exist""" for f in filepaths: if os.path.exists(f): os.remove(f) def main(args=None): """main function to collect arguments and run color_trace_multi args: if specified, a Namespace of arguments (see argparse) to use instead of those supplied to this script at the command line """ if args is None: args = get_args() #set verbosity level if args.verbose: global VERBOSITY_LEVEL VERBOSITY_LEVEL = 1 # set output filename pattern depending on --output argument if args.output is None: output_pattern = "{0}.svg" elif '*' in args.output: output_pattern = args.output.replace('*', "{0}") else: output_pattern = args.output # --directory: add dir to output paths if args.directory is not None: destdir = args.directory.strip('\"\'') output_pattern = os.path.join(destdir, output_pattern) # set processcount if not defined if args.cores is None: try: processcount = multiprocessing.cpu_count() except NotImplementedError: verbose("Could not determine total number of cores, assuming 1") processcount = 1 else: processcount = args.cores # collect only those arguments needed for color_trace_multi inputs_outputs = zip(*get_inputs_outputs(args.input, output_pattern)) try: inputs, outputs = inputs_outputs except ValueError: #nothing to unpack inputs, outputs = [], [] if args.floydsteinberg: dither = 'floydsteinberg' elif args.riemersma: dither = 'riemersma' else: dither = None colors = args.colors color_trace_kwargs = vars(args) for k in ('colors', 'directory', 'input', 'output', 'cores', 'floydsteinberg', 'riemersma', 'verbose'): color_trace_kwargs.pop(k) ## color_trace_multi(inputs, outputs, colors, dither=dither, **color_trace_kwargs) color_trace_multi(inputs, outputs, colors, processcount, dither=dither, **color_trace_kwargs) if __name__ == '__main__': main()