Commit 73443c0a authored by Juergen Weigert's avatar Juergen Weigert

V0.3 with parameters and filter options exposed in the GUI.

parent 9bb4f29c
...@@ -6,12 +6,21 @@ ...@@ -6,12 +6,21 @@
<dependency type="executable" location="extensions">inkex.py</dependency> <dependency type="executable" location="extensions">inkex.py</dependency>
<dependency type="executable" location="extensions">centerline-trace.py</dependency> <dependency type="executable" location="extensions">centerline-trace.py</dependency>
<param name="invert" type="boolean" _gui-text="Trace bright lines. Default: dark lines.">false</param>
<param name="megapixels" type="float" min="0.1" max="99.9" precision="1" _gui-text="Limit image size in megapixels (Default: 2.0; lower is faster).">2.0</param>
<param name="candidates" type="int" min="1" max="255" _gui-text="[1..255] Autotrace candidate runs. Use 1 with noisy photos. (Default: 1; lower is much faster)">1</param>
<param name="filters" type="description">
Preprocessing filters:
</param>
<param name="equal-light" type="float" min="0.0" max="1.9" precision="1" _gui-text="Equalize illumination. Use 1.0 with flash photography, use 0.0 to disable. (Default: 0.0)">0.0</param>
<param name="despecle" type="int" min="0" max="9" _gui-text="Apply a median filter. 0: no filter, 5: for strong noise reduction. (Default: 0)">0</param>
<!-- Keep in sync with chain_paths.py line 16 __version__ = ... --> <!-- Keep in sync with chain_paths.py line 16 __version__ = ... -->
<param name="about_version" type="description"> <param name="about_version" type="description">
https://github.com/fablabnbg/inkscape-centerline-trace https://github.com/fablabnbg/inkscape-centerline-trace
Version 0.2</param> Version 0.3</param>
<effect needs-live-preview="false" > <effect needs-live-preview="false" >
<object-type>path</object-type> <object-type>path</object-type>
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
# #
# Inkscape extension to vectorize bitmaps by tracing along the center of lines # Inkscape extension to vectorize bitmaps by tracing along the center of lines
# (C) 2016 juewei@fabmail.org # (C) 2016 juewei@fabmail.org
# Distribute under GPL-2.0 or ask.
# #
# code snippets visited to learn the extension 'effect' interface: # code snippets visited to learn the extension 'effect' interface:
# - convert2dashes.py # - convert2dashes.py
...@@ -24,7 +25,7 @@ ...@@ -24,7 +25,7 @@
# #
# Algorithm: # Algorithm:
# #
# The input image is converted to a graymap and histogram normalized with PIL.ImageOps.equalize. # The input image is converted to a graymap and histogram normalized with PIL.ImageOps.equalize. or autocontrast
# #
# autotrace needs a bi-level bitmap. In order to find the # autotrace needs a bi-level bitmap. In order to find the
# best threshold value, we run autotrace at multiple thresholds # best threshold value, we run autotrace at multiple thresholds
...@@ -41,9 +42,10 @@ ...@@ -41,9 +42,10 @@
# #
# 2016-05-10 jw, V0.1 -- initial draught # 2016-05-10 jw, V0.1 -- initial draught
# 2016-05-11 jw, V0.2 -- first usable inkscape-extension # 2016-05-11 jw, V0.2 -- first usable inkscape-extension
# 2016-05-15 jw, V0.3 -- equal spatial illumination applied. autocontrast instead of equalize. denoise.
# #
__version__ = '0.2' # Keep in sync with chain_paths.inx ca line 22 __version__ = '0.3' # Keep in sync with chain_paths.inx ca line 22
__author__ = 'Juergen Weigert <juewei@fabmail.org>' __author__ = 'Juergen Weigert <juewei@fabmail.org>'
import sys, os, re, math, tempfile, subprocess, base64 import sys, os, re, math, tempfile, subprocess, base64
...@@ -52,12 +54,13 @@ try: ...@@ -52,12 +54,13 @@ try:
from PIL import Image from PIL import Image
from PIL import ImageOps from PIL import ImageOps
from PIL import ImageStat from PIL import ImageStat
from PIL import ImageFilter
except: except:
print >>sys.stderr, "Error: Cannot import PIL. Try\n apt-get install python-pil" print >>sys.stderr, "Error: Cannot import PIL. Try\n apt-get install python-pil"
sys.exit(1) sys.exit(1)
#debug = True # debug = True
debug = False debug = False
# search path, so that inkscape libraries are found when we are standalone. # search path, so that inkscape libraries are found when we are standalone.
...@@ -93,7 +96,12 @@ class TraceCenterline(inkex.Effect): ...@@ -93,7 +96,12 @@ class TraceCenterline(inkex.Effect):
inkex.Effect.__init__(self) inkex.Effect.__init__(self)
self.dumpname= os.path.join(tempfile.gettempdir(), "trace-centerline.dump") self.dumpname= os.path.join(tempfile.gettempdir(), "trace-centerline.dump")
self.autotrace_opts=[] # extra options for autotrace tuning. self.autotrace_opts=[] # extra options for autotrace tuning.
self.megapixel_limit = 2.0 # max image size (limit needed, as we have no progress indicator)
self.invert_image = False # True: trace bright lines.
self.candidates = 15 # [1..255] Number of autotrace candidate runs.
self.filter_median = 0 # 0 to disable median filter.
self.filter_equal_light = 0.0 # [0.0 .. 1.9] Use 1.0 with photos. Use 0.0 with perfect scans.
try: try:
self.tty = open("/dev/tty", 'w') self.tty = open("/dev/tty", 'w')
...@@ -107,6 +115,15 @@ class TraceCenterline(inkex.Effect): ...@@ -107,6 +115,15 @@ class TraceCenterline(inkex.Effect):
self.OptionParser.add_option('-V', '--version', self.OptionParser.add_option('-V', '--version',
action = 'store_const', const=True, dest='version', default=False, action = 'store_const', const=True, dest='version', default=False,
help='Just print version number ("'+__version__+'") and exit.') help='Just print version number ("'+__version__+'") and exit.')
self.OptionParser.add_option('-i', '--invert', action='store', type='inkbool', default=False, help='Trace bright lines. (Default: dark lines)')
self.OptionParser.add_option('-m', '--megapixels', action='store',
type='float', default=2.0, help="Limit image size in megapixels. (Lower is faster)")
self.OptionParser.add_option('-e', '--equal-light', action='store',
type='float', default=0.0, help="Equalize illumination. Use 1.0 with flash photography, use 0.0 to disable.")
self.OptionParser.add_option('-c', '--candidates', action='store',
type='int', default=15, help="[1..255] Autotrace candidate runs. (Lower is much faster)")
self.OptionParser.add_option('-d', '--despecle', action='store',
type='int', default=0, help="[0..9] Apply median filter for noise reduction. (Default 0, off)")
def version(self): def version(self):
return __version__ return __version__
...@@ -114,18 +131,51 @@ class TraceCenterline(inkex.Effect): ...@@ -114,18 +131,51 @@ class TraceCenterline(inkex.Effect):
return __author__ return __author__
def svg_centerline_trace(self, image_file): def svg_centerline_trace(self, image_file):
num_attempts = 15 # min 1, max 255, beware it gets much slower with more attempts. """ svg_centerline_trace prepares the image by
a) limiting_size (aka runtime),
b) removing noise,
c) linear histogram expansion,
d) equalized spatial illumnination (my own algorithm)
Then we run several iterations of autotrace and find the optimal black white threshold by evaluating
all outputs. The output with the longest total path and the least path elements wins.
"""
num_attempts = self.candidates # 15 is great. min 1, max 255, beware it gets much slower with more attempts.
autotrace_cmd = ['autotrace', '--centerline', '--input-format=pbm', '--output-format=svg' ] autotrace_cmd = ['autotrace', '--centerline', '--input-format=pbm', '--output-format=svg' ]
autotrace_cmd += self.autotrace_opts autotrace_cmd += self.autotrace_opts
stroke_style_add = 'stroke-width:%.2f; fill:none; stroke-linecap:round;' stroke_style_add = 'stroke-width:%.2f; fill:none; stroke-linecap:round;'
if debug: print >>self.tty, image_file if debug: print >>sys.stderr, "svg_centerline_trace start "+image_file
im = Image.open(image_file) im = Image.open(image_file)
im = im.convert(mode='L', dither=None) im = im.convert(mode='L', dither=None)
if debug: print >>sys.stderr, "seen: " + str([im.format, im.size, im.mode]) if debug: print >>sys.stderr, "seen: " + str([im.format, im.size, im.mode])
im = ImageOps.equalize(im) # equalize histogram scale_limit = math.sqrt(im.size[0] * im.size[1] * 0.000001 / self.megapixel_limit)
#im.show() if scale_limit > 1.0:
print >>sys.stderr, "Megapixel limit ("+str(self.megapixel_limit)+ ") exceeded. Scaling down by factor : "+str(scale_limit)
im = im.resize((int(im.size[0]/scale_limit), int(im.size[1]/scale_limit)), resample = Image.BILINEAR)
if self.invert_image: im = ImageOps.invert(im)
if self.filter_median > 0:
if self.filter_median % 2 == 0: self.filter_median = self.filter_median + 1 # need odd values.
im = im.filter(ImageFilter.MedianFilter(size=self.filter_median)) # a feeble denoise attempt. FIXME: try ROF instead.
im = ImageOps.autocontrast(im, cutoff=2) # linear expand histogram (an alternative to equalize)
# not needed here:
# im = im.filter(ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3)) # parameters depend on size of image!
if self.filter_equal_light > 0.0:
scale_thumb = math.sqrt(im.size[0] * im.size[1] * 0.0001) # exactly 0.01 MP (e.g. 100x100)
im_neg_thumb = ImageOps.invert(im.resize((int(im.size[0]/scale_thumb), int(im.size[1]/scale_thumb)), resample = Image.BILINEAR))
im_neg_thumb = im_neg_thumb.filter(ImageFilter.GaussianBlur(radius=30))
im_neg_blur = im_neg_thumb.resize(im.size, resample=Image.BILINEAR)
if debug: im_neg_blur.show()
if debug: print >>sys.stderr, "ImageOps.equalize(im) done"
im = Image.blend(im, im_neg_blur, self.filter_equal_light*0.5)
im = ImageOps.autocontrast(im, cutoff=0) # linear expand histogram (an alternative to equalize)
if debug: im.show()
def svg_pathstats(path_d): def svg_pathstats(path_d):
""" calculate statistics from an svg path: """ calculate statistics from an svg path:
...@@ -176,12 +226,15 @@ class TraceCenterline(inkex.Effect): ...@@ -176,12 +226,15 @@ class TraceCenterline(inkex.Effect):
for i in range(num_attempts): for i in range(num_attempts):
threshold = int(256.*(1+i)/(num_attempts+1)) threshold = int(256.*(1+i)/(num_attempts+1))
lut = [ 255 for n in range(threshold) ] + [ 0 for n in range(threshold,256) ] lut = [ 255 for n in range(threshold) ] + [ 0 for n in range(threshold,256) ]
if debug: print >>sys.stderr, "attempt "+ str(i)
bw = im.point(lut, mode='1') bw = im.point(lut, mode='1')
if debug: print >>sys.stderr, "bw from lut done"
cand = { 'threshold':threshold, 'img_width':bw.size[0], 'img_height':bw.size[1], 'mean': ImageStat.Stat(im).mean[0] } cand = { 'threshold':threshold, 'img_width':bw.size[0], 'img_height':bw.size[1], 'mean': ImageStat.Stat(im).mean[0] }
fp = tempfile.NamedTemporaryFile(suffix='.pbm', delete=False) fp = tempfile.NamedTemporaryFile(suffix='.pbm', delete=False)
fp.write("P4\n%d %d\n" % (bw.size[0], bw.size[1])) fp.write("P4\n%d %d\n" % (bw.size[0], bw.size[1]))
fp.write(bw.tobytes()) fp.write(bw.tobytes())
fp.close() fp.close()
if debug: print >>sys.stderr, "pbm from bw done"
try: try:
p = subprocess.Popen(autotrace_cmd + [fp.name], stdout=subprocess.PIPE) p = subprocess.Popen(autotrace_cmd + [fp.name], stdout=subprocess.PIPE)
except Exception as e: except Exception as e:
...@@ -191,6 +244,8 @@ class TraceCenterline(inkex.Effect): ...@@ -191,6 +244,8 @@ class TraceCenterline(inkex.Effect):
sys.exit(1) sys.exit(1)
cand['svg'] = p.communicate()[0] cand['svg'] = p.communicate()[0]
if debug: print >>sys.stderr, "autotrace done"
os.unlink(fp.name) os.unlink(fp.name)
# <?xml version="1.0" standalone="yes"?>\n<svg width="86" height="83">\n<path style="stroke:#000000; fill:none;" d="M36 15C37.9219 18.1496 41.7926 19.6686 43.2585 23.1042C47.9556 34.1128 39.524 32.0995 35.179 37.6034C32.6296 40.8328 34 48.1105 34 52M36 17C32.075 22.4565 31.8375 30.074 35 36M74 42L46 38C45.9991 46.1415 46.7299 56.0825 45.6319 64C44.1349 74.7955 23.7094 77.5566 16.044 72.3966C7.27363 66.4928 8.04426 45.0047 16.2276 38.7384C20.6362 35.3626 27.7809 36.0006 33 36M44 37L45 37"/>\n</svg> # <?xml version="1.0" standalone="yes"?>\n<svg width="86" height="83">\n<path style="stroke:#000000; fill:none;" d="M36 15C37.9219 18.1496 41.7926 19.6686 43.2585 23.1042C47.9556 34.1128 39.524 32.0995 35.179 37.6034C32.6296 40.8328 34 48.1105 34 52M36 17C32.075 22.4565 31.8375 30.074 35 36M74 42L46 38C45.9991 46.1415 46.7299 56.0825 45.6319 64C44.1349 74.7955 23.7094 77.5566 16.044 72.3966C7.27363 66.4928 8.04426 45.0047 16.2276 38.7384C20.6362 35.3626 27.7809 36.0006 33 36M44 37L45 37"/>\n</svg>
xml = inkex.etree.fromstring(cand['svg']) xml = inkex.etree.fromstring(cand['svg'])
...@@ -207,7 +262,7 @@ class TraceCenterline(inkex.Effect): ...@@ -207,7 +262,7 @@ class TraceCenterline(inkex.Effect):
if cand['mean'] > 127: if cand['mean'] > 127:
cand['mean'] = 255 - cand['mean'] # should not happen cand['mean'] = 255 - cand['mean'] # should not happen
blackpixels = cand['img_width'] * cand['img_height'] * cand['mean'] / 255. blackpixels = cand['img_width'] * cand['img_height'] * cand['mean'] / 255.
cand['strokewidth'] = blackpixels / cand['length'] cand['strokewidth'] = blackpixels / max(cand['length'],1.0)
candidate[i] = cand candidate[i] = cand
def calc_weight(cand, idx): def calc_weight(cand, idx):
...@@ -246,6 +301,11 @@ class TraceCenterline(inkex.Effect): ...@@ -246,6 +301,11 @@ class TraceCenterline(inkex.Effect):
if self.options.version: if self.options.version:
print __version__ print __version__
sys.exit(0) sys.exit(0)
if self.options.megapixels is not None: self.megapixel_limit = self.options.megapixels
if self.options.candidates is not None: self.candidates = self.options.candidates
if self.options.invert is not None: self.invert_image = self.options.invert
if self.options.despecle is not None: self.filter_median = self.options.despecle
if self.options.equal_light is not None: self.filter_equal_light = self.options.equal_light
self.calc_unit_factor() self.calc_unit_factor()
...@@ -313,6 +373,7 @@ class TraceCenterline(inkex.Effect): ...@@ -313,6 +373,7 @@ class TraceCenterline(inkex.Effect):
# #
# Create SVG Path # Create SVG Path
style = { 'stroke': '#000000', 'fill': 'none', 'stroke-linecap': 'round', 'stroke-width': stroke_width } style = { 'stroke': '#000000', 'fill': 'none', 'stroke-linecap': 'round', 'stroke-width': stroke_width }
if self.invert_image: style['stroke'] = '#777777'
path_attr = { 'style': simplestyle.formatStyle(style), 'd': path_d, 'transform': matrix } path_attr = { 'style': simplestyle.formatStyle(style), 'd': path_d, 'transform': matrix }
## insert the new path object ## insert the new path object
inkex.etree.SubElement(self.current_layer, inkex.addNS('path', 'svg'), path_attr) inkex.etree.SubElement(self.current_layer, inkex.addNS('path', 'svg'), path_attr)
......
#!/bin/sh #!/bin/sh
# shell script wrapper to run an inkscape extension # shell script wrapper to run the centerline-trace
# as a standaloine tool # inkscape extension as a standalone tool
#
# (c) 2016, juewei@fabmail.com
opts='--invert=False'
# opts='--candidates=15'
# opts='--megapixels=2.0'
# opts='--megapixel=0.5 --invert=True --candidates=5'
# opts='--invert=True'
image=$1 image=$1
test -z "$image" && image=testdata/kringel.png test -z "$image" && image=testdata/kringel.png
...@@ -35,5 +43,5 @@ cat << EOF > $tmpsvg ...@@ -35,5 +43,5 @@ cat << EOF > $tmpsvg
</svg> </svg>
EOF EOF
python centerline-trace.py --id=image4421 $tmpsvg python centerline-trace.py $opts --id=image4421 $tmpsvg
#rm $tmpsvg #rm $tmpsvg
# http://www.janeriksolem.net/2009/03/these-days-it-seems-there-are-lots-of.html
#
# http://de.slideshare.net/Moment_of_Revelation/programming-computer-vision-with-python-32888766
# The ROF model has the interesting property that it finds a smoother version of
# the image while preserving edges and structures. The underlying mathematics
# of the ROF model and the solution techniques are quite advanced and outside
# the scope of this book. We’ll give a brief, simplified introduction before
# showing how to implement a ROF solver based on an algorithm by Cham- bolle
# [5].
#
# from PIL import Image
# import pylab
# import rof
#
# im = array(Image.open('empire.jpg').convert("L"))
# U,T = rof.denoise(im,im)
#
from numpy import *
def denoise(im, U_init, tolerance=0.1, tau=0.125, tv_weight=100):
""" An implementation of the Rudin-Osher-Fatemi (ROF) denoising model
using the numerical procedure presented in Eq. (11) of A. Chambolle
(2005). Implemented using periodic boundary conditions
(essentially turning the rectangular image domain into a torus!).
Input:
im - noisy input image (grayscale)
U_init - initial guess for U
tv_weight - weight of the TV-regularizing term
tau - steplength in the Chambolle algorithm
tolerance - tolerance for determining the stop criterion
Output:
U - denoised and detextured image (also the primal variable)
T - texture residual"""
#---Initialization
m,n = im.shape #size of noisy image
U = U_init
Px = im #x-component to the dual field
Py = im #y-component of the dual field
error = 1
iteration = 0
#---Main iteration
while (error > tolerance):
Uold = U
#Gradient of primal variable
LyU = vstack((U[1:,:],U[0,:])) #Left translation w.r.t. the y-direction
LxU = hstack((U[:,1:],U.take([0],axis=1))) #Left translation w.r.t. the x-direction
GradUx = LxU-U #x-component of U's gradient
GradUy = LyU-U #y-component of U's gradient
#First we update the dual varible
PxNew = Px + (tau/tv_weight)*GradUx #Non-normalized update of x-component (dual)
PyNew = Py + (tau/tv_weight)*GradUy #Non-normalized update of y-component (dual)
NormNew = maximum(1,sqrt(PxNew**2+PyNew**2))
Px = PxNew/NormNew #Update of x-component (dual)
Py = PyNew/NormNew #Update of y-component (dual)
#Then we update the primal variable
RxPx =hstack((Px.take([-1],axis=1),Px[:,0:-1])) #Right x-translation of x-component
RyPy = vstack((Py[-1,:],Py[0:-1,:])) #Right y-translation of y-component
DivP = (Px-RxPx)+(Py-RyPy) #Divergence of the dual field.
U = im + tv_weight*DivP #Update of the primal variable
#Update of error-measure
error = linalg.norm(U-Uold)/sqrt(n*m);
iteration += 1;
print iteration, error
#The texture residual
T = im - U
print 'Number of ROF iterations: ', iteration
return U,T
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="210mm"
height="297mm"
viewBox="0 0 210 297"
version="1.1"
id="svg8"
inkscape:version="0.91+devel r"
sodipodi:docname="3-images.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.61557941"
inkscape:cx="396.85039"
inkscape:cy="561.25984"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1600"
inkscape:window-height="848"
inkscape:window-x="0"
inkscape:window-y="1080"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1">
<image
sodipodi:absref="/home/jw/src/github/fablabnbg/inkscape-centerline-trace/testdata/muggeley.jpg"
xlink:href="muggeley.jpg"
style="stroke-width:2.6152637"
width="139.2084"
height="78.372177"
preserveAspectRatio="none"
id="image4421"
x="9.7982759"
y="4.9358096" />
<image
sodipodi:absref="/home/jw/src/github/fablabnbg/inkscape-centerline-trace/testdata/dolly.png"
xlink:href="dolly.png"
width="112.53612"
height="97.719444"
preserveAspectRatio="none"
id="image4486"
x="21.957706"
y="90.82914" />
<image
sodipodi:absref="/home/jw/src/github/fablabnbg/inkscape-centerline-trace/testdata/kringel.png"
xlink:href="kringel.png"
style="stroke-width:0.38679612"
width="78.436386"
height="75.700241"
preserveAspectRatio="none"
id="image4551"
x="46.293644"
y="208.00226" />
</g>
</svg>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment