diff --git a/extensions/fablabchemnitz/shapereco/shapereco.inx b/extensions/fablabchemnitz/shapereco/shapereco.inx new file mode 100644 index 00000000..718572da --- /dev/null +++ b/extensions/fablabchemnitz/shapereco/shapereco.inx @@ -0,0 +1,52 @@ + + + Shape Recognition + fablabchemnitz.de.shape_recognition + + + + false + true + + + + true + 10.0 + 0.2 + + true + + + + true + 0.500 + 0.48 + 0.50 + + + true + 0.3 + 0.025 + + + true + true + true + true + + + + path + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/shapereco/shapereco.py b/extensions/fablabchemnitz/shapereco/shapereco.py new file mode 100644 index 00000000..1fdbbfe6 --- /dev/null +++ b/extensions/fablabchemnitz/shapereco/shapereco.py @@ -0,0 +1,836 @@ +#!/usr/bin/env python +''' +Copyright (C) 2017 , Pierre-Antoine Delsart + +This file is part of InkscapeShapeReco. + +InkscapeShapeReco 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 InkscapeShapeReco; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + + + +Quick description: +This extension uses all selected path, ignoring all other selected objects. +It tries to regularize hand drawn paths BY : + - evaluating if the path is a full circle or ellipse + - else finding sequences of aligned points and replacing them by a simple segment. + - changing the segments angles to the closest remarkable angle (pi/2, pi/3, pi/6, etc...) + - eqalizing all segments lengths which are close to each other + - replacing 4 segments paths by a rectangle object if this makes sens (giving the correct rotation to the rectangle). + +Requires numpy. + +''' + +import sys +sys.path.append('/usr/share/inkscape/extensions') +import inkex +import gettext +_ = gettext.gettext + +# ************************************************************* +# debugging +def void(*l): + pass +def debug_on(*l): + sys.stderr.write(' '.join(str(i) for i in l) +'\n') +debug = void +#debug = debug_on + +from shaperrec import geometric +from shaperrec import internal +from shaperrec import groups +from shaperrec import manipulation +from shaperrec import extenders +from shaperrec import miscellaneous + +import numpy +numpy.set_printoptions(precision=3) + +class PreProcess(): + + def removeSmallEdge(paths, wTot, hTot): + """Remove small Path objects which stand between 2 Segments (or at the ends of the sequence). + Small means the bbox of the path is less then 5% of the mean of the 2 segments.""" + if len(paths)<2: + return + def getdiag(points): + xmin, ymin, w, h = geometric.computeBox(points) + return numpy.sqrt(w**2+h**2), w, h + removeSeg=[] + def remove(p): + removeSeg.append(p) + if hasattr(p, "__next__") : p.next.prev = p.prev + if p.prev: p.prev.next = p.__next__ if hasattr(p, "__next__") else None + p.effectiveNPoints =0 + debug(' --> remove !', p, p.length, len(p.points)) + for p in paths: + if len(p.points)==0 : + remove(p) + continue + # select only path between 2 segments + next, prev = p.__next__ if hasattr(p, "__next__") else None, p.prev + if next is None: next = prev + if prev is None: prev = next + if not (False if next == None else next.isSegment()) or not (False if prev == None else prev.isSegment()) : continue + #diag = getdiag(p.points) + diag, w, h = getdiag(p.points) + + debug(p, p.pointN, ' removing edge diag = ', diag, p.length, ' l=', next.length+prev.length, 'totDim ', (wTot, hTot)) + debug( ' ---> ', prev, next) + + #t TODO: his needs to be parameterized + # remove last or first very small in anycase + doRemove = prev==next and (diag < 0.05*(wTot+hTot)*0.5 ) + if not doRemove: + # check if this small + isLarge = diag > (next.length+prev.length)*0.1 # check size relative to neighbour + isLarge = isLarge or w > 0.2*wTot or h > 0.2*hTot # check size w.r.t total size + + # is it the small side of a long rectangle ? + dd = prev.distanceTo(next.pointN) + rect = abs(prev.unitv.dot(next.unitv))>0.98 and diag > dd*0.5 + doRemove = not( isLarge or rect ) + + if doRemove: + remove(p) + + if next != prev: + prev.setIntersectWithNext(next) + debug('removed Segments ', removeSeg) + for p in removeSeg: + paths.remove(p) + + def prepareParrallelize( segs): + """Group Segment by their angles (segments are grouped together if their deltAangle is within 0.15 rad) + The 'newAngle' member of segments in a group are then set to the mean angle of the group (where angles are all + considered in [-pi, pi]) + + segs : list of segments + """ + + angles = numpy.array([s.angle for s in segs ]) + angles[numpy.where(angles<0)] += geometric._pi # we care about direction, not angle orientation + clList = miscellaneous.clusterValues(angles, 0.30, refScaleAbs='abs')#was 15 + + pi = numpy.pi + for cl in clList: + anglecount = {} + for angle in angles[list(cl)]: + # #angleDeg = int(angle * 360.0 / (2.0*pi)) + if not angle in anglecount: + anglecount[angle] = 1 + else: + anglecount[angle] += 1 + + anglecount = {k: v for k, v in sorted(list(anglecount.items()), key=lambda item: item[1], reverse=True)} + meanA = anglecount.popitem()[0]#.items()[1]#sorted(anglecount.items(), key = lambda kv:(kv[1], kv[0]), reverse=True)[1][1] + #meanA = float(meanA) * (2.0*pi) / 360.0 + #meanA = angles[list(cl)].mean() + for i in cl: + seg = segs[i] + seg.newAngle = meanA if seg.angle>=0. else meanA-geometric._pi + + + def prepareDistanceEqualization(segs, relDelta=0.1): + """ Input segments are grouped according to their length : + - for each length L, find all other lengths within L*relDelta. of L. + - Find the larger of such subgroup. + - repeat the procedure on remaining lengths until none is left. + Each length in a group is set to the mean length of the group + + segs : a list of segments + relDelta : float, minimum relative distance. + """ + + lengths = numpy.array( [x.tempLength() for x in segs] ) + clusters = miscellaneous.clusterValues(lengths, relDelta) + + if len(clusters)==1: + # deal with special case with low num of segments + # --> don't let a single segment alone + if len(clusters[0])+1==len(segs): + clusters[0]=list(range(len(segs))) # all + + allDist = [] + for cl in clusters: + dmean = sum( lengths[i] for i in cl ) / len(cl) + allDist.append(dmean) + for i in cl: + segs[i].setNewLength(dmean) + debug( i, ' set newLength ', dmean, segs[i].length, segs[i].dumpShort()) + + return allDist + + + def prepareRadiusEqualization(circles, otherDists, relSize=0.2): + """group circles radius and distances into cluster. + Then set circles radius according to the mean of the clusters they belong to.""" + ncircles = len(circles) + lengths = numpy.array( [c.radius for c in circles]+otherDists ) + indices = numpy.array( list(range(ncircles+len(otherDists))) ) + clusters = miscellaneous.clusterValues(numpy.stack([ lengths, indices ], 1 ), relSize, refScaleAbs='local' ) + + debug('prepareRadiusEqualization radius ', repr(lengths)) + debug('prepareRadiusEqualization clusters ', clusters) + allDist = [] + for cl in clusters: + dmean = sum( lengths[i] for i in cl ) / len(cl) + #print cl , dmean , + allDist.append(dmean) + if len(cl)==1: + continue + for i in cl: + if i< ncircles: + circles[i].radius = dmean + debug(' post radius ', [c.radius for c in circles] ) + return allDist + + + def centerCircOnSeg(circles, segments, relSize=0.18): + """ move centers of circles onto the segments if close enough""" + for circ in circles: + circ.moved = False + for seg in segments: + for circ in circles: + d = seg.distanceTo(circ.center) + #debug( ' ', seg.projectPoint(circ.center)) + if d < circ.radius*relSize and not circ.moved : + circ.center = seg.projectPoint(circ.center) + circ.moved = True + + + def adjustToKnownAngle( paths): + """ Check current angle against remarkable angles. If close enough, change it + paths : a list of segments""" + for seg in paths: + a = seg.tempAngle() + i = (abs(geometric.vec_in_mPi_pPi(geometric.knownAngle - a) )).argmin() + seg.newAngle = geometric.knownAngle[i] + debug( ' Known angle ', seg, seg.tempAngle(), ' -> ', geometric.knownAngle[i]) + ## if abs(geometric.knownAngle[i] - a) < 0.08: + +class PostProcess(): + def mergeConsecutiveParralels(segments, options): + ignoreNext=False + newList=[] + for s in segments: + if ignoreNext: + ignoreNext=False + continue + if not s.isSegment(): + newList.append(s) + continue + if not hasattr(s, "__next__"): + newList.append(s) + continue + if not s.next.isSegment(): + newList.append(s) + continue + d = geometric.closeAngleAbs(s.angle, s.next.angle) + if d < options.segAngleMergePara: + debug("merging ", s.angle, s.next.angle ) + snew = s.mergedWithNext(doRefit=False) + ignoreNext=True + newList.append(snew) + else: + debug("notmerging ", s.angle, s.next.angle ) + newList.append(s) + if len(segments)>len(newList): + debug("merged parallel ", segments, '-->', newList) + return newList + + def uniformizeShapes(pathGroupList, options): + allSegs = [ p for g in pathGroupList for p in g.listOfPaths if p.isSegment() ] + + if options.doParrallelize: + PreProcess.prepareParrallelize(allSegs) + if options.doKnownAngle: + PreProcess.adjustToKnownAngle(allSegs) + + adjustAng = options.doKnownAngle or options.doParrallelize + + allShapeDist = [] + for g in [ group for group in pathGroupList if not isinstance(group, groups.Circle)]: + # first pass : independently per path + if adjustAng: + manipulation.adjustAllAngles(g.listOfPaths) + g.listOfPaths[:] = PostProcess.mergeConsecutiveParralels(g.listOfPaths, options) + if options.doEqualizeDist: + allShapeDist=allShapeDist + PreProcess.prepareDistanceEqualization([p for p in g.listOfPaths if p.isSegment()], options.shapeDistLocal ) ##0.30 + manipulation.adjustAllDistances([p for p in g.listOfPaths if p.isSegment()]) #findme was group.li.. + + ## # then 2nd global pass, with tighter criteria + if options.doEqualizeDist: + allShapeDist=PreProcess.prepareDistanceEqualization(allSegs, options.shapeDistGlobal) ##0.08 + for g in [ group for group in pathGroupList if not isinstance(group, groups.Circle)]: + manipulation.adjustAllDistances([p for p in g.listOfPaths if p.isSegment()]) + + #TODO: I think this is supposed to close thje paths and it is failing + for g in pathGroupList: + if g.isClosing and not isinstance(g, groups.Circle): + debug('Closing intersec ', g.listOfPaths[0].point1, g.listOfPaths[0].pointN ) + g.listOfPaths[-1].setIntersectWithNext(g.listOfPaths[0]) + + + circles=[ group for group in pathGroupList if isinstance(group, groups.Circle)] + if options.doEqualizeRadius: + PreProcess.prepareRadiusEqualization(circles, allShapeDist) + if options.doCenterCircOnSeg: + PreProcess.centerCircOnSeg(circles, allSegs) + + pathGroupList = [manipulation.toRemarkableShape(g) for g in pathGroupList] + return pathGroupList + +class FitShapes(): + def checkForCircle(points, tangents): + """Determine if the points and their tangents represent a circle + + The difficulty is to be able to recognize ellipse while avoiding paths small fluctuations a + nd false positive due to badly drawn rectangle or non-convex closed curves. + + Method : we consider angle of tangent as function of lenght on path. + For circles these are : angle = c1 x lenght + c0. (c1 ~1) + + We calculate dadl = d(angle)/d(length) and compare to c1. + We use 3 criteria : + * num(dadl > 6) : number of sharp angles + * length(dadl<0.3)/totalLength : lengths of straight lines within the path. + * totalLength/(2pi x radius) : fraction of lenght vs a plain circle + + Still failing to recognize elongated ellipses... + + """ + if len(points)<10: + return False, 0 + + if all(points[0]==points[-1]): # last exactly equals the first. + # Ignore last point for this check + points = points[:-1] + tangents = tangents[:-1] + #print 'Removed last ', points + xmin, ymin, w, h = geometric.computeBox( points) + diag2=(w*w+h*h) + + diag = numpy.sqrt(diag2)*0.5 + norms = numpy.sqrt(numpy.sum( tangents**2, 1 )) + + angles = numpy.arctan2( tangents[:, 1], tangents[:, 0] ) + #debug( 'angle = ', repr(angles)) + N = len(angles) + + deltas = points[1:] - points[:-1] + deltasD = numpy.concatenate([ [geometric.D(points[0], points[-1])/diag], numpy.sqrt(numpy.sum( deltas**2, 1 )) / diag] ) + + # locate and avoid the point when swicthing + # from -pi to +pi. The point is around the minimum + imin = numpy.argmin(angles) + debug(' imin ', imin) + angles = numpy.roll(angles, -imin) + deltasD = numpy.roll(deltasD, -imin) + n=int(N*0.1) + # avoid fluctuations by removing points around the min + angles=angles[n:-n] + deltasD=deltasD[n:-n] + deltasD = deltasD.cumsum() + N = len(angles) + + # smooth angles to avoid artificial bumps + angles = manipulation.smoothArray(angles, n=max(int(N*0.03), 2) ) + + deltaA = angles[1:] - angles[:-1] + deltasDD = (deltasD[1:] -deltasD[:-1]) + deltasDD[numpy.where(deltasDD==0.)] = 1e-5*deltasD[0] + dAdD = abs(deltaA/deltasDD) + belowT, count = True, 0 + for v in dAdD: + if v>6 and belowT: + count+=1 + belowT = False + belowT= (v<6) + + temp = (deltasD, angles, tangents, dAdD ) + fracStraight = numpy.sum(deltasDD[numpy.where(dAdD<0.3)])/(deltasD[-1]-deltasD[0]) + curveLength = deltasD[-1]/3.14 + #print "SSS ",count , fracStraight + if curveLength> 1.4 or fracStraight>0.4 or count > 6: + isCircle =False + else: + isCircle= (count < 4 and fracStraight<=0.3) or \ + (fracStraight<=0.1 and count<5) + + if not isCircle: + return False, 0 + + # It's a circle ! + radius = points - numpy.array([xmin+w*0.5, ymin+h*0.5]) + radius_n = numpy.sqrt(numpy.sum( radius**2, 1 )) # normalize + + mini = numpy.argmin(radius_n) + rmin = radius_n[mini] + maxi = numpy.argmax(radius_n) + rmax = radius_n[maxi] + # void points around maxi and mini to make sure the 2nd max is found + # on the "other" side + n = len(radius_n) + radius_n[maxi]=0 + radius_n[mini]=0 + for i in range(1, int(n/8+1)): + radius_n[(maxi+i)%n]=0 + radius_n[(maxi-i)%n]=0 + radius_n[(mini+i)%n]=0 + radius_n[(mini-i)%n]=0 + radius_n_2 = [ r for r in radius_n if r>0] + rmax_2 = max(radius_n_2) + rmin_2 = min(radius_n_2) # not good !! + anglemax = numpy.arccos( radius[maxi][0]/rmax)*numpy.sign(radius[maxi][1]) + return True, (xmin+w*0.5, ymin+h*0.5, 0.5*(rmin+rmin_2), 0.5*(rmax+rmax_2), anglemax) + + + def checkForArcs(points, tangents): + """Determine if the points and their tangents represent a circle + + The difficulty is to be able to recognize ellipse while avoiding paths small fluctuations a + nd false positive due to badly drawn rectangle or non-convex closed curves. + + Method : we consider angle of tangent as function of lenght on path. + For circles these are : angle = c1 x lenght + c0. (c1 ~1) + + We calculate dadl = d(angle)/d(length) and compare to c1. + We use 3 criteria : + * num(dadl > 6) : number of sharp angles + * length(dadl<0.3)/totalLength : lengths of straight lines within the path. + * totalLength/(2pi x radius) : fraction of lenght vs a plain circle + + Still failing to recognize elongated ellipses... + + """ + if len(points)<10: + return False, 0 + + if all(points[0]==points[-1]): # last exactly equals the first. + # Ignore last point for this check + points = points[:-1] + tangents = tangents[:-1] + print(('Removed last ', points)) + xmin, ymin, w, h = geometric.computeBox( points) + diag2=(w*w+h*h) + + diag = numpy.sqrt(diag2)*0.5 + norms = numpy.sqrt(numpy.sum( tangents**2, 1 )) + + angles = numpy.arctan2( tangents[:, 1], tangents[:, 0] ) + #debug( 'angle = ', repr(angles)) + N = len(angles) + + deltas = points[1:] - points[:-1] + deltasD = numpy.concatenate([ [geometric.D(points[0], points[-1])/diag], numpy.sqrt(numpy.sum( deltas**2, 1 )) / diag] ) + + # locate and avoid the point when swicthing + # from -pi to +pi. The point is around the minimum + imin = numpy.argmin(angles) + debug(' imin ', imin) + angles = numpy.roll(angles, -imin) + deltasD = numpy.roll(deltasD, -imin) + n=int(N*0.1) + # avoid fluctuations by removing points around the min + angles=angles[n:-n] + deltasD=deltasD[n:-n] + deltasD = deltasD.cumsum() + N = len(angles) + + # smooth angles to avoid artificial bumps + angles = manipulation.smoothArray(angles, n=max(int(N*0.03), 2) ) + + deltaA = angles[1:] - angles[:-1] + deltasDD = (deltasD[1:] -deltasD[:-1]) + deltasDD[numpy.where(deltasDD==0.)] = 1e-5*deltasD[0] + dAdD = abs(deltaA/deltasDD) + belowT, count = True, 0 + + + self.temp = (deltasD, angles, tangents, dAdD ) + #TODO: Loop over deltasDD searching for curved segments, no sharp bumps and a curve of at least 1/4 pi + curveStart = 0 + curveToTest= numpy.array([deltasDD[curveStart]]); + dAdDd = numpy.array([dAdD[curveStart]]) + v = dAdD[curveStart] + belowT= (v<6) + for i in range(1, deltasDD.size): + curveToTest = numpy.append(curveToTest, deltasDD[i]) + dAdDd = numpy.append(dAdDd, dAdD[i]) + fracStraight = numpy.sum(curveToTest[numpy.where(dAdDd<0.3)])/(deltasD[i]-deltasD[curveStart]) + curveLength = (deltasD[i]-deltasD[curveStart])/3.14 + + v = dAdD[i] + if v>6 and belowT: + count+=1 + belowT = False + belowT= (v<6) + inkex.debug("SSS "+str(count) +":"+ str(fracStraight)) + if curveLength> 1.4 or fracStraight>0.4 or count > 8: + inkex.debug("curveLengtha:" + str(curveLength) +"fracStraight:"+str(fracStraight)+"count:"+str(count)) + isArc=False + curveStart=int(i) + curveToTest= numpy.array([deltasDD[curveStart]]); + v = dAdD[curveStart] + dAdDd = numpy.array([dAdD[curveStart]]) + belowT= (v<6) + count = 0 + continue + else: + inkex.debug("curveLengthb:" + str(curveLength) +"fracStraight:"+str(fracStraight)+"count:"+str(count)) + isArc= (count < 4 and fracStraight<=0.3) or \ + (fracStraight<=0.1 and count<5) + + if not isArc: + return False, 0 + + # It's a circle ! + radius = points - numpy.array([xmin+w*0.5, ymin+h*0.5]) + radius_n = numpy.sqrt(numpy.sum( radius**2, 1 )) # normalize + + mini = numpy.argmin(radius_n) + rmin = radius_n[mini] + maxi = numpy.argmax(radius_n) + rmax = radius_n[maxi] + # void points around maxi and mini to make sure the 2nd max is found + # on the "other" side + n = len(radius_n) + radius_n[maxi]=0 + radius_n[mini]=0 + for i in range(1, int(n/8+1)): + radius_n[(maxi+i)%n]=0 + radius_n[(maxi-i)%n]=0 + radius_n[(mini+i)%n]=0 + radius_n[(mini-i)%n]=0 + radius_n_2 = [ r for r in radius_n if r>0] + rmax_2 = max(radius_n_2) + rmin_2 = min(radius_n_2) # not good !! + anglemax = numpy.arccos( radius[maxi][0]/rmax)*numpy.sign(radius[maxi][1]) + return True, (xmin+w*0.5, ymin+h*0.5, 0.5*(rmin+rmin_2), 0.5*(rmax+rmax_2), anglemax) + + + + + def tangentEnvelop(svgCommandsList, refNode, options): + a, svgCommandsList = geometric.toArray(svgCommandsList) + tangents = manipulation.buildTangents(a) + + newSegs = [ internal.Segment.fromCenterAndDir( p, t ) for (p, t) in zip(a, tangents) ] + debug("build envelop ", newSegs[0].point1, newSegs[0].pointN) + clustersInd = manipulation.clusterAngles( [s.angle for s in newSegs] ) + debug("build envelop cluster: ", clustersInd) + + return TangentEnvelop( newSegs, svgCommandsList, refNode) + + def isClosing(wTot, hTot, d): + aR = min(wTot/hTot, hTot/wTot) + maxDim = max(wTot, hTot) + # was 0.2 + return aR*0.5 > d/maxDim + + + def curvedFromTangents(svgCommandsList, refNode, x, y, wTot, hTot, d, isClosing, sourcepoints, tangents, options): + +# debug('isClosing ', isClosing, maxDim, d) + + # global quantities : + hasArcs = False + res = () + # Check if circle ----------------------- + if isClosing: + if len(sourcepoints)<9: + return groups.PathGroup.toSegments(sourcepoints, svgCommandsList, refNode, isClosing=True) + isCircle, res = FitShapes.checkForCircle( sourcepoints, tangents) + debug("Is Circle = ", isCircle ) + if isCircle: + x, y, rmin, rmax, angle = res + debug("Circle -> ", rmin, rmax, angle ) + if rmin/rmax>0.7: + circ = groups.Circle((x, y), 0.5*(rmin+rmax), refNode ) + else: + circ = groups.Circle((x, y), rmin, refNode, rmax=rmax, angle=angle) + circ.points = sourcepoints + return circ + #else: + # hasArcs, res = FitShapes.checkForArcs( sourcepoints, tangents) + #else: + #hasArcs, res = FitShapes.checkForArcs( sourcepoints, tangents) + # ----------------------- + if hasArcs: + x, y, rmin, rmax, angle = res + debug("Circle -> ", rmin, rmax, angle ) + if rmin/rmax>0.7: + circ = groups.Circle((x, y), 0.5*(rmin+rmax), refNode ) + else: + circ = groups.Circle((x, y), rmin, refNode, rmax=rmax, angle=angle) + circ.points = sourcepoints + return circ + return None + + def segsFromTangents(svgCommandsList, refNode, options): + """Finds segments part in a list of points represented by svgCommandsList. + + The method is to build the (averaged) tangent vectors to the curve. + Aligned points will have tangent with similar angle, so we cluster consecutive angles together + to define segments. + Then we extend segments to connected points not already part of other segments. + Then we merge consecutive segments with similar angles. + + """ + sourcepoints, svgCommandsList = geometric.toArray(svgCommandsList) + + d = geometric.D(sourcepoints[0], sourcepoints[-1]) + x, y, wTot, hTot = geometric.computeBox(sourcepoints) + if wTot == 0: wTot = 0.001 + if hTot == 0: hTot = 0.001 + if d==0: + # then we remove the last point to avoid null distance + # in other calculations + sourcepoints = sourcepoints[:-1] + svgCommandsList = svgCommandsList[:-1] + + isClosing = FitShapes.isClosing(wTot, hTot, d) + + if len(sourcepoints) < 4: + return groups.PathGroup.toSegments(sourcepoints, svgCommandsList, refNode, isClosing=isClosing) + + tangents = manipulation.buildTangents(sourcepoints, isClosing=isClosing) + + aCurvedSegment = FitShapes.curvedFromTangents(svgCommandsList, refNode, x, y, wTot, hTot, d, isClosing, sourcepoints, tangents, options) + + if not aCurvedSegment == None: + return aCurvedSegment + + # cluster points by angle of their tangents ------------- + tgSegs = [ internal.Segment.fromCenterAndDir( p, t ) for (p, t) in zip(sourcepoints, tangents) ] + clustersInd = sorted(manipulation.clusterAngles( [s.angle for s in tgSegs] )) + debug("build envelop cluster: ", clustersInd) + + # build Segments from clusters + newSegs = [] + for imin, imax in clustersInd: + if imin+1< imax: # consider clusters with more than 3 points + seg = manipulation.fitSingleSegment(sourcepoints[imin:imax+1]) + elif imin+1==imax: # 2 point path : we build a segment + seg = internal.Segment.from2Points(sourcepoints[imin], sourcepoints[imax], sourcepoints[imin:imax+1]) + else: + seg = internal.Path( sourcepoints[imin:imax+1] ) + seg.sourcepoints = sourcepoints + newSegs.append( seg ) + manipulation.resetPrevNextSegment( newSegs ) + debug(newSegs) + # ----------------------- + + + # ----------------------- + # Merge consecutive Path objects + updatedSegs=[] + def toMerge(p): + l=[p] + setattr(p, 'merged', True) + if hasattr(p, "__next__") and not p.next.isSegment(): + l += toMerge(p.next) + return l + + for i, seg in enumerate(newSegs[:-1]): + if seg.isSegment(): + updatedSegs.append( seg) + continue + if hasattr(seg, 'merged'): continue + mergeList = toMerge(seg) + debug('merging ', mergeList) + p = internal.Path(numpy.concatenate([ p.points for p in mergeList]) ) + debug('merged == ', p.points) + updatedSegs.append(p) + + if not hasattr(newSegs[-1], 'merged'): updatedSegs.append( newSegs[-1]) + debug("merged path", updatedSegs) + newSegs = manipulation.resetPrevNextSegment( updatedSegs ) + + + # Extend segments ----------------------------------- + if options.segExtensionEnable: + newSegs = extenders.SegmentExtender.extendSegments( newSegs, options.segExtensionDtoSeg, options.segExtensionQual ) + debug("extended segs", newSegs) + newSegs = manipulation.resetPrevNextSegment( newSegs ) + debug("extended segs", newSegs) + + # ---------------------------------------- + + + # --------------------------------------- + # merge consecutive segments with close angle + updatedSegs=[] + + if options.segAngleMergeEnable: + newSegs = miscellaneous.mergeConsecutiveCloseAngles( newSegs, mangle=options.segAngleMergeTol1 ) + newSegs=manipulation.resetPrevNextSegment(newSegs) + debug(' __ 2nd angle merge') + newSegs = miscellaneous.mergeConsecutiveCloseAngles( newSegs, mangle=options.segAngleMergeTol2 ) # 2nd pass + newSegs=manipulation.resetPrevNextSegment(newSegs) + debug('after merge ', len(newSegs), newSegs) + # Check if first and last also have close angles. + if isClosing and len(newSegs)>2 : + first, last = newSegs[0], newSegs[-1] + if first.isSegment() and last.isSegment(): + if geometric.closeAngleAbs( first.angle, last.angle) < 0.1: + # force merge + points= numpy.concatenate( [ last.points, first.points] ) + newseg = manipulation.fitSingleSegment(points) + newseg.next = first.__next__ if hasattr(first, "__next__") else None + last.prev.next = None + newSegs[0]=newseg + newSegs.pop() + + # ----------------------------------------------------- + # remove negligible Path/Segments between 2 large Segments + if options.segRemoveSmallEdge: + PreProcess.removeSmallEdge(newSegs, wTot, hTot) + newSegs=manipulation.resetPrevNextSegment(newSegs) + + debug('after remove small ', len(newSegs), newSegs) + # ----------------------------------------------------- + + # ----------------------------------------------------- + # Extend segments to their intersections + for p in newSegs: + if p.isSegment() and hasattr(p, "__next__"): + p.setIntersectWithNext() + # ----------------------------------------------------- + + return groups.PathGroup(newSegs, svgCommandsList, refNode, isClosing) + + + + +# ************************************************************* +# The inkscape extension +# ************************************************************* +class ShapeReco(inkex.EffectExtension): + + def add_arguments(self, pars): + pars.add_argument("--title") + pars.add_argument("--keepOrigin", dest="keepOrigin", default=False, type=inkex.Boolean, help="Do not replace path") + pars.add_argument("--MainTabs") + pars.add_argument("--segExtensionDtoSeg", dest="segExtensionDtoSeg", default=0.03, type=float, help="max distance from point to segment") + pars.add_argument("--segExtensionQual", dest="segExtensionQual", default=0.5, type=float, help="segment extension fit quality") + pars.add_argument("--segExtensionEnable", dest="segExtensionEnable", default=True, type=inkex.Boolean, help="Enable segment extension") + pars.add_argument("--segAngleMergeEnable", dest="segAngleMergeEnable", default=True, type=inkex.Boolean, help="Enable merging of almost aligned consecutive segments") + pars.add_argument("--segAngleMergeTol1", dest="segAngleMergeTol1", default=0.2, type=float, help="merging with tollarance 1") + pars.add_argument("--segAngleMergeTol2", dest="segAngleMergeTol2", default=0.35, type=float, help="merging with tollarance 2") + pars.add_argument("--segAngleMergePara", dest="segAngleMergePara", default=0.001, type=float, help="merge lines as parralels if they fit") + pars.add_argument("--segRemoveSmallEdge", dest="segRemoveSmallEdge", default=True, type=inkex.Boolean, help="Enable removing very small segments") + pars.add_argument("--doUniformization", dest="doUniformization", default=True, type=inkex.Boolean, help="Preform angles and distances uniformization") + for opt in ["doParrallelize", "doKnownAngle", "doEqualizeDist", "doEqualizeRadius", "doCenterCircOnSeg"]: + pars.add_argument( "--"+opt, dest=opt, default=True, type=inkex.Boolean, help=opt) + pars.add_argument("--shapeDistLocal", dest="shapeDistLocal", default=0.3, type=float, help="Pthe percentage of difference at which we make lengths equal, locally") + pars.add_argument("--shapeDistGlobal", dest="shapeDistGlobal", default=0.025, type=float, help="Pthe percentage of difference at which we make lengths equal, globally") + + + + def effect(self): + + rej='{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}type' + paths = [] + for id, node in list(self.svg.selected.items()): + if node.tag == '{http://www.w3.org/2000/svg}path' and rej not in list(node.keys()): + paths.append(node) + + shapes = self.extractShapes(paths) + # add new shapes in SVG document + self.addShapesToDoc( shapes ) + + def extractShapesFromID( self, *nids, **options ): + """for debugging purpose """ + eList = [] + for nid in nids: + el = self.getElementById(nid) + if el is None: + print(("Cant find ", nid)) + return + eList.append(el) + class tmp: + pass + + self.options = self.OptionParser.parse_args()[0] + self.options._update_careful(options) + nodes=self.extractShapes(eList) + self.shape = nodes[0] + + + def buildShape(self, node): + def rotationAngle(tr): + if tr and tr.startswith('rotate'): + # retrieve the angle : + return float(tr[7:-1].split(',')) + else: + return 0. + + if node.tag.endswith('path'): + g = FitShapes.segsFromTangents(node.path.to_arrays(), node, self.options) + elif node.tag.endswith('rect'): + tr = node.get('transform', None) + if tr and tr.startswith('matrix'): + return None # can't deal with scaling + recSize = numpy.array([node.get('width'), node.get('height')]) + recCenter = numpy.array([node.get('x'), node.get('y')]) + recSize/2 + angle=rotationAngle(tr) + g = groups.Rectangle( recSize, recCenter, 0, [], node) + elif node.tag.endswith('circle'): + g = groups.Circle(node.get('cx'), node.get('cy'), node.get('r'), [], node ) + elif node.tag.endswith('ellipse'): + if tr and tr.startswith('matrix'): + return None # can't deal with scaling + angle=rotationAngle(tr) + rx = node.get('rx') + ry = node.get('ry') + g = groups.Circle(node.get('cx'), node.get('cy'), ry, rmax=rx, angle=angle, refNode=node) + + return g + + def extractShapes( self, nodes ): + """The main function. + nodes : a list of nodes""" + analyzedNodes = [] + + # convert nodes to list of segments (groups.PathGroup) or Circle + for n in nodes : + g = self.buildShape(n) + if g : + analyzedNodes.append( g ) + + # uniformize shapes + if self.options.doUniformization: + analyzedNodes = PostProcess.uniformizeShapes(analyzedNodes, self.options) + + return analyzedNodes + + def addShapesToDoc(self, pathGroupList): + for group in pathGroupList: + + debug("final ", group.listOfPaths, group.refNode ) + debug("final-style ", group.refNode.get('style')) + # change to Rectangle if possible : + finalshape = manipulation.toRemarkableShape( group ) + ele = group.addToNode( group.refNode) + group.setNodeStyle(ele, group.refNode) + if not self.options.keepOrigin: + group.refNode.xpath('..')[0].remove(group.refNode) + + + +if __name__ == '__main__': + ShapeReco().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/shapereco/shaperrec/extenders.py b/extensions/fablabchemnitz/shapereco/shaperrec/extenders.py new file mode 100644 index 00000000..29c7712e --- /dev/null +++ b/extensions/fablabchemnitz/shapereco/shaperrec/extenders.py @@ -0,0 +1,130 @@ +import numpy +import sys +from shaperrec import manipulation + +# ************************************************************* +# debugging +def void(*l): + pass +def debug_on(*l): + sys.stderr.write(' '.join(str(i) for i in l) +'\n') +debug = void +#debug = debug_on + +##************************************** +## +class SegmentExtender: + """Extend Segments part of a list of Path by aggregating points from neighbouring Path objects. + + There are 2 concrete subclasses for extending forward and backward (due to technical reasons). + """ + + def __init__(self, relD, fitQ): + self.relD = relD + self.fitQ = fitQ + + def nextPaths(self, seg): + pL = [] + p = self.getNext(seg) # prev or next + while p : + if p.isSegment(): break + if p.mergedObj is None: break + pL.append(p) + p = self.getNext(p) + if pL==[]: + return [] + return pL + + def extend(self, seg): + nextPathL = self.nextPaths(seg) + debug('extend ', self.extDir, seg, nextPathL, seg.length, len(nextPathL)) + if nextPathL==[]: return seg + pointsToTest = numpy.concatenate( [p.points for p in nextPathL] ) + mergeD = seg.length*self.relD + #print seg.point1 , seg.pointN, pointsToTest + pointsToFit, addedPoints = self.pointsToFit(seg, pointsToTest, mergeD) + if len(pointsToFit)==0: + return seg + newseg = manipulation.fitSingleSegment(pointsToFit) + if newseg.quality()>self.fitQ: # fit failed + return seg + debug( ' EXTENDING ! ', len(seg.points), len(addedPoints) ) + self.removePath(seg, newseg, nextPathL, addedPoints ) + newseg.points = pointsToFit + seg.mergedObj= newseg + newseg.sourcepoints = seg.sourcepoints + + return newseg + + @staticmethod + def extendSegments(segmentList, relD=0.03, qual=0.5): + """Perform Segment extension from list of Path segmentList + returns the updated list of Path objects""" + fwdExt = FwdExtender(relD, qual) + bwdExt = BwdExtender(relD, qual) + # tag all objects with an attribute pointing to the extended object + for seg in segmentList: + seg.mergedObj = seg # by default the extended object is self + # extend each segments, starting by the longest + for seg in sorted(segmentList, key = lambda s : s.length, reverse=True): + if seg.isSegment(): + newseg=fwdExt.extend(seg) + seg.mergedObj = bwdExt.extend(newseg) + # the extension procedure has marked as None the mergedObj + # which have been swallowed by an extension. + # filter them out : + updatedSegs=[seg.mergedObj for seg in segmentList if seg.mergedObj] + return updatedSegs + + +class FwdExtender(SegmentExtender): + extDir='Fwd' + def getNext(self, seg): + return seg.__next__ if hasattr(seg, "__next__") else None + def pointsToFit(self, seg, pointsToTest, mergeD): + distancesToLine =abs(seg.a*pointsToTest[:, 0]+seg.b*pointsToTest[:, 1]+seg.c) + goodInd=len(pointsToTest) + for i, d in reversed(list(enumerate(distancesToLine))): + if dnexp: + np = int(deltaD[ind]/medDelta) + pL = [a[ind]] + #print i,'=',ind,'adding ', np,' _ ', deltaD[ind], a[ind], a[ind+1] + for j in range(np-1): + f = float(j+1)/np + #print '------> ', (1-f)*a[ind]+f*a[ind+1] + pL.append( (1-f)*a[ind]+f*a[ind+1] ) + newpoints[ind] = pL + else: + newpoints[ind]=[a[ind]] + if(D(a[0], a[-1])/sizeC > 0.005 ) : + newpoints[-1]=[a[-1]] + + points = numpy.concatenate([p for p in newpoints if p!=None] ) +# ## print ' medDelta ', medDelta, deltaD[sortedDind[-1]] +# ## print len(a) ,' ------> ', len(points) + + rel_norms = numpy.sqrt(numpy.sum( deltas**2, 1 )) / sizeC + keep = numpy.concatenate([numpy.where( rel_norms >0.005 )[0], numpy.array([len(a)-1])]) + + #return a[keep] , [ parsedList[i] for i in keep] + #print len(a),' ',len(points) + return points, [] + +rotMat = numpy.array( [[1, -1], [1, 1]] )/numpy.sqrt(2) +unrotMat = numpy.array( [[1, 1], [-1, 1]] )/numpy.sqrt(2) + +def setupKnownAngles(): + pi = numpy.pi + #l = [ i*pi/8 for i in range(0, 9)] +[ i*pi/6 for i in [1,2,4,5,] ] + l = [ i*pi/8 for i in range(0, 9)] +[ i*pi/6 for i in [1, 2, 4, 5,] ] + [i*pi/12 for i in (1, 5, 7, 11)] + knownAngle = numpy.array( l ) + return numpy.concatenate( [-knownAngle[:0:-1], knownAngle ]) +knownAngle = setupKnownAngles() + +_twopi = 2*numpy.pi +_pi = numpy.pi + +def deltaAngle(a1, a2): + d = a1 - a2 + return d if d > -_pi else d+_twopi + +def closeAngleAbs(a1, a2): + d = abs(a1 - a2 ) + return min( abs(d-_pi), abs( _twopi - d), d) + +def deltaAngleAbs(a1, a2): + return abs(in_mPi_pPi(a1 - a2 )) + +def in_mPi_pPi(a): + if(a>_pi): return a-_twopi + if(a<-_pi): return a+_twopi + return a +vec_in_mPi_pPi = numpy.vectorize(in_mPi_pPi) + +def D2(p1, p2): + return ((p1-p2)**2).sum() + +def D(p1, p2): + return numpy.sqrt(D2(p1, p2) ) + +def norm(p): + return numpy.sqrt( (p**2).sum() ) + +def computeBox(a): + """returns the bounding box enclosing the array of points a + in the form (x,y, width, height) """ + + xmin, ymin = a[:, 0].min(), a[:, 1].min() + xmax, ymax = a[:, 0].max(), a[:, 1].max() + return xmin, ymin, xmax-xmin, ymax-ymin + +def dirAndLength(p1, p2): + #l = max(D(p1, p2) ,1e-4) + l = D(p1, p2) + uv = (p1-p2)/l + return l, uv + +def length(p1, p2): + return numpy.sqrt( D2(p1, p2) ) + +def barycenter(points): + """ + """ + return points.sum(axis=0)/len(points) \ No newline at end of file diff --git a/extensions/fablabchemnitz/shapereco/shaperrec/groups.py b/extensions/fablabchemnitz/shapereco/shaperrec/groups.py new file mode 100644 index 00000000..4016f234 --- /dev/null +++ b/extensions/fablabchemnitz/shapereco/shaperrec/groups.py @@ -0,0 +1,278 @@ +import numpy +import sys +import inkex +from lxml import etree +from shaperrec import geometric +from shaperrec import miscellaneous +from shaperrec import internal +from shaperrec import manipulation + +# ************************************************************* +# debugging +def void(*l): + pass +def debug_on(*l): + sys.stderr.write(' '.join(str(i) for i in l) +'\n') +debug = void +#debug = debug_on + +# ************************************************************* +# ************************************************************* +# Groups of Path +# +class PathGroup(object): + """A group of Path representing one SVG node. + - a list of Path + - a list of SVG commands describe the full node (=SVG path element) + - a reference to the inkscape node object + + """ + listOfPaths = [] + refSVGPathList = [] + isClosing = False + refNode = None + + def __init__(self, listOfPaths, refSVGPathList, refNode=None, isClosing=False): + self.refNode = refNode + self.listOfPaths = listOfPaths + self.refSVGPathList = refSVGPathList + self.isClosing=isClosing + + def addToNode(self, node): + newList = miscellaneous.reformatList( self.listOfPaths) + ele = miscellaneous.addPath( newList, node) + debug("PathGroup ", newList) + return ele + + def setNodeStyle(self, ele, node): + style = node.get('style') + cssClass = node.get('class') + debug("style ", style) + debug("class ", cssClass) + if style == None and cssClass == None : + style = 'fill:none; stroke:red; stroke-width:1' + + if not cssClass == None: + ele.set('class', cssClass) + if not style == None: + ele.set('style', style) + + @staticmethod + def toSegments(points, refSVGPathList, refNode, isClosing=False): + """ + """ + segs = [ internal.Segment.from2Points(p, points[i+1], points[i:i+2] ) for (i, p) in enumerate(points[:-1]) ] + manipulation.resetPrevNextSegment(segs) + return PathGroup( segs, refSVGPathList, refNode, isClosing) + +class TangentEnvelop(PathGroup): + """Specialization where the Path objects are all Segments and represent tangents to a curve """ + def addToNode(self, node): + newList = [ ] + for s in self.listOfPaths: + newList += s.asSVGCommand(firstP=True) + debug("TangentEnvelop ", newList) + ele = miscellaneous.addPath( newList, node) + return ele + + def setNodeStyle(self, ele, node): + style = node.get('style')+';marker-end:url(#Arrow1Lend)' + ele.set('style', style) + + +class Circle(PathGroup): + """Specialization where the list of Path objects + is to be replaced by a Circle specified by a center and a radius. + + If an other radius 'rmax' is given than the object represents an ellipse. + """ + isClosing= True + def __init__(self, center, rad, refNode=None, rmax=None, angle=0.): + self.listOfPaths = [] + self.refNode = refNode + self.center = numpy.array(center) + self.radius = rad + if rmax: + self.type ='ellipse' + else: + self.type = 'circle' + self.rmax = rmax + self.angle = angle + + def addToNode(self, refnode): + """Add a node in the xml structure corresponding to this rect + refnode : xml node used as a reference, new point will be inserted a same level""" + ele = etree.Element('{http://www.w3.org/2000/svg}'+self.type) + + ele.set('cx', str(self.center[0])) + ele.set('cy', str(self.center[1])) + if self.rmax: + ele.set('ry', str(self.radius)) + ele.set('rx', str(self.rmax)) + ele.set('transform', 'rotate(%3.2f,%f,%f)'%(numpy.degrees(self.angle), self.center[0], self.center[1])) + else: + ele.set('r', str(self.radius)) + refnode.xpath('..')[0].append(ele) + return ele + + +class Rectangle(PathGroup): + """Specialization where the list of Path objects + is to be replaced by a Rectangle specified by a center and size (w,h) and a rotation angle. + + """ + def __init__(self, center, size, angle, listOfPaths, refNode=None): + self.listOfPaths = listOfPaths + self.refNode = refNode + self.center = center + self.size = size + self.bbox = size + self.angle = angle + pos = self.center - numpy.array( size )/2 + if angle != 0. : + cosa = numpy.cos(angle) + sina = numpy.sin(angle) + self.rotMat = numpy.matrix( [ [ cosa, sina], [-sina, cosa] ] ) + self.rotMatstr = 'matrix(%1.7f,%1.7f,%1.7f,%1.7f,0,0)'%(cosa, sina, -sina, cosa) + + + #debug(' !!!!! Rotated rectangle !!', self.size, self.bbox, ' angles ', a, self.angle ,' center',self.center) + else : + self.rotMatstr = None + self.pos = pos + debug(' !!!!! Rectangle !!', self.size, self.bbox, ' angles ', self.angle, ' center', self.center) + + def addToNode(self, refnode): + """Add a node in the xml structure corresponding to this rect + refnode : xml node used as a reference, new point will be inserted a same level""" + ele = etree.Element('{http://www.w3.org/2000/svg}rect') + self.fill(ele) + refnode.xpath('..')[0].append(ele) + return ele + + def fill(self, ele): + w, h = self.size + ele.set('width', str(w)) + ele.set('height', str(h)) + w, h = self.bbox + ele.set('x', str(self.pos[0])) + ele.set('y', str(self.pos[1])) + if self.rotMatstr: + ele.set('transform', 'rotate(%3.2f,%f,%f)'%(numpy.degrees(self.angle), self.center[0], self.center[1])) + #ele.set('transform', self.rotMatstr) + + @staticmethod + def isRectangle( pathGroup): + """Check if the segments in pathGroups can form a rectangle. + Returns a Rectangle or None""" + #print 'xxxxxxxx isRectangle',pathGroups + if isinstance(pathGroup, Circle ): return None + segmentList = [p for p in pathGroup.listOfPaths if p.isSegment() ]#or p.effectiveNPoints >0] + if len(segmentList) != 4: + debug( 'rectangle Failed at length ', len(segmentList)) + return None + a, b, c, d = segmentList + + if geometric.length(a.point1, d.pointN)> 0.2*(a.length+d.length)*0.5: + debug('rectangle test failed closing ', geometric.length(a.point1, d.pointN), a.length, d.length) + return None + + Aac, Abd = geometric.closeAngleAbs(a.angle, c.angle), geometric.closeAngleAbs(b.angle, d.angle) + if min(Aac, Abd) > 0.07 or max(Aac, Abd) >0.27 : + debug( 'rectangle Failed at angles', Aac, Abd) + return None + notsimilarL = lambda d1, d2: abs(d1-d2)>0.20*min(d1, d2) + + pi, twopi = numpy.pi, 2*numpy.pi + angles = numpy.array( [p.angle for p in segmentList] ) + minAngleInd = numpy.argmin( numpy.minimum( abs(angles), abs( abs(angles)-pi), abs( abs(angles)-twopi) ) ) + rotAngle = angles[minAngleInd] + width = (segmentList[minAngleInd].length + segmentList[(minAngleInd+2)%4].length)*0.5 + height = (segmentList[(minAngleInd+1)%4].length + segmentList[(minAngleInd+3)%4].length)*0.5 + # set rectangle center as the bbox center + x, y, w, h = geometric.computeBox( numpy.concatenate( [ p.points for p in segmentList]) ) + r = Rectangle( numpy.array( [x+w/2, y+h/2]), (width, height), rotAngle, pathGroup.listOfPaths, pathGroup.refNode) + + debug( ' found a rectangle !! ', a.length, b.length, c.length, d.length ) + return r + + +class CurveGroup(PathGroup): + """Specialization where the list of Path objects + is to be replaced by a Rectangle specified by a center and size (w,h) and a rotation angle. + + """ + def __init__(self, center, size, angle, listOfPaths, refNode=None): + self.listOfPaths = listOfPaths + self.refNode = refNode + self.center = center + self.size = size + self.bbox = size + self.angle = angle + pos = self.center - numpy.array( size )/2 + if angle != 0. : + cosa = numpy.cos(angle) + sina = numpy.sin(angle) + self.rotMat = numpy.matrix( [ [ cosa, sina], [-sina, cosa] ] ) + self.rotMatstr = 'matrix(%1.7f,%1.7f,%1.7f,%1.7f,0,0)'%(cosa, sina, -sina, cosa) + + + #debug(' !!!!! Rotated rectangle !!', self.size, self.bbox, ' angles ', a, self.angle ,' center',self.center) + else : + self.rotMatstr = None + self.pos = pos + debug(' !!!!! Rectangle !!', self.size, self.bbox, ' angles ', self.angle, ' center', self.center) + + def addToNode(self, refnode): + """Add a node in the xml structure corresponding to this rect + refnode : xml node used as a reference, new point will be inserted a same level""" + ele = etree.Element('{http://www.w3.org/2000/svg}rect') + self.fill(ele) + refnode.xpath('..')[0].append(ele) + return ele + +# def fill(self, ele): +# w, h = self.size +# ele.set('width', str(w)) +# ele.set('height', str(h)) +# w, h = self.bbox +# ele.set('x', str(self.pos[0])) +# ele.set('y', str(self.pos[1])) +# if self.rotMatstr: +# ele.set('transform', 'rotate(%3.2f,%f,%f)'%(numpy.degrees(self.angle), self.center[0], self.center[1])) +# #ele.set('transform', self.rotMatstr) + + @staticmethod + def isCurvedSegment( pathGroup): + """Check if the segments in pathGroups can form a rectangle. + Returns a Rectangle or None""" + #print 'xxxxxxxx isRectangle',pathGroups + if isinstance(pathGroup, Circle ): return None + segmentList = [p for p in pathGroup.listOfPaths if p.isSegment() ]#or p.effectiveNPoints >0] + if len(segmentList) != 4: + debug( 'rectangle Failed at length ', len(segmentList)) + return None + a, b, c, d = segmentList + + if geometric.length(a.point1, d.pointN)> 0.2*(a.length+d.length)*0.5: + debug('rectangle test failed closing ', geometric.length(a.point1, d.pointN), a.length, d.length) + return None + + Aac, Abd = geometric.closeAngleAbs(a.angle, c.angle), geometric.closeAngleAbs(b.angle, d.angle) + if min(Aac, Abd) > 0.07 or max(Aac, Abd) >0.27 : + debug( 'rectangle Failed at angles', Aac, Abd) + return None + notsimilarL = lambda d1, d2: abs(d1-d2)>0.20*min(d1, d2) + + pi, twopi = numpy.pi, 2*numpy.pi + angles = numpy.array( [p.angle for p in segmentList] ) + minAngleInd = numpy.argmin( numpy.minimum( abs(angles), abs( abs(angles)-pi), abs( abs(angles)-twopi) ) ) + rotAngle = angles[minAngleInd] + width = (segmentList[minAngleInd].length + segmentList[(minAngleInd+2)%4].length)*0.5 + height = (segmentList[(minAngleInd+1)%4].length + segmentList[(minAngleInd+3)%4].length)*0.5 + # set rectangle center as the bbox center + x, y, w, h = geometric.computeBox( numpy.concatenate( [ p.points for p in segmentList]) ) + r = Rectangle( numpy.array( [x+w/2, y+h/2]), (width, height), rotAngle, pathGroup.listOfPaths, pathGroup.refNode) + + debug( ' found a rectangle !! ', a.length, b.length, c.length, d.length ) + return r diff --git a/extensions/fablabchemnitz/shapereco/shaperrec/internal.py b/extensions/fablabchemnitz/shapereco/shaperrec/internal.py new file mode 100644 index 00000000..edd99407 --- /dev/null +++ b/extensions/fablabchemnitz/shapereco/shaperrec/internal.py @@ -0,0 +1,351 @@ +import numpy +import sys +from shaperrec import geometric +from shaperrec import miscellaneous + +# ************************************************************* +# debugging +def void(*l): + pass +def debug_on(*l): + sys.stderr.write(' '.join(str(i) for i in l) +'\n') +debug = void +#debug = debug_on + +# ************************************************************* +# Internal Objects +class Path(object): + """Private representation of a sequence of points. + A SVG node of type 'path' is splitted in several of these Path objects. + """ + next = None # next Path in the sequence of path corresponding to a SVG node + prev = None # previous Path in the sequence of path corresponding to a SVG node + sourcepoints = None # the full list of points from which this path is a subset + + normalv = None # normal vector to this Path + + def __init__(self, points): + """points an array of points """ + self.points = points + self.init() + + def init(self): + self.effectiveNPoints = len(self.points) + if self.effectiveNPoints>1: + self.length, self.univ = geometric.dirAndLength(self.points[0], self.points[-1]) + else: + self.length, self.univ = 0, numpy.array([0, 0]) + if self.effectiveNPoints>0: + self.pointN=self.points[-1] + self.point1=self.points[0] + + def isSegment(self): + return False + + def quality(self): + return 1000 + + def dump(self): + n = len(self.points) + if n>0: + return 'path at '+str(self.points[0])+ ' to '+ str(self.points[-1])+' npoints=%d / %d (eff)'%(n, self.effectiveNPoints) + else: + return 'path Void !' + + def setNewLength(self, l): + self.newLength = l + + def removeLastPoints(self, n): + self.points = self.points[:-n] + self.init() + def removeFirstPoints(self, n): + self.points = self.points[n:] + self.init() + + def costheta(self, seg): + return self.unitv.dot(seg.unitv) + + def translate(self, tr): + """Translate this path by tr""" + self.points = self.points + tr + + def asSVGCommand(self, firstP=False): + svgCommands = [] + com = 'M' if firstP else 'L' + for p in self.points: + svgCommands.append( [com, [p[0], p[1]] ] ) + com='L' + return svgCommands + + + def setIntersectWithNext(self, next=None): + pass + + def mergedWithNext(self, newPath=None): + """ Returns the combination of self and self.next. + sourcepoints has to be set + """ + if newPath is None: newPath = Path( numpy.concatenate([self.points, self.next.points]) ) + + newPath.sourcepoints = self.sourcepoints + newPath.prev = self.prev + if self.prev : newPath.prev.next = newPath + newPath.next = self.next.__next__ + if newPath.__next__: + newPath.next.prev = newPath + return newPath + +# ************************************************************* +# +class Segment(Path): + """ A segment. Defined by its line equation ax+by+c=0 and the points from orignal paths + it is ensured that a**2+b**2 = 1 + """ + QUALITYCUT = 0.9 + + newAngle = None # temporary angle set during the "parralelization" step + newLength = None # temporary lenght set during the "parralelization" step + + # Segment Builders + @staticmethod + def from2Points( p1, p2, refPoints = None): + dirV = p2-p1 + center = 0.5*(p2+p1) + return Segment.fromCenterAndDir(center, dirV, refPoints) + + @staticmethod + def fromCenterAndDir( center, dirV, refPoints=None): + b = dirV[0] + a = -dirV[1] + c = - (a*center[0]+b*center[1]) + + if refPoints is None: + refPoints = numpy.array([ center-0.5*dirV, center+0.5*dirV] ) + s = Segment( a, b, c, refPoints) + return s + + + def __init__(self, a,b,c, points, doinit=True): + """a,b,c: the line parameters. + points : the array of 2D points represented by this Segment + doinit : if true will compute additionnal parameters to this Segment (first/last points, unit vector,...) + """ + self.a = a + self.b = b + self.c = c + + self.points = points + d = numpy.sqrt(a**2+b**2) + if d != 1. : + self.a /= d + self.b /= d + self.c /= d + + if doinit : + self.init() + + + def init(self): + a, b, c = self.a, self.b, self.c + x, y = self.points[0] + self.point1 = numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) + x, y = self.points[-1] + self.pointN = numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) + uv = self.computeDirLength() + self.distancesToLine = self.computeDistancesToLine(self.points) + self.normalv = numpy.array( [ a, b ]) + + self.angle = numpy.arccos( uv[0] )*numpy.sign(uv[1] ) + + + def computeDirLength(self): + """re-compute and set unit vector and length """ + self.length, uv = geometric.dirAndLength(self.pointN, self.point1) + self.unitv = uv + return uv + + def isSegment(self): + return True + + def recomputeEndPoints(self): + a, b, c = self.a, self.b, self.c + x, y = self.points[0] + self.point1 = numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) + x, y = self.points[-1] + + self.pointN = numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) + + self.length = numpy.sqrt( geometric.D2(self.pointN, self.point1) ) + + def projectPoint(self, p): + """ return the point projection of p onto this segment""" + a, b, c = self.a, self.b, self.c + x, y = p + return numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) + + + def intersect(self, seg): + """Returns the intersection of this line with the line seg""" + nu, nv = self.normalv, seg.normalv + u = numpy.array([[-self.c], [-seg.c]]) + doRotation = min(nu.min(), nv.min()) <1e-4 + if doRotation: + # rotate to avoid numerical issues + nu = numpy.array(geometric.rotMat.dot(nu))[0] + nv = numpy.array(geometric.rotMat.dot(nv))[0] + m = numpy.matrix( (nu, nv) ) + + i = (m**-1).dot(u) + i=numpy.array( i).swapaxes(0, 1)[0] + debug(' intersection ', nu, nv, self.angle, seg.angle, ' --> ', i) + if doRotation: + i = geometric.unrotMat.dot(i).A1 + debug(' ', i) + + + return i + + def setIntersectWithNext(self, next=None): + """Modify self such as self.pointN is the intersection with next segment """ + if next is None: + next = self.__next__ + if next and next.isSegment(): + if abs(self.normalv.dot(next.unitv)) < 1e-3: + return + debug(' Intersect', self, next, ' from ', self.point1, self.pointN, ' to ', next.point1, next.pointN,) + inter = self.intersect(next) + debug(' --> ', inter, ' d=', geometric.D(self.pointN, inter) ) + next.point1 = inter + self.pointN = inter + self.computeDirLength() + next.computeDirLength() + + def computeDistancesToLine(self, points): + """points: array of points. + returns the array of distances to this segment""" + return abs(self.a*points[:, 0]+self.b*points[:, 1]+self.c) + + + def distanceTo(self, point): + return abs(self.a*point[0]+self.b*point[1]+self.c) + + def inverse(self): + """swap all x and y values. """ + def inv(v): + v[0], v[1] = v[1], v[0] + for v in [self.point1, self.pointN, self.unitv, self.normalv]: + inv(v) + + self.points = numpy.roll(self.points, 1, axis=1) + self.a, self.b = self.b, self.a + self.angle = numpy.arccos( self.unitv[0] )*numpy.sign(self.unitv[1] ) + return + + def dumpShort(self): + return 'seg '+' '+str(self.point1 )+'to '+str(self.pointN)+ ' npoints=%d | angle,offset=(%.2f,%.2f )'%(len(self.points), self.angle, self.c)+' ', self.normalv + + def dump(self): + v = self.variance() + n = len(self.points) + return 'seg '+str(self.point1 )+' , '+str(self.pointN)+ ' v/l=%.2f / %.2f = %.2f r*numpy.sqrt(n)=%.2f npoints=%d | angle,offset=(%.2f,%.2f )'%(v, self.length, v/self.length, v/self.length*numpy.sqrt(n), n, self.angle, self.c) + + def variance(self): + d = self.distancesToLine + return numpy.sqrt( (d**2).sum()/len(d) ) + + def quality(self): + n = len(self.points) + return min(self.variance()/self.length*numpy.sqrt(n), 1000) + + def formatedSegment(self, firstP=False): + return self.asSVGCommand(firstP) + + def asSVGCommand(self, firstP=False): + + if firstP: + segment = [ ['M', [self.point1[0], self.point1[1] ] ], + ['L', [self.pointN[0], self.pointN[1] ] ] + ] + else: + segment = [ ['L', [self.pointN[0], self.pointN[1] ] ] ] + #debug("Segment, format : ", segment) + return segment + + def replaceInList(self, startPos, fullList): + code0 = fullList[startPos][0] + segment = [ [code0, [self.point1[0], self.point1[1] ] ], + ['L', [self.pointN[0], self.pointN[1] ] ] + ] + l = fullList[:startPos]+segment+fullList[startPos+len(self.points):] + return l + + + + + def mergedWithNext(self, doRefit=True): + """ Returns the combination of self and self.next. + sourcepoints has to be set + """ + spoints = numpy.concatenate([self.points, self.next.points]) + + if doRefit: + newSeg = fitSingleSegment(spoints) + else: + newSeg = Segment.fromCenterAndDir(geometric.barycenter(spoints), self.unitv, spoints) + + newSeg = Path.mergedWithNext(self, newSeg) + return newSeg + + + + def center(self): + return 0.5*(self.point1+self.pointN) + + def box(self): + return geometric.computeBox(self.points) + + + def translate(self, tr): + """Translate this segment by tr """ + c = self.c -self.a*tr[0] -self.b*tr[1] + self.c =c + self.pointN = self.pointN+tr + self.point1 = self.point1+tr + self.points +=tr + + def adjustToNewAngle(self): + """reset all parameters so that self.angle is change to self.newAngle """ + + self.a, self.b, self.c = miscellaneous.parametersFromPointAngle( 0.5*(self.point1+self.pointN), self.newAngle) + + #print 'adjustToNewAngle ', self, self.angle, self.newAngle + self.angle = self.newAngle + self.normalv = numpy.array( [ self.a, self.b ]) + self.unitv = numpy.array( [ self.b, -self.a ]) + if abs(self.angle) > numpy.pi/2 : + if self.b > 0: self.unitv *= -1 + elif self.b<0 : self.unitv *= -1 + + self.point1 = self.projectPoint(self.point1) # reset point1 + if not hasattr(self, "__next__") or not self.next.isSegment(): + # move the last point (no intersect with next) + + pN = self.projectPoint(self.pointN) + dirN = pN - self.point1 + lN = geometric.length(pN, self.point1) + self.pointN = dirN/lN*self.length + self.point1 + #print ' ... adjusting last seg angle ',p.dump() , ' normalv=', p.normalv, 'unitv ', p.unitv + else: + self.setIntersectWithNext() + + def adjustToNewDistance(self): + self.pointN = self.newLength* self.unitv + self.point1 + self.length = self.newLength + + def tempLength(self): + if self.newLength : return self.newLength + else : return self.length + + def tempAngle(self): + if self.newAngle: return self.newAngle + return self.angle \ No newline at end of file diff --git a/extensions/fablabchemnitz/shapereco/shaperrec/manipulation.py b/extensions/fablabchemnitz/shapereco/shaperrec/manipulation.py new file mode 100644 index 00000000..e2c6b9d6 --- /dev/null +++ b/extensions/fablabchemnitz/shapereco/shaperrec/manipulation.py @@ -0,0 +1,187 @@ +import numpy +import sys +from shaperrec import groups +from shaperrec import geometric +from shaperrec import internal + +# ************************************************************* +# debugging +def void(*l): + pass +def debug_on(*l): + sys.stderr.write(' '.join(str(i) for i in l) +'\n') +debug = void +#debug = debug_on + +# ************************************************************* +# Object manipulation functions + +def toRemarkableShape( group ): + """Test if PathGroup instance 'group' looks like a remarkable shape (ex: Rectangle). + if so returns a new shape instance else returns group unchanged""" + r = groups.Rectangle.isRectangle( group ) + if r : return r + return group + + +def resetPrevNextSegment(segs): + for i, seg in enumerate(segs[:-1]): + s = segs[i+1] + seg.next = s + s.prev = seg + return segs + + +def fitSingleSegment(a): + xmin, ymin, w, h = geometric.computeBox(a) + inverse = w ', tangents ) + + if averaged: + # average over neighbours + avTan = numpy.array(tangents) + avTan[:-1] += tangents[1:] + avTan[1:] += tangents[:-1] + if isClosing: + tangents[0]+=tangents[-1] + tangents[1]+=tangents[0] + avTan *= 1./3 + if not isClosing: + avTan[0] *=1.5 + avTan[-1] *=1.5 + + return avTan + + +def clusterAngles(array, dAng=0.15): + """Cluster together consecutive angles with similar values (within 'dAng'). + array : flat array of angles + returns [ ..., (indi_0, indi_1),...] where each tuple are indices of cluster i + """ + N = len(array) + + closebyAng = numpy.zeros( (N, 4), dtype=int) + + for i, a in enumerate(array): + cb = closebyAng[i] + cb[0] =i + cb[2]=i + cb[3]=i + c=i-1 + # find number of angles within dAng in nearby positions + while c>-1: # indices below i + d=geometric.closeAngleAbs(a, array[c]) + if d>dAng: + break + cb[1]+=1 + cb[2]=c + c-=1 + c=i+1 + while cdAng: + break + cb[1]+=1 + cb[3]=c + c+=1 + closebyAng= closebyAng[numpy.argsort(closebyAng[:, 1]) ] + + clusteredPos = numpy.zeros(N, dtype=int) + clusters = [] + for cb in reversed(closebyAng): + if clusteredPos[cb[0]]==1: + continue + # try to build a cluster + minI = cb[2] + while clusteredPos[minI]==1: + minI+=1 + maxI = cb[3] + while clusteredPos[maxI]==1: + maxI-=1 + for i in range(minI, maxI+1): + clusteredPos[i] = 1 + clusters.append( (minI, maxI) ) + + return clusters + + + + +def adjustAllAngles(paths): + for p in paths: + if p.isSegment() and p.newAngle is not None: + p.adjustToNewAngle() + # next translate to fit end points + tr = numpy.zeros(2) + for p in paths[1:]: + if p.isSegment() and p.prev.isSegment(): + tr = p.prev.pointN - p.point1 + debug(' translating ', p, ' prev is', p.prev, ' ', tr, ) + p.translate(tr) + +def adjustAllDistances(paths): + for p in paths: + if p.isSegment() and p.newLength is not None: + p.adjustToNewDistance() + # next translate to fit end points + tr = numpy.zeros(2) + for p in paths[1:]: + if p.isSegment() and p.prev.isSegment(): + tr = p.prev.pointN - p.point1 + p.translate(tr) diff --git a/extensions/fablabchemnitz/shapereco/shaperrec/miscellaneous.py b/extensions/fablabchemnitz/shapereco/shaperrec/miscellaneous.py new file mode 100644 index 00000000..3949c411 --- /dev/null +++ b/extensions/fablabchemnitz/shapereco/shaperrec/miscellaneous.py @@ -0,0 +1,209 @@ +import sys +import inkex +from inkex import Path +import numpy +from shaperrec import manipulation +from lxml import etree + + + +# ************************************************************* +# debugging +def void(*l): + pass +def debug_on(*l): + sys.stderr.write(' '.join(str(i) for i in l) +'\n') +debug = void +#debug = debug_on + +errwrite = void + + +# miscellaneous helper functions to sort + +# merge consecutive segments with close angle + +def mergeConsecutiveCloseAngles( segList , mangle =0.25 , q=0.5): + + def toMerge(seg): + l=[seg] + setattr(seg, 'merged', True) + if hasattr(seg, "__next__") and seg.next.isSegment() : + debug('merging segs ', seg.angle, ' with : ', seg.next.point1, seg.next.pointN, ' ang=', seg.next.angle) + if geometric.deltaAngleAbs( seg.angle, seg.next.angle) < mangle: + l += toMerge(seg.next) + return l + + updatedSegs = [] + for i, seg in enumerate(segList[:-1]): + if not seg.isSegment() : + updatedSegs.append(seg) + continue + if hasattr(seg, 'merged'): + continue + debug(i, ' inspect merge : ', seg.point1, '-', seg.pointN, seg.angle, ' q=', seg.quality()) + mList = toMerge(seg) + debug(' --> tomerge ', len(mList)) + if len(mList)<2: + delattr(seg, 'merged') + updatedSegs.append(seg) + continue + points= numpy.concatenate( [p.points for p in mList] ) + newseg = fitSingleSegment(points) + if newseg.quality()>q: + delattr(seg, 'merged') + updatedSegs.append(seg) + continue + for p in mList: + setattr(seg, 'merged', True) + newseg.sourcepoints = seg.sourcepoints + debug(' --> post merge qual = ', newseg.quality(), seg.pointN, ' --> ', newseg.pointN, newseg.angle) + newseg.prev = mList[0].prev + newseg.next = mList[-1].__next__ + updatedSegs.append(newseg) + if not hasattr(segList[-1], 'merged') : updatedSegs.append( segList[-1]) + return updatedSegs + + + + +def parametersFromPointAngle(point, angle): + unitv = numpy.array([ numpy.cos(angle), numpy.sin(angle) ]) + ortangle = angle+numpy.pi/2 + normal = numpy.array([ numpy.cos(ortangle), numpy.sin(ortangle) ]) + genOffset = -normal.dot(point) + a, b = normal + return a, b, genOffset + + + +def addPath(newList, refnode): + """Add a node in the xml structure corresponding to the content of newList + newList : list of Segment or Path + refnode : xml node used as a reference, new point will be inserted a same level""" + ele = etree.Element('{http://www.w3.org/2000/svg}path') + errwrite("newList = " + str(newList) + "\n") + ele.set('d', str(Path(newList))) + refnode.xpath('..')[0].append(ele) + return ele + +def reformatList( listOfPaths): + """ Returns a SVG paths list (same format as simplepath.parsePath) from a list of Path objects + - Segments in paths are added in the new list + - simple Path are retrieved from the original refSVGPathList and put in the new list (thus preserving original bezier curves) + """ + newList = [] + first = True + for seg in listOfPaths: + newList += seg.asSVGCommand(first) + first = False + return newList + + +def clusterValues( values, relS=0.1 , refScaleAbs='range' ): + """form clusters of similar quantities from input 'values'. + Clustered values are not necessarily contiguous in the input array. + Clusters size (that is max-min) is < relS*cluster_average """ + if len(values)==0: + return [] + if len(values.shape)==1: + sortedV = numpy.stack([ values, numpy.arange(len(values))], 1) + else: + # Assume value.shape = (N,2) and index are ok + sortedV = values + sortedV = sortedV[ numpy.argsort(sortedV[:, 0]) ] + + sortedVV = sortedV[:, 0] + refScale = sortedVV[-1]-sortedVV[0] + #sortedVV += 2*min(sortedVV)) # shift to avoid numerical issues around 0 + + #print sortedVV + class Cluster: + def __init__(self, delta, sum, indices): + self.delta = delta + self.sum = sum + self.N=len(indices) + self.indices = indices + def size(self): + return self.delta/refScale + + def combine(self, c): + #print ' combine ', self.indices[0], c.indices[-1], ' -> ', sortedVV[c.indices[-1]] - sortedVV[self.indices[0]] + newC = Cluster(sortedVV[c.indices[-1]] - sortedVV[self.indices[0]], + self.sum+c.sum, + self.indices+c.indices) + return newC + + def originIndices(self): + return tuple(int(sortedV[i][1]) for i in self.indices) + + def size_local(self): + return self.delta / sum( sortedVV[i] for i in self.indices) *len(self.indices) + def size_range(self): + return self.delta/refScale + def size_abs(self): + return self.delta + + if refScaleAbs=='range': + Cluster.size = size_range + elif refScaleAbs=='local': + Cluster.size = size_local + elif refScaleAbs=='abs': + Cluster.size = size_abs + + class ClusterPair: + next=None + prev=None + def __init__(self, c1, c2 ): + self.c1=c1 + self.c2=c2 + self.refresh() + def refresh(self): + self.potentialC =self.c1.combine(self.c2) + self.size = self.potentialC.size() + def setC1(self, c1): + self.c1=c1 + self.refresh() + def setC2(self, c2): + self.c2=c2 + self.refresh() + + #ave = 0.5*(sortedVV[1:,0]+sortedV[:-1,0]) + #deltaR = (sortedV[1:,0]-sortedV[:-1,0])/ave + + cList = [Cluster(0, v, (i,)) for (i, v) in enumerate(sortedVV) ] + cpList = [ ClusterPair( c, cList[i+1] ) for (i, c) in enumerate(cList[:-1]) ] + manipulation.resetPrevNextSegment( cpList ) + + #print cpList + def reduceCL( cList ): + if len(cList)<=1: + return cList + cp = min(cList, key=lambda cp:cp.size) + #print '==', cp.size , relS, cp.c1.indices , cp.c2.indices, cp.potentialC.indices + + while cp.size < relS: + if hasattr(cp, "__next__"): + cp.next.setC1(cp.potentialC) + cp.next.prev = cp.prev + if cp.prev: + cp.prev.setC2(cp.potentialC) + cp.prev.next = cp.__next__ if hasattr(cp, "__next__") else None + cList.remove(cp) + if len(cList)<2: + break + cp = min(cList, key=lambda cp:cp.size) + #print ' -----> ', [ (cp.c1.indices , cp.c2.indices) for cp in cList] + return cList + + cpList = reduceCL(cpList) + if len(cpList)==1: + cp = cpList[0] + if cp.potentialC.size()