はじめに
potraceというプログラムがありまして、これは「ビットマップをsvgなどのベクタ線画画像に変換する」ものなのですが、応用として「鉛筆で描いた絵をペン画風に自動クリンナップする」ような用途にも使えます。
このpotraceは実に素晴らしいプログラムでして、既にinkscapeなどにも同じロジックのものが組み込まれていますが、コマンドラインで呼べることで色々自動化できたりもします。
しかし残念なことに、読み込む画像としてjpgやpngに対応していないなど若干古い制限が有ります。
そこで画像のjpg->ppmの変換をPythonにより自動化し、ImageMagickやInkscapeなどと合わせて使うことで、あたかもpotraceが色々対応したかのように見えるスクリプトを作りました。
svgを吐くだけでなく、--renderオプションを使うとpngに自動で変換してくれます。
必要な環境
PythonとpotraceとImageMagickとInkscapeが必要です。
Linux系OSでないとそのままでは使えないと思いますが、ソースを手直しすれば使えると思います。CONFIG_FILESの辺りとパス区切り文字、外部プログラム呼び出しなどを修正すれば使えそうかもです。
使い方・動作の説明
いきなり凡例で説明とさせていただきます。
./mypotrace.py --render=outimg.png -i15040302.JPG --scale-width=1440 --transform=turn-right
このようにすると
- 15040302.JPGを読み込み、テンポラリフォルダでppmに変換
- そのppmからsvgに変換。--transformで指定された回転はこの時実行される
- svgを 1440ピクセル幅、高さは自動計算でoutimg.pngに変換
として、outimg.pngを得ることができます。
なお、--blacklevel等のpotrace用オプションはそのままpotrace呼び出し時に使われます。
デフォルト設定ファイルについて
たとえばスキャナの動作が、jpgはカラースキャン、tiffはモノクロスキャンなのでそれぞれ別のブラックレベルがふさわしい…という時の為に、デフォルトのpotraceオプションを設定ファイルに書いておけるようにしてみました。
CONFIG_FILESタプルに設定されたパス、たとえば$HOME/mypotrace.confが存在すれば、それを読み込みます。書式はjsonです。
{ "blacklevel" : "0.79", ".jpg" : { "blacklevel" : "0.81" } }
一番デフォルトのオプションはそのままオプション名をキーにした連想配列で、拡張子ごとの設定は「.拡張子」をキーとして、その中にさらにオプションを書いた連想配列を入れておくという仕組みです。
コード
汚くて長いコードですが勘弁してください。まぁバグバグの予感…
#!/usr/bin/env python # -*- coding: UTF-8 -*- import os import subprocess import glob import shutil import time import random CONFIG_NAME='mypotrace.conf' CONFIG_FILES=( "/usr/local/etc/%s" % CONFIG_NAME, "%s/.config/%s" % (os.environ["HOME"],CONFIG_NAME), "%s/%s" % (os.environ["HOME"],CONFIG_NAME) ) def load_configs(potarg): """ config file (myportace.conf) is a json file basically,it contains a dictionary of default values of potrace arguments such as blacklevel,turdsize,etc. and,filetype(of input file) specific configuration can be described with exteision named key. for example, { "blacklevel" : "0.80", "longcurve" : "", ".jpg" : { "blacklevel" : "0.83" }, ".tiff" : { "blacklevel" : "0.75" } } To enable switch-options like longcurve,set empty string as value. To disable switch-options,just remove it from line or rename as wrong name. Options which have wrong name for potrace are simply ignored. config file can be placed at CONFIG_FILE path, Only a file which is found first is processed,others are just ignored. """ ftconf={} for cf in CONFIG_FILES: if os.path.exists(cf): import json with open(cf,'r') as ifp: print('[INFO] loading config file %s.' % cf) set_configs(potarg,json.load(ifp),ftconf) return ftconf def set_configs(potarg,conf,ftconf=None): """ to share same processing with filetype-config and default-setting-config """ for ck in conf: if ck[0]=='.': # this must be filetype specific configuration. if ftconf!=None: ftconf[ck]=conf[ck] else: try: potarg.add(ck,conf[ck]) except KeyError: pass #print('[ERROR] setting of %s is not potrace option,so ignored.' % ck) def get_tempfilename(workdir,head,ext): while True: tfname="%s/%s-%08x-%08x.%s" % (workdir,head,random.randint(0,pow(2,32)),random.randint(0,pow(2,32)),ext) if not os.path.exists(tfname): return tfname else: print('[WARNING] the workfile %s already exists' % tfname) time.sleep(0.5) def get_fnames(ifname): indir=os.path.dirname(ifname) if indir=='': indir=os.path.abspath(os.path.curdir) inbase,inext=os.path.splitext(os.path.basename(ifname)) return (indir,inbase,inext.lower()) class ArgumentGenerator: def __init__(self): self.args={} self.argtable={ "--blacklevel" : "-k", "--turdsize" : "-t", "--longcurve" : "-n", "--opttolerance" : "-O", "--unit" : "-u", "--rotate" : "-A"} # using argtable to initialize argtable itself # for abbr-form of options. for ck in self.argtable.keys(): self.argtable[self.argtable[ck]]=self.argtable[ck] def get_arg(self): ret=[] for ck in self.args: ret.append(ck) if self.args[ck]!='':# current option may not be switch-option ret.append(str(self.args[ck])) return ret def add(self,arg,value): if arg[0]==arg[1]=='-': self.args[self.argtable[arg]]=value else: self.args[self.argtable["--%s" % arg]]=value class DefaultConfigurator: def __init__(self): pass class Potconv: def __init__(self,workdir,ifname,ofname): self.workdir=workdir self.ifname=ifname self.ofname=ofname def process_potrace(self,arg_src,ifname,ofname): args=['potrace'] args+=arg_src.get_arg() args.append('-s') args.append(ifname) #print(args) subprocess.check_call(args) if ofname!=None: indir,inbase,inext=get_fnames(ifname) tout="%s/%s.svg" % (indir,inbase) print("output vector file %s" % ofname) shutil.move(tout,ofname) def execute(self,args): indir,inbase,inext=get_fnames(self.ifname) if self.ofname==None: self.ofname="%s/%s.svg" % (indir,inbase) #workfile="%s/%s.bmp" % (workdir,inbase) workfile="%s/%s.ppm" % (self.workdir,inbase) # use ppm, to avoid imagemagick bug. if os.path.exists(self.ifname): if inext == '.bmp': self.process_potrace(args,self.ifname,self.ofname) else: subprocess.check_call(['convert',self.ifname,workfile]) if inext == '.tif': wbase=os.path.basename(workfile) wbase,wext=os.path.splitext(wbase) lst=glob.glob("%s/%s-*.bmp" % (workdir,wbase)) if len(lst)>0: #print lst idx=0 for cf in lst: ofname='%s/%s-%d.svg' % (indir,inbase,idx) self.process_potrace(args,cf,self.ofname) idx+=1 else: self.process_potrace(args,workfile,self.ofname) else: self.process_potrace(args,workfile,self.ofname) else: print("ERROR:file %s does not exist." % self.ifname) def render(self,postprocess_output,scale_factor):#,delete_workfile): """ render a svg file as png with inkscape """ tfname=get_tempfilename(self.workdir,'mptc','png') #cmdlst=['inkscape',self.ofname,'-e',postprocess_output] cmdlst=['inkscape',self.ofname,'-e',tfname] if scale_factor: # these lines needs to place here # because dpi scaling needs to assign Inkscape,not Imagemagick dpi,scale_width,scale_height,ratio = scale_factor if dpi: cmdlst.append('-d') cmdlst.append(str(dpi)) elif ratio: ratio=float(ratio) # we cannot call identify command # because we have not called Inkscape yet # so we does not have rendered image yet, # no way to know the dimension of rendered image. else: pass subprocess.check_call(cmdlst) if scale_factor: # scale_factor check and scale_width/height check can be done at one line, # but I split them into two if-conditional lines for furture expansion. if scale_width or scale_height: # there is no need to analyse src image file # when using Imagemagick for scaling. # so only set width or height to geometry option. if scale_width: geometry="%sx" % scale_width elif scale_height: geometry="x%s" % scale_height elif ratio: cl=subprocess.check_output( ('identify',tfname) ).split() src_width,src_height=[int(x) for x in cl[2].split('x')] scale_width=int(src_width*ratio) scale_height=int(src_height*ratio) geometry="%sx" % scale_width print("[INFO] scaling ratio %.4f assigned, so scaled to %dx%d (source image is %dx%d)" % (ratio,scale_width,scale_height,src_width,src_height)) else: print('[ERROR] scale_width and scale_height are missing') return False # below this line,common processing. subprocess.check_call( ('convert' , tfname, '-adaptive-resize', geometry, '-colorspace','rgb','-type','truecolormatte', postprocess_output ) ) # Imagemagick 'convert' command does not delete source(temporary) image # so we need delete it now. os.unlink(tfname) else: # otherwise,simply move(rename) final rendered image. shutil.move(tfname,postprocess_output) if __name__ == '__main__': import getopt,sys ofname=None try: shortcmd="hk:o:w:z:t:a:O:u:ni:Ar:d:" longcmd=["help", "output=", "input=", "workdir=", "blacklevel=", "turnpolicy=", "turdsize=", "alphamax=", "opttolerance=", "unit=", "longcurve", "rotate=", "transform=", "render=", "scale-dpi", "scale-ratio=", "scale-width=", "scale-height=",] opts,args=getopt.getopt(sys.argv[1:],shortcmd,longcmd) if len(opts)==0 and len(args)>1: firstarg=args[0] opts,args=getopt.getopt(sys.argv[2:],shortcmd,longcmd) args.append(firstarg) if len(args) > 0: try: ifname=args[0] ofname=args[1] except IndexError: pass except getopt.GetoptError,e: # something getopterror happen print(str(e)) sys.exit(1) except IndexError: # no argument assigned pass a=ArgumentGenerator() postprocess_output=None # scale factor is a tuple, which contains # (dpi,width to scale,height to scale,ratio of scale) # assigning None to this means 'disable scaling' scale_factor=None workdir='/tmp' a.add('--blacklevel',0.81) # setting default blacklevel for potrace # NOTE: the higher blacklevel value set,lines become much darker. # after hard-coded default setting,load user config file ftconf=load_configs(a) # pre-processing of commandline options for cmd,cmdarg in opts: if cmd in ('-i','--input'): # set source-filetype based configuration # prior to commandline option decoding fb,ext=os.path.splitext(cmdarg) ext=ext.lower() if ext in ftconf: print("[INFO] filetype %s specific setting is found and applied." % ext) set_configs(a,ftconf[ext]) # and then,commandline option process to overwrite above settings. for cmd,cmdarg in opts: if cmd=='-h' or cmd=='--help': #print(open(sys.argv[0],'r').read()) import re,os ro=re.compile('^\s*#-+#\s*$') # getting script file itself filename, # to dump some part of sourcecode as help document ofname=os.path.realpath(__file__) if ofname[-1]=='c': ofname=ofname[:-1] # adjust filename when .pyc compiled file executed with open(ofname,'rt') as fp: cnt=0 for cl in fp.readlines(): if cnt==1: print cl.rstrip() m=ro.search(cl) if m: cnt+=1 sys.exit(0) else: try: # as a default, incoming arguments are treated as potrace argument. # if it is not for potrace,KeyError exception should be raised. a.add(cmd,cmdarg) except KeyError: # Incoming arguments are not for potrace, # but it may be mypotrace argument. #-----# # sample: # mypotrace.py --infile=hoge.jpg --render=hoge_1.png --transform=turn-left --scale-width=1280 if cmd=='-o' or cmd in ('--output','--outfile'): # output filename of SVG. # when missing this option,filename should be generated automatically. ofname=cmdarg elif cmd=='-i' or cmd in ('-i','--input'): # ** NEEDED,MOST IMPORTANT OPTION ** # source of bitmap file to convert to SVG. ifname=cmdarg elif cmd=='-w' or cmd=='--workdir': # you can assign work directory to generate temporary files. # for example,to save SSD usage or use ramdisk for faster operation. workdir=cmdarg elif cmd=='--transform': # transform argument to assign mainly rotation,for now. # the format is 'verb''hyphen''direction/form' # such as --transform=turn-left move,dir=cmdarg.split('-') if move in ('turn','rotate'): if dir == 'left': a.add('--rotate','90') elif dir == 'right': a.add('--rotate','-90') elif dir in ('over','around'): a.add('--rotate','180') elif cmd in ('-r','--render'): # rendering output filename of PNG # scanned bitmap -> SVG -> SVG rendered bitmap of PNG # this argument needs Inkscape to be installed. postprocess_output=cmdarg ofname=get_tempfilename(workdir,'mpt','svg') elif cmd in ('-d','--scale-dpi'): # -d and --scale-dpi option assigns the scaling size by dpi(dot per inch) # first of all,scanning dpi considered as 300 dpi in this script. # so,for example,--scale-dpi=75 means # 'scale final output image as 75/300=0.25=quarter size of original' scale_factor=(cmdarg,None,None,None) elif cmd == '--scale-width': # --scale-width and --scale-height # these option needs --render option. # to scale(resize) final rendered bitmap. # scale dimension culculated automatically, # so you can assign only width or height. scale_factor=(None,cmdarg,None,None) elif cmd == '--scale-height': scale_factor=(None,None,cmdarg,None) elif cmd == '--scale-ratio': # --scale-ratio option assigns the scaling ratio of # final rendered PNG. # for example,--scale-ratio=0.5 means # scale output PNG as half size(quarter square) of original. scale_factor=(None,None,None,cmdarg) #-----# p=Potconv(workdir,ifname,ofname) p.execute(a) if postprocess_output: p.render(postprocess_output,scale_factor)